@astrofoundry/pi-astro 0.18.5 → 0.19.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.
- package/README.md +4 -4
- package/agents/arcane.md +1 -1
- package/agents/dns.md +23 -0
- package/agents/edge.md +22 -0
- package/agents/identity.md +1 -1
- package/agents/network.md +1 -1
- package/agents/security.md +21 -0
- package/extensions/astro-footer/segments.test.ts +2 -1
- package/extensions/astro-footer/segments.ts +2 -1
- package/extensions/astro-subagents/index.ts +22 -9
- package/extensions/astro-subagents/ticker.test.ts +40 -0
- package/extensions/astro-subagents/ticker.ts +36 -0
- package/package.json +1 -1
- package/skills/dns/SKILL.md +59 -0
- package/skills/edge/SKILL.md +54 -0
- package/skills/network/SKILL.md +1 -1
- package/skills/security/SKILL.md +51 -0
- package/specialists/AGENTS.md +10 -5
- package/specialists/README.md +36 -13
- package/specialists/arcane/run.ts +14 -123
- package/specialists/dns/run.ts +339 -0
- package/specialists/edge/run.ts +224 -0
- package/specialists/entry/remote/spc-edge-frontdoor-entry.sh +47 -0
- package/specialists/entry/remote/spc-edge-frontdoor-install.sh +20 -0
- package/specialists/entry/remote/spc-edge-frontdoor-sudoers +2 -0
- package/specialists/entry/remote/spc-security-obs-entry.sh +54 -0
- package/specialists/install/install.sh +2 -2
- package/specialists/lib/repo.ts +171 -0
- package/specialists/lib/ssh.ts +24 -22
- package/specialists/lib/tunnel.test.ts +44 -0
- package/specialists/lib/tunnel.ts +76 -0
- package/specialists/security/run.ts +199 -0
- package/specialists/wrappers.test.ts +131 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readConfig } from "../lib/config.ts";
|
|
4
|
+
import { ServiceError, UsageError } from "../lib/errors.ts";
|
|
5
|
+
import { main } from "../lib/main.ts";
|
|
6
|
+
import { printJson, printRaw } from "../lib/output.ts";
|
|
7
|
+
import { specialistsHome, workDir } from "../lib/paths.ts";
|
|
8
|
+
import { run } from "../lib/proc.ts";
|
|
9
|
+
import { REPO_HELP, ensureCheckout, repoCommand, repoStatus, safeRepoPath, type RepoSpec } from "../lib/repo.ts";
|
|
10
|
+
import { secretPath } from "../lib/secrets.ts";
|
|
11
|
+
import { sshFixed } from "../lib/ssh.ts";
|
|
12
|
+
import { openTunnel } from "../lib/tunnel.ts";
|
|
13
|
+
|
|
14
|
+
const SERVICE = "edge";
|
|
15
|
+
|
|
16
|
+
interface EdgeConfig extends Record<string, string | number> {
|
|
17
|
+
gcloud: string;
|
|
18
|
+
/** Python 3.10+ interpreter for gcloud; the Homebrew cask installs none of its own. */
|
|
19
|
+
gcloudPython: string;
|
|
20
|
+
project: string;
|
|
21
|
+
zone: string;
|
|
22
|
+
instance: string;
|
|
23
|
+
serviceAccount: string;
|
|
24
|
+
/** Local port of the IAP tunnel to the instance's SSH port. */
|
|
25
|
+
iapLocalPort: number;
|
|
26
|
+
sshUser: string;
|
|
27
|
+
publicIp: string;
|
|
28
|
+
/** Public hostname whose OpenID discovery document `probe` fetches through the VPS address. */
|
|
29
|
+
probeHost: string;
|
|
30
|
+
homelabRemote: string;
|
|
31
|
+
homelabBranch: string;
|
|
32
|
+
/** Folder of the homelab repository this specialist may write. */
|
|
33
|
+
repoArea: string;
|
|
34
|
+
/** Tracked nginx configuration deployed by `deploy-nginx`. */
|
|
35
|
+
nginxConf: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SHAPE = {
|
|
39
|
+
gcloud: "string",
|
|
40
|
+
gcloudPython: "string",
|
|
41
|
+
project: "string",
|
|
42
|
+
zone: "string",
|
|
43
|
+
instance: "string",
|
|
44
|
+
serviceAccount: "string",
|
|
45
|
+
iapLocalPort: "number",
|
|
46
|
+
sshUser: "string",
|
|
47
|
+
publicIp: "string",
|
|
48
|
+
probeHost: "string",
|
|
49
|
+
homelabRemote: "string",
|
|
50
|
+
homelabBranch: "string",
|
|
51
|
+
repoArea: "string",
|
|
52
|
+
nginxConf: "string",
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
/** Fixed remote commands; the forced command on the VPS accepts exactly these names. */
|
|
56
|
+
export const VPS_COMMANDS: Readonly<Record<string, { args: number; help: string; stdin?: true }>> = {
|
|
57
|
+
status: { args: 0, help: "unit states, WireGuard handshake age, pending reboot, kernel, uptime" },
|
|
58
|
+
"nginx-conf": { args: 0, help: "print the installed /etc/nginx/nginx.conf" },
|
|
59
|
+
"nginx-test": { args: 0, help: "nginx -t on the installed configuration" },
|
|
60
|
+
journal: { args: 2, help: "journal <unit> <since>, e.g. journal nginx '1 hour ago'" },
|
|
61
|
+
wg: { args: 0, help: "wg show wg0 (peers, endpoints, handshakes)" },
|
|
62
|
+
bouncer: { args: 0, help: "crowdsec-firewall-bouncer status and the nftables block set" },
|
|
63
|
+
updates: { args: 0, help: "upgradable packages and the packages that need a reboot" },
|
|
64
|
+
"deploy-nginx": { args: 0, help: "install the tracked nginx.conf from the pushed homelab checkout (nginx -t, install, reload)", stdin: true },
|
|
65
|
+
reboot: { args: 0, help: "systemctl reboot (needs --confirm)" },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const VPS_UNITS = ["nginx", "wg-quick@wg0", "crowdsec-firewall-bouncer", "wazuh-agent", "rsyslog", "unattended-upgrades", "ssh"];
|
|
69
|
+
const SINCE = /^[A-Za-z0-9 :\-+]{1,40}$/;
|
|
70
|
+
|
|
71
|
+
const HELP = `edge specialist (Frontdoor VPS through an IAP tunnel)
|
|
72
|
+
|
|
73
|
+
probe GET the public OpenID discovery document through the VPS address
|
|
74
|
+
vps <command> [args] fixed operations through the forced-command SSH key
|
|
75
|
+
${Object.entries(VPS_COMMANDS)
|
|
76
|
+
.map(([name, c]) => ` ${name.padEnd(14)} ${c.help}`)
|
|
77
|
+
.join("\n")}
|
|
78
|
+
vps reboot --confirm reboot; run probe first and after
|
|
79
|
+
${REPO_HELP}
|
|
80
|
+
(homelab repository; writes are limited to the configured area)
|
|
81
|
+
|
|
82
|
+
deploy-nginx refuses when the checkout is dirty or differs from origin: commit and push first.`;
|
|
83
|
+
|
|
84
|
+
export function buildVpsRemote(args: string[]): { remote: string; stdin: boolean } {
|
|
85
|
+
const [name, ...rest] = args;
|
|
86
|
+
const spec = name === undefined ? undefined : VPS_COMMANDS[name];
|
|
87
|
+
if (!spec) throw new UsageError(`unknown vps command: ${name ?? "(none)"}`);
|
|
88
|
+
if (name === "reboot") {
|
|
89
|
+
if (rest.length !== 1 || rest[0] !== "--confirm") throw new UsageError("vps reboot --confirm (probe first; the public path goes down for about a minute)");
|
|
90
|
+
return { remote: "reboot", stdin: false };
|
|
91
|
+
}
|
|
92
|
+
if (rest.length !== spec.args) throw new UsageError(`vps ${name} takes ${spec.args} argument(s)`);
|
|
93
|
+
if (name === "journal") {
|
|
94
|
+
if (!VPS_UNITS.includes(rest[0])) throw new UsageError(`unit must be one of ${VPS_UNITS.join(", ")}`);
|
|
95
|
+
if (!SINCE.test(rest[1])) throw new UsageError("since: letters, digits, spaces, colon, plus, minus only");
|
|
96
|
+
}
|
|
97
|
+
return { remote: [name, ...rest].join(" "), stdin: spec.stdin === true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function gcloudEnv(config: EdgeConfig): Record<string, string> {
|
|
101
|
+
return {
|
|
102
|
+
CLOUDSDK_CONFIG: workDir("gcloud"),
|
|
103
|
+
CLOUDSDK_PYTHON: config.gcloudPython,
|
|
104
|
+
CLOUDSDK_CORE_DISABLE_PROMPTS: "1",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function activateServiceAccount(config: EdgeConfig): Promise<void> {
|
|
109
|
+
const result = await run(
|
|
110
|
+
config.gcloud,
|
|
111
|
+
["auth", "activate-service-account", config.serviceAccount, "--key-file", secretPath(SERVICE, "GCP_SA_KEY_JSON"), "--project", config.project, "--quiet"],
|
|
112
|
+
{ env: gcloudEnv(config), timeoutMs: 60_000 },
|
|
113
|
+
);
|
|
114
|
+
if (result.code !== 0) throw new ServiceError(`gcloud auth activate-service-account failed: ${result.stderr.trim()}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Opens the IAP tunnel, runs one fixed command over it, closes the tunnel. */
|
|
118
|
+
async function overTunnel(config: EdgeConfig, remote: string, timeoutMs: number, input?: string): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
119
|
+
await activateServiceAccount(config);
|
|
120
|
+
const tunnel = await openTunnel(
|
|
121
|
+
config.gcloud,
|
|
122
|
+
[
|
|
123
|
+
"compute",
|
|
124
|
+
"start-iap-tunnel",
|
|
125
|
+
config.instance,
|
|
126
|
+
"22",
|
|
127
|
+
`--local-host-port=localhost:${config.iapLocalPort}`,
|
|
128
|
+
"--zone",
|
|
129
|
+
config.zone,
|
|
130
|
+
"--project",
|
|
131
|
+
config.project,
|
|
132
|
+
"--quiet",
|
|
133
|
+
],
|
|
134
|
+
{ env: gcloudEnv(config), port: config.iapLocalPort, readyTimeoutMs: 45_000 },
|
|
135
|
+
);
|
|
136
|
+
try {
|
|
137
|
+
return await sshFixed(
|
|
138
|
+
{
|
|
139
|
+
host: "127.0.0.1",
|
|
140
|
+
port: config.iapLocalPort,
|
|
141
|
+
hostKeyAlias: config.instance,
|
|
142
|
+
user: config.sshUser,
|
|
143
|
+
keyFile: secretPath(SERVICE, "SSH_KEY_FRONTDOOR"),
|
|
144
|
+
knownHostsFile: join(specialistsHome(), "config", "known_hosts"),
|
|
145
|
+
},
|
|
146
|
+
remote,
|
|
147
|
+
timeoutMs,
|
|
148
|
+
input,
|
|
149
|
+
);
|
|
150
|
+
} finally {
|
|
151
|
+
await tunnel.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function repoSpec(config: EdgeConfig): RepoSpec {
|
|
156
|
+
return {
|
|
157
|
+
service: SERVICE,
|
|
158
|
+
checkoutName: "homelab",
|
|
159
|
+
remote: config.homelabRemote,
|
|
160
|
+
branch: config.homelabBranch,
|
|
161
|
+
deployKeyField: "HOMELAB_DEPLOY_KEY",
|
|
162
|
+
writeRoot: config.repoArea,
|
|
163
|
+
help: HELP,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The nginx.conf to deploy: only what origin already has. */
|
|
168
|
+
async function trackedNginxConf(config: EdgeConfig): Promise<string> {
|
|
169
|
+
const spec = repoSpec(config);
|
|
170
|
+
const dir = await ensureCheckout(spec);
|
|
171
|
+
const status = await repoStatus(spec, dir);
|
|
172
|
+
if (status.changed.length > 0) throw new UsageError(`checkout has uncommitted changes: ${status.changed.join(", ")}; commit and push first`);
|
|
173
|
+
if (status.ahead > 0) throw new UsageError(`checkout is ${status.ahead} commit(s) ahead of origin; push first`);
|
|
174
|
+
if (status.behind > 0) throw new UsageError(`checkout is ${status.behind} commit(s) behind origin; run git pull first`);
|
|
175
|
+
return readFileSync(safeRepoPath(dir, config.nginxConf), "utf-8");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function vps(config: EdgeConfig, args: string[]): Promise<number> {
|
|
179
|
+
const { remote, stdin } = buildVpsRemote(args);
|
|
180
|
+
const input = stdin ? await trackedNginxConf(config) : undefined;
|
|
181
|
+
const result = await overTunnel(config, remote, remote === "reboot" ? 30_000 : 120_000, input);
|
|
182
|
+
if (result.stdout.length > 0) printRaw(result.stdout);
|
|
183
|
+
if (remote === "reboot") {
|
|
184
|
+
printJson({ rebooting: true, note: "the ssh session ends with the reboot; run probe until it succeeds, then vps status" });
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `vps ${args[0]} exited ${result.code}`);
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function probe(config: EdgeConfig): Promise<number> {
|
|
192
|
+
const url = `https://${config.probeHost}/.well-known/openid-configuration`;
|
|
193
|
+
const result = await run(
|
|
194
|
+
"/usr/bin/curl",
|
|
195
|
+
["--silent", "--output", "/dev/null", "--max-time", "5", "--resolve", `${config.probeHost}:443:${config.publicIp}`, "--write-out", "%{http_code} %{time_total}", url],
|
|
196
|
+
{ timeoutMs: 15_000 },
|
|
197
|
+
);
|
|
198
|
+
const [httpCode, seconds] = result.stdout.trim().split(" ");
|
|
199
|
+
const ok = result.code === 0 && httpCode === "200";
|
|
200
|
+
printJson({ ok, url, via: config.publicIp, httpCode: httpCode || null, seconds: seconds ? Number(seconds) : null, curlExit: result.code });
|
|
201
|
+
return ok ? 0 : 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function command(args: string[]): Promise<number> {
|
|
205
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
|
|
206
|
+
printRaw(HELP);
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
const config = readConfig<EdgeConfig>(SERVICE, SHAPE);
|
|
210
|
+
switch (args[0]) {
|
|
211
|
+
case "probe":
|
|
212
|
+
return probe(config);
|
|
213
|
+
case "vps":
|
|
214
|
+
return vps(config, args.slice(1));
|
|
215
|
+
case "git":
|
|
216
|
+
return repoCommand(repoSpec(config), args.slice(1));
|
|
217
|
+
default:
|
|
218
|
+
throw new UsageError(`unknown command: ${args[0]}\n${HELP}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
|
223
|
+
await main(SERVICE, command);
|
|
224
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Forced command for the spc-edge key on the Frontdoor VPS (user spc-edge).
|
|
3
|
+
# authorized_keys line:
|
|
4
|
+
# command="/usr/local/sbin/spc-edge-frontdoor-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-edge-frontdoor@cortex
|
|
5
|
+
# Privileged steps go through the exact sudoers entries in sudoers-spc-edge.
|
|
6
|
+
set -eu
|
|
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
|
+
units="nginx wg-quick@wg0 crowdsec-firewall-bouncer wazuh-agent rsyslog"
|
|
13
|
+
staged=/var/lib/spc-edge/nginx.conf.new
|
|
14
|
+
|
|
15
|
+
case "$name" in
|
|
16
|
+
status)
|
|
17
|
+
for s in $units; do printf '%-28s %s\n' "$s" "$(systemctl is-active "$s" || true)"; done
|
|
18
|
+
printf '%-28s %s\n' "wireguard handshake" "$(sudo -n /usr/bin/wg show wg0 latest-handshakes | awk '{ print systime()-$2 "s ago" }')"
|
|
19
|
+
printf '%-28s %s\n' "reboot required" "$([ -f /var/run/reboot-required ] && echo yes || echo no)"
|
|
20
|
+
printf '%-28s %s\n' "kernel" "$(uname -r)"
|
|
21
|
+
printf '%-28s %s\n' "uptime" "$(uptime -p)" ;;
|
|
22
|
+
nginx-conf) cat /etc/nginx/nginx.conf ;;
|
|
23
|
+
nginx-test) sudo -n /usr/sbin/nginx -t ;;
|
|
24
|
+
journal)
|
|
25
|
+
unit="${rest%% *}"; since="${rest#* }"
|
|
26
|
+
case "$unit" in nginx|wg-quick@wg0|crowdsec-firewall-bouncer|wazuh-agent|rsyslog|unattended-upgrades|ssh) ;; *) echo "bad unit" >&2; exit 2 ;; esac
|
|
27
|
+
since_ok "$since" || { echo "bad since" >&2; exit 2; }
|
|
28
|
+
journalctl -u "$unit" --since "$since" --no-pager -n 200 ;;
|
|
29
|
+
wg) sudo -n /usr/bin/wg show wg0 ;;
|
|
30
|
+
bouncer)
|
|
31
|
+
systemctl status crowdsec-firewall-bouncer --no-pager --lines=5 || true
|
|
32
|
+
sudo -n /usr/sbin/nft list set ip crowdsec crowdsec-blacklists ;;
|
|
33
|
+
updates)
|
|
34
|
+
apt list --upgradable 2>/dev/null
|
|
35
|
+
if [ -f /var/run/reboot-required.pkgs ]; then echo "reboot required by:"; cat /var/run/reboot-required.pkgs; fi ;;
|
|
36
|
+
deploy-nginx)
|
|
37
|
+
umask 022
|
|
38
|
+
cat > "$staged"
|
|
39
|
+
sudo -n /usr/sbin/nginx -t -c "$staged"
|
|
40
|
+
sudo -n /usr/bin/install -o root -g root -m 644 "$staged" /etc/nginx/nginx.conf
|
|
41
|
+
rm -f "$staged"
|
|
42
|
+
sudo -n /usr/sbin/nginx -t
|
|
43
|
+
sudo -n /usr/bin/systemctl reload nginx
|
|
44
|
+
systemctl is-active nginx ;;
|
|
45
|
+
reboot) sudo -n /usr/bin/systemctl reboot ;;
|
|
46
|
+
*) echo "refused: $cmd" >&2; exit 2 ;;
|
|
47
|
+
esac
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# One-time setup of the spc-edge user on the Frontdoor VPS. Run as root with
|
|
3
|
+
# these files in /tmp: spc-edge-frontdoor-entry, spc-edge-frontdoor-sudoers,
|
|
4
|
+
# spc-edge-frontdoor.pub. Idempotent.
|
|
5
|
+
set -eu
|
|
6
|
+
for f in spc-edge-frontdoor-entry spc-edge-frontdoor-sudoers spc-edge-frontdoor.pub; do
|
|
7
|
+
[ -f "/tmp/$f" ] || { echo "missing /tmp/$f" >&2; exit 2; }
|
|
8
|
+
done
|
|
9
|
+
id spc-edge >/dev/null 2>&1 || useradd --system --create-home --home-dir /var/lib/spc-edge --shell /bin/sh --groups systemd-journal spc-edge
|
|
10
|
+
install -o root -g root -m 0755 /tmp/spc-edge-frontdoor-entry /usr/local/sbin/spc-edge-frontdoor-entry
|
|
11
|
+
install -o root -g root -m 0440 /tmp/spc-edge-frontdoor-sudoers /etc/sudoers.d/spc-edge
|
|
12
|
+
visudo -cf /etc/sudoers.d/spc-edge
|
|
13
|
+
install -d -o spc-edge -g spc-edge -m 0700 /var/lib/spc-edge/.ssh
|
|
14
|
+
printf 'command="/usr/local/sbin/spc-edge-frontdoor-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding %s\n' "$(cat /tmp/spc-edge-frontdoor.pub)" > /var/lib/spc-edge/.ssh/authorized_keys
|
|
15
|
+
chown spc-edge:spc-edge /var/lib/spc-edge/.ssh/authorized_keys
|
|
16
|
+
chmod 0600 /var/lib/spc-edge/.ssh/authorized_keys
|
|
17
|
+
rm -f /tmp/spc-edge-frontdoor-entry /tmp/spc-edge-frontdoor-sudoers /tmp/spc-edge-frontdoor.pub
|
|
18
|
+
echo "spc-edge ready: $(id spc-edge)"
|
|
19
|
+
echo "sshd key sources:"
|
|
20
|
+
sshd -T 2>/dev/null | grep -Ei '^(authorizedkeysfile|authorizedkeyscommand|pubkeyauthentication) ' || true
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
# /etc/sudoers.d/spc-edge (0440): the exact privileged commands spc-edge-frontdoor-entry runs.
|
|
2
|
+
spc-edge ALL=(root) NOPASSWD: /usr/sbin/nginx -t, /usr/sbin/nginx -t -c /var/lib/spc-edge/nginx.conf.new, /usr/bin/install -o root -g root -m 644 /var/lib/spc-edge/nginx.conf.new /etc/nginx/nginx.conf, /usr/bin/systemctl reload nginx, /usr/bin/systemctl reboot, /usr/bin/wg show wg0, /usr/bin/wg show wg0 latest-handshakes, /usr/sbin/nft list set ip crowdsec crowdsec-blacklists
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Forced command for the spc-security key on VM 106 (user observability, passwordless sudo).
|
|
3
|
+
# authorized_keys line:
|
|
4
|
+
# command="/usr/local/sbin/spc-security-obs-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-security-obs@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
|
+
int_ok() { printf '%s' "$1" | grep -Eq '^[1-9][0-9]{0,3}$'; }
|
|
12
|
+
name_ok() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$'; }
|
|
13
|
+
ip_ok() { printf '%s' "$1" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}$'; }
|
|
14
|
+
wazuh=observability-wazuh-manager-1
|
|
15
|
+
|
|
16
|
+
case "$name" in
|
|
17
|
+
status)
|
|
18
|
+
for s in rsyslog crowdsec nftables docker security-attention.timer update-home-whitelist.timer; do
|
|
19
|
+
printf '%-32s %s\n' "$s" "$(systemctl is-active "$s" || true)"
|
|
20
|
+
done
|
|
21
|
+
sudo -n docker ps --format '{{.Names}} {{.Status}}' ;;
|
|
22
|
+
attention) sudo -n /usr/local/sbin/security-attention status ;;
|
|
23
|
+
agents) sudo -n docker exec "$wazuh" /var/ossec/bin/agent_control -l ;;
|
|
24
|
+
alerts)
|
|
25
|
+
days="${rest%% *}"; ip=""; [ "$rest" != "$days" ] && ip="${rest#* }"
|
|
26
|
+
int_ok "$days" || { echo "bad days" >&2; exit 2; }
|
|
27
|
+
if [ -n "$ip" ]; then
|
|
28
|
+
ip_ok "$ip" || { echo "bad ip" >&2; exit 2; }
|
|
29
|
+
sudo -n cscli alerts list --since "${days}d" -l 0 -o json --ip "$ip"
|
|
30
|
+
else
|
|
31
|
+
sudo -n cscli alerts list --since "${days}d" -l 0 -o json
|
|
32
|
+
fi ;;
|
|
33
|
+
decisions) sudo -n cscli decisions list -l 0 -o json ;;
|
|
34
|
+
bouncers) sudo -n cscli bouncers list -o json ;;
|
|
35
|
+
metrics) sudo -n cscli metrics -o json ;;
|
|
36
|
+
wazuh-alerts)
|
|
37
|
+
int_ok "$rest" || { echo "bad days" >&2; exit 2; }
|
|
38
|
+
sudo -n docker exec "$wazuh" sh -c "find /var/ossec/logs/alerts -type f -name '*.json.gz' -mtime -$((rest + 1)) -exec zcat {} + 2>/dev/null; cat /var/ossec/logs/alerts/alerts.json" ;;
|
|
39
|
+
wazuh-log)
|
|
40
|
+
int_ok "$rest" || { echo "bad lines" >&2; exit 2; }
|
|
41
|
+
sudo -n docker logs --tail "$rest" "$wazuh" 2>&1 ;;
|
|
42
|
+
remote-hosts) sudo -n find /var/log/remote -mindepth 2 -maxdepth 2 -name '*.log' -printf '%P\n' | sort ;;
|
|
43
|
+
remote-log)
|
|
44
|
+
set -- $rest
|
|
45
|
+
[ "$#" -eq 3 ] || { echo "remote-log <host> <program> <lines>" >&2; exit 2; }
|
|
46
|
+
name_ok "$1" && name_ok "$2" && int_ok "$3" || { echo "bad argument" >&2; exit 2; }
|
|
47
|
+
sudo -n tail -n "$3" "/var/log/remote/$1/$2.log" ;;
|
|
48
|
+
journal)
|
|
49
|
+
unit="${rest%% *}"; since="${rest#* }"
|
|
50
|
+
case "$unit" in crowdsec|rsyslog|nftables|docker|security-attention|update-home-whitelist|ssh) ;; *) echo "bad unit" >&2; exit 2 ;; esac
|
|
51
|
+
since_ok "$since" || { echo "bad since" >&2; exit 2; }
|
|
52
|
+
sudo -n journalctl -u "$unit" --since "$since" --no-pager -n 200 ;;
|
|
53
|
+
*) echo "refused: $cmd" >&2; exit 2 ;;
|
|
54
|
+
esac
|
|
@@ -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"
|
|
14
|
+
rm -rf "$bin/lib" "$bin/arcane" "$bin/identity" "$bin/network" "$bin/dns" "$bin/edge" "$bin/security"
|
|
15
15
|
cp -R "$src/specialists/lib" "$bin/lib"
|
|
16
|
-
for s in arcane identity network; do
|
|
16
|
+
for s in arcane identity network dns edge security; 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,171 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { ServiceError, UsageError } from "./errors.ts";
|
|
4
|
+
import { printJson, printRaw } from "./output.ts";
|
|
5
|
+
import { specialistsHome, workDir } from "./paths.ts";
|
|
6
|
+
import { run } from "./proc.ts";
|
|
7
|
+
import { secretPath } from "./secrets.ts";
|
|
8
|
+
|
|
9
|
+
/** A Git repository the specialist edits through a write deploy key. */
|
|
10
|
+
export interface RepoSpec {
|
|
11
|
+
/** Specialist that owns the deploy key. */
|
|
12
|
+
service: string;
|
|
13
|
+
/** Folder name under ~/.specialists/work. */
|
|
14
|
+
checkoutName: string;
|
|
15
|
+
remote: string;
|
|
16
|
+
branch: string;
|
|
17
|
+
/** Secret field holding the private deploy key. */
|
|
18
|
+
deployKeyField: string;
|
|
19
|
+
/** Folder inside the repository that `git write` may touch; the whole checkout when absent. */
|
|
20
|
+
writeRoot?: string;
|
|
21
|
+
/** Wrapper help, printed with usage errors. */
|
|
22
|
+
help: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const REPO_HELP = ` git status branch, ahead/behind, changed files of the repository checkout
|
|
26
|
+
git pull fast-forward the checkout from origin
|
|
27
|
+
git ls [dir] list files in the checkout
|
|
28
|
+
git show <path> print a file from the checkout
|
|
29
|
+
git diff unstaged and staged changes
|
|
30
|
+
git log [n] last n commits (default 10)
|
|
31
|
+
git write <path> <content> write a file inside the checkout (creates folders)
|
|
32
|
+
git commit <message> stage everything and commit
|
|
33
|
+
git push push the branch to origin`;
|
|
34
|
+
|
|
35
|
+
const TIMEOUT_MS = 240_000;
|
|
36
|
+
|
|
37
|
+
export interface RepoStatus {
|
|
38
|
+
branch: string;
|
|
39
|
+
ahead: number;
|
|
40
|
+
behind: number;
|
|
41
|
+
changed: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function gitEnv(spec: RepoSpec): Record<string, string> {
|
|
45
|
+
const key = secretPath(spec.service, spec.deployKeyField);
|
|
46
|
+
const knownHosts = join(specialistsHome(), "config", "known_hosts");
|
|
47
|
+
return {
|
|
48
|
+
GIT_SSH_COMMAND: `/usr/bin/ssh -i ${key} -o IdentitiesOnly=yes -o BatchMode=yes -o UserKnownHostsFile=${knownHosts} -o StrictHostKeyChecking=accept-new`,
|
|
49
|
+
GIT_AUTHOR_NAME: "specialist",
|
|
50
|
+
GIT_AUTHOR_EMAIL: "specialists@monadeo.com",
|
|
51
|
+
GIT_COMMITTER_NAME: "specialist",
|
|
52
|
+
GIT_COMMITTER_EMAIL: "specialists@monadeo.com",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function git(spec: RepoSpec, dir: string, args: string[]): Promise<string> {
|
|
57
|
+
const result = await run("/usr/bin/git", ["-C", dir, ...args], { env: gitEnv(spec), timeoutMs: TIMEOUT_MS });
|
|
58
|
+
if (result.code !== 0) throw new ServiceError(`git ${args[0]} failed: ${result.stderr.trim()}`);
|
|
59
|
+
return result.stdout;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function ensureCheckout(spec: RepoSpec): Promise<string> {
|
|
63
|
+
const dir = workDir(spec.checkoutName);
|
|
64
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
65
|
+
mkdirSync(dirname(dir), { recursive: true, mode: 0o700 });
|
|
66
|
+
const result = await run("/usr/bin/git", ["clone", "--branch", spec.branch, "--single-branch", spec.remote, dir], {
|
|
67
|
+
env: gitEnv(spec),
|
|
68
|
+
timeoutMs: TIMEOUT_MS,
|
|
69
|
+
});
|
|
70
|
+
if (result.code !== 0) throw new ServiceError(`git clone failed: ${result.stderr.trim()}`);
|
|
71
|
+
return dir;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function safeRepoPath(dir: string, rel: string): string {
|
|
75
|
+
if (rel.length === 0 || rel.startsWith("/") || rel.split("/").includes("..") || rel.includes("\0")) {
|
|
76
|
+
throw new UsageError(`invalid repository path: ${rel}`);
|
|
77
|
+
}
|
|
78
|
+
const target = resolve(dir, rel);
|
|
79
|
+
const inside = relative(dir, target);
|
|
80
|
+
if (inside.startsWith("..") || inside.startsWith(".git/") || inside === ".git") {
|
|
81
|
+
throw new UsageError(`path escapes the checkout: ${rel}`);
|
|
82
|
+
}
|
|
83
|
+
return target;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `git write` target; refuses paths outside `writeRoot` when the spec has one. */
|
|
87
|
+
export function safeWritePath(dir: string, rel: string, writeRoot?: string): string {
|
|
88
|
+
const target = safeRepoPath(dir, rel);
|
|
89
|
+
if (writeRoot !== undefined) {
|
|
90
|
+
const inside = relative(resolve(dir, writeRoot), target);
|
|
91
|
+
if (inside.length === 0 || inside.startsWith("..")) throw new UsageError(`writes are limited to ${writeRoot}/`);
|
|
92
|
+
}
|
|
93
|
+
return target;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Fetches origin and reports how the checkout relates to it. */
|
|
97
|
+
export async function repoStatus(spec: RepoSpec, dir: string): Promise<RepoStatus> {
|
|
98
|
+
await git(spec, dir, ["fetch", "--quiet", "origin", spec.branch]);
|
|
99
|
+
const branch = (await git(spec, dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
100
|
+
const counts = (await git(spec, dir, ["rev-list", "--left-right", "--count", `HEAD...origin/${spec.branch}`])).trim();
|
|
101
|
+
const [ahead, behind] = counts.split(/\s+/).map(Number);
|
|
102
|
+
const changed = (await git(spec, dir, ["status", "--porcelain"])).split("\n").filter(Boolean);
|
|
103
|
+
return { branch, ahead, behind, changed };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function repoCommand(spec: RepoSpec, args: string[]): Promise<number> {
|
|
107
|
+
const [sub, ...rest] = args;
|
|
108
|
+
const dir = await ensureCheckout(spec);
|
|
109
|
+
switch (sub) {
|
|
110
|
+
case "status":
|
|
111
|
+
printJson(await repoStatus(spec, dir));
|
|
112
|
+
return 0;
|
|
113
|
+
case "pull":
|
|
114
|
+
printRaw(await git(spec, dir, ["pull", "--ff-only", "origin", spec.branch]));
|
|
115
|
+
return 0;
|
|
116
|
+
case "diff":
|
|
117
|
+
printRaw((await git(spec, dir, ["diff", "HEAD"])) || "(no changes)");
|
|
118
|
+
return 0;
|
|
119
|
+
case "ls": {
|
|
120
|
+
if (rest.length > 1) throw new UsageError("git ls [dir]");
|
|
121
|
+
const target = rest[0] === undefined ? dir : safeRepoPath(dir, rest[0]);
|
|
122
|
+
if (!existsSync(target) || !statSync(target).isDirectory()) throw new UsageError(`not a directory in the checkout: ${rest[0] ?? "."}`);
|
|
123
|
+
printJson(
|
|
124
|
+
readdirSync(target, { withFileTypes: true })
|
|
125
|
+
.filter((e) => e.name !== ".git")
|
|
126
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
|
127
|
+
.sort(),
|
|
128
|
+
);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
case "show": {
|
|
132
|
+
if (rest.length !== 1) throw new UsageError("git show <path>");
|
|
133
|
+
const target = safeRepoPath(dir, rest[0]);
|
|
134
|
+
if (!existsSync(target) || !statSync(target).isFile()) throw new UsageError(`not a file in the checkout: ${rest[0]}`);
|
|
135
|
+
printRaw(readFileSync(target, "utf-8"));
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
case "log": {
|
|
139
|
+
const n = rest[0] === undefined ? 10 : Number(rest[0]);
|
|
140
|
+
if (!Number.isInteger(n) || n <= 0 || n > 200) throw new UsageError("git log [n]: n must be 1..200");
|
|
141
|
+
printRaw(await git(spec, dir, ["log", `-n${n}`, "--format=%h %ad %s", "--date=short"]));
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
case "write": {
|
|
145
|
+
if (rest.length !== 2) throw new UsageError("git write <path> <content>");
|
|
146
|
+
const target = safeWritePath(dir, rest[0], spec.writeRoot);
|
|
147
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
148
|
+
writeFileSync(target, rest[1].endsWith("\n") ? rest[1] : `${rest[1]}\n`, { mode: 0o600 });
|
|
149
|
+
printJson({ written: rest[0], bytes: Buffer.byteLength(rest[1]) });
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
case "commit": {
|
|
153
|
+
const message = rest.join(" ").trim();
|
|
154
|
+
if (message.length === 0) throw new UsageError("git commit <message>");
|
|
155
|
+
await git(spec, dir, ["add", "--all"]);
|
|
156
|
+
const staged = (await git(spec, dir, ["diff", "--cached", "--name-only"])).split("\n").filter(Boolean);
|
|
157
|
+
if (staged.length === 0) throw new UsageError("nothing to commit");
|
|
158
|
+
await git(spec, dir, ["commit", "--quiet", "-m", message]);
|
|
159
|
+
printJson({ committed: (await git(spec, dir, ["rev-parse", "--short", "HEAD"])).trim(), files: staged });
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
case "push": {
|
|
163
|
+
const result = await run("/usr/bin/git", ["-C", dir, "push", "origin", spec.branch], { env: gitEnv(spec), timeoutMs: TIMEOUT_MS });
|
|
164
|
+
if (result.code !== 0) throw new ServiceError(`git push failed: ${result.stderr.trim()}`);
|
|
165
|
+
printRaw(result.stderr.trim() || "pushed");
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
default:
|
|
169
|
+
throw new UsageError(`unknown git subcommand: ${sub ?? "(none)"}\n${spec.help}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
package/specialists/lib/ssh.ts
CHANGED
|
@@ -5,32 +5,34 @@ export interface SshTarget {
|
|
|
5
5
|
user: string;
|
|
6
6
|
keyFile: string;
|
|
7
7
|
knownHostsFile: string;
|
|
8
|
+
/** TCP port; ssh's default when absent. */
|
|
9
|
+
port?: number;
|
|
10
|
+
/** known_hosts name when `host` is a local tunnel endpoint. */
|
|
11
|
+
hostKeyAlias?: string;
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
15
|
* Runs one fixed remote command through a key that the target pins to a
|
|
12
16
|
* forced command. `remote` is a string from the wrapper's own table, never
|
|
13
|
-
* agent input.
|
|
17
|
+
* agent input. `input`, when given, becomes the remote command's stdin.
|
|
14
18
|
*/
|
|
15
|
-
export function sshFixed(target: SshTarget, remote: string, timeoutMs: number): Promise<RunResult> {
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
{ timeoutMs },
|
|
35
|
-
);
|
|
19
|
+
export function sshFixed(target: SshTarget, remote: string, timeoutMs: number, input?: string): Promise<RunResult> {
|
|
20
|
+
const args = [
|
|
21
|
+
"-i",
|
|
22
|
+
target.keyFile,
|
|
23
|
+
"-o",
|
|
24
|
+
"IdentitiesOnly=yes",
|
|
25
|
+
"-o",
|
|
26
|
+
"BatchMode=yes",
|
|
27
|
+
"-o",
|
|
28
|
+
`UserKnownHostsFile=${target.knownHostsFile}`,
|
|
29
|
+
"-o",
|
|
30
|
+
"StrictHostKeyChecking=accept-new",
|
|
31
|
+
"-o",
|
|
32
|
+
"ConnectTimeout=10",
|
|
33
|
+
];
|
|
34
|
+
if (target.port !== undefined) args.push("-p", String(target.port));
|
|
35
|
+
if (target.hostKeyAlias !== undefined) args.push("-o", `HostKeyAlias=${target.hostKeyAlias}`);
|
|
36
|
+
args.push(`${target.user}@${target.host}`, remote);
|
|
37
|
+
return run("/usr/bin/ssh", args, { timeoutMs, input });
|
|
36
38
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { connect } from "node:net";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { ServiceError } from "./errors.ts";
|
|
4
|
+
import { openTunnel } from "./tunnel.ts";
|
|
5
|
+
|
|
6
|
+
function freePort(): Promise<number> {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
import("node:net").then(({ createServer }) => {
|
|
9
|
+
const server = createServer();
|
|
10
|
+
server.listen(0, "127.0.0.1", () => {
|
|
11
|
+
const address = server.address();
|
|
12
|
+
server.close(() => (typeof address === "object" && address ? resolve(address.port) : reject(new Error("no port"))));
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("openTunnel", () => {
|
|
19
|
+
it("resolves once the process listens and closes it afterwards", async () => {
|
|
20
|
+
const port = await freePort();
|
|
21
|
+
const script = `setTimeout(() => require("node:net").createServer().listen(${port}, "127.0.0.1"), 300); setInterval(() => {}, 1000);`;
|
|
22
|
+
const tunnel = await openTunnel(process.execPath, ["-e", script], { env: {}, port, readyTimeoutMs: 5000 });
|
|
23
|
+
await tunnel.close();
|
|
24
|
+
const closed = await new Promise<boolean>((resolve) => {
|
|
25
|
+
const socket = connect({ port, host: "127.0.0.1" });
|
|
26
|
+
socket.once("connect", () => {
|
|
27
|
+
socket.destroy();
|
|
28
|
+
resolve(false);
|
|
29
|
+
});
|
|
30
|
+
socket.once("error", () => resolve(true));
|
|
31
|
+
});
|
|
32
|
+
expect(closed).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("reports a process that exits before listening", async () => {
|
|
36
|
+
const port = await freePort();
|
|
37
|
+
await expect(openTunnel("/bin/sh", ["-c", "echo denied >&2; exit 3"], { env: {}, port, readyTimeoutMs: 5000 })).rejects.toThrow(/exited with code 3: denied/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("gives up when nothing listens in time", async () => {
|
|
41
|
+
const port = await freePort();
|
|
42
|
+
await expect(openTunnel("/bin/sleep", ["5"], { env: {}, port, readyTimeoutMs: 600 })).rejects.toThrow(ServiceError);
|
|
43
|
+
});
|
|
44
|
+
});
|