@astrofoundry/pi-astro 0.19.3 → 0.20.0

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.
@@ -0,0 +1,21 @@
1
+ #!/bin/sh
2
+ # Forced command for the spc-backup key on VM 100 (user arcane, passwordless sudo).
3
+ # authorized_keys line:
4
+ # command="/usr/local/sbin/spc-backup-arcane-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-backup-arcane@cortex
5
+ set -eu
6
+ cmd="${SSH_ORIGINAL_COMMAND:-}"
7
+ name="${cmd%% *}"
8
+ rest=""
9
+ [ "$cmd" != "$name" ] && rest="${cmd#* }"
10
+ since_ok() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9 :+-]{1,40}$'; }
11
+ unit_status() { systemctl show "$1" -p Result -p ExecMainStatus -p ExecMainStartTimestamp -p ExecMainExitTimestamp; systemctl list-timers "$2" --no-pager --all; }
12
+
13
+ case "$name" in
14
+ arcane-backup-status) unit_status arcane-backup.service arcane-backup.timer ;;
15
+ arcane-backup-run) sudo -n systemctl start arcane-backup.service; unit_status arcane-backup.service arcane-backup.timer ;;
16
+ arcane-backup-journal) since_ok "$rest" || { echo "bad since" >&2; exit 2; }; sudo -n journalctl -u arcane-backup.service --since "$rest" --no-pager -n 200 ;;
17
+ restic-check-status) unit_status restic-check.service restic-check.timer ;;
18
+ restic-check-run) sudo -n systemctl start restic-check.service; unit_status restic-check.service restic-check.timer ;;
19
+ restic-check-journal) since_ok "$rest" || { echo "bad since" >&2; exit 2; }; sudo -n journalctl -u restic-check.service --since "$rest" --no-pager -n 200 ;;
20
+ *) echo "refused: $cmd" >&2; exit 2 ;;
21
+ esac
@@ -0,0 +1,51 @@
1
+ #!/bin/sh
2
+ # Forced command for the spc-backup key on Pulsar (root).
3
+ # authorized_keys line:
4
+ # command="/usr/local/sbin/spc-backup-pulsar-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-backup-pulsar@cortex
5
+ set -eu
6
+ export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
7
+ export HOME=/root
8
+ export RESTIC_REPOSITORY=/mnt/pve/unas-backup/restic
9
+ export RESTIC_PASSWORD_FILE=/root/.restic-pw
10
+ cmd="${SSH_ORIGINAL_COMMAND:-}"
11
+ name="${cmd%% *}"
12
+ rest=""
13
+ [ "$cmd" != "$name" ] && rest="${cmd#* }"
14
+ since_ok() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9 :+-]{1,40}$'; }
15
+ tag_ok() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9._-]{1,40}$'; }
16
+ snap_ok() { printf '%s' "$1" | grep -Eq '^([0-9a-f]{8,64}|latest)$'; }
17
+ path_ok() { printf '%s' "$1" | grep -Eq '^/[A-Za-z0-9._/-]{1,200}$' && ! printf '%s' "$1" | grep -q '\.\.'; }
18
+ restore_root=/var/tmp/spc-restore
19
+ unit_status() { systemctl show "$1" -p Result -p ExecMainStatus -p ExecMainStartTimestamp -p ExecMainExitTimestamp; systemctl list-timers "$2" --no-pager --all; }
20
+
21
+ case "$name" in
22
+ vzdump-jobs) pvesh get /cluster/backup --output-format json ;;
23
+ vzdump-archives) pvesh get /nodes/pulsar/storage/unas-backup/content --content backup --output-format json ;;
24
+ vzdump-tasks) pvesh get /nodes/pulsar/tasks --typefilter vzdump --limit 20 --output-format json ;;
25
+ vzdump-run)
26
+ case "$rest" in 1[0-9][0-9]) ;; *) echo "bad vmid" >&2; exit 2 ;; esac
27
+ vzdump "$rest" --storage unas-backup --mode snapshot --compress zstd ;;
28
+ host-backup-status) unit_status pulsar-config-backup.service pulsar-config-backup.timer ;;
29
+ host-backup-run) systemctl start pulsar-config-backup.service; unit_status pulsar-config-backup.service pulsar-config-backup.timer ;;
30
+ host-backup-journal) since_ok "$rest" || { echo "bad since" >&2; exit 2; }; journalctl -u pulsar-config-backup.service --since "$rest" --no-pager -n 200 ;;
31
+ restic-snapshots)
32
+ if [ -n "$rest" ]; then tag_ok "$rest" || { echo "bad tag" >&2; exit 2; }; restic snapshots --json --no-lock --tag "$rest"; else restic snapshots --json --no-lock; fi ;;
33
+ restic-stats)
34
+ if [ -n "$rest" ]; then tag_ok "$rest" || { echo "bad tag" >&2; exit 2; }; restic stats --json --no-lock --tag "$rest" latest; else restic stats --json --no-lock latest; fi ;;
35
+ restic-ls)
36
+ snap="${rest%% *}"; sub=""; [ "$rest" != "$snap" ] && sub="${rest#* }"
37
+ snap_ok "$snap" || { echo "bad snapshot" >&2; exit 2; }
38
+ if [ -n "$sub" ]; then path_ok "$sub" || { echo "bad path" >&2; exit 2; }; restic ls --json --no-lock "$snap" "$sub"; else restic ls --json --no-lock "$snap"; fi ;;
39
+ restic-restore)
40
+ snap="${rest%% *}"; sub="${rest#* }"
41
+ snap_ok "$snap" && path_ok "$sub" || { echo "bad snapshot or path" >&2; exit 2; }
42
+ install -d -o root -g root -m 0700 "$restore_root/$snap"
43
+ restic restore --no-lock "$snap" --target "$restore_root/$snap" --include "$sub"
44
+ find "$restore_root/$snap" -maxdepth 6 | head -n 200 ;;
45
+ restore-ls) [ -d "$restore_root" ] && find "$restore_root" -maxdepth 4 | head -n 500 || echo "nothing restored" ;;
46
+ restore-clean) rm -rf "$restore_root"; echo "removed $restore_root" ;;
47
+ gcs-status) pct exec 102 -- systemctl show gcs-backup.service -p Result -p ExecMainStatus -p ExecMainStartTimestamp -p ExecMainExitTimestamp; pct exec 102 -- systemctl list-timers gcs-backup.timer --no-pager --all ;;
48
+ gcs-run) pct exec 102 -- systemctl start gcs-backup.service; pct exec 102 -- systemctl show gcs-backup.service -p Result -p ExecMainStatus -p ExecMainExitTimestamp ;;
49
+ gcs-journal) since_ok "$rest" || { echo "bad since" >&2; exit 2; }; pct exec 102 -- journalctl -u gcs-backup.service --since "$rest" --no-pager -n 200 ;;
50
+ *) echo "refused: $cmd" >&2; exit 2 ;;
51
+ esac
@@ -0,0 +1,27 @@
1
+ #!/bin/sh
2
+ # Forced command for the spc-inference key on LXC 101 (user hermes, no sudo).
3
+ # authorized_keys line in /home/hermes/.ssh/authorized_keys:
4
+ # command="/usr/local/sbin/spc-inference-hermes-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-inference-hermes@cortex
5
+ set -eu
6
+ export PATH="$HOME/.local/bin:$HOME/.hermes/hermes-agent/venv/bin:/usr/local/bin:/usr/bin:/bin"
7
+ export XDG_RUNTIME_DIR="/run/user/$(id -u)"
8
+ cmd="${SSH_ORIGINAL_COMMAND:-}"
9
+ name="${cmd%% *}"
10
+ rest=""
11
+ [ "$cmd" != "$name" ] && rest="${cmd#* }"
12
+ since_ok() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9 :+-]{1,40}$'; }
13
+ units="hermes-gateway.service hermes-gateway-api.service"
14
+
15
+ case "$name" in
16
+ hermes-status) systemctl --user status $units --no-pager --lines=5 || true ;;
17
+ hermes-journal) since_ok "$rest" || { echo "bad since" >&2; exit 2; }; journalctl --user -u hermes-gateway.service -u hermes-gateway-api.service --since "$rest" --no-pager -n 200 ;;
18
+ hermes-errors) hermes logs errors ;;
19
+ hermes-restart)
20
+ case "$rest" in gateway) u=hermes-gateway.service ;; api) u=hermes-gateway-api.service ;; *) echo "bad service" >&2; exit 2 ;; esac
21
+ systemctl --user restart "$u"; sleep 3; systemctl --user status "$u" --no-pager --lines=5 || true ;;
22
+ hermes-api-health)
23
+ printf 'health=%s\n' "$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://localhost:8650/health || echo 000)"
24
+ printf 'models=%s (401 expected)\n' "$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://localhost:8650/v1/models || echo 000)" ;;
25
+ hermes-version) hermes --version | head -1 ;;
26
+ *) echo "refused: $cmd" >&2; exit 2 ;;
27
+ esac
@@ -0,0 +1,37 @@
1
+ #!/bin/sh
2
+ # Forced command for the spc-inference key on Nexus (macOS, user nexus).
3
+ # authorized_keys line in /Users/nexus/.ssh/authorized_keys:
4
+ # command="/usr/local/sbin/spc-inference-nexus-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-inference-nexus@cortex
5
+ set -eu
6
+ export PATH=/usr/bin:/bin:/usr/sbin:/sbin
7
+ cmd="${SSH_ORIGINAL_COMMAND:-}"
8
+ name="${cmd%% *}"
9
+ rest=""
10
+ [ "$cmd" != "$name" ] && rest="${cmd#* }"
11
+ uid="$(id -u)"
12
+ label() { case "$1" in embed) echo com.nexus.llama-embed ;; rerank) echo com.nexus.llama-rerank ;; *) return 1 ;; esac; }
13
+ port() { case "$1" in embed) echo 8081 ;; rerank) echo 8082 ;; esac; }
14
+ agent_state() {
15
+ l="$(label "$1")"
16
+ printf '%-8s ' "$1"
17
+ launchctl print "gui/$uid/$l" 2>/dev/null | grep -E '^\s*(state|pid) = ' | tr -s ' ' | tr '\n' ' ' || printf 'not loaded '
18
+ printf 'health=%s\n' "$(curl -s -o /dev/null -w '%{http_code}' -m 5 "http://127.0.0.1:$(port "$1")/health" || echo 000)"
19
+ }
20
+
21
+ case "$name" in
22
+ nexus-status) agent_state embed; agent_state rerank ;;
23
+ nexus-restart) l="$(label "$rest")" || { echo "bad service" >&2; exit 2; }; launchctl kickstart -k "gui/$uid/$l"; sleep 3; agent_state "$rest" ;;
24
+ nexus-start)
25
+ l="$(label "$rest")" || { echo "bad service" >&2; exit 2; }
26
+ plist="$HOME/Library/LaunchAgents/$l.plist"
27
+ [ -f "$plist" ] || { echo "missing $plist" >&2; exit 1; }
28
+ launchctl bootstrap "gui/$uid" "$plist"; sleep 3; agent_state "$rest" ;;
29
+ nexus-stop) l="$(label "$rest")" || { echo "bad service" >&2; exit 2; }; launchctl bootout "gui/$uid/$l"; agent_state "$rest" ;;
30
+ nexus-log)
31
+ svc="${rest%% *}"; lines="${rest#* }"
32
+ label "$svc" >/dev/null || { echo "bad service" >&2; exit 2; }
33
+ printf '%s' "$lines" | grep -Eq '^[1-9][0-9]{0,3}$' || { echo "bad lines" >&2; exit 2; }
34
+ tail -n "$lines" "$HOME/llama-$svc.log" ;;
35
+ nexus-disk) df -h / ;;
36
+ *) echo "refused: $cmd" >&2; exit 2 ;;
37
+ esac
@@ -0,0 +1,14 @@
1
+ #!/bin/sh
2
+ # Forced command for the spc-inference key on Pulsar (root): Hermes guest operations.
3
+ # authorized_keys line:
4
+ # command="/usr/local/sbin/spc-inference-pulsar-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-inference-pulsar@cortex
5
+ set -eu
6
+ export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
7
+ cmd="${SSH_ORIGINAL_COMMAND:-}"
8
+
9
+ case "$cmd" in
10
+ hermes-guest-status) pct status 101 ;;
11
+ hermes-snapshots) pct listsnapshot 101 ;;
12
+ hermes-upgrade) /usr/local/bin/hermes-upgrade ;;
13
+ *) echo "refused: $cmd" >&2; exit 2 ;;
14
+ esac
@@ -0,0 +1,44 @@
1
+ #!/bin/sh
2
+ # Forced command for the spc-proxmox key on Pulsar (root): host facts and fixed reads inside guests.
3
+ # authorized_keys line:
4
+ # command="/usr/local/sbin/spc-proxmox-pulsar-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-proxmox-pulsar@cortex
5
+ set -eu
6
+ export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
7
+ cmd="${SSH_ORIGINAL_COMMAND:-}"
8
+ name="${cmd%% *}"
9
+ rest=""
10
+ [ "$cmd" != "$name" ] && rest="${cmd#* }"
11
+ since_ok() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9 :+-]{1,40}$'; }
12
+
13
+ guest_cmd() {
14
+ case "$1" in
15
+ status) echo 'systemctl --failed --no-pager; uptime' ;;
16
+ journal) echo 'journalctl -p warning --since "24 hours ago" --no-pager -n 100' ;;
17
+ df) echo 'df -h -x tmpfs -x devtmpfs' ;;
18
+ updates) echo 'apt list --upgradable 2>/dev/null' ;;
19
+ *) return 1 ;;
20
+ esac
21
+ }
22
+
23
+ case "$name" in
24
+ host-status)
25
+ pveversion
26
+ uptime
27
+ free -h
28
+ df -h / /var/lib/vz
29
+ sensors 2>/dev/null | grep -E 'Tctl|Composite|edge' || true ;;
30
+ host-journal) since_ok "$rest" || { echo "bad since" >&2; exit 2; }; journalctl -p warning --since "$rest" --no-pager -n 200 ;;
31
+ updates) apt list --upgradable 2>/dev/null ;;
32
+ guest-exec)
33
+ vmid="${rest%% *}"; action="${rest#* }"
34
+ case "$vmid" in 1[0-9][0-9]) ;; *) echo "bad vmid" >&2; exit 2 ;; esac
35
+ inner="$(guest_cmd "$action")" || { echo "bad action" >&2; exit 2; }
36
+ if pct status "$vmid" >/dev/null 2>&1; then
37
+ pct exec "$vmid" -- sh -c "$inner"
38
+ elif qm status "$vmid" >/dev/null 2>&1; then
39
+ qm guest exec "$vmid" --timeout 60 -- sh -c "$inner"
40
+ else
41
+ echo "no such guest" >&2; exit 2
42
+ fi ;;
43
+ *) echo "refused: $cmd" >&2; exit 2 ;;
44
+ esac
@@ -0,0 +1,167 @@
1
+ import { join } from "node:path";
2
+ import { readConfig } from "../lib/config.ts";
3
+ import { ServiceError, UsageError } from "../lib/errors.ts";
4
+ import { main } from "../lib/main.ts";
5
+ import { printJson, printRaw } from "../lib/output.ts";
6
+ import { specialistsHome } from "../lib/paths.ts";
7
+ import { run } from "../lib/proc.ts";
8
+ import { secretPath } from "../lib/secrets.ts";
9
+ import { sshFixed } from "../lib/ssh.ts";
10
+
11
+ const SERVICE = "inference";
12
+
13
+ interface InferenceConfig extends Record<string, string> {
14
+ nexusHost: string;
15
+ nexusUser: string;
16
+ hermesHost: string;
17
+ hermesUser: string;
18
+ pulsarHost: string;
19
+ pulsarUser: string;
20
+ europaHealthUrl: string;
21
+ /** GitHub repository whose releases carry the Hermes upgrade notes, owner/name. */
22
+ hermesRepo: string;
23
+ }
24
+
25
+ const SHAPE = {
26
+ nexusHost: "string",
27
+ nexusUser: "string",
28
+ hermesHost: "string",
29
+ hermesUser: "string",
30
+ pulsarHost: "string",
31
+ pulsarUser: "string",
32
+ europaHealthUrl: "string",
33
+ hermesRepo: "string",
34
+ } as const;
35
+
36
+ type Target = "nexus" | "hermes" | "pulsar";
37
+
38
+ interface RemoteSpec {
39
+ target: Target;
40
+ args: number;
41
+ help: string;
42
+ timeoutMs: number;
43
+ confirm?: true;
44
+ }
45
+
46
+ const MINUTE = 60_000;
47
+
48
+ /** Fixed remote commands; the forced commands on Nexus (nexus), LXC 101 (hermes), and Pulsar (root) accept exactly these names. */
49
+ export const INFERENCE_COMMANDS: Readonly<Record<string, RemoteSpec>> = {
50
+ "nexus-status": { target: "nexus", args: 0, help: "LaunchAgent states and /health codes of the embed and rerank servers", timeoutMs: MINUTE },
51
+ "nexus-restart": { target: "nexus", args: 1, help: "nexus-restart <embed|rerank> launchctl kickstart -k", timeoutMs: 2 * MINUTE },
52
+ "nexus-start": { target: "nexus", args: 1, help: "nexus-start <embed|rerank> launchctl bootstrap from the tracked plist", timeoutMs: 2 * MINUTE },
53
+ "nexus-stop": { target: "nexus", args: 1, help: "nexus-stop <embed|rerank> --confirm launchctl bootout", timeoutMs: 2 * MINUTE, confirm: true },
54
+ "nexus-log": { target: "nexus", args: 2, help: "nexus-log <embed|rerank> <lines>", timeoutMs: MINUTE },
55
+ "nexus-disk": { target: "nexus", args: 0, help: "free space on the Nexus disk", timeoutMs: MINUTE },
56
+ "hermes-status": { target: "hermes", args: 0, help: "both gateway user services", timeoutMs: MINUTE },
57
+ "hermes-journal": { target: "hermes", args: 1, help: "hermes-journal <since> user journal of both gateways", timeoutMs: MINUTE },
58
+ "hermes-errors": { target: "hermes", args: 0, help: "hermes logs errors", timeoutMs: MINUTE },
59
+ "hermes-restart": { target: "hermes", args: 1, help: "hermes-restart <gateway|api>", timeoutMs: 2 * MINUTE },
60
+ "hermes-api-health": { target: "hermes", args: 0, help: "/health and /v1/models codes on localhost:8650 (expect 200 and 401)", timeoutMs: MINUTE },
61
+ "hermes-version": { target: "hermes", args: 0, help: "installed Hermes version", timeoutMs: MINUTE },
62
+ "hermes-guest-status": { target: "pulsar", args: 0, help: "pct status 101", timeoutMs: MINUTE },
63
+ "hermes-snapshots": { target: "pulsar", args: 0, help: "pct listsnapshot 101", timeoutMs: MINUTE },
64
+ "hermes-upgrade": { target: "pulsar", args: 0, help: "hermes-upgrade --confirm snapshot LXC 101, hermes update, restart, health check", timeoutMs: 20 * MINUTE, confirm: true },
65
+ };
66
+
67
+ const NEXUS_SERVICES = ["embed", "rerank"];
68
+ const HERMES_SERVICES = ["gateway", "api"];
69
+ const SINCE = /^[A-Za-z0-9 :\-+]{1,40}$/;
70
+
71
+ const HELP = `inference specialist (llama.cpp on Nexus, Hermes on LXC 101, Europa health)
72
+
73
+ <command> [args] fixed operations through forced-command SSH keys
74
+ ${Object.entries(INFERENCE_COMMANDS)
75
+ .map(([name, c]) => ` ${name.padEnd(22)} ${c.help}`)
76
+ .join("\n")}
77
+ europa-health GET the Europa embedding /health over HTTP (Europa is usually off by design)
78
+ hermes-releases [n] latest n upstream Hermes releases with notes (default 5); read them before hermes-upgrade
79
+
80
+ Europa control and Hermes configuration are operator tasks.`;
81
+
82
+ export function buildInferenceRemote(args: string[]): { remote: string; spec: RemoteSpec } {
83
+ const [name, ...given] = args;
84
+ const spec = name === undefined ? undefined : INFERENCE_COMMANDS[name];
85
+ if (!spec) throw new UsageError(`unknown command: ${name ?? "(none)"}`);
86
+ let rest = given;
87
+ if (spec.confirm) {
88
+ const index = rest.indexOf("--confirm");
89
+ if (index === -1) throw new UsageError(`${name} interrupts a service; add --confirm when the task asks for it explicitly`);
90
+ rest = rest.filter((_, i) => i !== index);
91
+ }
92
+ if (rest.length !== spec.args) throw new UsageError(`${name} takes ${spec.args} argument(s)`);
93
+ if (name.startsWith("nexus-") && spec.args >= 1 && !NEXUS_SERVICES.includes(rest[0])) throw new UsageError(`service must be one of ${NEXUS_SERVICES.join(", ")}`);
94
+ if (name === "nexus-log") {
95
+ const n = Number(rest[1]);
96
+ if (!Number.isInteger(n) || n < 1 || n > 2000) throw new UsageError("lines must be an integer from 1 to 2000");
97
+ }
98
+ if (name === "hermes-restart" && !HERMES_SERVICES.includes(rest[0])) throw new UsageError(`service must be one of ${HERMES_SERVICES.join(", ")}`);
99
+ if (name === "hermes-journal" && !SINCE.test(rest[0])) throw new UsageError("since: letters, digits, spaces, colon, plus, minus only");
100
+ return { remote: [name, ...rest].join(" "), spec };
101
+ }
102
+
103
+ interface GithubRelease {
104
+ tag_name?: string;
105
+ name?: string;
106
+ published_at?: string;
107
+ html_url?: string;
108
+ prerelease?: boolean;
109
+ body?: string;
110
+ }
111
+
112
+ /** Trims GitHub release objects to what an upgrade decision needs. */
113
+ export function compactReleases(releases: GithubRelease[]): { tag: string; name: string; published: string; prerelease: boolean; url: string; notes: string }[] {
114
+ return releases.map((r) => ({
115
+ tag: r.tag_name ?? "?",
116
+ name: r.name ?? "",
117
+ published: r.published_at ?? "",
118
+ prerelease: r.prerelease === true,
119
+ url: r.html_url ?? "",
120
+ notes: (r.body ?? "").slice(0, 6000),
121
+ }));
122
+ }
123
+
124
+ async function hermesReleases(config: InferenceConfig, countArg: string | undefined): Promise<number> {
125
+ const n = countArg === undefined ? 5 : Number(countArg);
126
+ if (!Number.isInteger(n) || n < 1 || n > 30) throw new UsageError("hermes-releases [n]: n must be 1..30");
127
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.hermesRepo)) throw new ServiceError("hermesRepo must be owner/name");
128
+ const response = await fetch(`https://api.github.com/repos/${config.hermesRepo}/releases?per_page=${n}`, {
129
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "pi-astro-inference-specialist" },
130
+ signal: AbortSignal.timeout(30_000),
131
+ });
132
+ if (!response.ok) throw new ServiceError(`GitHub answered ${response.status} for ${config.hermesRepo} releases`);
133
+ printJson({ repository: config.hermesRepo, releases: compactReleases((await response.json()) as GithubRelease[]) });
134
+ return 0;
135
+ }
136
+
137
+ async function europaHealth(config: InferenceConfig): Promise<number> {
138
+ const result = await run("/usr/bin/curl", ["--silent", "--output", "/dev/null", "--max-time", "5", "--write-out", "%{http_code}", config.europaHealthUrl], { timeoutMs: 15_000 });
139
+ const httpCode = result.stdout.trim();
140
+ const ok = result.code === 0 && httpCode === "200";
141
+ printJson({ ok, url: config.europaHealthUrl, httpCode: httpCode || null, curlExit: result.code, note: ok ? "Europa is up" : "Europa is off or unreachable; it is off by design most of the time" });
142
+ return 0;
143
+ }
144
+
145
+ export async function command(args: string[]): Promise<number> {
146
+ if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
147
+ printRaw(HELP);
148
+ return 0;
149
+ }
150
+ const config = readConfig<InferenceConfig>(SERVICE, SHAPE);
151
+ if (args[0] === "europa-health") return europaHealth(config);
152
+ if (args[0] === "hermes-releases") return hermesReleases(config, args[1]);
153
+ const { remote, spec } = buildInferenceRemote(args);
154
+ const targets: Record<Target, { host: string; user: string; keyFile: string }> = {
155
+ nexus: { host: config.nexusHost, user: config.nexusUser, keyFile: secretPath(SERVICE, "SSH_KEY_NEXUS") },
156
+ hermes: { host: config.hermesHost, user: config.hermesUser, keyFile: secretPath(SERVICE, "SSH_KEY_HERMES") },
157
+ pulsar: { host: config.pulsarHost, user: config.pulsarUser, keyFile: secretPath(SERVICE, "SSH_KEY_PULSAR") },
158
+ };
159
+ const result = await sshFixed({ ...targets[spec.target], knownHostsFile: join(specialistsHome(), "config", "known_hosts") }, remote, spec.timeoutMs);
160
+ if (result.stdout.length > 0) printRaw(result.stdout);
161
+ if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `${args[0]} exited ${result.code}`);
162
+ return 0;
163
+ }
164
+
165
+ if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
166
+ await main(SERVICE, command);
167
+ }
@@ -11,9 +11,9 @@ bin="$root/bin"
11
11
 
