@astrofoundry/pi-astro 0.33.2 → 0.34.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.
@@ -1,17 +1,23 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { dirname, join } from "node:path";
5
+ import { buildHostRemote, compareAudit, HOST_COMMANDS, type AuditFile } from "../lib/arcane-host.ts";
6
+ import { failedChecks, MANAGER_COMPOSE, MANAGER_IMAGE, normalizeVersion, olderThan, pinCompose, projectSync, syncReady, utcStamp, type VerifyCheck } from "../lib/arcane-manager.ts";
4
7
  import { readConfig } from "../lib/config.ts";
8
+ import { containerFacts, deploymentChecks, listRows, parseDeploymentArgs, syncFacts, type DeploymentExpectation } from "../lib/deployment.ts";
5
9
  import { ServiceError, UsageError } from "../lib/errors.ts";
6
10
  import { main } from "../lib/main.ts";
7
11
  import { buildUpdateReport, parseTag, resolveRef, stackServices, type CheckEntry } from "../lib/image-updates.ts";
8
12
  import { printJson, printRaw } from "../lib/output.ts";
9
- import { run } from "../lib/proc.ts";
13
+ import { specialistsHome } from "../lib/paths.ts";
14
+ import { run, type RunResult } from "../lib/proc.ts";
10
15
  import { HttpRegistry, parseImageRef } from "../lib/registry.ts";
11
16
  import { releaseNotesFor } from "../lib/releases.ts";
12
- import { REPO_HELP, repoCommand, type RepoSpec } from "../lib/repo.ts";
17
+ import { ensureCheckout, git, REPO_HELP, repoCommand, repoStatus, type RepoSpec } from "../lib/repo.ts";
13
18
  import { ARCANE_DENIED_KEYS, filterJsonText, stripKeys } from "../lib/sanitize.ts";
14
- import { readSecret } from "../lib/secrets.ts";
19
+ import { readSecret, secretPath } from "../lib/secrets.ts";
20
+ import { sshFixed } from "../lib/ssh.ts";
15
21
  import { mintAccessToken, parseServiceAccountKey } from "../lib/zitadel.ts";
16
22
 
17
23
  const SERVICE = "arcane";
@@ -36,6 +42,14 @@ const SHAPE = {
36
42
  gitopsBranch: "string",
37
43
  } as const;
38
44
 
45
+ /** The Arcane VM through the forced-command key of user `spc-arcane`; read only by commands that use the host. */
46
+ interface HostConfig extends Record<string, string> {
47
+ hostAddress: string;
48
+ hostUser: string;
49
+ }
50
+
51
+ const HOST_SHAPE = { hostAddress: "string", hostUser: "string" } as const;
52
+
39
53
  /** CLI paths that would alter the wrapper's own setup or the binary; everything else passes through. */
40
54
  export const REFUSED_PREFIXES: readonly string[] = ["config", "auth", "self-update", "completion"];
41
55
 
