@seekrit/cli 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +582 -197
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -5053,7 +5053,7 @@ async function createAgentTaskToken() {
|
|
|
5053
5053
|
}
|
|
5054
5054
|
//#endregion
|
|
5055
5055
|
//#region package.json
|
|
5056
|
-
var version = "1.
|
|
5056
|
+
var version = "1.6.1";
|
|
5057
5057
|
//#endregion
|
|
5058
5058
|
//#region ../../packages/api-client/src/index.ts
|
|
5059
5059
|
var SeekritApiError = class extends Error {
|
|
@@ -7524,7 +7524,7 @@ function registerEnvDiff(env) {
|
|
|
7524
7524
|
//#endregion
|
|
7525
7525
|
//#region src/kms.ts
|
|
7526
7526
|
/** Collect a repeatable option into a list. */
|
|
7527
|
-
function collect$
|
|
7527
|
+
function collect$8(value, acc = []) {
|
|
7528
7528
|
acc.push(value);
|
|
7529
7529
|
return acc;
|
|
7530
7530
|
}
|
|
@@ -7592,7 +7592,7 @@ async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
|
|
|
7592
7592
|
}
|
|
7593
7593
|
function registerKmsCommands(program) {
|
|
7594
7594
|
const kms = program.command("kms").description("managed keys for application-layer encryption & signing (client-side)");
|
|
7595
|
-
kms.command("create").description("create a managed key (material is generated locally and wrapped, never sent)").requiredOption("--name <name>", "org-unique key name").requiredOption("--purpose <purpose>", "encrypt | sign").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "scope the key to an application").option("--group <slug>", "scope the key to a group").option("--grant-user <email>", "also grant an org member (repeatable)", collect$
|
|
7595
|
+
kms.command("create").description("create a managed key (material is generated locally and wrapped, never sent)").requiredOption("--name <name>", "org-unique key name").requiredOption("--purpose <purpose>", "encrypt | sign").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "scope the key to an application").option("--group <slug>", "scope the key to a group").option("--grant-user <email>", "also grant an org member (repeatable)", collect$8, []).option("--grant-token <tokenId>", "also grant a service token (repeatable)", collect$8, []).action(async (options) => {
|
|
7596
7596
|
if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
|
|
7597
7597
|
if (options.app && options.group) fail("pass at most one of --app or --group");
|
|
7598
7598
|
const ctx = buildContext();
|
|
@@ -7814,7 +7814,7 @@ function registerKmsCommands(program) {
|
|
|
7814
7814
|
//#endregion
|
|
7815
7815
|
//#region src/recovery.ts
|
|
7816
7816
|
/** Collect a repeatable option into a list. */
|
|
7817
|
-
function collect$
|
|
7817
|
+
function collect$7(value, acc = []) {
|
|
7818
7818
|
acc.push(value);
|
|
7819
7819
|
return acc;
|
|
7820
7820
|
}
|
|
@@ -7911,7 +7911,7 @@ function registerRecoveryCommands(program) {
|
|
|
7911
7911
|
for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
|
|
7912
7912
|
if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
|
|
7913
7913
|
});
|
|
7914
|
-
recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$
|
|
7914
|
+
recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$7, []).option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
7915
7915
|
const ctx = buildContext();
|
|
7916
7916
|
const org = await resolveOrg(ctx, options.org);
|
|
7917
7917
|
const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
|
|
@@ -7929,7 +7929,7 @@ function registerRecoveryCommands(program) {
|
|
|
7929
7929
|
const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
|
|
7930
7930
|
console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
|
|
7931
7931
|
});
|
|
7932
|
-
recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$
|
|
7932
|
+
recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$7, []).option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
7933
7933
|
const ctx = buildContext();
|
|
7934
7934
|
const org = await resolveOrg(ctx, options.org);
|
|
7935
7935
|
const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
|
|
@@ -9070,7 +9070,7 @@ function formatSecrets(values, format) {
|
|
|
9070
9070
|
* `buildContext()` in any of them silently breaks the promise the feature makes.
|
|
9071
9071
|
*/
|
|
9072
9072
|
/** Collect a repeatable option into a list. */
|
|
9073
|
-
function collect$
|
|
9073
|
+
function collect$6(value, acc = []) {
|
|
9074
9074
|
acc.push(value);
|
|
9075
9075
|
return acc;
|
|
9076
9076
|
}
|
|
@@ -9343,7 +9343,7 @@ function registerArchiveCommands(program) {
|
|
|
9343
9343
|
} catch {}
|
|
9344
9344
|
fail("none of the recovery shares in this archive unwrap with that key");
|
|
9345
9345
|
});
|
|
9346
|
-
archive.command("decrypt <file>").description("decrypt an archive's secrets with your own key (offline)").option("-o, --out <dir>", "write one file per environment into this directory").option("--stdout", "print plaintext to stdout instead of writing files").option("--env <label>", "only this environment (app/env, group@env, slug, or id)").option("--format <format>", "dotenv | json | shell", "dotenv").option("--token <skt_…>", "decrypt as a service token instead of a passphrase").option("--key-file <path>", "decrypt with a private key JWK file").option("--share <file>", "custodian share for an offline quorum (repeatable)", collect$
|
|
9346
|
+
archive.command("decrypt <file>").description("decrypt an archive's secrets with your own key (offline)").option("-o, --out <dir>", "write one file per environment into this directory").option("--stdout", "print plaintext to stdout instead of writing files").option("--env <label>", "only this environment (app/env, group@env, slug, or id)").option("--format <format>", "dotenv | json | shell", "dotenv").option("--token <skt_…>", "decrypt as a service token instead of a passphrase").option("--key-file <path>", "decrypt with a private key JWK file").option("--share <file>", "custodian share for an offline quorum (repeatable)", collect$6).option("-y, --yes", "skip the confirmation when printing plaintext to stdout").action(async (file, options) => {
|
|
9347
9347
|
if (!options.out && !options.stdout) fail("choose a destination: --out <dir> to write files, or --stdout to print plaintext");
|
|
9348
9348
|
if (![
|
|
9349
9349
|
"dotenv",
|
|
@@ -11263,7 +11263,7 @@ function parseTtlSeconds$4(input) {
|
|
|
11263
11263
|
}[m[2] || "s"] ?? 1);
|
|
11264
11264
|
}
|
|
11265
11265
|
/** Collect a repeatable option into an array. */
|
|
11266
|
-
function collect$
|
|
11266
|
+
function collect$5(value, previous) {
|
|
11267
11267
|
return [...previous, value];
|
|
11268
11268
|
}
|
|
11269
11269
|
/** Parse `readWrite@app` → { role, db } for a custom target. */
|
|
@@ -11288,7 +11288,7 @@ function resolveAdminUri(uri) {
|
|
|
11288
11288
|
function registerMongoCommands(program) {
|
|
11289
11289
|
const mongo = program.command("mongodb").description("temporary MongoDB credentials (createUser, zero-knowledge delivery)");
|
|
11290
11290
|
const target = mongo.command("target").description("manage MongoDB targets");
|
|
11291
|
-
target.command("add").description("register a MongoDB cluster to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-app").requiredOption("--database <db>", "the database leased users get access to, e.g. app").option("--uri <uri>", "admin connection string (else SEEKRIT_MONGODB_ADMIN_URL)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--role <role@db>", "grant for a custom target (repeatable)", collect$
|
|
11291
|
+
target.command("add").description("register a MongoDB cluster to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-app").requiredOption("--database <db>", "the database leased users get access to, e.g. app").option("--uri <uri>", "admin connection string (else SEEKRIT_MONGODB_ADMIN_URL)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--role <role@db>", "grant for a custom target (repeatable)", collect$5, []).option("--auth-source <db>", "authentication database (default admin)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--no-tls", "disable TLS to the cluster (TLS is on by default)").action(async (options) => {
|
|
11292
11292
|
const ctx = buildContext();
|
|
11293
11293
|
const org = await resolveOrg(ctx, options.org);
|
|
11294
11294
|
const adminUri = resolveAdminUri(options.uri);
|
|
@@ -11450,7 +11450,7 @@ function generateUserName$1(prefix = "tmp") {
|
|
|
11450
11450
|
function registerMysqlCommands(program) {
|
|
11451
11451
|
const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
|
|
11452
11452
|
const target = mysql.command("target").description("manage provisioning targets");
|
|
11453
|
-
target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$
|
|
11453
|
+
target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$4, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$4, []).action(async (options) => {
|
|
11454
11454
|
const ctx = buildContext();
|
|
11455
11455
|
const org = await resolveOrg(ctx, options.org);
|
|
11456
11456
|
const executor = options.executor === "remote" ? "remote" : "in_do";
|
|
@@ -11553,11 +11553,577 @@ function registerMysqlCommands(program) {
|
|
|
11553
11553
|
});
|
|
11554
11554
|
}
|
|
11555
11555
|
/** Collect a repeatable option into an array. */
|
|
11556
|
-
function collect$
|
|
11556
|
+
function collect$4(value, acc) {
|
|
11557
11557
|
acc.push(value);
|
|
11558
11558
|
return acc;
|
|
11559
11559
|
}
|
|
11560
11560
|
//#endregion
|
|
11561
|
+
//#region src/proxy-binary.ts
|
|
11562
|
+
/**
|
|
11563
|
+
* Fetch and run the `seekrit-proxy` binary without a Rust toolchain.
|
|
11564
|
+
*
|
|
11565
|
+
* The proxy is the strongest answer seekrit has for an untrusted workload — the
|
|
11566
|
+
* agent holds `{{seekrit:NAME}}` and never the key — and it was also the hardest
|
|
11567
|
+
* thing here to *try*, because trying it meant `cargo` and a TOML file. This
|
|
11568
|
+
* module removes the first half: it resolves a prebuilt, checksum-verified
|
|
11569
|
+
* binary for the host platform and execs it, so `npx @seekrit/proxy` and
|
|
11570
|
+
* `seekrit proxy run` behave like the proxy was already installed.
|
|
11571
|
+
*
|
|
11572
|
+
* The logic lives in the CLI (and is re-exported as `@seekrit/cli/proxy-launcher`)
|
|
11573
|
+
* for the same reason the MCP server does: `@seekrit/proxy` is a thin npx
|
|
11574
|
+
* entrypoint over it, and the two must not drift.
|
|
11575
|
+
*
|
|
11576
|
+
* Three properties worth stating, since this downloads and executes code:
|
|
11577
|
+
*
|
|
11578
|
+
* - **The checksum is verified before anything is executed**, against a
|
|
11579
|
+
* `.sha256` fetched from the same release. That is integrity, not provenance —
|
|
11580
|
+
* it proves the bytes match what the release published, which is exactly the
|
|
11581
|
+
* guarantee `install.sh` gives and no more.
|
|
11582
|
+
* - **Nothing is fetched when a binary is already available.** `SEEKRIT_PROXY_BIN`
|
|
11583
|
+
* short-circuits entirely, and a cached download for the same version+target is
|
|
11584
|
+
* reused, so this is a one-time cost per version.
|
|
11585
|
+
* - **Version is pinned, not floating.** A default of `latest` would make two
|
|
11586
|
+
* machines run different proxies from the same command; the pinned constant is
|
|
11587
|
+
* what this CLI was built against, overridable when you want otherwise.
|
|
11588
|
+
*/
|
|
11589
|
+
/**
|
|
11590
|
+
* The proxy version this CLI was built against.
|
|
11591
|
+
*
|
|
11592
|
+
* Bumped by release-please when `apps/proxy` releases (an `extra-files` entry in
|
|
11593
|
+
* release-please-config.json), so the pin follows the crate without anyone
|
|
11594
|
+
* remembering to move it.
|
|
11595
|
+
*/
|
|
11596
|
+
const PROXY_VERSION = "0.12.1";
|
|
11597
|
+
const BIN = "seekrit-proxy";
|
|
11598
|
+
/**
|
|
11599
|
+
* Host → Rust target triple.
|
|
11600
|
+
*
|
|
11601
|
+
* Linux always resolves to **musl**: that build is statically linked, so one
|
|
11602
|
+
* artifact covers glibc, musl, alpine, and distroless, and there is no libc
|
|
11603
|
+
* detection to get wrong on a machine where `ldd` says something unexpected.
|
|
11604
|
+
*/
|
|
11605
|
+
function detectTarget(os = platform(), cpu = arch()) {
|
|
11606
|
+
const machine = cpu === "x64" ? "x86_64" : cpu === "arm64" ? "aarch64" : null;
|
|
11607
|
+
if (!machine) throw new Error(`unsupported architecture "${cpu}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
|
|
11608
|
+
switch (os) {
|
|
11609
|
+
case "linux": return {
|
|
11610
|
+
target: `${machine}-unknown-linux-musl`,
|
|
11611
|
+
exe: ""
|
|
11612
|
+
};
|
|
11613
|
+
case "darwin": return {
|
|
11614
|
+
target: `${machine}-apple-darwin`,
|
|
11615
|
+
exe: ""
|
|
11616
|
+
};
|
|
11617
|
+
case "win32":
|
|
11618
|
+
if (machine !== "x86_64") throw new Error(`no prebuilt seekrit-proxy for ${machine} Windows — set SEEKRIT_PROXY_BIN to a binary you built`);
|
|
11619
|
+
return {
|
|
11620
|
+
target: "x86_64-pc-windows-msvc",
|
|
11621
|
+
exe: ".exe"
|
|
11622
|
+
};
|
|
11623
|
+
default: throw new Error(`unsupported platform "${os}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
|
|
11624
|
+
}
|
|
11625
|
+
}
|
|
11626
|
+
/** `latest` stays `latest`; everything else is normalized to `v<x.y.z>`. */
|
|
11627
|
+
function versionPrefix(version) {
|
|
11628
|
+
if (version === "latest") return "latest";
|
|
11629
|
+
return version.startsWith("v") ? version : `v${version}`;
|
|
11630
|
+
}
|
|
11631
|
+
function resolveVersion(explicit) {
|
|
11632
|
+
return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.12.1";
|
|
11633
|
+
}
|
|
11634
|
+
function resolveBaseUrl(explicit) {
|
|
11635
|
+
return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
|
|
11636
|
+
}
|
|
11637
|
+
/** Where a resolved binary is kept, keyed so versions and targets never collide. */
|
|
11638
|
+
function proxyBinaryPath(version, target, exe) {
|
|
11639
|
+
return join(defaultCacheDir(), "proxy", versionPrefix(version), target, `${BIN}${exe}`);
|
|
11640
|
+
}
|
|
11641
|
+
async function fetchBytes$1(url) {
|
|
11642
|
+
const res = await fetch(url);
|
|
11643
|
+
if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
|
|
11644
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
11645
|
+
}
|
|
11646
|
+
/**
|
|
11647
|
+
* Ensure a `seekrit-proxy` binary exists locally and return its path.
|
|
11648
|
+
*
|
|
11649
|
+
* Order: an explicit `SEEKRIT_PROXY_BIN`, then a cached download for this
|
|
11650
|
+
* version+target, then a fresh download. A binary already on `PATH` is
|
|
11651
|
+
* deliberately *not* used — silently running a different version than the one
|
|
11652
|
+
* this CLI pins is the kind of surprise that costs an afternoon.
|
|
11653
|
+
*/
|
|
11654
|
+
async function resolveProxyBinary(options = {}) {
|
|
11655
|
+
const override = process.env.SEEKRIT_PROXY_BIN;
|
|
11656
|
+
if (override) {
|
|
11657
|
+
if (!existsSync(override)) throw new Error(`SEEKRIT_PROXY_BIN points at ${override}, which does not exist`);
|
|
11658
|
+
return override;
|
|
11659
|
+
}
|
|
11660
|
+
const version = resolveVersion(options.version);
|
|
11661
|
+
const { target, exe } = detectTarget();
|
|
11662
|
+
const dest = proxyBinaryPath(version, target, exe);
|
|
11663
|
+
if (!options.force && version !== "latest" && existsSync(dest)) return dest;
|
|
11664
|
+
const baseUrl = resolveBaseUrl(options.baseUrl);
|
|
11665
|
+
const prefix = versionPrefix(version);
|
|
11666
|
+
const name = `${BIN}-${target}${exe}`;
|
|
11667
|
+
const binUrl = `${baseUrl}/${prefix}/bin/${name}`;
|
|
11668
|
+
const sumUrl = `${binUrl}.sha256`;
|
|
11669
|
+
if (!options.quiet) process.stderr.write(`seekrit: fetching ${BIN} ${prefix} (${target})…\n`);
|
|
11670
|
+
let bytes;
|
|
11671
|
+
let expected;
|
|
11672
|
+
try {
|
|
11673
|
+
[bytes, expected] = await Promise.all([fetchBytes$1(binUrl), fetchBytes$1(sumUrl).then((b) => Buffer.from(b).toString("utf8").trim().split(/\s+/)[0] ?? "")]);
|
|
11674
|
+
} catch (err) {
|
|
11675
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11676
|
+
throw new Error(`could not download ${BIN} ${prefix} for ${target}: ${message}\n Set SEEKRIT_PROXY_BIN to a binary you already have, or build it from apps/proxy.`);
|
|
11677
|
+
}
|
|
11678
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
11679
|
+
if (!expected || actual !== expected.toLowerCase()) throw new Error(`checksum mismatch for ${name}: expected ${expected || "(none published)"}, got ${actual}. Refusing to run it.`);
|
|
11680
|
+
const dir = dirname(dest);
|
|
11681
|
+
mkdirSync(dir, { recursive: true });
|
|
11682
|
+
const staging = join(dir, `.${BIN}-${process.pid}-${actual.slice(0, 12)}${exe}`);
|
|
11683
|
+
try {
|
|
11684
|
+
writeFileSync(staging, bytes, { mode: 493 });
|
|
11685
|
+
renameSync(staging, dest);
|
|
11686
|
+
} catch (err) {
|
|
11687
|
+
rmSync(staging, { force: true });
|
|
11688
|
+
throw err;
|
|
11689
|
+
}
|
|
11690
|
+
chmodSync(dest, 493);
|
|
11691
|
+
return dest;
|
|
11692
|
+
}
|
|
11693
|
+
/**
|
|
11694
|
+
* Run the proxy, forwarding stdio, signals, and its exit status.
|
|
11695
|
+
*
|
|
11696
|
+
* The proxy is a long-lived foreground process, so this wrapper has to be
|
|
11697
|
+
* transparent: Node cannot exec-replace itself, and without relaying signals
|
|
11698
|
+
* Node's default SIGINT handler would kill *this* process on Ctrl-C and leave
|
|
11699
|
+
* the proxy running, holding decrypted secrets, with the shell prompt back.
|
|
11700
|
+
*/
|
|
11701
|
+
async function runProxyBinary(argv, options = {}) {
|
|
11702
|
+
const bin = await resolveProxyBinary(options);
|
|
11703
|
+
const child = spawn(bin, argv, {
|
|
11704
|
+
stdio: "inherit",
|
|
11705
|
+
env: {
|
|
11706
|
+
...process.env,
|
|
11707
|
+
...options.env
|
|
11708
|
+
}
|
|
11709
|
+
});
|
|
11710
|
+
const signals = [
|
|
11711
|
+
"SIGINT",
|
|
11712
|
+
"SIGTERM",
|
|
11713
|
+
"SIGHUP",
|
|
11714
|
+
"SIGQUIT"
|
|
11715
|
+
];
|
|
11716
|
+
const forward = (signal) => {
|
|
11717
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
11718
|
+
child.kill(signal);
|
|
11719
|
+
};
|
|
11720
|
+
for (const signal of signals) process.on(signal, forward);
|
|
11721
|
+
return new Promise((resolve, reject) => {
|
|
11722
|
+
child.on("error", (err) => {
|
|
11723
|
+
for (const s of signals) process.off(s, forward);
|
|
11724
|
+
reject(/* @__PURE__ */ new Error(`could not start ${bin}: ${err.message}\n If this is a fresh download, the platform may not match — set SEEKRIT_PROXY_BIN.`));
|
|
11725
|
+
});
|
|
11726
|
+
child.on("exit", (code, signal) => {
|
|
11727
|
+
for (const s of signals) process.off(s, forward);
|
|
11728
|
+
resolve(signal ? 128 + signalNumber(signal) : code ?? 0);
|
|
11729
|
+
});
|
|
11730
|
+
});
|
|
11731
|
+
}
|
|
11732
|
+
/** Signal name → number, for the 128+n exit convention. */
|
|
11733
|
+
function signalNumber(signal) {
|
|
11734
|
+
return {
|
|
11735
|
+
SIGHUP: 1,
|
|
11736
|
+
SIGINT: 2,
|
|
11737
|
+
SIGQUIT: 3,
|
|
11738
|
+
SIGKILL: 9,
|
|
11739
|
+
SIGTERM: 15
|
|
11740
|
+
}[signal] ?? 0;
|
|
11741
|
+
}
|
|
11742
|
+
//#endregion
|
|
11743
|
+
//#region src/nomad.ts
|
|
11744
|
+
/**
|
|
11745
|
+
* `seekrit nomad` — wire seekrit into [HashiCorp Nomad](https://developer.hashicorp.com/nomad)
|
|
11746
|
+
* as a **secret provider plugin**.
|
|
11747
|
+
*
|
|
11748
|
+
* Nomad 1.11 grew a `secret` block in the job specification plus an external
|
|
11749
|
+
* plugin framework: drop an executable in `client.common_plugin_dir/secrets/`,
|
|
11750
|
+
* answer `fingerprint` and `fetch` with JSON on stdout, and jobs name
|
|
11751
|
+
* credentials instead of holding them. Two built-in providers ship (Nomad
|
|
11752
|
+
* Variables and Vault); everything else is a community binary.
|
|
11753
|
+
*
|
|
11754
|
+
* The plugin itself is Rust (`apps/nomad-plugin`), not this CLI, because it runs
|
|
11755
|
+
* as the Nomad agent's user on every client node — bare Linux hosts where
|
|
11756
|
+
* requiring a Node runtime would be a real adoption tax. This command covers the
|
|
11757
|
+
* three things around it that are not the protocol: putting the binary in the
|
|
11758
|
+
* right place, printing the configuration, and proving the install works.
|
|
11759
|
+
*
|
|
11760
|
+
* Three facts about Nomad shaped this file, and **all three fail silently**:
|
|
11761
|
+
*
|
|
11762
|
+
* **The `provider` value is the executable's filename.** Nomad looks for an
|
|
11763
|
+
* executable named exactly what the job's `provider` says. So the binary is
|
|
11764
|
+
* installed as `secrets/seekrit`, not as its artifact name — get it wrong and
|
|
11765
|
+
* the plugin is not registered, with no error and no log line pointing here.
|
|
11766
|
+
*
|
|
11767
|
+
* **Plugins are fingerprinted once, at client startup.** Installing a plugin
|
|
11768
|
+
* into a running agent does nothing until it restarts. So every path here says
|
|
11769
|
+
* so, rather than letting someone conclude the plugin is broken.
|
|
11770
|
+
*
|
|
11771
|
+
* **`common_plugin_dir` is opt-in.** An agent with no `common_plugin_dir` set
|
|
11772
|
+
* scans nothing. `init` prints the stanza because the most likely reason a
|
|
11773
|
+
* correct install does not work is that the agent was never told where to look.
|
|
11774
|
+
*/
|
|
11775
|
+
/**
|
|
11776
|
+
* The name a job's `secret` block puts in `provider`, **and** the filename the
|
|
11777
|
+
* binary must have on disk. Nomad ties the two together; they are one constant
|
|
11778
|
+
* here so they cannot drift.
|
|
11779
|
+
*/
|
|
11780
|
+
const PROVIDER_NAME = "seekrit";
|
|
11781
|
+
/** The artifact's own name — what the release publishes and install.sh fetches. */
|
|
11782
|
+
const PLUGIN_BIN = "seekrit-nomad-secrets";
|
|
11783
|
+
/**
|
|
11784
|
+
* The plugin version this CLI installs by default.
|
|
11785
|
+
*
|
|
11786
|
+
* Bumped by release-please when `apps/nomad-plugin` releases (an `extra-files`
|
|
11787
|
+
* entry in release-please-config.json), so the pin follows the crate without
|
|
11788
|
+
* anyone remembering to move it — the same arrangement `PROXY_VERSION` has.
|
|
11789
|
+
*/
|
|
11790
|
+
const NOMAD_PLUGIN_VERSION = "0.2.0";
|
|
11791
|
+
/** Public artifact host. Mirrors GitHub Releases; public, no egress fees. */
|
|
11792
|
+
const DEFAULT_BASE_URL = "https://nomad.seekrit.dev";
|
|
11793
|
+
/** Where the plugin reads its node config, matching `config::DEFAULT_CONFIG_PATH`. */
|
|
11794
|
+
const DEFAULT_CONFIG_PATH = "/etc/seekrit/nomad.json";
|
|
11795
|
+
/**
|
|
11796
|
+
* Plugin directories to look in when `--plugin-dir` is not given.
|
|
11797
|
+
*
|
|
11798
|
+
* Deliberately a short list of conventional paths rather than a guess: an agent
|
|
11799
|
+
* can put `common_plugin_dir` anywhere, and installing into the wrong directory
|
|
11800
|
+
* produces a plugin that does not exist as far as Nomad is concerned.
|
|
11801
|
+
*/
|
|
11802
|
+
const PLUGIN_DIR_CANDIDATES = ["/opt/nomad/plugins", "/var/lib/nomad/plugins"];
|
|
11803
|
+
/** The first candidate that exists, or undefined — never a blind default. */
|
|
11804
|
+
function detectPluginDir(candidates = PLUGIN_DIR_CANDIDATES) {
|
|
11805
|
+
return candidates.find((dir) => existsSync(dir));
|
|
11806
|
+
}
|
|
11807
|
+
/** Where the executable has to end up for `provider = "seekrit"` to find it. */
|
|
11808
|
+
function pluginPath(pluginDir) {
|
|
11809
|
+
return join(pluginDir, "secrets", PROVIDER_NAME);
|
|
11810
|
+
}
|
|
11811
|
+
/** The agent stanza that makes Nomad scan for plugins at all. */
|
|
11812
|
+
function agentStanza(pluginDir) {
|
|
11813
|
+
return [
|
|
11814
|
+
"client {",
|
|
11815
|
+
" enabled = true",
|
|
11816
|
+
` common_plugin_dir = ${JSON.stringify(pluginDir)}`,
|
|
11817
|
+
"}"
|
|
11818
|
+
].join("\n");
|
|
11819
|
+
}
|
|
11820
|
+
/** The node-side config file: which credential, and who may use it. */
|
|
11821
|
+
function pluginConfig(opts) {
|
|
11822
|
+
const profile = { token_file: opts.tokenFile };
|
|
11823
|
+
if (opts.namespaces?.length) profile.namespaces = opts.namespaces;
|
|
11824
|
+
if (opts.jobs?.length) profile.jobs = opts.jobs;
|
|
11825
|
+
return `${JSON.stringify({ profiles: { [opts.profile]: profile } }, null, 2)}\n`;
|
|
11826
|
+
}
|
|
11827
|
+
/**
|
|
11828
|
+
* A job snippet.
|
|
11829
|
+
*
|
|
11830
|
+
* `path` is the profile name, because the first segment of a path is always a
|
|
11831
|
+
* profile — never a secret name. seekrit names and slugs overlap (`api_key` is
|
|
11832
|
+
* a legal spelling of both), so a single-segment path that could mean either
|
|
11833
|
+
* would resolve differently depending on what a node happened to have
|
|
11834
|
+
* configured, which is the worst possible failure mode for an access decision.
|
|
11835
|
+
*/
|
|
11836
|
+
function jobSnippet(profile, names) {
|
|
11837
|
+
const vars = names.length > 0 ? names : ["DATABASE_URL", "STRIPE_SECRET_KEY"];
|
|
11838
|
+
const width = Math.max(...vars.map((n) => n.length));
|
|
11839
|
+
const INTERP = "${secret.seekrit.";
|
|
11840
|
+
const env = vars.map((n) => ` ${n.padEnd(width)} = "${INTERP}${n}}"`).join("\n");
|
|
11841
|
+
return [
|
|
11842
|
+
"job \"example\" {",
|
|
11843
|
+
" secret \"seekrit\" {",
|
|
11844
|
+
` provider = ${JSON.stringify(PROVIDER_NAME)}`,
|
|
11845
|
+
` path = ${JSON.stringify(profile)}`,
|
|
11846
|
+
" }",
|
|
11847
|
+
"",
|
|
11848
|
+
" group \"app\" {",
|
|
11849
|
+
" task \"server\" {",
|
|
11850
|
+
" driver = \"docker\"",
|
|
11851
|
+
" env {",
|
|
11852
|
+
env,
|
|
11853
|
+
" }",
|
|
11854
|
+
" }",
|
|
11855
|
+
" }",
|
|
11856
|
+
"}"
|
|
11857
|
+
].join("\n");
|
|
11858
|
+
}
|
|
11859
|
+
async function fetchBytes(url) {
|
|
11860
|
+
const res = await fetch(url);
|
|
11861
|
+
if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
|
|
11862
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
11863
|
+
}
|
|
11864
|
+
/**
|
|
11865
|
+
* Download, verify, and place the plugin.
|
|
11866
|
+
*
|
|
11867
|
+
* Same integrity story as the proxy launcher: the SHA-256 is checked against a
|
|
11868
|
+
* `.sha256` published beside the binary *before* anything is written to the
|
|
11869
|
+
* plugin directory. That proves the bytes match what the release published —
|
|
11870
|
+
* integrity, not provenance.
|
|
11871
|
+
*
|
|
11872
|
+
* The staging file lives in the destination directory rather than `TMPDIR` so
|
|
11873
|
+
* the rename is atomic: a cross-device rename degrades to a copy, which would
|
|
11874
|
+
* let Nomad fingerprint a half-written executable.
|
|
11875
|
+
*/
|
|
11876
|
+
async function installPlugin(pluginDir, options = {}) {
|
|
11877
|
+
const version = options.version ?? "0.2.0";
|
|
11878
|
+
const baseUrl = (options.baseUrl ?? "https://nomad.seekrit.dev").replace(/\/+$/, "");
|
|
11879
|
+
const { target, exe } = detectTarget();
|
|
11880
|
+
if (exe !== "") fail("Nomad secret provider plugins are not supported on Windows clients");
|
|
11881
|
+
const prefix = versionPrefix(version);
|
|
11882
|
+
const name = `${PLUGIN_BIN}-${target}`;
|
|
11883
|
+
const binUrl = `${baseUrl}/${prefix}/bin/${name}`;
|
|
11884
|
+
if (!options.quiet) process.stderr.write(`seekrit: fetching ${PLUGIN_BIN} ${prefix} (${target})…\n`);
|
|
11885
|
+
let bytes;
|
|
11886
|
+
let expected;
|
|
11887
|
+
try {
|
|
11888
|
+
[bytes, expected] = await Promise.all([fetchBytes(binUrl), fetchBytes(`${binUrl}.sha256`).then((b) => Buffer.from(b).toString("utf8").trim().split(/\s+/)[0] ?? "")]);
|
|
11889
|
+
} catch (err) {
|
|
11890
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11891
|
+
fail(`could not download ${PLUGIN_BIN} ${prefix} for ${target}: ${message}\n Build it from apps/nomad-plugin, or use https://nomad.seekrit.dev/install.sh`);
|
|
11892
|
+
}
|
|
11893
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
11894
|
+
if (!expected || actual !== expected.toLowerCase()) fail(`checksum mismatch for ${name}: expected ${expected || "(none published)"}, got ${actual}. Refusing to install it.`);
|
|
11895
|
+
const dest = pluginPath(pluginDir);
|
|
11896
|
+
const dir = dirname(dest);
|
|
11897
|
+
try {
|
|
11898
|
+
mkdirSync(dir, { recursive: true });
|
|
11899
|
+
} catch (err) {
|
|
11900
|
+
fail(`could not create ${dir}: ${err instanceof Error ? err.message : String(err)}\n The plugin directory usually belongs to root — try again with sudo.`);
|
|
11901
|
+
}
|
|
11902
|
+
const staging = join(dir, `.${PROVIDER_NAME}-${process.pid}-${actual.slice(0, 12)}`);
|
|
11903
|
+
try {
|
|
11904
|
+
writeFileSync(staging, bytes, { mode: 493 });
|
|
11905
|
+
renameSync(staging, dest);
|
|
11906
|
+
} catch (err) {
|
|
11907
|
+
rmSync(staging, { force: true });
|
|
11908
|
+
fail(`could not install to ${dest}: ${err instanceof Error ? err.message : String(err)}\n The plugin directory usually belongs to root — try again with sudo.`);
|
|
11909
|
+
}
|
|
11910
|
+
chmodSync(dest, 493);
|
|
11911
|
+
return {
|
|
11912
|
+
path: dest,
|
|
11913
|
+
version: prefix,
|
|
11914
|
+
target,
|
|
11915
|
+
sha256: actual
|
|
11916
|
+
};
|
|
11917
|
+
}
|
|
11918
|
+
/**
|
|
11919
|
+
* Exercise an installed plugin exactly the way Nomad will.
|
|
11920
|
+
*
|
|
11921
|
+
* This exists because every way to get a Nomad plugin wrong fails *silently* —
|
|
11922
|
+
* a misnamed file, an unset `common_plugin_dir`, a plugin installed but never
|
|
11923
|
+
* fingerprinted because the agent was not restarted. None of those produce a
|
|
11924
|
+
* diagnostic, so the only reliable check is to run the thing yourself.
|
|
11925
|
+
*/
|
|
11926
|
+
function checkPlugin(pluginDir, opts = {}) {
|
|
11927
|
+
const steps = [];
|
|
11928
|
+
const bin = pluginPath(pluginDir);
|
|
11929
|
+
if (!existsSync(bin)) {
|
|
11930
|
+
steps.push({
|
|
11931
|
+
name: "installed",
|
|
11932
|
+
ok: false,
|
|
11933
|
+
detail: `${bin} is missing — run \`seekrit nomad install\``
|
|
11934
|
+
});
|
|
11935
|
+
return steps;
|
|
11936
|
+
}
|
|
11937
|
+
steps.push({
|
|
11938
|
+
name: "installed",
|
|
11939
|
+
ok: true,
|
|
11940
|
+
detail: bin
|
|
11941
|
+
});
|
|
11942
|
+
const fingerprint = spawnSync(bin, ["fingerprint"], {
|
|
11943
|
+
encoding: "utf8",
|
|
11944
|
+
timeout: 6e4
|
|
11945
|
+
});
|
|
11946
|
+
const fingerprintBody = safeJson(fingerprint.stdout);
|
|
11947
|
+
const fingerprintOk = fingerprint.status === 0 && fingerprintBody?.type === "secrets";
|
|
11948
|
+
steps.push({
|
|
11949
|
+
name: "fingerprint",
|
|
11950
|
+
ok: fingerprintOk,
|
|
11951
|
+
detail: fingerprintOk ? String(fingerprintBody?.version ?? "") : (fingerprint.stderr || fingerprint.stdout || "no output").trim()
|
|
11952
|
+
});
|
|
11953
|
+
if (!fingerprintOk) return steps;
|
|
11954
|
+
const fetch = spawnSync(bin, [...opts.config ? [
|
|
11955
|
+
"--config",
|
|
11956
|
+
opts.config,
|
|
11957
|
+
"fetch"
|
|
11958
|
+
] : ["fetch"], opts.path ?? ""], {
|
|
11959
|
+
encoding: "utf8",
|
|
11960
|
+
timeout: 6e4,
|
|
11961
|
+
env: {
|
|
11962
|
+
...process.env,
|
|
11963
|
+
...opts.namespace ? { NOMAD_NAMESPACE: opts.namespace } : {},
|
|
11964
|
+
...opts.job ? { NOMAD_JOB_ID: opts.job } : {}
|
|
11965
|
+
}
|
|
11966
|
+
});
|
|
11967
|
+
const body = safeJson(fetch.stdout);
|
|
11968
|
+
if (!body) {
|
|
11969
|
+
steps.push({
|
|
11970
|
+
name: "fetch",
|
|
11971
|
+
ok: false,
|
|
11972
|
+
detail: (fetch.stderr || "no JSON on stdout").trim()
|
|
11973
|
+
});
|
|
11974
|
+
return steps;
|
|
11975
|
+
}
|
|
11976
|
+
if (typeof body.error === "string") {
|
|
11977
|
+
steps.push({
|
|
11978
|
+
name: "fetch",
|
|
11979
|
+
ok: false,
|
|
11980
|
+
detail: body.error
|
|
11981
|
+
});
|
|
11982
|
+
return steps;
|
|
11983
|
+
}
|
|
11984
|
+
const count = Object.keys(body.result ?? {}).length;
|
|
11985
|
+
steps.push({
|
|
11986
|
+
name: "fetch",
|
|
11987
|
+
ok: true,
|
|
11988
|
+
detail: `${count} ${count === 1 ? "secret" : "secrets"}`
|
|
11989
|
+
});
|
|
11990
|
+
return steps;
|
|
11991
|
+
}
|
|
11992
|
+
function safeJson(text) {
|
|
11993
|
+
if (!text) return void 0;
|
|
11994
|
+
try {
|
|
11995
|
+
const parsed = JSON.parse(text.trim());
|
|
11996
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
11997
|
+
} catch {
|
|
11998
|
+
return;
|
|
11999
|
+
}
|
|
12000
|
+
}
|
|
12001
|
+
function requirePluginDir(explicit) {
|
|
12002
|
+
if (explicit) return resolve(explicit);
|
|
12003
|
+
const found = detectPluginDir();
|
|
12004
|
+
if (found) return found;
|
|
12005
|
+
fail("could not find the Nomad plugin directory — pass --plugin-dir <path> matching your agent's client.common_plugin_dir");
|
|
12006
|
+
}
|
|
12007
|
+
function collect$3(value, previous = []) {
|
|
12008
|
+
return [...previous, value];
|
|
12009
|
+
}
|
|
12010
|
+
function registerNomadCommands(program) {
|
|
12011
|
+
const nomad = program.command("nomad").description("wire seekrit into Nomad as a secret provider (`seekrit nomad --help`)");
|
|
12012
|
+
nomad.command("init").description("print the agent stanza, plugin config, and job snippet for this client").option("--plugin-dir <path>", "the agent's client.common_plugin_dir").option("--profile <name>", "profile name jobs will reference in `path`", "default").option("--token-file <path>", "where the profile's service token lives").option("--namespace <ns>", "restrict the profile to a Nomad namespace (repeatable)", collect$3).option("--job <id>", "restrict the profile to a Nomad job id (repeatable)", collect$3).option("--config <path>", "plugin config path", DEFAULT_CONFIG_PATH).option("--write", "write the plugin config file instead of printing it").option("--json", "machine-readable output").action((options) => {
|
|
12013
|
+
const pluginDir = options.pluginDir ? resolve(options.pluginDir) : detectPluginDir() ?? PLUGIN_DIR_CANDIDATES[0];
|
|
12014
|
+
const profile = options.profile ?? "default";
|
|
12015
|
+
const tokenFile = options.tokenFile ?? `/etc/seekrit/tokens/${profile}`;
|
|
12016
|
+
const configPath = resolve(options.config ?? "/etc/seekrit/nomad.json");
|
|
12017
|
+
const config = pluginConfig({
|
|
12018
|
+
profile,
|
|
12019
|
+
tokenFile,
|
|
12020
|
+
namespaces: options.namespace,
|
|
12021
|
+
jobs: options.job
|
|
12022
|
+
});
|
|
12023
|
+
let wrote = false;
|
|
12024
|
+
if (options.write) try {
|
|
12025
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
12026
|
+
writeFileSync(configPath, config, { mode: 384 });
|
|
12027
|
+
wrote = true;
|
|
12028
|
+
} catch (err) {
|
|
12029
|
+
fail(`could not write ${configPath}: ${err instanceof Error ? err.message : String(err)}\n This path usually belongs to root — try again with sudo.`);
|
|
12030
|
+
}
|
|
12031
|
+
emit(options, {
|
|
12032
|
+
pluginDir,
|
|
12033
|
+
pluginPath: pluginPath(pluginDir),
|
|
12034
|
+
configPath,
|
|
12035
|
+
profile,
|
|
12036
|
+
wrote
|
|
12037
|
+
}, () => {
|
|
12038
|
+
section("nomad secret provider");
|
|
12039
|
+
printFields([
|
|
12040
|
+
["plugin dir", pluginDir],
|
|
12041
|
+
["plugin path", pluginPath(pluginDir)],
|
|
12042
|
+
["provider", PROVIDER_NAME],
|
|
12043
|
+
["config", configPath],
|
|
12044
|
+
["profile", profile],
|
|
12045
|
+
["config written", wrote ? "yes" : "no"]
|
|
12046
|
+
]);
|
|
12047
|
+
console.log();
|
|
12048
|
+
console.log("1. Install the plugin on every Nomad client:");
|
|
12049
|
+
console.log(" seekrit nomad install");
|
|
12050
|
+
console.log();
|
|
12051
|
+
console.log(" The file must be named `seekrit` — Nomad matches a job's `provider`");
|
|
12052
|
+
console.log(" against the executable's filename, and a mismatch registers nothing.");
|
|
12053
|
+
console.log();
|
|
12054
|
+
console.log("2. Point the agent at the plugin directory (/etc/nomad.d/client.hcl):");
|
|
12055
|
+
console.log();
|
|
12056
|
+
console.log(indent(agentStanza(pluginDir)));
|
|
12057
|
+
console.log();
|
|
12058
|
+
console.log(`3. ${wrote ? `Written to ${configPath}:` : `Put this in ${configPath} (mode 0600):`}`);
|
|
12059
|
+
console.log();
|
|
12060
|
+
console.log(indent(config.trimEnd()));
|
|
12061
|
+
console.log();
|
|
12062
|
+
console.log(` Then write the token itself to ${tokenFile} (mode 0600):`);
|
|
12063
|
+
console.log(` seekrit token create --name nomad-${profile} --app <app> --env <env>`);
|
|
12064
|
+
console.log(` # then: echo "skt_…" | sudo tee ${tokenFile} && sudo chmod 600 ${tokenFile}`);
|
|
12065
|
+
console.log();
|
|
12066
|
+
console.log("4. Restart the agent — plugins are fingerprinted at client startup:");
|
|
12067
|
+
console.log(" sudo systemctl restart nomad");
|
|
12068
|
+
console.log();
|
|
12069
|
+
console.log("5. Reference it from a job:");
|
|
12070
|
+
console.log();
|
|
12071
|
+
console.log(indent(jobSnippet(profile, [])));
|
|
12072
|
+
console.log();
|
|
12073
|
+
console.log("Check your work with: seekrit nomad check");
|
|
12074
|
+
});
|
|
12075
|
+
});
|
|
12076
|
+
nomad.command("install").description("download the secret provider plugin onto this Nomad client").option("--plugin-dir <path>", "the agent's client.common_plugin_dir").option("--version <version>", "plugin version to install", NOMAD_PLUGIN_VERSION).option("--base-url <url>", "artifact host", DEFAULT_BASE_URL).option("--json", "machine-readable output").action(async (options) => {
|
|
12077
|
+
const result = await installPlugin(requirePluginDir(options.pluginDir), {
|
|
12078
|
+
version: options.version,
|
|
12079
|
+
baseUrl: options.baseUrl,
|
|
12080
|
+
quiet: options.json === true
|
|
12081
|
+
});
|
|
12082
|
+
emit(options, result, () => {
|
|
12083
|
+
section("nomad plugin installed");
|
|
12084
|
+
printFields([
|
|
12085
|
+
["path", result.path],
|
|
12086
|
+
["version", result.version],
|
|
12087
|
+
["target", result.target],
|
|
12088
|
+
["sha256", result.sha256],
|
|
12089
|
+
["provider", PROVIDER_NAME]
|
|
12090
|
+
]);
|
|
12091
|
+
console.log();
|
|
12092
|
+
console.log("Nomad fingerprints plugins at client startup — restart the agent:");
|
|
12093
|
+
console.log(" sudo systemctl restart nomad");
|
|
12094
|
+
console.log();
|
|
12095
|
+
console.log("Then: seekrit nomad check");
|
|
12096
|
+
});
|
|
12097
|
+
});
|
|
12098
|
+
nomad.command("check").description("run the installed plugin the way Nomad does, and report what happened").option("--plugin-dir <path>", "the agent's client.common_plugin_dir").option("--path <path>", "the `path` a job would use (profile, or profile/NAME)", "").option("--namespace <ns>", "pretend to be a job in this Nomad namespace").option("--job <id>", "pretend to be this Nomad job id").option("--config <path>", "check against this plugin config instead of the installed one").option("--json", "machine-readable output").action((options) => {
|
|
12099
|
+
const pluginDir = requirePluginDir(options.pluginDir);
|
|
12100
|
+
const steps = checkPlugin(pluginDir, {
|
|
12101
|
+
path: options.path,
|
|
12102
|
+
namespace: options.namespace,
|
|
12103
|
+
job: options.job,
|
|
12104
|
+
config: options.config
|
|
12105
|
+
});
|
|
12106
|
+
const ok = steps.every((s) => s.ok);
|
|
12107
|
+
emit(options, {
|
|
12108
|
+
pluginDir,
|
|
12109
|
+
ok,
|
|
12110
|
+
steps
|
|
12111
|
+
}, () => {
|
|
12112
|
+
section("nomad plugin check");
|
|
12113
|
+
printFields(steps.map((s) => [s.name, `${s.ok ? "ok" : "FAILED"} — ${s.detail}`]));
|
|
12114
|
+
console.log();
|
|
12115
|
+
if (ok) {
|
|
12116
|
+
console.log("The plugin resolves. If a job still cannot, the agent has not been");
|
|
12117
|
+
console.log("restarted since the install, or client.common_plugin_dir is unset.");
|
|
12118
|
+
} else console.log("See `seekrit nomad init` for the configuration this expects.");
|
|
12119
|
+
});
|
|
12120
|
+
if (!ok) process.exitCode = 1;
|
|
12121
|
+
});
|
|
12122
|
+
}
|
|
12123
|
+
function indent(text) {
|
|
12124
|
+
return text.split("\n").map((line) => line.length > 0 ? ` ${line}` : line).join("\n");
|
|
12125
|
+
}
|
|
12126
|
+
//#endregion
|
|
11561
12127
|
//#region src/orgs.ts
|
|
11562
12128
|
/**
|
|
11563
12129
|
* Counts for `org show`. Each list is admin-gated to a different degree, so a
|
|
@@ -12683,188 +13249,6 @@ function collect$2(value, acc) {
|
|
|
12683
13249
|
return acc;
|
|
12684
13250
|
}
|
|
12685
13251
|
//#endregion
|
|
12686
|
-
//#region src/proxy-binary.ts
|
|
12687
|
-
/**
|
|
12688
|
-
* Fetch and run the `seekrit-proxy` binary without a Rust toolchain.
|
|
12689
|
-
*
|
|
12690
|
-
* The proxy is the strongest answer seekrit has for an untrusted workload — the
|
|
12691
|
-
* agent holds `{{seekrit:NAME}}` and never the key — and it was also the hardest
|
|
12692
|
-
* thing here to *try*, because trying it meant `cargo` and a TOML file. This
|
|
12693
|
-
* module removes the first half: it resolves a prebuilt, checksum-verified
|
|
12694
|
-
* binary for the host platform and execs it, so `npx @seekrit/proxy` and
|
|
12695
|
-
* `seekrit proxy run` behave like the proxy was already installed.
|
|
12696
|
-
*
|
|
12697
|
-
* The logic lives in the CLI (and is re-exported as `@seekrit/cli/proxy-launcher`)
|
|
12698
|
-
* for the same reason the MCP server does: `@seekrit/proxy` is a thin npx
|
|
12699
|
-
* entrypoint over it, and the two must not drift.
|
|
12700
|
-
*
|
|
12701
|
-
* Three properties worth stating, since this downloads and executes code:
|
|
12702
|
-
*
|
|
12703
|
-
* - **The checksum is verified before anything is executed**, against a
|
|
12704
|
-
* `.sha256` fetched from the same release. That is integrity, not provenance —
|
|
12705
|
-
* it proves the bytes match what the release published, which is exactly the
|
|
12706
|
-
* guarantee `install.sh` gives and no more.
|
|
12707
|
-
* - **Nothing is fetched when a binary is already available.** `SEEKRIT_PROXY_BIN`
|
|
12708
|
-
* short-circuits entirely, and a cached download for the same version+target is
|
|
12709
|
-
* reused, so this is a one-time cost per version.
|
|
12710
|
-
* - **Version is pinned, not floating.** A default of `latest` would make two
|
|
12711
|
-
* machines run different proxies from the same command; the pinned constant is
|
|
12712
|
-
* what this CLI was built against, overridable when you want otherwise.
|
|
12713
|
-
*/
|
|
12714
|
-
/**
|
|
12715
|
-
* The proxy version this CLI was built against.
|
|
12716
|
-
*
|
|
12717
|
-
* Bumped by release-please when `apps/proxy` releases (an `extra-files` entry in
|
|
12718
|
-
* release-please-config.json), so the pin follows the crate without anyone
|
|
12719
|
-
* remembering to move it.
|
|
12720
|
-
*/
|
|
12721
|
-
const PROXY_VERSION = "0.12.0";
|
|
12722
|
-
const BIN = "seekrit-proxy";
|
|
12723
|
-
/**
|
|
12724
|
-
* Host → Rust target triple.
|
|
12725
|
-
*
|
|
12726
|
-
* Linux always resolves to **musl**: that build is statically linked, so one
|
|
12727
|
-
* artifact covers glibc, musl, alpine, and distroless, and there is no libc
|
|
12728
|
-
* detection to get wrong on a machine where `ldd` says something unexpected.
|
|
12729
|
-
*/
|
|
12730
|
-
function detectTarget(os = platform(), cpu = arch()) {
|
|
12731
|
-
const machine = cpu === "x64" ? "x86_64" : cpu === "arm64" ? "aarch64" : null;
|
|
12732
|
-
if (!machine) throw new Error(`unsupported architecture "${cpu}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
|
|
12733
|
-
switch (os) {
|
|
12734
|
-
case "linux": return {
|
|
12735
|
-
target: `${machine}-unknown-linux-musl`,
|
|
12736
|
-
exe: ""
|
|
12737
|
-
};
|
|
12738
|
-
case "darwin": return {
|
|
12739
|
-
target: `${machine}-apple-darwin`,
|
|
12740
|
-
exe: ""
|
|
12741
|
-
};
|
|
12742
|
-
case "win32":
|
|
12743
|
-
if (machine !== "x86_64") throw new Error(`no prebuilt seekrit-proxy for ${machine} Windows — set SEEKRIT_PROXY_BIN to a binary you built`);
|
|
12744
|
-
return {
|
|
12745
|
-
target: "x86_64-pc-windows-msvc",
|
|
12746
|
-
exe: ".exe"
|
|
12747
|
-
};
|
|
12748
|
-
default: throw new Error(`unsupported platform "${os}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
|
|
12749
|
-
}
|
|
12750
|
-
}
|
|
12751
|
-
/** `latest` stays `latest`; everything else is normalized to `v<x.y.z>`. */
|
|
12752
|
-
function versionPrefix(version) {
|
|
12753
|
-
if (version === "latest") return "latest";
|
|
12754
|
-
return version.startsWith("v") ? version : `v${version}`;
|
|
12755
|
-
}
|
|
12756
|
-
function resolveVersion(explicit) {
|
|
12757
|
-
return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.12.0";
|
|
12758
|
-
}
|
|
12759
|
-
function resolveBaseUrl(explicit) {
|
|
12760
|
-
return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
|
|
12761
|
-
}
|
|
12762
|
-
/** Where a resolved binary is kept, keyed so versions and targets never collide. */
|
|
12763
|
-
function proxyBinaryPath(version, target, exe) {
|
|
12764
|
-
return join(defaultCacheDir(), "proxy", versionPrefix(version), target, `${BIN}${exe}`);
|
|
12765
|
-
}
|
|
12766
|
-
async function fetchBytes(url) {
|
|
12767
|
-
const res = await fetch(url);
|
|
12768
|
-
if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
|
|
12769
|
-
return new Uint8Array(await res.arrayBuffer());
|
|
12770
|
-
}
|
|
12771
|
-
/**
|
|
12772
|
-
* Ensure a `seekrit-proxy` binary exists locally and return its path.
|
|
12773
|
-
*
|
|
12774
|
-
* Order: an explicit `SEEKRIT_PROXY_BIN`, then a cached download for this
|
|
12775
|
-
* version+target, then a fresh download. A binary already on `PATH` is
|
|
12776
|
-
* deliberately *not* used — silently running a different version than the one
|
|
12777
|
-
* this CLI pins is the kind of surprise that costs an afternoon.
|
|
12778
|
-
*/
|
|
12779
|
-
async function resolveProxyBinary(options = {}) {
|
|
12780
|
-
const override = process.env.SEEKRIT_PROXY_BIN;
|
|
12781
|
-
if (override) {
|
|
12782
|
-
if (!existsSync(override)) throw new Error(`SEEKRIT_PROXY_BIN points at ${override}, which does not exist`);
|
|
12783
|
-
return override;
|
|
12784
|
-
}
|
|
12785
|
-
const version = resolveVersion(options.version);
|
|
12786
|
-
const { target, exe } = detectTarget();
|
|
12787
|
-
const dest = proxyBinaryPath(version, target, exe);
|
|
12788
|
-
if (!options.force && version !== "latest" && existsSync(dest)) return dest;
|
|
12789
|
-
const baseUrl = resolveBaseUrl(options.baseUrl);
|
|
12790
|
-
const prefix = versionPrefix(version);
|
|
12791
|
-
const name = `${BIN}-${target}${exe}`;
|
|
12792
|
-
const binUrl = `${baseUrl}/${prefix}/bin/${name}`;
|
|
12793
|
-
const sumUrl = `${binUrl}.sha256`;
|
|
12794
|
-
if (!options.quiet) process.stderr.write(`seekrit: fetching ${BIN} ${prefix} (${target})…\n`);
|
|
12795
|
-
let bytes;
|
|
12796
|
-
let expected;
|
|
12797
|
-
try {
|
|
12798
|
-
[bytes, expected] = await Promise.all([fetchBytes(binUrl), fetchBytes(sumUrl).then((b) => Buffer.from(b).toString("utf8").trim().split(/\s+/)[0] ?? "")]);
|
|
12799
|
-
} catch (err) {
|
|
12800
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
12801
|
-
throw new Error(`could not download ${BIN} ${prefix} for ${target}: ${message}\n Set SEEKRIT_PROXY_BIN to a binary you already have, or build it from apps/proxy.`);
|
|
12802
|
-
}
|
|
12803
|
-
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
12804
|
-
if (!expected || actual !== expected.toLowerCase()) throw new Error(`checksum mismatch for ${name}: expected ${expected || "(none published)"}, got ${actual}. Refusing to run it.`);
|
|
12805
|
-
const dir = dirname(dest);
|
|
12806
|
-
mkdirSync(dir, { recursive: true });
|
|
12807
|
-
const staging = join(dir, `.${BIN}-${process.pid}-${actual.slice(0, 12)}${exe}`);
|
|
12808
|
-
try {
|
|
12809
|
-
writeFileSync(staging, bytes, { mode: 493 });
|
|
12810
|
-
renameSync(staging, dest);
|
|
12811
|
-
} catch (err) {
|
|
12812
|
-
rmSync(staging, { force: true });
|
|
12813
|
-
throw err;
|
|
12814
|
-
}
|
|
12815
|
-
chmodSync(dest, 493);
|
|
12816
|
-
return dest;
|
|
12817
|
-
}
|
|
12818
|
-
/**
|
|
12819
|
-
* Run the proxy, forwarding stdio, signals, and its exit status.
|
|
12820
|
-
*
|
|
12821
|
-
* The proxy is a long-lived foreground process, so this wrapper has to be
|
|
12822
|
-
* transparent: Node cannot exec-replace itself, and without relaying signals
|
|
12823
|
-
* Node's default SIGINT handler would kill *this* process on Ctrl-C and leave
|
|
12824
|
-
* the proxy running, holding decrypted secrets, with the shell prompt back.
|
|
12825
|
-
*/
|
|
12826
|
-
async function runProxyBinary(argv, options = {}) {
|
|
12827
|
-
const bin = await resolveProxyBinary(options);
|
|
12828
|
-
const child = spawn(bin, argv, {
|
|
12829
|
-
stdio: "inherit",
|
|
12830
|
-
env: {
|
|
12831
|
-
...process.env,
|
|
12832
|
-
...options.env
|
|
12833
|
-
}
|
|
12834
|
-
});
|
|
12835
|
-
const signals = [
|
|
12836
|
-
"SIGINT",
|
|
12837
|
-
"SIGTERM",
|
|
12838
|
-
"SIGHUP",
|
|
12839
|
-
"SIGQUIT"
|
|
12840
|
-
];
|
|
12841
|
-
const forward = (signal) => {
|
|
12842
|
-
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
12843
|
-
child.kill(signal);
|
|
12844
|
-
};
|
|
12845
|
-
for (const signal of signals) process.on(signal, forward);
|
|
12846
|
-
return new Promise((resolve, reject) => {
|
|
12847
|
-
child.on("error", (err) => {
|
|
12848
|
-
for (const s of signals) process.off(s, forward);
|
|
12849
|
-
reject(/* @__PURE__ */ new Error(`could not start ${bin}: ${err.message}\n If this is a fresh download, the platform may not match — set SEEKRIT_PROXY_BIN.`));
|
|
12850
|
-
});
|
|
12851
|
-
child.on("exit", (code, signal) => {
|
|
12852
|
-
for (const s of signals) process.off(s, forward);
|
|
12853
|
-
resolve(signal ? 128 + signalNumber(signal) : code ?? 0);
|
|
12854
|
-
});
|
|
12855
|
-
});
|
|
12856
|
-
}
|
|
12857
|
-
/** Signal name → number, for the 128+n exit convention. */
|
|
12858
|
-
function signalNumber(signal) {
|
|
12859
|
-
return {
|
|
12860
|
-
SIGHUP: 1,
|
|
12861
|
-
SIGINT: 2,
|
|
12862
|
-
SIGQUIT: 3,
|
|
12863
|
-
SIGKILL: 9,
|
|
12864
|
-
SIGTERM: 15
|
|
12865
|
-
}[signal] ?? 0;
|
|
12866
|
-
}
|
|
12867
|
-
//#endregion
|
|
12868
13252
|
//#region src/proxy.ts
|
|
12869
13253
|
/**
|
|
12870
13254
|
* `seekrit proxy` — get `seekrit-proxy` running without a Rust toolchain or a
|
|
@@ -13194,10 +13578,10 @@ Next:
|
|
|
13194
13578
|
});
|
|
13195
13579
|
console.log(path);
|
|
13196
13580
|
const { target } = detectTarget();
|
|
13197
|
-
process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.12.
|
|
13581
|
+
process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.12.1")} (${target})\n`);
|
|
13198
13582
|
});
|
|
13199
13583
|
proxy.command("where").description("show which binary `seekrit proxy run` would use, without fetching it").option("--proxy-version <version>", `version to report (default: ${PROXY_VERSION})`).option("--json", "machine-readable output").action((options) => {
|
|
13200
|
-
const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.12.
|
|
13584
|
+
const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.12.1";
|
|
13201
13585
|
const override = process.env.SEEKRIT_PROXY_BIN;
|
|
13202
13586
|
const { target, exe } = detectTarget();
|
|
13203
13587
|
const path = override ?? proxyBinaryPath(version, target, exe);
|
|
@@ -13256,7 +13640,7 @@ Next:
|
|
|
13256
13640
|
process.stdout.write(renderComposeSnippet(plan, {
|
|
13257
13641
|
service: options.service,
|
|
13258
13642
|
workload: options.workload,
|
|
13259
|
-
image: options.image ?? `seekritdev/proxy:0.12.
|
|
13643
|
+
image: options.image ?? `seekritdev/proxy:0.12.1`,
|
|
13260
13644
|
publish: Boolean(options.publish)
|
|
13261
13645
|
}));
|
|
13262
13646
|
});
|
|
@@ -15130,6 +15514,7 @@ registerProvisionerCommands(program);
|
|
|
15130
15514
|
registerProxyCommands(program);
|
|
15131
15515
|
registerAgentCommands(program);
|
|
15132
15516
|
registerPaperclipCommands(program);
|
|
15517
|
+
registerNomadCommands(program);
|
|
15133
15518
|
registerOpenclawCommands(program);
|
|
15134
15519
|
registerHermesCommands(program);
|
|
15135
15520
|
registerSshCommands(program);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seekrit/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"tsdown": "^0.22.3",
|
|
32
32
|
"vitest": "^4.1.11",
|
|
33
33
|
"@seekrit/api-client": "0.0.1",
|
|
34
|
-
"@seekrit/
|
|
35
|
-
"@seekrit/
|
|
34
|
+
"@seekrit/core": "0.0.1",
|
|
35
|
+
"@seekrit/crypto": "0.0.1"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsdown",
|