@astrofoundry/pi-astro 0.20.3 → 0.21.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/README.md CHANGED
@@ -79,7 +79,7 @@ pi # launch; confirm [Extensions] lists astro-subagents, grimoire
79
79
 
80
80
  **Bundled subagents** (call them as `astro.<name>`):
81
81
  - General-purpose: `code-reviewer`, `google-tech-lead`, `spec-writer`, `tester-api`, `tester-ui`, `ui-architect`, `ui-design-system`, `ui-frontend-developer`
82
- - Specialists (one homelab area each, need the host setup in [specialists/README.md](specialists/README.md)): `arcane` (Arcane container platform, GitOps first), `identity` (Zitadel, Pomerium, certificates), `network` (UniFi, FreeRADIUS, Tailscale, read-only), `dns` (Technitium resolvers, Cloudflare zones), `edge` (Frontdoor VPS over IAP, nginx SNI allowlist through the homelab repository), `security` (Wazuh, CrowdSec, log archive, read-only), `backup` (vzdump, Restic layers, GCS offsite, restore tests), `proxmox` (guest lifecycle, snapshots, resources; no create or destroy), `inference` (llama.cpp on Nexus, Hermes gateways and upgrades, Europa health)
82
+ - Specialists (one homelab area each, need the host setup in [specialists/README.md](specialists/README.md)): `arcane` (Arcane container platform, GitOps first), `identity` (Zitadel, Pomerium routes through the homelab repository, certificates), `network` (UniFi reads and firewall policies, FreeRADIUS, Tailscale), `dns` (Technitium resolvers, Cloudflare zones), `edge` (Frontdoor VPS over IAP, nginx SNI allowlist through the homelab repository), `security` (Wazuh, CrowdSec, log archive, read-only), `backup` (vzdump, Restic layers, GCS offsite, restore tests), `proxmox` (guest lifecycle, snapshots, resources; no create or destroy), `inference` (llama.cpp on Nexus, Hermes gateways and upgrades, Europa health)
83
83
  - Flourish pipeline: `flourish-input-vision` (image to spec), `flourish-input-narrative` (text to spec), `flourish-input-codebase` (HTML to spec), `flourish-architect` (spec to plan), `flourish-template-developer` (plan to SDK template, needs `@flourish/sdk`), `flourish-example-builder` (template to realistic example), `flourish-preview-runner` (multi-breakpoint preview + screenshots, needs `@flourish/sdk`), `flourish-reviewer` (audit)
84
84
 
85
85
  **Skills** (loadable via `/skill:<name>`):
@@ -17,6 +17,6 @@ Rules:
17
17
  - Read before you write. List existing Zitadel resources before creating one; never create duplicates.
18
18
  - Zitadel calls use the v2 resource APIs (`/v2/...`); use `/management/v1/` or `/admin/v1/` only when v2 lacks the operation.
19
19
  - Responses are redacted; a client secret or key is shown once by Zitadel and is not recoverable through this tool. When an operation returns a secret, tell the caller to store it in Vaultwarden immediately.
20
- - Pomerium route changes are edits to `02-pulsar-proxmox/dmz/config.yaml` in the homelab repository, deployed with its `deploy.sh`. Through this tool you can render, restart, reload, renew certificates, and read state. Propose the route diff; do not claim to have deployed it.
20
+ - Pomerium routes live in `02-pulsar-proxmox/dmz/config.yaml` in the homelab repository. Change them there (`git pull`, `git show`, `git write`, `git diff`, `git commit`, `git push`), then `dmz deploy-config`, then `dmz status` and `dmz journal pomerium`. Never describe a route as live before `deploy-config` succeeded; when it rolls back, report the error and leave the repository commit for the operator to revert or fix.
21
21
  - Confirm destructive operations (deleting users, applications, projects) are explicitly requested in the task.
22
22
  - Delegate through `subagent` only to another specialist (`astro.arcane`, `astro.network`, `astro.dns`, `astro.edge`, `astro.security`, `astro.backup`, `astro.proxmox`, `astro.inference`) and only for that specialist's own area.
package/agents/network.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: network
3
- description: Operates the UniFi network (sites, devices, clients, networks, firewall policies), FreeRADIUS on LXC 108, and the Tailscale router on LXC 105 through the network tool. Use for network, VLAN, Wi-Fi, policy, and remote-access questions and checks.
3
+ description: Operates the UniFi network (sites, devices, clients, networks, zones, firewall policies including policy create, update, and delete), FreeRADIUS on LXC 108, and the Tailscale router on LXC 105 through the network tool. Use for network, VLAN, Wi-Fi, firewall policy, and remote-access questions, checks, and policy changes.
4
4
  tools: read, network, subagent
5
5
  skills: network
6
6
  systemPromptMode: replace
@@ -14,7 +14,9 @@ You are the network specialist. The `network` tool is your only way to reach Uni
14
14
 
15
15
  Rules:
16
16
 
17
- - UniFi access is read-only in this version. Report configuration and state; when a change is needed, describe the exact UniFi setting and value and stop.
17
+ - UniFi writes are limited to firewall policies. Before `policy-create`, read an existing policy of the same kind with `firewall-policy` and copy its structure; read `firewall-zones` for the zone ids; never invent a field name. After a write, read the policy back and report id, name, zones, filters, and action.
18
+ - `policy-delete` only when the task names the policy exactly; read it first and quote its name.
19
+ - Every other UniFi change (networks, Wi-Fi, devices, zones) is an operator task: describe the exact setting and value and stop.
18
20
  - Start with `["unifi", "sites"]` to get the site id; every other UniFi read needs it.
19
21
  - RADIUS and Tailscale operations are fixed checks on the guests; `radius-check` validates the FreeRADIUS configuration without restarting it.
20
22
  - Wi-Fi CA rotation and device certificates are out of scope for this tool; point the caller to the homelab repository's `radius` folder.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.20.3",
