@astrofoundry/pi-astro 0.20.3 → 0.21.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 +1 -1
- package/agents/identity.md +1 -1
- package/agents/network.md +4 -2
- package/package.json +1 -1
- package/skills/identity/SKILL.md +11 -1
- package/skills/network/SKILL.md +20 -3
- package/specialists/AGENTS.md +3 -2
- package/specialists/README.md +7 -6
- package/specialists/edge/run.ts +2 -14
- package/specialists/entry/remote/spc-identity-dmz-entry.sh +17 -0
- package/specialists/identity/run.ts +38 -4
- package/specialists/lib/repo.ts +10 -0
- package/specialists/network/run.ts +54 -6
- package/specialists/wrappers.test.ts +18 -1
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
|
|
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>`):
|
package/agents/identity.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
package/skills/identity/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/skills/network/SKILL.md
CHANGED
|
@@ -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.
|
package/specialists/AGENTS.md
CHANGED
|
@@ -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`
|
|
42
|
+
- Repository access is `lib/repo.ts` (`repoCommand`, `RepoSpec`, `trackedFile`): one clone per specialist under `~/.specialists/work/<checkoutName>`, 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
|
|
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.
|
package/specialists/README.md
CHANGED
|
@@ -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`
|
|
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
|
|
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",
|
package/specialists/edge/run.ts
CHANGED
|
@@ -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,
|
|
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";
|
|
@@ -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
|
|
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 = {
|
|
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
|
|
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",
|
|
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
|
-
|
|
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
|
}
|
package/specialists/lib/repo.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
121
|
-
headers: {
|
|
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
|
|
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");
|