@@ -69,6 +83,21 @@ const HELP = `arcane specialist
69
83
 
70
84
  <arcane-cli args...> run arcane-cli with a fresh federated token; JSON output, sensitive fields removed
71
85
  (arcane-cli gitops ... manages the server-side syncs)
86
+ project-status [name] projects with their GitOps sync: commit, state, hook state (no error text)
87
+ deployment check|wait <project> --commit <sha> [--container NAME=sha256:ID]... [--job NAME=sha256:ID]...
88
+ [--allow-no-health NAME]... [--timeout s]
89
+ compare the sync commit and state, hook, and each container's project, image ID,
90
+ state and health; wait polls every 10 s (default 600 s); the answer's status says ok
91
+ manager-upgrade <version> upgrade Arcane itself (arcane/AGENTS.md "Manager upgrades"): digest pin commit, file-only
92
+ sync, pull, cold backup, restart of the manager only, checks; a failed check rolls back
93
+ the image and Compose file (the data stays; R3 is manual). Owner approval.
94
+ manager-rollback <stamp> put back the Compose file and image recorded at <stamp>; revert the pin in git yourself.
95
+ Owner approval.
96
+ host <command> [args] fixed operations on the Arcane VM (sanitized; raw logs never leave the guest):
97
+ ${Object.entries(HOST_COMMANDS)
98
+ .filter(([, c]) => !c.internal)
99
+ .map(([, c]) => ` ${c.help}`)
100
+ .join("\n")}
72
101
  update-report [--all] fresh image update check for every stack, with running and latest release versions
73
102
  from the registries; JSON with a ready table (--all also lists up-to-date services).
74
103
  Checks only: never pulls images or redeploys.
@@ -209,6 +238,248 @@ async function releaseNotes(config: ArcaneConfig, args: string[]): Promise<numbe
209
238
  });
210
239
  }
211
240
 
241
+ // ---------------------------------------------------------------- Arcane VM
242
+
243
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
244
+
245
+ async function hostCall(args: string[], allowInternal: boolean): Promise<RunResult> {
246
+ const { remote, spec } = buildHostRemote(args, allowInternal);
247
+ const host = readConfig<HostConfig>(SERVICE, HOST_SHAPE);
248
+ const input = spec.stdinSecret ? readSecret(SERVICE, spec.stdinSecret) : undefined;
249
+ const target = { host: host.hostAddress, user: host.hostUser, keyFile: secretPath(SERVICE, "SSH_KEY_ARCANE_HOST"), knownHostsFile: join(specialistsHome(), "config", "known_hosts") };
250
+ return sshFixed(target, remote, spec.timeoutMs, input);
251
+ }
252
+
253
+ /** One host operation whose answer is JSON; its refusal or failure text becomes the error. */
254
+ async function hostJson(args: string[]): Promise<Record<string, unknown>> {
255
+ const result = await hostCall(args, true);
256
+ if (result.code !== 0) throw new ServiceError(`host ${args[0]}: ${result.stderr.trim() || `exited ${result.code}`}`);
257
+ try {
258
+ const value: unknown = JSON.parse(result.stdout);
259
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("not an object");
260
+ if ((value as { error?: unknown }).error === true) throw new ServiceError(`host ${args[0]}: the check could not complete; its details stay on the guest`);
261
+ return value as Record<string, unknown>;
262
+ } catch (err) {
263
+ if (err instanceof ServiceError) throw err;
264
+ throw new ServiceError(`host ${args[0]}: output is not a JSON object`);
265
+ }
266
+ }
267
+
268
+ async function hostCommand(config: ArcaneConfig, args: string[]): Promise<number> {
269
+ if (args[0] === "host-audit") {
270
+ if (args.length !== 1) throw new UsageError("host host-audit takes no arguments");
271
+ const audit = await hostJson(["host-audit"]);
272
+ const spec = repoSpec(config);
273
+ const dir = await ensureCheckout(spec);
274
+ let pulled = true;
275
+ try {
276
+ await git(spec, dir, ["pull", "--ff-only", "origin", config.gitopsBranch]);
277
+ } catch {
278
+ pulled = false;
279
+ }
280
+ const files = compareAudit(Array.isArray(audit.files) ? (audit.files as AuditFile[]) : [], dir);
281
+ printJson({ files, units: audit.units, comparedWith: pulled ? `origin/${config.gitopsBranch}` : "the local checkout (pull failed)" });
282
+ return 0;
283
+ }
284
+ const result = await hostCall(args, false);
285
+ if (result.stdout.length > 0) printRaw(result.stdout);
286
+ if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `host ${args[0]} exited ${result.code}`);
287
+ return 0;
288
+ }
289
+
290
+ async function projectStatus(config: ArcaneConfig, args: string[]): Promise<number> {
291
+ if (args.length > 1 || (args[0] !== undefined && !NAME.test(args[0]))) throw new UsageError("project-status [name]");
292
+ return withCli(config, async (cli) => {
293
+ const projects = listRows(await cliJson(cli, ["projects", "list", "--all"]));
294
+ const syncs = listRows(await cliJson(cli, ["gitops", "list", "--all"]));
295
+ const picked = args[0] === undefined ? projects : projects.filter((p) => p.name === args[0]);
296
+ if (args[0] !== undefined && picked.length !== 1) throw new UsageError(`no single project named ${args[0]}`);
297
+ printJson({
298
+ projects: picked.map((p) => ({
299
+ name: p.name,
300
+ status: p.status,
301
+ running: p.runningCount,
302
+ services: p.serviceCount,
303
+ syncs: syncs
304
+ .filter((s) => s.projectId === p.id)
305
+ .map((s) => ({ name: s.name, autoSync: s.autoSync, redeployAfterSync: s.redeployAfterSync, pullImageAfterSync: s.pullImageAfterSync, ...syncFacts(s) })),
306
+ })),
307
+ });
308
+ return 0;
309
+ });
310
+ }
311
+
312
+ async function deploymentSnapshot(config: ArcaneConfig, expect: DeploymentExpectation): Promise<Record<string, unknown>> {
313
+ return withCli(config, async (cli) => {
314
+ const project = listRows(await cliJson(cli, ["projects", "list", "--all"])).filter((p) => p.name === expect.project);
315
+ if (project.length !== 1) throw new UsageError(`no single project named ${expect.project}`);
316
+ const mine = listRows(await cliJson(cli, ["gitops", "list", "--all"])).filter((s) => s.projectId === project[0].id);
317
+ if (mine.length !== 1) throw new UsageError(`project ${expect.project} needs exactly one GitOps sync, found ${mine.length}`);
318
+ const containers = [];
319
+ for (const name of [...Object.keys(expect.services), ...Object.keys(expect.jobs)]) {
320
+ try {
321
+ containers.push(containerFacts(await cliJson(cli, ["containers", "get", name]), name));
322
+ } catch {
323
+ containers.push(null);
324
+ }
325
+ }
326
+ const after = listRows(await cliJson(cli, ["gitops", "list", "--all"])).find((s) => s.id === mine[0].id) ?? {};
327
+ return { project: expect.project, ...deploymentChecks(expect, syncFacts(mine[0]), containers, syncFacts(after)) };
328
+ });
329
+ }
330
+
331
+ /** One federated token per check, because a wait can outlive the token's five minutes. */
332
+ async function deployment(config: ArcaneConfig, args: string[]): Promise<number> {
333
+ const expect = parseDeploymentArgs(args);
334
+ const deadline = Date.now() + expect.timeoutS * 1000;
335
+ for (;;) {
336
+ const snapshot = await deploymentSnapshot(config, expect);
337
+ if (snapshot.status === "ok" || !expect.wait) {
338
+ printJson(snapshot);
339
+ return 0;
340
+ }
341
+ if (Date.now() + 10_000 > deadline) {
342
+ printJson({ status: "timeout", lastCheck: snapshot });
343
+ return 0;
344
+ }
345
+ await new Promise((r) => setTimeout(r, 10_000));
346
+ }
347
+ }
348
+
349
+ const RELEASE_COMMIT_URL = "https://api.github.com/repos/getarcaneapp/arcane/commits/";
350
+
351
+ /** The commit the official release tag points to, which the image's revision label must name. */
352
+ async function releaseCommit(tag: string): Promise<string> {
353
+ const res = await fetch(`${RELEASE_COMMIT_URL}${encodeURIComponent(tag)}`, { headers: { accept: "application/vnd.github+json", "user-agent": "pi-astro-arcane" }, signal: AbortSignal.timeout(15_000) });
354
+ if (!res.ok) throw new ServiceError(`GitHub returned ${res.status} for release ${tag}`);
355
+ const sha = ((await res.json()) as { sha?: unknown }).sha;
356
+ if (typeof sha !== "string" || !/^[0-9a-f]{40}$/.test(sha)) throw new ServiceError(`GitHub returned no commit for ${tag}`);
357
+ return sha;
358
+ }
359
+
360
+ function sha256(text: string): string {
361
+ return createHash("sha256").update(text).digest("hex");
362
+ }
363
+
364
+ const MINUTE = 60_000;
365
+ const SYNC_WAIT_MS = 30 * MINUTE;
366
+
367
+ /**
368
+ * arcane/AGENTS.md "Manager upgrades", end to end. Prints one JSON answer with
369
+ * `status`: upgraded, rolled back (the new image failed its checks), or
370
+ * stopped (nothing was restarted). The exit code is 0 in all three so the
371
+ * caller always gets the answer; usage and precondition errors still fail.
372
+ */
373
+ async function managerUpgrade(config: ArcaneConfig, args: string[]): Promise<number> {
374
+ if (args.length !== 1) throw new UsageError("manager-upgrade <version>");
375
+ const version = normalizeVersion(args[0]);
376
+ const tag = `v${version}`;
377
+ const registry = new HttpRegistry();
378
+ const ref = parseImageRef(`${MANAGER_IMAGE}:${tag}`);
379
+ const digest = await registry.digest(ref, tag);
380
+ if (digest === null) throw new ServiceError(`${MANAGER_IMAGE}:${tag} does not exist`);
381
+ const revision = (await registry.labels(ref, tag))["org.opencontainers.image.revision"] ?? null;
382
+ const commitOfTag = await releaseCommit(tag);
383
+ if (revision !== commitOfTag) throw new ServiceError(`image revision ${revision ?? "(none)"} is not the commit of release ${tag} (${commitOfTag})`);
384
+
385
+ const state = await hostJson(["manager-state"]);
386
+ const runningVersion = typeof state.version === "string" ? normalizeVersion(state.version) : null;
387
+ if (runningVersion === null) throw new ServiceError("the running manager reports no version; check it before an upgrade");
388
+ if (!olderThan(runningVersion, version)) throw new UsageError(`Arcane runs ${runningVersion}; ${version} is not newer`);
389
+ const container = state.container as { platform?: unknown } | undefined;
390
+
391
+ const spec = repoSpec(config);
392
+ const dir = await ensureCheckout(spec);
393
+ await git(spec, dir, ["pull", "--ff-only", "origin", config.gitopsBranch]);
394
+ const status = await repoStatus(spec, dir);
395
+ if (status.changed.length > 0) throw new ServiceError(`the arcane checkout has uncommitted changes: ${status.changed.join(", ")}`);
396
+ const file = join(dir, MANAGER_COMPOSE);
397
+ const pinned = pinCompose(readFileSync(file, "utf-8"), digest);
398
+
399
+ const stamp = utcStamp(new Date());
400
+ const steps: Record<string, unknown> = { from: runningVersion, to: version, digest, revision, stamp };
401
+ await hostJson(["manager-prepare", stamp]);
402
+ writeFileSync(file, pinned);
403
+ await git(spec, dir, ["add", MANAGER_COMPOSE]);
404
+ await git(spec, dir, ["commit", "--quiet", "-m", `arcane: manager ${tag} (${digest})`]);
405
+ const commit = (await git(spec, dir, ["rev-parse", "HEAD"])).trim();
406
+ await git(spec, dir, ["push", "origin", config.gitopsBranch]);
407
+ steps.commit = commit;
408
+
409
+ const stop = async (reason: string): Promise<number> => {
410
+ await git(spec, dir, ["revert", "--no-edit", commit]);
411
+ await git(spec, dir, ["push", "origin", config.gitopsBranch]);
412
+ printJson({ status: "stopped", reason, ...steps, revertedPin: true, note: "the manager was not restarted; the next file-only sync puts the previous Compose file back" });
413
+ return 0;
414
+ };
415
+
416
+ const deadline = Date.now() + SYNC_WAIT_MS;
417
+ for (;;) {
418
+ try {
419
+ const sync = await withCli(config, async (cli) => projectSync(await cliJson(cli, ["projects", "list", "--all"]), await cliJson(cli, ["gitops", "list", "--all"]), "arcane"));
420
+ const deployed = await hostJson(["manager-state"]);
421
+ if (syncReady(sync, commit, String(deployed.compose_sha256), sha256(pinned))) break;
422
+ if (Date.now() > deadline) return stop(`the file-only sync did not report success at ${commit.slice(0, 12)} with the committed Compose file within 30 minutes (last: ${sync.status ?? "none"} at ${sync.commit ?? "none"})`);
423
+ } catch (err) {
424
+ return stop(`waiting for the sync failed: ${err instanceof Error ? err.message : String(err)}`);
425
+ }
426
+ await new Promise((r) => setTimeout(r, 30_000));
427
+ }
428
+ let pulled: Record<string, unknown>;
429
+ try {
430
+ pulled = await hostJson(["manager-pull", digest]);
431
+ } catch (err) {
432
+ return stop(err instanceof Error ? err.message : String(err));
433
+ }
434
+ if (pulled.revision !== revision || pulled.platform !== container?.platform) {
435
+ return stop(`the pulled image is ${String(pulled.platform)} at ${String(pulled.revision)}, expected ${String(container?.platform)} at ${revision}`);
436
+ }
437
+ try {
438
+ steps.backup = await hostJson(["manager-backup", stamp]);
439
+ } catch (err) {
440
+ return stop(err instanceof Error ? err.message : String(err));
441
+ }
442
+ let checks: VerifyCheck[];
443
+ try {
444
+ await hostJson(["manager-start"]);
445
+ const verify = await hostJson(["manager-verify", stamp, digest, version]);
446
+ checks = (Array.isArray(verify.checks) ? verify.checks : []) as VerifyCheck[];
447
+ } catch (err) {
448
+ checks = [{ check: "start and verify", ok: false, observed: err instanceof Error ? err.message : String(err) }];
449
+ }
450
+ let cliOk = true;
451
+ try {
452
+ await withCli(config, (cli) => cliJson(cli, ["version"]));
453
+ } catch {
454
+ cliOk = false;
455
+ }
456
+ checks.push({ check: "federated arcane-cli login", ok: cliOk });
457
+ if (checks.length > 1 && checks.every((c) => c.ok)) {
458
+ printJson({ status: "upgraded", ...steps, checks, arcaneCli: version, note: `install the matching arcane-cli: specialist-install arcane-cli ${version}` });
459
+ return 0;
460
+ }
461
+ const reason = failedChecks(checks) || "the checks returned nothing";
462
+ await git(spec, dir, ["revert", "--no-edit", commit]);
463
+ await git(spec, dir, ["push", "origin", config.gitopsBranch]);
464
+ const rollback = await hostJson(["manager-rollback", stamp]);
465
+ printJson({
466
+ status: "rolled back",
467
+ reason,
468
+ ...steps,
469
+ checks,
470
+ rollback,
471
+ revertedPin: true,
472
+ note: "the previous image and Compose file run again; if the new version migrated the database, restore the pre-upgrade data by hand (arcane/AGENTS.md, Upgrade rollback)",
473
+ });
474
+ return 0;
475
+ }
476
+
477
+ async function managerRollback(args: string[]): Promise<number> {
478
+ if (args.length !== 1) throw new UsageError("manager-rollback <stamp>");
479
+ printJson(await hostJson(["manager-rollback", args[0]]));
480
+ return 0;
481
+ }
482
+
212
483
  export async function command(args: string[]): Promise<number> {
213
484
  if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
214
485
  printRaw(HELP);
@@ -218,6 +489,11 @@ export async function command(args: string[]): Promise<number> {
218
489
  if (args[0] === "git") return repoCommand(repoSpec(config), args.slice(1));
219
490
  if (args[0] === "update-report") return updateReport(config, args.slice(1));
220
491
  if (args[0] === "release-notes") return releaseNotes(config, args.slice(1));
492
+ if (args[0] === "host") return hostCommand(config, args.slice(1));
493
+ if (args[0] === "project-status") return projectStatus(config, args.slice(1));
494
+ if (args[0] === "deployment") return deployment(config, args.slice(1));
495
+ if (args[0] === "manager-upgrade") return managerUpgrade(config, args.slice(1));
496
+ if (args[0] === "manager-rollback") return managerRollback(args.slice(1));
221
497
  return passthrough(config, args);
222
498
  }
223
499
 
@@ -73,9 +73,29 @@ const SINCE = /^[A-Za-z0-9 :\-+]{1,40}$/;
73
73
  /** Debian package names: lower-case letters, digits, plus, minus, period. */
74
74
  const PACKAGE = /^[a-z0-9][a-z0-9+.-]{0,63}$/;
75
75
 
76
+ /**
77
+ * Public routes of services on the Arcane VM, checked from Cortex through
78
+ * public DNS the way users reach them: status code only, no redirect
79
+ * followed, no credentials. `expect` holds the codes a healthy route returns
80
+ * (the Supabase settings endpoint wants an API key, so 401; Studio redirects
81
+ * to the Pomerium sign-in; the rest answer 200, as arcane-ops judged them).
82
+ */
83
+ export const PUBLIC_ROUTES: Readonly<Record<string, { url: string; expect: readonly number[] }>> = {
84
+ "supabase-auth-settings": { url: "https://supabase.37pla.net/auth/v1/settings", expect: [401] },
85
+ "supabase-jwks": { url: "https://supabase.37pla.net/auth/v1/.well-known/jwks.json", expect: [200] },
86
+ "supabase-openid": { url: "https://supabase.37pla.net/auth/v1/.well-known/openid-configuration", expect: [200] },
87
+ "supabase-oauth-discovery": { url: "https://supabase.37pla.net/auth/v1/.well-known/oauth-authorization-server", expect: [200] },
88
+ "supabase-studio": { url: "https://supabase-studio.37pla.net/", expect: [301, 302, 303, 307, 308] },
89
+ "grimoire-public": { url: "https://cli.grimoire.monadeo.com/v1/auth/config", expect: [200] },
90
+ "grimoire-public-health": { url: "https://cli.grimoire.monadeo.com/healthz", expect: [200] },
91
+ "mealie-public": { url: "https://mealie.37pla.net/api/recipes?page=1&perPage=1", expect: [301, 302, 303, 307, 308] },
92
+ };
93
+
76
94
  const HELP = `edge specialist (Frontdoor VPS through an IAP tunnel)