3
+ "version": "0.21.1",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -34,10 +34,20 @@ Fixed operations on VM 104 through a forced-command SSH key. No other command re
34
34
  | `pomerium-restart` | restart Pomerium |
35
35
  | `nginx-reload` | `nginx -t` then reload |
36
36
  | `lego-renew` | run the certificate renewal now |
37
+ | `deploy-config` | install the pushed `config.yaml` as `/etc/pomerium/config.yaml.template`, render with the Vaultwarden values, restart Pomerium, check it is active; on failure restores the previous template and restarts |
38
+
39
+ ## `git <command>` (homelab repository)
40
+
41
+ `status`, `pull`, `ls [dir]`, `show <path>`, `diff`, `log [n]`, `write <path> <content>`, `commit <message>`, `push`. Writes are limited to `02-pulsar-proxmox/dmz/`. The checkout is the specialist's own clone; `git pull` before reading or editing.
37
42
 
38
43
  ## Pomerium route changes
39
44
 
40
- Routes are defined in `02-pulsar-proxmox/dmz/config.yaml` in the homelab repository and deployed with `deploy.sh --proxy-only`, which also runs `render`. You cannot deploy a route change through this tool. Produce the exact YAML diff for the caller, then after deployment verify with `status` and `journal pomerium`.
45
+ 1. `git pull`, `git show 02-pulsar-proxmox/dmz/config.yaml`; find a route of the same kind (public with policy, or internal) and copy its shape. Secrets in the file are `{{ }}` placeholders rendered on the guest from Vaultwarden; never write a literal secret.
46
+ 2. `git write 02-pulsar-proxmox/dmz/config.yaml <whole file>`, `git diff` (only the intended lines), `git commit "Add Pomerium route for <hostname>"`, `git push`.
47
+ 3. `dmz deploy-config`; it refuses while the checkout is dirty or differs from origin.
48
+ 4. `dmz status`, `dmz journal pomerium '5 minutes ago'`. Report the route, the commit, and the health.
49
+
50
+ A public hostname also needs the VPS SNI entry (edge specialist), the internal DNS zone pointing at `10.0.20.10` and the public record (dns specialist); say which of those still misses.
41
51
 
42
52
  ## When to stop and report
43
53
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: network
3
- description: How the network specialist reads UniFi and checks FreeRADIUS and Tailscale through the network tool.
3
+ description: How the network specialist reads UniFi, manages firewall policies, and checks FreeRADIUS and Tailscale through the network tool.
4
4
  ---
5
5
 
6
6
  # Network specialist
@@ -18,7 +18,9 @@ Read-only calls to the console's Network API, TLS pinned to the console certific
18
18
  | `device <siteId> <deviceId>` | one device with ports and radios |
19
19
  | `clients <siteId>` | connected clients |
20
20
  | `networks <siteId>` | networks and VLANs |
21
- | `firewall-policies <siteId>` | zone policies |
21
+ | `firewall-policies <siteId>` | zone policies (filter with `--filter "name.like('%from Cortex')"`) |
22
+ | `firewall-policy <siteId> <policyId>` | one policy, full object |
23
+ | `firewall-zones <siteId>` | zones with ids and member networks |
22
24
  | `wans <siteId>` | WAN interfaces |
23
25
  | `get /v1/...` | any other documented GET |
24
26
 
@@ -31,6 +33,21 @@ Filters use the API's syntax, for example `--filter "state.eq('ONLINE')"`. Pagin
31
33
 
32
34
  `unifi fingerprint` prints the console certificate fingerprint; the operator uses it to pin the certificate in the wrapper config.
33
35
 
36
+ ## `unifi policy-create|policy-update|policy-delete`
37
+
38
+ | Command | Effect |
39
+ |---|---|
40
+ | `policy-create <siteId> <json>` | `POST /v1/sites/{siteId}/firewall/policies`; the body needs `name`, `enabled`, `action`, `source`, `destination`, `ipProtocolScope`, `loggingEnabled` |
41
+ | `policy-update <siteId> <policyId> <json>` | `PUT` with the complete policy object |
42
+ | `policy-delete <siteId> <policyId> --confirm` | delete |
43
+
44
+ Procedure for a new allow policy: `firewall-zones` for the two zone ids; `firewall-policies` with a name filter to find a policy of the same shape (the `from Cortex` family: source zone plus source IP filter, destination zone plus IP and port filter, TCP, IPv4, return traffic allowed, logging off); `firewall-policy` to read it in full; build the new body from that object with new `name`, addresses, and ports, without `id`, `index`, or `metadata`; `policy-create`; read the result back. UniFi validates the body; quote its error message when it rejects one. Names follow `<what> - from <source>`.
45
+
46
+ ```json
47
+ { "args": ["unifi", "firewall-policies", "<siteId>", "--filter", "name.like('%from Cortex')"] }
48
+ { "args": ["unifi", "policy-create", "<siteId>", "{\"name\":\"Proxmox API - from Cortex\", ...}"] }
49
+ ```
50
+
34
51
  ## `pulsar <command>`
35
52
 
36
53
  Fixed checks through a forced-command SSH key to Pulsar (Proxmox host `10.0.10.37`), which runs them inside the guests.