12
12
  [ -d "$src/specialists/lib" ] || { echo "not a pi-astro package: $src" >&2; exit 2; }
13
13
  mkdir -p "$bin" "$root/config" "$root/secrets" "$root/work"
14
- rm -rf "$bin/lib" "$bin/arcane" "$bin/identity" "$bin/network" "$bin/dns" "$bin/edge" "$bin/security"
14
+ rm -rf "$bin/lib" "$bin/arcane" "$bin/identity" "$bin/network" "$bin/dns" "$bin/edge" "$bin/security" "$bin/backup" "$bin/proxmox" "$bin/inference"
15
15
  cp -R "$src/specialists/lib" "$bin/lib"
16
- for s in arcane identity network dns edge security; do
16
+ for s in arcane identity network dns edge security backup proxmox inference; do
17
17
  mkdir -p "$bin/$s"
18
18
  cp "$src/specialists/$s/run.ts" "$bin/$s/run.ts"
19
19
  done
@@ -0,0 +1,49 @@
1
+ import { request as httpsRequest } from "node:https";
2
+ import { ServiceError } from "./errors.ts";
3
+
4
+ export interface PinnedResponse {
5
+ status: number;
6
+ body: string;
7
+ fingerprint: string;
8
+ }
9
+
10
+ export interface PinnedOptions {
11
+ method: string;
12
+ headers: Record<string, string>;
13
+ body?: string;
14
+ /** SHA-256 fingerprint of the expected certificate (colon-separated hex); null probes without checking. */
15
+ expectedFingerprint: string | null;
16
+ timeoutMs: number;
17
+ }
18
+
19
+ /**
20
+ * HTTPS request to a self-signed service, accepted only when the peer
21
+ * certificate matches the pinned fingerprint. Used for consoles that
22
+ * cannot present a CA-signed certificate (UniFi, Proxmox VE).
23
+ */
24
+ export function pinnedRequest(url: URL, options: PinnedOptions): Promise<PinnedResponse> {
25
+ return new Promise((resolve, reject) => {
26
+ const req = httpsRequest(
27
+ url,
28
+ { method: options.method, rejectUnauthorized: false, headers: options.headers, timeout: options.timeoutMs },
29
+ (res) => {
30
+ const socket = res.socket as { getPeerCertificate?: () => { fingerprint256?: string } };
31
+ const fingerprint = socket.getPeerCertificate?.().fingerprint256 ?? "";
32
+ if (options.expectedFingerprint !== null && fingerprint.toUpperCase() !== options.expectedFingerprint.toUpperCase()) {
33
+ res.destroy();
34
+ reject(new ServiceError(`${url.host}: certificate fingerprint ${fingerprint} does not match the pinned value`));
35
+ return;
36
+ }
37
+ let body = "";
38
+ res.setEncoding("utf-8");
39
+ res.on("data", (chunk: string) => {
40
+ body += chunk;
41
+ });
42
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, body, fingerprint }));
43
+ },
44
+ );
45
+ req.on("timeout", () => req.destroy(new ServiceError(`${url.host}: request timed out`)));
46
+ req.on("error", (err) => reject(err instanceof ServiceError ? err : new ServiceError(`${url.host}: request failed: ${err.message}`)));
47
+ req.end(options.body);
48
+ });
49
+ }
@@ -1,10 +1,10 @@
1
- import { request as httpsRequest } from "node:https";
2
1
  import { join } from "node:path";