77
95
 
78
96
  probe GET the public OpenID discovery document through the VPS address
97
+ route-probe [name...] HTTP status of public routes from Cortex, no redirects, no credentials:
98
+ ${Object.keys(PUBLIC_ROUTES).join(", ")}
79
99
  vps <command> [args] fixed operations through the forced-command SSH key
80
100
  ${Object.entries(VPS_COMMANDS)
81
101
  .map(([name, c]) => ` ${name.padEnd(14)} ${c.help}`)
@@ -206,11 +226,31 @@ async function probe(config: EdgeConfig): Promise<number> {
206
226
  return ok ? 0 : 1;
207
227
  }
208
228
 
229
+ /** One status check per route; redirects are reported, never followed. */
230
+ export async function routeProbe(names: string[], curl: (args: string[]) => Promise<{ stdout: string; code: number }>): Promise<Record<string, unknown>[]> {
231
+ const unknown = names.filter((n) => !(n in PUBLIC_ROUTES));
232
+ if (unknown.length > 0) throw new UsageError(`unknown route: ${unknown.join(", ")}; known: ${Object.keys(PUBLIC_ROUTES).join(", ")}`);
233
+ const picked = names.length > 0 ? names : Object.keys(PUBLIC_ROUTES);
234
+ return Promise.all(
235
+ picked.map(async (name) => {
236
+ const route = PUBLIC_ROUTES[name];
237
+ const result = await curl(["--silent", "--output", "/dev/null", "--max-time", "10", "--proto", "=https", "--write-out", "%{http_code} %{time_total}", route.url]);
238
+ const [code, seconds] = result.stdout.trim().split(" ");
239
+ const status = Number(code);
240
+ return { route: name, url: route.url, httpCode: Number.isInteger(status) && status > 0 ? status : null, expected: route.expect, ok: result.code === 0 && route.expect.includes(status), seconds: seconds ? Number(seconds) : null, curlExit: result.code };
241
+ }),
242
+ );
243
+ }
244
+
209
245
  export async function command(args: string[]): Promise<number> {
210
246
  if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
211
247
  printRaw(HELP);
212
248
  return 0;
213
249
  }
250
+ if (args[0] === "route-probe") {
251
+ printJson(await routeProbe(args.slice(1), (curlArgs) => run("/usr/bin/curl", curlArgs, { timeoutMs: 20_000 })));
252
+ return 0;
253
+ }
214
254
  const config = readConfig<EdgeConfig>(SERVICE, SHAPE);
215
255
  switch (args[0]) {
216
256
  case "probe":
@@ -0,0 +1,15 @@
1
+ #!/bin/sh
2
+ # Forced command for the arcane specialist's key on VM 100 (user spc-arcane, no docker group).
3
+ # authorized_keys line:
4
+ # command="/usr/local/sbin/spc-arcane-host-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-arcane-host@cortex
5
+ # The command is split into words without globbing and handed to the root-owned
6
+ # spc-arcane-op, the only command its sudoers entry allows; that file checks the
7
+ # operation name and every argument. Standard input passes through (mealie-api).
8
+ set -eu
9
+ cmd="${SSH_ORIGINAL_COMMAND:-}"
10
+ case "$cmd" in
11
+ "" | *[!A-Za-z0-9\ ._:/+-]*) echo "refused: characters outside A-Z a-z 0-9 space . _ : / + -" >&2; exit 2 ;;
12
+ esac
13
+ set -f
14
+ # shellcheck disable=SC2086
15
+ exec /usr/bin/sudo -n /usr/local/sbin/spc-arcane-op $cmd
@@ -0,0 +1,35 @@
1
+ #!/bin/sh
2
+ # One-time setup of the spc-arcane user on VM 100 for the arcane specialist.
3
+ # Run as root with these files in /tmp/spc-arcane: spc-arcane-host-entry,
4
+ # spc-arcane-op, spc-arcane-sudoers, spc-arcane-host.pub, and the arcane-ops
5
+ # probes host_probe.py, resource_probe.py, incident_probe.py, kernel_probe.py,
6
+ # storage_probe.py, queue_probe.py, mealie_probe.py, radarr_probe.py.
7
+ # Idempotent; reinstalling updates the files and keeps the user.
8
+ set -eu
9
+ src=/tmp/spc-arcane
10
+ probes="host_probe resource_probe incident_probe kernel_probe storage_probe queue_probe mealie_probe radarr_probe"
11
+ for f in spc-arcane-host-entry spc-arcane-op spc-arcane-sudoers spc-arcane-host.pub; do
12
+ [ -f "$src/$f" ] || { echo "missing $src/$f" >&2; exit 2; }
13
+ done
14
+ for p in $probes; do
15
+ [ -f "$src/$p.py" ] || { echo "missing $src/$p.py" >&2; exit 2; }
16
+ done
17
+ visudo -cf "$src/spc-arcane-sudoers"
18
+ id spc-arcane >/dev/null 2>&1 || useradd --system --create-home --home-dir /var/lib/spc-arcane --shell /bin/sh spc-arcane
19
+ install -d -o root -g root -m 0755 /usr/local/lib/spc-arcane
20
+ for p in $probes; do
21
+ install -o root -g root -m 0644 "$src/$p.py" "/usr/local/lib/spc-arcane/$p.py"
22
+ done
23
+ install -o root -g root -m 0755 "$src/spc-arcane-op" /usr/local/sbin/spc-arcane-op
24
+ install -o root -g root -m 0755 "$src/spc-arcane-host-entry" /usr/local/sbin/spc-arcane-host-entry
25
+ install -o root -g root -m 0440 "$src/spc-arcane-sudoers" /etc/sudoers.d/spc-arcane
26
+ install -d -o root -g root -m 0700 /var/backups/spc-arcane
27
+ install -d -o spc-arcane -g spc-arcane -m 0700 /var/lib/spc-arcane/.ssh
28
+ printf 'command="/usr/local/sbin/spc-arcane-host-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding %s\n' "$(cat "$src/spc-arcane-host.pub")" > /var/lib/spc-arcane/.ssh/authorized_keys
29
+ chown spc-arcane:spc-arcane /var/lib/spc-arcane/.ssh/authorized_keys
30
+ chmod 0600 /var/lib/spc-arcane/.ssh/authorized_keys
31
+ rm -rf "$src"
32
+ echo "spc-arcane ready: $(id spc-arcane)"
33
+ echo "groups (must not include docker): $(id -nG spc-arcane)"
34
+ echo "sshd key sources and user filters:"
35
+ sshd -T 2>/dev/null | grep -Ei '^(authorizedkeysfile|authorizedkeyscommand|pubkeyauthentication|allowusers|allowgroups|denyusers|denygroups) ' || true