@@ -47,4 +64,4 @@ Fixed checks through a forced-command SSH key to Pulsar (Proxmox host `10.0.10.3
47
64
 
48
65
  ## Out of scope in this version
49
66
 
50
- UniFi changes, Wi-Fi CA rotation, device certificate enrollment, and DNS. Describe the needed change precisely and stop. DNS belongs to the `dns` specialist.
67
+ UniFi changes other than firewall policies (networks, Wi-Fi, zones, devices), Wi-Fi CA rotation, device certificate enrollment, and DNS. Describe the needed change precisely and stop. DNS belongs to the `dns` specialist.
@@ -39,7 +39,8 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
39
39
  - Exit codes: `UsageError` 2, `ServiceError` 1, success 0. Diagnostics on stderr, results on stdout, JSON where possible.
40
40
  - Output filters: Arcane responses pass `stripKeys(ARCANE_DENIED_KEYS)`; Zitadel responses pass `redactSecrets`. Add keys there rather than in a wrapper.
41
41
  - Fixed remote commands: `DMZ_COMMANDS`, `PULSAR_COMMANDS`, `VPS_COMMANDS`, `OBS_COMMANDS`, `BACKUP_COMMANDS`, `HOST_COMMANDS`, and `INFERENCE_COMMANDS` must match the entry scripts in `entry/remote/` and their tracked copies in the homelab repository (`02-pulsar-proxmox/dmz/system/`, `02-pulsar-proxmox/pulsar/system/`, `00-frontdoor-vps/system/`, `02-pulsar-proxmox/observability/system/`, `02-pulsar-proxmox/hermes/system/`, `04-nexus-macstudio/system/`, and `host/` of the `astronaute77/arcane` repository). Pulsar's root `authorized_keys` carries one forced key per specialist (network, backup, proxmox, inference), each pinned to its own entry script. Change both sides in the same commit and redeploy the remote script. A new privileged step on the VPS also needs its exact line in `spc-edge-frontdoor-sudoers`.
42
- - Repository access is `lib/repo.ts` (`repoCommand`, `RepoSpec`): one clone per specialist under `~/.specialists/work/<checkoutName>`, a write deploy key, and an optional `writeRoot` that limits `git write`. `edge` deploys only what origin has: `deploy-nginx` refuses a dirty, ahead, or behind checkout.
42
+ - Repository access is `lib/repo.ts` (`repoCommand`, `RepoSpec`, `trackedFile`): one clone per specialist under `~/.specialists/work/<checkoutName>` (`homelab-edge`, `homelab-identity`; never share a checkout between specialists), a write deploy key, and an optional `writeRoot` that limits `git write`. `edge` (`deploy-nginx`) and `identity` (`dmz deploy-config`) deploy only what origin has: `trackedFile` refuses a dirty, ahead, or behind checkout. The DMZ entry keeps the previous template and restores it when render or restart fails.
43
+ - `network` firewall policy writes are `policyWrite`: JSON body from the agent, checked for the documented required fields, sent as `POST`, `PUT`, or `DELETE` through the same pinned request; UniFi validates the rest.
43
44
  - The IAP tunnel is `lib/tunnel.ts`: `gcloud compute start-iap-tunnel <instance> 22 --local-host-port=localhost:<port>` (a documented flag; the hidden `--listen-on-stdin` is not used), ready when the port accepts a connection, closed after the ssh call. `gcloud auth activate-service-account` runs before every tunnel with `CLOUDSDK_CONFIG` under `~/.specialists/work/gcloud` and `CLOUDSDK_PYTHON` from the config, because the Homebrew cask ships no interpreter.
44
45
  - `dns` builds every Technitium call from `TECHNITIUM_READS` and `TECHNITIUM_WRITES`; `key=value` record parameters pass through by name (`token` and `node` refused) because the API documents dozens of type-specific parameters. Writes are refused on the secondary in code: the catalog zone is the only replication path.
45
46
  - `security` parses Wazuh alert lines locally (`summariseWazuh`) so the guest never needs `jq`.
@@ -68,4 +69,4 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
68
69
 
69
70
  ## Next
70
71
 
71
- All nine specialists exist. Next in the agreed order: second-level tools (UniFi policy writes for `network`, Pomerium route deployment for `identity`, Europa scheduled-task control for `inference`), then Discord access to the specialists, then the Astrogate route.
72
+ All nine specialists exist; `network` writes firewall policies and `identity` deploys Pomerium routes. Open: Europa scheduled-task control for `inference` (needs Europa on to test Windows OpenSSH forced commands), then Discord access to the specialists, then the Astrogate route.
@@ -5,8 +5,8 @@ A specialist is a Pi subagent that is the only way to operate one area of the ho
5
5
  | Agent | Area | What it can do |
6
6
  |---|---|---|
7
7
  | `astro.arcane` | Arcane container platform (VM 100) and its GitOps repository | Every `arcane-cli` operation except its own `config`, `auth`, `self-update`; Compose changes of GitOps-managed projects through `git show|write|commit|push`, then `arcane-cli gitops` sync |
8
- | `astro.identity` | Zitadel; Pomerium, nginx, lego on VM 104 | Any Zitadel API call (v2, management, admin, auth) with responses redacted; `dmz status|journal|config-template|render|pomerium-restart|nginx-reload|lego-renew` |
9
- | `astro.network` | UniFi, FreeRADIUS (LXC 108), Tailscale (LXC 105) | Read UniFi sites, devices, clients, networks, firewall policies, WANs; RADIUS and Tailscale checks on Pulsar |
8
+ | `astro.identity` | Zitadel; Pomerium, nginx, lego on VM 104; `02-pulsar-proxmox/dmz/` in the homelab repository | Any Zitadel API call (v2, management, admin, auth) with responses redacted; `dmz status|journal|config-template|render|pomerium-restart|nginx-reload|lego-renew|deploy-config`; `git` on the homelab checkout, writes limited to `02-pulsar-proxmox/dmz/` |
9
+ | `astro.network` | UniFi, FreeRADIUS (LXC 108), Tailscale (LXC 105) | Read UniFi sites, devices, clients, networks, zones, firewall policies, WANs; create, update, delete firewall policies; RADIUS and Tailscale checks on Pulsar |
10
10
  | `astro.dns` | Technitium primary (Synapse) and secondary (LXC 107); Cloudflare zones `37pla.net`, `monadeo.com` | Read zones and records on both resolvers, resolve, stats; create and delete zones, add, update, delete records on the primary; Cloudflare zones and record add, update, delete |
11
11
  | `astro.edge` | Frontdoor VPS (`frontdoor-1337`) over an IAP tunnel; `00-frontdoor-vps/` in the homelab repository | `probe` the public path; `vps status|nginx-conf|nginx-test|journal|wg|bouncer|updates|deploy-nginx|reboot --confirm`; `git` on the homelab checkout, writes limited to `00-frontdoor-vps/` |
12
12
  | `astro.security` | Wazuh, CrowdSec, rsyslog archive on VM 106 | Read-only: `status`, `attention`, `agents`, `alerts`, `decisions`, `bouncers`, `metrics`, `wazuh-alerts`, `wazuh-log`, `remote-hosts`, `remote-log`, `journal` |
@@ -24,7 +24,7 @@ Call one with `/run astro.<name> -- <task>` or through the `subagent` tool (both
24
24
  - Every call is logged in `/Users/specialist/.specialists/audit.log`.
25
25
  - The `specialist-gate` extension registers the tool only inside the matching specialist child and blocks other agents from calling the CLIs or reading `/Users/specialist`. `security-guard` carries the same blocks.
26
26
 
27
- Limits: `network` is read-only on UniFi. `security` is read-only. `identity` cannot deploy Pomerium route changes; those are edits to `02-pulsar-proxmox/dmz/config.yaml` in the homelab repository, deployed with its `deploy.sh`. `edge` changes only `nginx.conf` on the VPS; `dns` writes only to the Technitium primary. `proxmox` cannot create, clone, or destroy guests, change disks, or touch the host. `backup` cannot change retention or schedules. `inference` cannot start Europa or change models.
27
+ Limits: `network` writes only firewall policies on UniFi. `security` is read-only. `identity` deploys only `config.yaml` to the DMZ. `edge` changes only `nginx.conf` on the VPS; `dns` writes only to the Technitium primary. `proxmox` cannot create, clone, or destroy guests, change disks, or touch the host. `backup` cannot change retention or schedules. `inference` cannot start Europa or change models.
28
28
 
29
29
  ## Enable specialists on a host
30
30
 
@@ -32,7 +32,7 @@ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14
32
32
 
33
33
  1. **Vaultwarden.** Account `specialists@monadeo.com` with **Can view** on collection `specialists` in organization `homelab`. Secure Notes with Hidden fields (`_B64` fields hold `base64 -i <keyfile> | tr -d '\n'`):
34
34
  - `arcane`: `ZITADEL_KEY_JSON`, `GITOPS_DEPLOY_KEY_B64`, `ZITADEL_USER_ID` (plain).
35
- - `identity`: `ZITADEL_PAT`, `SSH_KEY_DMZ_B64`.
35
+ - `identity`: `ZITADEL_PAT`, `SSH_KEY_DMZ_B64`, `HOMELAB_DEPLOY_KEY_B64`.
36
36
  - `network`: `UNIFI_API_KEY`, `SSH_KEY_PULSAR_B64`.
37
37
  - `dns`: `TECHNITIUM_TOKEN`, `TECHNITIUM2_TOKEN` (Technitium API tokens of the primary and the secondary), `CLOUDFLARE_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`.
38
38
  - `edge`: `GCP_SA_KEY_JSON` (service account key), `SSH_KEY_FRONTDOOR_B64`, `HOMELAB_DEPLOY_KEY_B64`.
@@ -41,7 +41,7 @@ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14
41
41
  - `proxmox`: `PVE_TOKEN_ID` (plain, `spc-proxmox@pve!cortex`), `PVE_TOKEN_SECRET`, `SSH_KEY_PULSAR_B64`.
42
42
  - `inference`: `SSH_KEY_NEXUS_B64`, `SSH_KEY_HERMES_B64`, `SSH_KEY_PULSAR_B64`.
43
43
  2. **Identities.** Zitadel (`https://auth.37pla.net/ui/console`): service user `spc-arcane` with Access Token Type JWT and a JSON key; service user `spc-identity` with a Personal Access Token and the Instance Owner role. Technitium (both resolvers): a user with Zones View/Modify/Delete, Dashboard View, DNS Client View, and an API token each. Cloudflare: an account-owned API token with `Zone:Read` and `DNS:Edit` on both zones. GCP project `frontdoor-1337`: service account `spc-edge` with `roles/iap.tunnelResourceAccessor` and `roles/compute.viewer`, JSON key. Proxmox (`ssh pulsar`): user `spc-proxmox@pve`, role `SpecialistGuest` = `VM.Audit VM.PowerMgmt VM.Snapshot VM.Snapshot.Rollback VM.Config.CPU VM.Config.Memory VM.Config.Options VM.Config.Network VM.Config.CDROM VM.GuestAgent.Audit`, ACLs `PVEAuditor` on `/` and `SpecialistGuest` on `/vms` for both the user and the token `spc-proxmox@pve!cortex` (`-privsep 1`).
44
- 3. **Services.** Arcane (Settings, Authentication, Federated Credentials): Issuer `https://auth.37pla.net`, Audience `387825541182390275`, Subject exact = `spc-arcane` user ID, Role Admin, Global, lifetime 300. UniFi: API key (Settings, API Keys) and policies allowing Cortex `10.0.40.37` to TCP 22 on `10.0.20.10`, `10.0.10.37`, `10.0.90.10`, TCP 5380 on `10.0.40.53`, `10.0.40.54`, TCP 8006 on `10.0.10.37`, and TCP 8081 on `10.0.60.5`. GitHub: `spc-arcane-gitops` as a write deploy key on `astronaute77/arcane`, `spc-edge-homelab` as a write deploy key on `astronaute77/homelab`.
44
+ 3. **Services.** Arcane (Settings, Authentication, Federated Credentials): Issuer `https://auth.37pla.net`, Audience `387825541182390275`, Subject exact = `spc-arcane` user ID, Role Admin, Global, lifetime 300. UniFi: API key (Settings, API Keys) and policies allowing Cortex `10.0.40.37` to TCP 22 on `10.0.20.10`, `10.0.10.37`, `10.0.90.10`, TCP 5380 on `10.0.40.53`, `10.0.40.54`, TCP 8006 on `10.0.10.37`, and TCP 8081 on `10.0.60.5`. GitHub: `spc-arcane-gitops` as a write deploy key on `astronaute77/arcane`, `spc-edge-homelab` and `spc-identity-homelab` as write deploy keys on `astronaute77/homelab`.
45
45
  4. **Sudo and user**, as an administrator:
46
46
  ```sh
47
47
  printf 'cortex ALL=(ALL) ALL\n' | sudo tee /etc/sudoers.d/cortex >/dev/null
@@ -59,7 +59,8 @@ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14
59
59
  { "serverUrl": "http://10.0.40.30:3552", "zitadelDomain": "auth.37pla.net", "audience": "387825541182390275",
60
60
  "arcaneCli": "/Users/specialist/.specialists/bin/arcane-cli", "arcaneCliConfig": "/Users/specialist/.specialists/config/arcanecli.yml",
61
61
  "gitopsRemote": "git@github.com:astronaute77/arcane.git", "gitopsBranch": "main" }
62
- { "zitadelDomain": "auth.37pla.net", "dmzHost": "10.0.20.10", "dmzUser": "dmz" }
62
+ { "zitadelDomain": "auth.37pla.net", "dmzHost": "10.0.20.10", "dmzUser": "dmz",
63
+ "homelabRemote": "git@github.com:astronaute77/homelab.git", "homelabBranch": "main", "repoArea": "02-pulsar-proxmox/dmz", "pomeriumConfig": "02-pulsar-proxmox/dmz/config.yaml" }
63
64
  { "unifiBaseUrl": "https://10.0.10.1/proxy/network/integration", "unifiCertSha256": "<from: specialist-cli network -- unifi fingerprint>", "pulsarHost": "10.0.10.37", "pulsarUser": "root" }
64
65
  { "technitiumPrimaryUrl": "http://10.0.40.53:5380", "technitiumSecondaryUrl": "http://10.0.40.54:5380", "technitiumCatalog": "catalog.invalid" }
65
66
  { "gcloud": "/opt/homebrew/bin/gcloud", "gcloudPython": "/opt/homebrew/opt/python@3.14/bin/python3.14",
@@ -1,4 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { join } from "node:path";
3
2
  import { readConfig } from "../lib/config.ts";
4
3
  import { ServiceError, UsageError } from "../lib/errors.ts";
@@ -6,7 +5,7 @@ import { main } from "../lib/main.ts";
6
5
  import { printJson, printRaw } from "../lib/output.ts";
7
6
  import { specialistsHome, workDir } from "../lib/paths.ts";
8
7
  import { run } from "../lib/proc.ts";
9
- import { REPO_HELP, ensureCheckout, repoCommand, repoStatus, safeRepoPath, type RepoSpec } from "../lib/repo.ts";
8
+ import { REPO_HELP, repoCommand, trackedFile, type RepoSpec } from "../lib/repo.ts";
10
9
  import { secretPath } from "../lib/secrets.ts";
11
10
  import { sshFixed } from "../lib/ssh.ts";
12
11
  import { openTunnel } from "../lib/tunnel.ts";
@@ -155,7 +154,7 @@ async function overTunnel(config: EdgeConfig, remote: string, timeoutMs: number,
155
154
  function repoSpec(config: EdgeConfig): RepoSpec {
156
155
  return {
157
156
  service: SERVICE,
158
- checkoutName: "homelab",
157
+ checkoutName: "homelab-edge",
159
158
  remote: config.homelabRemote,
160
159
  branch: config.homelabBranch,
161
160
  deployKeyField: "HOMELAB_DEPLOY_KEY",
@@ -164,20 +163,9 @@ function repoSpec(config: EdgeConfig): RepoSpec {
164
163
  };
165
164
  }
166
165
 
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
166
  async function vps(config: EdgeConfig, args: string[]): Promise<number> {
179
167
  const { remote, stdin } = buildVpsRemote(args);
180
- const input = stdin ? await trackedNginxConf(config) : undefined;
168
+ const input = stdin ? await trackedFile(repoSpec(config), config.nginxConf) : undefined;
181
169
  const result = await overTunnel(config, remote, remote === "reboot" ? 30_000 : 120_000, input);
182
170
  if (result.stdout.length > 0) printRaw(result.stdout);
183
171
  if (remote === "reboot") {
@@ -21,5 +21,22 @@ case "$name" in
21
21
  pomerium-restart) sudo systemctl restart pomerium && systemctl is-active pomerium ;;
22
22
  nginx-reload) sudo nginx -t && sudo systemctl reload nginx ;;
23
23
  lego-renew) sudo systemctl start lego-renew.service && systemctl status lego-renew.service --no-pager --lines=10 ;;
24
+ deploy-config)
25
+ stage="$(mktemp -d)"
26
+ cat > "$stage/config.yaml"
27
+ sudo cp -p /etc/pomerium/config.yaml.template "$stage/template.previous"
28
+ sudo install -o root -g pomerium -m 0640 "$stage/config.yaml" /etc/pomerium/config.yaml.template
29
+ if sudo /usr/local/sbin/render-pomerium && sudo systemctl restart pomerium && systemctl is-active --quiet pomerium; then
30
+ echo "deployed Pomerium configuration; pomerium active"
31
+ sudo rm -rf "$stage"
32
+ else
33
+ echo "deploy failed; restoring the previous template" >&2
34
+ sudo install -o root -g pomerium -m 0640 "$stage/template.previous" /etc/pomerium/config.yaml.template
35
+ sudo /usr/local/sbin/render-pomerium || true
36
+ sudo systemctl restart pomerium || true
37
+ systemctl is-active pomerium >&2 || true
38
+ sudo rm -rf "$stage"
39
+ exit 1
40
+ fi ;;
24
41
  *) echo "refused: $cmd" >&2; exit 2 ;;
25
42
  esac
@@ -4,6 +4,7 @@ import { ServiceError, UsageError } from "../lib/errors.ts";
4
4
  import { main } from "../lib/main.ts";
5
5
  import { printJson, printRaw } from "../lib/output.ts";
6
6
  import { specialistsHome } from "../lib/paths.ts";
7
+ import { REPO_HELP, repoCommand, trackedFile, type RepoSpec } from "../lib/repo.ts";
7
8
  import { redactSecrets } from "../lib/sanitize.ts";
8
9
  import { readSecret, secretPath } from "../lib/secrets.ts";
9
10
  import { sshFixed } from "../lib/ssh.ts";
@@ -14,9 +15,23 @@ interface IdentityConfig extends Record<string, string> {
14
15
  zitadelDomain: string;
15
16
  dmzHost: string;
16
17
  dmzUser: string;
18
+ homelabRemote: string;
19
+ homelabBranch: string;
20
+ /** Folder of the homelab repository this specialist may write. */
21
+ repoArea: string;
22
+ /** Tracked Pomerium configuration deployed by `dmz deploy-config`. */
23
+ pomeriumConfig: string;
17
24
  }
18
25
 
19
- const SHAPE = { zitadelDomain: "string", dmzHost: "string", dmzUser: "string" } as const;
26
+ const SHAPE = {
27
+ zitadelDomain: "string",
28
+ dmzHost: "string",
29
+ dmzUser: "string",
30
+ homelabRemote: "string",
31
+ homelabBranch: "string",
32
+ repoArea: "string",
33
+ pomeriumConfig: "string",
34
+ } as const;
20
35
 
21
36
  const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
22
37
  type Method = (typeof METHODS)[number];
@@ -25,7 +40,7 @@ type Method = (typeof METHODS)[number];
25
40
  export const API_PREFIXES: readonly string[] = ["/v2/", "/management/v1/", "/admin/v1/", "/auth/v1/"];
26
41
 
27
42
  /** Fixed remote commands; the target's forced command accepts exactly these names. */
28
- export const DMZ_COMMANDS: Readonly<Record<string, { args: number; help: string }>> = {
43
+ export const DMZ_COMMANDS: Readonly<Record<string, { args: number; help: string; stdin?: true }>> = {
29
44
  status: { args: 0, help: "pomerium, nginx, lego-renew.timer status" },
30
45
  journal: { args: 2, help: "journal <pomerium|nginx|lego-renew> <since>, e.g. journal pomerium '1 hour ago'" },
31
46
  "config-template": { args: 0, help: "print /etc/pomerium/config.yaml.template (no secrets; rendered values come from Vaultwarden)" },
@@ -33,6 +48,7 @@ export const DMZ_COMMANDS: Readonly<Record<string, { args: number; help: string
33
48
  "pomerium-restart": { args: 0, help: "restart pomerium" },
34
49
  "nginx-reload": { args: 0, help: "test nginx config and reload" },
35
50
  "lego-renew": { args: 0, help: "run lego-renew.service now" },
51
+ "deploy-config": { args: 0, help: "install the tracked Pomerium config.yaml from the pushed homelab checkout as the template, render, restart, check; rolls back on failure", stdin: true },
36
52
  };
37
53
 
38
54
  const DMZ_UNITS = ["pomerium", "nginx", "lego-renew"];
@@ -46,8 +62,10 @@ const HELP = `identity specialist
46
62
  ${Object.entries(DMZ_COMMANDS)
47
63
  .map(([name, c]) => ` ${name.padEnd(18)} ${c.help}`)
48
64
  .join("\n")}
65
+ ${REPO_HELP}
66
+ (homelab repository; writes are limited to the configured area)
49
67
 
50
- Pomerium route changes are edits to 02-pulsar-proxmox/dmz/config.yaml in the homelab repository, deployed with its deploy.sh. This wrapper renders, restarts, and reads.`;
68
+ Pomerium routes: edit config.yaml in the checkout, commit, push, then dmz deploy-config; it refuses while the checkout is dirty or differs from origin.`;
51
69
 
52
70
  export function validateApiPath(path: string): string {
53
71
  if (!API_PREFIXES.some((p) => path.startsWith(p))) throw new UsageError(`path must start with one of ${API_PREFIXES.join(", ")}`);
@@ -97,8 +115,21 @@ async function zitadel(config: IdentityConfig, args: string[]): Promise<number>
97
115
  return 0;
98
116
  }
99
117
 
118
+ function repoSpec(config: IdentityConfig): RepoSpec {
119
+ return {
120
+ service: SERVICE,
121
+ checkoutName: "homelab-identity",
122
+ remote: config.homelabRemote,
123
+ branch: config.homelabBranch,
124
+ deployKeyField: "HOMELAB_DEPLOY_KEY",
125
+ writeRoot: config.repoArea,
126
+ help: HELP,
127
+ };
128
+ }
129
+
100
130
  async function dmz(config: IdentityConfig, args: string[]): Promise<number> {
101
131
  const remote = buildDmzRemote(args);
132
+ const input = DMZ_COMMANDS[args[0]]?.stdin ? await trackedFile(repoSpec(config), config.pomeriumConfig) : undefined;
102
133
  const result = await sshFixed(
103
134
  {
104
135
  host: config.dmzHost,
@@ -107,7 +138,8 @@ async function dmz(config: IdentityConfig, args: string[]): Promise<number> {
107
138
  knownHostsFile: join(specialistsHome(), "config", "known_hosts"),
108
139
  },
109
140
  remote,
110
- 120_000,
141
+ 180_000,
142
+ input,
111
143
  );
112
144
  if (result.stdout.length > 0) printRaw(result.stdout);
113
145
  if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `dmz ${args[0]} exited ${result.code}`);
@@ -125,6 +157,8 @@ export async function command(args: string[]): Promise<number> {
125
157
  return zitadel(config, args.slice(1));
126
158
  case "dmz":
127
159
  return dmz(config, args.slice(1));
160
+ case "git":
161
+ return repoCommand(repoSpec(config), args.slice(1));
128
162
  default:
129
163
  throw new UsageError(`unknown command: ${args[0]}\n${HELP}`);
130
164
  }
@@ -103,6 +103,16 @@ export async function repoStatus(spec: RepoSpec, dir: string): Promise<RepoStatu
103
103
  return { branch, ahead, behind, changed };
104
104
  }
105
105
 
106
+ /** Content of a tracked file, only when the checkout equals origin: nothing uncommitted, nothing unpushed, nothing behind. */
107
+ export async function trackedFile(spec: RepoSpec, rel: string): Promise<string> {
108
+ const dir = await ensureCheckout(spec);
109
+ const status = await repoStatus(spec, dir);
110
+ if (status.changed.length > 0) throw new UsageError(`checkout has uncommitted changes: ${status.changed.join(", ")}; commit and push first`);
111
+ if (status.ahead > 0) throw new UsageError(`checkout is ${status.ahead} commit(s) ahead of origin; push first`);
112
+ if (status.behind > 0) throw new UsageError(`checkout is ${status.behind} commit(s) behind origin; run git pull first`);
113
+ return readFileSync(safeRepoPath(dir, rel), "utf-8");
114
+ }
115
+
106
116
  export async function repoCommand(spec: RepoSpec, args: string[]): Promise<number> {
107
117
  const [sub, ...rest] = args;
108
118
  const dir = await ensureCheckout(spec);
@@ -31,6 +31,8 @@ export const UNIFI_READS: Readonly<Record<string, { params: number; path: (p: st
31
31
  clients: { params: 1, path: ([s]) => `/v1/sites/${s}/clients`, help: "clients <siteId>" },
32
32
  networks: { params: 1, path: ([s]) => `/v1/sites/${s}/networks`, help: "networks <siteId>" },
33
33
  "firewall-policies": { params: 1, path: ([s]) => `/v1/sites/${s}/firewall/policies`, help: "firewall-policies <siteId>" },
34
+ "firewall-policy": { params: 2, path: ([s, p]) => `/v1/sites/${s}/firewall/policies/${p}`, help: "firewall-policy <siteId> <policyId>" },
35
+ "firewall-zones": { params: 1, path: ([s]) => `/v1/sites/${s}/firewall/zones`, help: "firewall-zones <siteId>" },
34
36
  wans: { params: 1, path: ([s]) => `/v1/sites/${s}/wans`, help: "wans <siteId>" },
35
37
  };
36
38
 
@@ -55,6 +57,9 @@ ${Object.values(UNIFI_READS)
55
57
  .map((r) => ` ${r.help}`)
56
58
  .join("\n")}
57
59
  unifi get <path> any GET under /v1 (path must start with /v1/)
60
+ unifi policy-create <siteId> <json> POST a firewall policy (name, enabled, action, source, destination, ipProtocolScope, loggingEnabled)
61
+ unifi policy-update <siteId> <policyId> <json> PUT the full policy object
62
+ unifi policy-delete <siteId> <policyId> --confirm
58
63
  unifi fingerprint print the console certificate fingerprint (for the initial pin)
59
64
  pulsar <command> [args] fixed operations through a forced-command SSH key
60
65
  ${Object.entries(PULSAR_COMMANDS)
@@ -114,16 +119,58 @@ export function unifiPath(args: string[]): string {
114
119
  return qs.length > 0 ? `${path}?${qs}` : path;
115
120
  }
116
121
 
117
- /** GET over TLS pinned to the console certificate; the console is self-signed. */
118
- export function pinnedGet(url: URL, apiKey: string | null, expectedFingerprint: string | null): Promise<PinnedResponse> {
122
+ /** Request over TLS pinned to the console certificate; the console is self-signed. */
123
+ export function pinnedGet(url: URL, apiKey: string | null, expectedFingerprint: string | null, method = "GET", body?: string): Promise<PinnedResponse> {
119
124
  return pinnedRequest(url, {
120
- method: "GET",
121
- headers: { Accept: "application/json", ...(apiKey ? { "X-API-KEY": apiKey } : {}) },
125
+ method,
126
+ headers: {
127
+ Accept: "application/json",
128
+ ...(apiKey ? { "X-API-KEY": apiKey } : {}),
129
+ ...(body === undefined ? {} : { "Content-Type": "application/json", "Content-Length": String(Buffer.byteLength(body)) }),
130
+ },
131
+ body,
122
132
  expectedFingerprint,
123
133
  timeoutMs: 30_000,
124
134
  });
125
135
  }
126
136
 
137
+ /** Documented required fields of "Create or update firewall policy". */
138
+ export const POLICY_REQUIRED = ["name", "enabled", "action", "source", "destination", "ipProtocolScope", "loggingEnabled"];
139
+
140
+ export interface PolicyWrite {
141
+ method: "POST" | "PUT" | "DELETE";
142
+ path: string;
143
+ body?: string;
144
+ }
145
+
146
+ /** Maps policy-create, policy-update, policy-delete to the firewall policy endpoints. */
147
+ export function policyWrite(args: string[]): PolicyWrite {
148
+ const [name, ...rest] = args;
149
+ const confirmed = rest.filter((a) => a !== "--confirm");
150
+ const site = confirmed[0];
151
+ if (site === undefined || !UUID.test(site)) throw new UsageError(`${name}: siteId must be a UUID`);
152
+ if (name === "policy-delete") {
153
+ if (rest.length !== 3 || !rest.includes("--confirm") || !UUID.test(confirmed[1] ?? "")) throw new UsageError("policy-delete <siteId> <policyId> --confirm");
154
+ return { method: "DELETE", path: `/v1/sites/${site}/firewall/policies/${confirmed[1]}` };
155
+ }
156
+ const jsonText = name === "policy-create" ? confirmed[1] : confirmed[2];
157
+ if (name === "policy-create" ? confirmed.length !== 2 : confirmed.length !== 3 || !UUID.test(confirmed[1] ?? "")) {
158
+ throw new UsageError(name === "policy-create" ? "policy-create <siteId> <json>" : "policy-update <siteId> <policyId> <json>");
159
+ }
160
+ let parsed: unknown;
161
+ try {
162
+ parsed = JSON.parse(jsonText);
163
+ } catch {
164
+ throw new UsageError(`${name}: body must be JSON`);
165
+ }
166
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new UsageError(`${name}: body must be a JSON object`);
167
+ const missing = POLICY_REQUIRED.filter((k) => !(k in (parsed as Record<string, unknown>)));
168
+ if (missing.length > 0) throw new UsageError(`${name}: body lacks ${missing.join(", ")}`);
169
+ return name === "policy-create"
170
+ ? { method: "POST", path: `/v1/sites/${site}/firewall/policies`, body: JSON.stringify(parsed) }
171
+ : { method: "PUT", path: `/v1/sites/${site}/firewall/policies/${confirmed[1]}`, body: JSON.stringify(parsed) };
172
+ }
173
+
127
174
  async function unifi(config: NetworkConfig, args: string[]): Promise<number> {
128
175
  const base = new URL(config.unifiBaseUrl);
129
176
  if (args[0] === "fingerprint") {
@@ -131,9 +178,10 @@ async function unifi(config: NetworkConfig, args: string[]): Promise<number> {
131
178
  printJson({ host: base.host, fingerprint256: probe.fingerprint });
132
179
  return 0;
133
180
  }
134
- const url = new URL(`${base.pathname.replace(/\/$/, "")}${unifiPath(args)}`, base);
135
181
  const apiKey = readSecret(SERVICE, "UNIFI_API_KEY");
136
- const response = await pinnedGet(url, apiKey, config.unifiCertSha256);
182
+ const write = args[0] === "policy-create" || args[0] === "policy-update" || args[0] === "policy-delete" ? policyWrite(args) : null;
183
+ const url = new URL(`${base.pathname.replace(/\/$/, "")}${write ? write.path : unifiPath(args)}`, base);
184
+ const response = write ? await pinnedGet(url, apiKey, config.unifiCertSha256, write.method, write.body) : await pinnedGet(url, apiKey, config.unifiCertSha256);
137
185
  let parsed: unknown = response.body;
138
186
  try {
139
187
  parsed = JSON.parse(response.body);
@@ -10,7 +10,7 @@ import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityComma
10
10
  import { buildInferenceRemote, compactReleases, command as inferenceCommand } from "./inference/run.ts";
11
11
  import { UsageError } from "./lib/errors.ts";
12
12
  import { safeRepoPath, safeWritePath } from "./lib/repo.ts";
13
- import { buildPulsarRemote, parseQuery, unifiPath, command as networkCommand } from "./network/run.ts";
13
+ import { POLICY_REQUIRED, buildPulsarRemote, parseQuery, policyWrite, unifiPath, command as networkCommand } from "./network/run.ts";
14
14
  import { buildHostRemote, needsConfirm, parseSetPairs, parseVmid, validateApiPath as validatePvePath, command as proxmoxCommand } from "./proxmox/run.ts";
15
15
  import { buildObsRemote, compactDecisions, summariseAlerts, summariseWazuh, command as securityCommand } from "./security/run.ts";
16
16
 
@@ -62,6 +62,7 @@ describe("identity wrapper", () => {
62
62
  expect(() => buildDmzRemote(["journal", "pomerium", "x; rm -rf /"])).toThrow(/since/);
63
63
  expect(() => buildDmzRemote(["render", "extra"])).toThrow(/0 argument/);
64
64
  expect(() => buildDmzRemote(["reboot"])).toThrow(/unknown dmz command/);
65
+ expect(buildDmzRemote(["deploy-config"])).toBe("deploy-config");
65
66
  });
66
67
 
67
68
  it("prints help without touching config", async () => {
@@ -94,6 +95,22 @@ describe("network wrapper", () => {
94
95
  expect(() => unifiPath(["reboot"])).toThrow(/unknown unifi read/);
95
96
  });
96
97
 
98
+ it("maps firewall policy writes to the documented endpoints", () => {
99
+ const policy = { name: "x", enabled: true, action: { type: "ALLOW" }, source: { zoneId: site }, destination: { zoneId: site }, ipProtocolScope: { ipVersion: "IPV4" }, loggingEnabled: false };
100
+ const body = JSON.stringify(policy);
101
+ expect(policyWrite(["policy-create", site, body])).toEqual({ method: "POST", path: `/v1/sites/${site}/firewall/policies`, body });
102
+ expect(policyWrite(["policy-update", site, site, body])).toEqual({ method: "PUT", path: `/v1/sites/${site}/firewall/policies/${site}`, body });
103
+ expect(policyWrite(["policy-delete", site, site, "--confirm"])).toEqual({ method: "DELETE", path: `/v1/sites/${site}/firewall/policies/${site}` });
104
+ expect(() => policyWrite(["policy-delete", site, site])).toThrow(/--confirm/);
105
+ expect(() => policyWrite(["policy-create", "default", body])).toThrow(/UUID/);
106
+ expect(() => policyWrite(["policy-create", site, "{"])).toThrow(/JSON/);
107
+ expect(() => policyWrite(["policy-create", site, JSON.stringify({ name: "x" })])).toThrow(/lacks/);
108
+ expect(() => policyWrite(["policy-update", site, "nope", body])).toThrow(UsageError);
109
+ expect(unifiPath(["firewall-zones", site])).toBe(`/v1/sites/${site}/firewall/zones`);
110
+ expect(unifiPath(["firewall-policy", site, site])).toBe(`/v1/sites/${site}/firewall/policies/${site}`);
111
+ expect(POLICY_REQUIRED).toContain("ipProtocolScope");
112
+ });
113
+
97
114
  it("maps pulsar commands to fixed remote strings", () => {
98
115
  expect(buildPulsarRemote(["guest-status", "108"])).toBe("guest-status 108");
99
116
  expect(buildPulsarRemote(["radius-journal", "2 hours ago"])).toBe("radius-journal 2 hours ago");