3
2
  import { readConfig } from "../lib/config.ts";
4
3
  import { ServiceError, UsageError } from "../lib/errors.ts";
5
4
  import { main } from "../lib/main.ts";
6
5
  import { printJson, printRaw } from "../lib/output.ts";
7
6
  import { specialistsHome } from "../lib/paths.ts";
7
+ import { pinnedRequest, type PinnedResponse } from "../lib/pinned.ts";
8
8
  import { readSecret, secretPath } from "../lib/secrets.ts";
9
9
  import { sshFixed } from "../lib/ssh.ts";
10
10
 
@@ -114,42 +114,13 @@ export function unifiPath(args: string[]): string {
114
114
  return qs.length > 0 ? `${path}?${qs}` : path;
115
115
  }
116
116
 
117
- interface PinnedResponse {
118
- status: number;
119
- body: string;
120
- fingerprint: string;
121
- }
122
-
123
117
  /** GET over TLS pinned to the console certificate; the console is self-signed. */
124
118
  export function pinnedGet(url: URL, apiKey: string | null, expectedFingerprint: string | null): Promise<PinnedResponse> {
125
- return new Promise((resolve, reject) => {
126
- const req = httpsRequest(
127
- url,
128
- {
129
- method: "GET",
130
- rejectUnauthorized: false,
131
- headers: { Accept: "application/json", ...(apiKey ? { "X-API-KEY": apiKey } : {}) },
132
- timeout: 30_000,
133
- },
134
- (res) => {
135
- const socket = res.socket as { getPeerCertificate?: () => { fingerprint256?: string } };
136
- const fingerprint = socket.getPeerCertificate?.().fingerprint256 ?? "";
137
- if (expectedFingerprint !== null && fingerprint.toUpperCase() !== expectedFingerprint.toUpperCase()) {
138
- res.destroy();
139
- reject(new ServiceError(`console certificate fingerprint ${fingerprint} does not match the pinned value`));
140
- return;
141
- }
142
- let body = "";
143
- res.setEncoding("utf-8");
144
- res.on("data", (chunk: string) => {
145
- body += chunk;
146
- });
147
- res.on("end", () => resolve({ status: res.statusCode ?? 0, body, fingerprint }));
148
- },
149
- );
150
- req.on("timeout", () => req.destroy(new ServiceError("UniFi request timed out")));
151
- req.on("error", (err) => reject(err instanceof ServiceError ? err : new ServiceError(`UniFi request failed: ${err.message}`)));
152
- req.end();
119
+ return pinnedRequest(url, {
120
+ method: "GET",
121
+ headers: { Accept: "application/json", ...(apiKey ? { "X-API-KEY": apiKey } : {}) },
122
+ expectedFingerprint,
123
+ timeoutMs: 30_000,
153
124
  });
154
125
  }
155
126