@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
package/specialists/README.md
CHANGED
|
@@ -7,6 +7,9 @@ A specialist is a Pi subagent that is the only way to operate one area of the ho
|
|
|
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
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
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 |
|
|
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
|
+
| `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
|
+
| `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` |
|
|
10
13
|
|
|
11
14
|
Call one with `/run astro.<name> -- <task>` or through the `subagent` tool (both from the bundled `astro-subagents` extension). Specialists may call each other once (`maxSubagentDepth: 1`). They run on `openai-codex/gpt-6-astra` with `xhigh` thinking, set by `model` and `thinking` in each `agents/<name>.md`; an agent without these fields inherits the caller's model and thinking. Each tool takes `args`, an array of strings; `["--help"]` lists the subcommands.
|
|
12
15
|
|
|
@@ -14,19 +17,25 @@ Call one with `/run astro.<name> -- <task>` or through the `subagent` tool (both
|
|
|
14
17
|
|
|
15
18
|
- Credentials and CLIs belong to a dedicated macOS user `specialist` on the host. Agents run as the main user and reach the wrappers only through `sudo -n -u specialist -H /usr/local/bin/specialist-cli`, the single passwordless sudo rule. General sudo asks for a password.
|
|
16
19
|
- Credentials come from Vaultwarden (`homelab` / `specialists`, one Secure Note per specialist) through `specialist-cli provision`, into `0600` files. Wrappers never call `bw`.
|
|
17
|
-
- Arcane is reached with a Zitadel service user: a fresh token per call, exchanged for a 300 s Arcane token. Other services use API keys, a PAT, or SSH keys that the targets pin to forced commands.
|
|
20
|
+
- Arcane is reached with a Zitadel service user: a fresh token per call, exchanged for a 300 s Arcane token. Other services use API keys, a PAT, tokens, or SSH keys that the targets pin to forced commands. The VPS is reached through `gcloud compute start-iap-tunnel` with a service account that may only open IAP tunnels and read instances; its gcloud state lives in `/Users/specialist/.specialists/work/gcloud`.
|
|
18
21
|
- Every call is logged in `/Users/specialist/.specialists/audit.log`.
|
|
19
22
|
- 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.
|
|
20
23
|
|
|
21
|
-
Limits: `network` is read-only on UniFi. `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`.
|
|
24
|
+
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.
|
|
22
25
|
|
|
23
26
|
## Enable specialists on a host
|
|
24
27
|
|
|
25
|
-
Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, git). Steps 1 to 3 are one-time on the services; 4 to 8 are per host.
|
|
28
|
+
Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14`, git). Steps 1 to 3 are one-time on the services; 4 to 8 are per host.
|
|
26
29
|
|
|
27
|
-
1. **Vaultwarden.** Account `specialists@monadeo.com` with **Can view** on collection `specialists` in organization `homelab`. Secure Notes with Hidden fields
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
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'`):
|
|
31
|
+
- `arcane`: `ZITADEL_KEY_JSON`, `GITOPS_DEPLOY_KEY_B64`, `ZITADEL_USER_ID` (plain).
|
|
32
|
+
- `identity`: `ZITADEL_PAT`, `SSH_KEY_DMZ_B64`.
|
|
33
|
+
- `network`: `UNIFI_API_KEY`, `SSH_KEY_PULSAR_B64`.
|
|
34
|
+
- `dns`: `TECHNITIUM_TOKEN`, `TECHNITIUM2_TOKEN` (Technitium API tokens of the primary and the secondary), `CLOUDFLARE_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`.
|
|
35
|
+
- `edge`: `GCP_SA_KEY_JSON` (service account key), `SSH_KEY_FRONTDOOR_B64`, `HOMELAB_DEPLOY_KEY_B64`.
|
|
36
|
+
- `security`: `SSH_KEY_OBS_B64`.
|
|
37
|
+
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.
|
|
38
|
+
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` and TCP 5380 on `10.0.40.53`, `10.0.40.54`. GitHub: `spc-arcane-gitops` as a write deploy key on `astronaute77/arcane`, `spc-edge-homelab` as a write deploy key on `astronaute77/homelab`.
|
|
30
39
|
4. **Sudo and user**, as an administrator:
|
|
31
40
|
```sh
|
|
32
41
|
printf 'cortex ALL=(ALL) ALL\n' | sudo tee /etc/sudoers.d/cortex >/dev/null
|
|
@@ -35,33 +44,47 @@ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, git). Steps 1 to 3 are one-tim
|
|
|
35
44
|
sudo sysadminctl -addUser specialist -fullName "Specialists" -home /Users/specialist -shell /bin/zsh -password -
|
|
36
45
|
sudo dscl . create /Users/specialist IsHidden 1 && sudo createhomedir -c -u specialist && sudo chmod 700 /Users/specialist
|
|
37
46
|
sudo install -m 0755 -o root -g wheel <package>/specialists/entry/specialist-cli.sh /usr/local/bin/specialist-cli
|
|
47
|
+
brew install --cask gcloud-cli
|
|
38
48
|
```
|
|
39
49
|
Add `cortex ALL=(specialist) NOPASSWD: ALL` as `/etc/sudoers.d/specialists-setup` (mode `0440`) for the duration of steps 5 to 7, then delete it.
|
|
40
50
|
5. **Bootstrap**, as `specialist` (`sudo -u specialist -H zsh`, `umask 077`): `~/.config/specialists/client.env` with `BW_SERVER=https://vault.37pla.net`, `BW_CLIENTID`, `BW_CLIENTSECRET`; `~/.config/specialists/master-password`. Both `0600`.
|
|
41
|
-
6. **Install and configure**, as `specialist`: `sh <package>/specialists/install/install.sh <package> 2.
|
|
51
|
+
6. **Install and configure**, as `specialist`: `sh <package>/specialists/install/install.sh <package> 2.12.0` (`<package>` is the installed pi-astro directory, `~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro`). Write `~/.specialists/config/<name>.json` for every specialist:
|
|
42
52
|
```json
|
|
43
53
|
{ "serverUrl": "http://10.0.40.30:3552", "zitadelDomain": "auth.37pla.net", "audience": "387825541182390275",
|
|
44
54
|
"arcaneCli": "/Users/specialist/.specialists/bin/arcane-cli", "arcaneCliConfig": "/Users/specialist/.specialists/config/arcanecli.yml",
|
|
45
55
|
"gitopsRemote": "git@github.com:astronaute77/arcane.git", "gitopsBranch": "main" }
|
|
46
56
|
{ "zitadelDomain": "auth.37pla.net", "dmzHost": "10.0.20.10", "dmzUser": "dmz" }
|
|
47
57
|
{ "unifiBaseUrl": "https://10.0.10.1/proxy/network/integration", "unifiCertSha256": "<from: specialist-cli network -- unifi fingerprint>", "pulsarHost": "10.0.10.37", "pulsarUser": "root" }
|
|
58
|
+
{ "technitiumPrimaryUrl": "http://10.0.40.53:5380", "technitiumSecondaryUrl": "http://10.0.40.54:5380", "technitiumCatalog": "catalog.invalid" }
|
|
59
|
+
{ "gcloud": "/opt/homebrew/bin/gcloud", "gcloudPython": "/opt/homebrew/opt/python@3.14/bin/python3.14",
|
|
60
|
+
"project": "frontdoor-1337", "zone": "us-east1-c", "instance": "frontdoor-1337", "serviceAccount": "spc-edge@frontdoor-1337.iam.gserviceaccount.com",
|
|
61
|
+
"iapLocalPort": 22337, "sshUser": "spc-edge", "publicIp": "35.237.66.101", "probeHost": "auth.37pla.net",
|
|
62
|
+
"homelabRemote": "git@github.com:astronaute77/homelab.git", "homelabBranch": "main", "repoArea": "00-frontdoor-vps", "nginxConf": "00-frontdoor-vps/nginx.conf" }
|
|
63
|
+
{ "obsHost": "10.0.90.10", "obsUser": "observability" }
|
|
48
64
|
```
|
|
49
65
|
Then `sudo -n -u specialist -H /usr/local/bin/specialist-cli provision`; it lists every note and field.
|
|
50
|
-
7. **SSH targets.**
|
|
51
|
-
`command="/usr/local/sbin/<entry>",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519
|
|
66
|
+
7. **SSH targets.** Each target gets an entry script from `entry/remote/` as `/usr/local/sbin/<entry>` (`0755`, root) and the public key in the right `authorized_keys` as
|
|
67
|
+
`command="/usr/local/sbin/<entry>",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 ...`:
|
|
68
|
+
- VM 104, user `dmz`: `spc-identity-dmz-entry.sh`.
|
|
69
|
+
- Pulsar, root: `spc-network-pulsar-entry.sh`.
|
|
70
|
+
- VM 106, user `observability`: `spc-security-obs-entry.sh` (tracked in the homelab repository, installed by `02-pulsar-proxmox/observability/deploy.sh`).
|
|
71
|
+
- Frontdoor VPS: `spc-edge-frontdoor-install.sh` creates user `spc-edge` (group `systemd-journal`), installs `spc-edge-frontdoor-entry.sh` and the sudoers file `spc-edge-frontdoor-sudoers`, and writes `authorized_keys`. Copy the three files and the public key to `/tmp` with `gcloud compute scp --tunnel-through-iap`, then run it as root.
|
|
52
72
|
8. **Pi**, as the main user: `~/.pi/agent/specialists.json` (below) and the specialist blocks in `~/.pi/agent/security-guard.json` (`specialist-cli`, `arcane-cli`, `-u specialist` in `operations`; `/Users/specialist/**` in `reads` and `writes`; keep the default rules next to them).
|
|
53
73
|
```json
|
|
54
74
|
{ "entry": "/usr/local/bin/specialist-cli", "user": "specialist",
|
|
55
75
|
"specialists": [
|
|
56
76
|
{ "agent": "astro.arcane", "tool": "arcane", "service": "arcane", "timeoutMs": 300000 },
|
|
57
77
|
{ "agent": "astro.identity", "tool": "identity", "service": "identity", "timeoutMs": 120000 },
|
|
58
|
-
{ "agent": "astro.network", "tool": "network", "service": "network", "timeoutMs": 120000 }
|
|
78
|
+
{ "agent": "astro.network", "tool": "network", "service": "network", "timeoutMs": 120000 },
|
|
79
|
+
{ "agent": "astro.dns", "tool": "dns", "service": "dns", "timeoutMs": 60000 },
|
|
80
|
+
{ "agent": "astro.edge", "tool": "edge", "service": "edge", "timeoutMs": 300000 },
|
|
81
|
+
{ "agent": "astro.security", "tool": "security", "service": "security", "timeoutMs": 120000 } ] }
|
|
59
82
|
```
|
|
60
83
|
|
|
61
|
-
Check: `sudo -n -u specialist -H /usr/local/bin/specialist-cli arcane --caller test -- projects list` prints JSON; the same for `identity -- dmz status
|
|
84
|
+
Check: `sudo -n -u specialist -H /usr/local/bin/specialist-cli arcane --caller test -- projects list` prints JSON; the same for `identity -- dmz status`, `network -- unifi sites`, `dns -- technitium primary zones`, `edge -- vps status`, `security -- attention`; `/run astro.arcane -- list projects` works in Pi; `bash` with `arcane-cli --help` is blocked in a normal session.
|
|
62
85
|
|
|
63
86
|
## Rotation and operations
|
|
64
87
|
|
|
65
|
-
Rotation steps for every field live in the homelab repository, `03-cortex-macmini/ROTATION.md`. After any change in Vaultwarden run `specialist-cli provision`. Host state and the sudo rules are documented in `03-cortex-macmini/README.md`; the forced-command scripts are tracked under `02-pulsar-proxmox/dmz/system
|
|
88
|
+
Rotation steps for every field live in the homelab repository, `03-cortex-macmini/ROTATION.md`. After any change in Vaultwarden run `specialist-cli provision`. Host state and the sudo rules are documented in `03-cortex-macmini/README.md`; the forced-command scripts are tracked under `02-pulsar-proxmox/dmz/system/`, `02-pulsar-proxmox/pulsar/system/`, `02-pulsar-proxmox/observability/system/`, and `00-frontdoor-vps/system/`.
|
|
66
89
|
|
|
67
|
-
Planned specialists (not built): `
|
|
90
|
+
Planned specialists (not built): `backup`, `proxmox`, `inference` (batch 3). See `AGENTS.md` for how to add one.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { dirname, join
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import { readConfig } from "../lib/config.ts";
|
|
5
5
|
import { ServiceError, UsageError } from "../lib/errors.ts";
|
|
6
6
|
import { main } from "../lib/main.ts";
|
|
7
|
-
import {
|
|
8
|
-
import { specialistsHome, workDir } from "../lib/paths.ts";
|
|
7
|
+
import { printRaw } from "../lib/output.ts";
|
|
9
8
|
import { run } from "../lib/proc.ts";
|
|
9
|
+
import { REPO_HELP, repoCommand, type RepoSpec } from "../lib/repo.ts";
|
|
10
10
|
import { ARCANE_DENIED_KEYS, filterJsonText, stripKeys } from "../lib/sanitize.ts";
|
|
11
|
-
import { readSecret
|
|
11
|
+
import { readSecret } from "../lib/secrets.ts";
|
|
12
12
|
import { mintAccessToken, parseServiceAccountKey } from "../lib/zitadel.ts";
|
|
13
13
|
|
|
14
14
|
const SERVICE = "arcane";
|
|
@@ -66,131 +66,22 @@ const HELP = `arcane specialist
|
|
|
66
66
|
|
|
67
67
|
<arcane-cli args...> run arcane-cli with a fresh federated token; JSON output, sensitive fields removed
|
|
68
68
|
(arcane-cli gitops ... manages the server-side syncs)
|
|
69
|
-
|
|
70
|
-
git pull fast-forward the checkout from origin
|
|
71
|
-
git ls [dir] list files in the checkout
|
|
72
|
-
git show <path> print a file from the checkout
|
|
73
|
-
git diff unstaged and staged changes
|
|
74
|
-
git log [n] last n commits (default 10)
|
|
75
|
-
git write <path> <content> write a file inside the checkout (creates folders)
|
|
76
|
-
git commit <message> stage everything and commit
|
|
77
|
-
git push push the branch to origin
|
|
78
|
-
|
|
69
|
+
${REPO_HELP}
|
|
79
70
|
Refused: ${REFUSED_PREFIXES.join(", ")}. GitOps-managed projects (gitOpsManagedBy set) change in the repository, then sync.`;
|
|
80
71
|
|
|
81
72
|
const TIMEOUT_MS = 240_000;
|
|
82
73
|
|
|
83
|
-
function
|
|
84
|
-
const key = secretPath(SERVICE, "GITOPS_DEPLOY_KEY");
|
|
85
|
-
const knownHosts = join(specialistsHome(), "config", "known_hosts");
|
|
74
|
+
function repoSpec(config: ArcaneConfig): RepoSpec {
|
|
86
75
|
return {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
76
|
+
service: SERVICE,
|
|
77
|
+
checkoutName: "arcane-gitops",
|
|
78
|
+
remote: config.gitopsRemote,
|
|
79
|
+
branch: config.gitopsBranch,
|
|
80
|
+
deployKeyField: "GITOPS_DEPLOY_KEY",
|
|
81
|
+
help: HELP,
|
|
92
82
|
};
|
|
93
83
|
}
|
|
94
84
|
|
|
95
|
-
async function git(dir: string, args: string[]): Promise<string> {
|
|
96
|
-
const result = await run("/usr/bin/git", ["-C", dir, ...args], { env: gitEnv(), timeoutMs: TIMEOUT_MS });
|
|
97
|
-
if (result.code !== 0) throw new ServiceError(`git ${args[0]} failed: ${result.stderr.trim()}`);
|
|
98
|
-
return result.stdout;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async function ensureCheckout(config: ArcaneConfig): Promise<string> {
|
|
102
|
-
const dir = workDir("arcane-gitops");
|
|
103
|
-
if (existsSync(join(dir, ".git"))) return dir;
|
|
104
|
-
mkdirSync(dirname(dir), { recursive: true, mode: 0o700 });
|
|
105
|
-
const result = await run(
|
|
106
|
-
"/usr/bin/git",
|
|
107
|
-
["clone", "--branch", config.gitopsBranch, "--single-branch", config.gitopsRemote, dir],
|
|
108
|
-
{ env: gitEnv(), timeoutMs: TIMEOUT_MS },
|
|
109
|
-
);
|
|
110
|
-
if (result.code !== 0) throw new ServiceError(`git clone failed: ${result.stderr.trim()}`);
|
|
111
|
-
return dir;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function safeRepoPath(dir: string, rel: string): string {
|
|
115
|
-
if (rel.length === 0 || rel.startsWith("/") || rel.split("/").includes("..") || rel.includes("\0")) {
|
|
116
|
-
throw new UsageError(`invalid repository path: ${rel}`);
|
|
117
|
-
}
|
|
118
|
-
const target = resolve(dir, rel);
|
|
119
|
-
const inside = relative(dir, target);
|
|
120
|
-
if (inside.startsWith("..") || inside.startsWith(".git/") || inside === ".git") {
|
|
121
|
-
throw new UsageError(`path escapes the checkout: ${rel}`);
|
|
122
|
-
}
|
|
123
|
-
return target;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function repo(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
127
|
-
const [sub, ...rest] = args;
|
|
128
|
-
const dir = await ensureCheckout(config);
|
|
129
|
-
switch (sub) {
|
|
130
|
-
case "status": {
|
|
131
|
-
await git(dir, ["fetch", "--quiet", "origin", config.gitopsBranch]);
|
|
132
|
-
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
133
|
-
const counts = (await git(dir, ["rev-list", "--left-right", "--count", `HEAD...origin/${config.gitopsBranch}`])).trim();
|
|
134
|
-
const [ahead, behind] = counts.split(/\s+/).map(Number);
|
|
135
|
-
const changed = (await git(dir, ["status", "--porcelain"])).split("\n").filter(Boolean);
|
|
136
|
-
printJson({ branch, ahead, behind, changed });
|
|
137
|
-
return 0;
|
|
138
|
-
}
|
|
139
|
-
case "pull":
|
|
140
|
-
printRaw(await git(dir, ["pull", "--ff-only", "origin", config.gitopsBranch]));
|
|
141
|
-
return 0;
|
|
142
|
-
case "diff":
|
|
143
|
-
printRaw((await git(dir, ["diff", "HEAD"])) || "(no changes)");
|
|
144
|
-
return 0;
|
|
145
|
-
case "ls": {
|
|
146
|
-
if (rest.length > 1) throw new UsageError("git ls [dir]");
|
|
147
|
-
const target = rest[0] === undefined ? dir : safeRepoPath(dir, rest[0]);
|
|
148
|
-
if (!existsSync(target) || !statSync(target).isDirectory()) throw new UsageError(`not a directory in the checkout: ${rest[0] ?? "."}`);
|
|
149
|
-
printJson(readdirSync(target, { withFileTypes: true }).filter((e) => e.name !== ".git").map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).sort());
|
|
150
|
-
return 0;
|
|
151
|
-
}
|
|
152
|
-
case "show": {
|
|
153
|
-
if (rest.length !== 1) throw new UsageError("git show <path>");
|
|
154
|
-
const target = safeRepoPath(dir, rest[0]);
|
|
155
|
-
if (!existsSync(target) || !statSync(target).isFile()) throw new UsageError(`not a file in the checkout: ${rest[0]}`);
|
|
156
|
-
printRaw(readFileSync(target, "utf-8"));
|
|
157
|
-
return 0;
|
|
158
|
-
}
|
|
159
|
-
case "log": {
|
|
160
|
-
const n = rest[0] === undefined ? 10 : Number(rest[0]);
|
|
161
|
-
if (!Number.isInteger(n) || n <= 0 || n > 200) throw new UsageError("git log [n]: n must be 1..200");
|
|
162
|
-
printRaw(await git(dir, ["log", `-n${n}`, "--format=%h %ad %s", "--date=short"]));
|
|
163
|
-
return 0;
|
|
164
|
-
}
|
|
165
|
-
case "write": {
|
|
166
|
-
if (rest.length !== 2) throw new UsageError("git write <path> <content>");
|
|
167
|
-
const target = safeRepoPath(dir, rest[0]);
|
|
168
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
169
|
-
writeFileSync(target, rest[1].endsWith("\n") ? rest[1] : `${rest[1]}\n`, { mode: 0o600 });
|
|
170
|
-
printJson({ written: rest[0], bytes: Buffer.byteLength(rest[1]) });
|
|
171
|
-
return 0;
|
|
172
|
-
}
|
|
173
|
-
case "commit": {
|
|
174
|
-
const message = rest.join(" ").trim();
|
|
175
|
-
if (message.length === 0) throw new UsageError("git commit <message>");
|
|
176
|
-
await git(dir, ["add", "--all"]);
|
|
177
|
-
const staged = (await git(dir, ["diff", "--cached", "--name-only"])).split("\n").filter(Boolean);
|
|
178
|
-
if (staged.length === 0) throw new UsageError("nothing to commit");
|
|
179
|
-
await git(dir, ["commit", "--quiet", "-m", message]);
|
|
180
|
-
printJson({ committed: (await git(dir, ["rev-parse", "--short", "HEAD"])).trim(), files: staged });
|
|
181
|
-
return 0;
|
|
182
|
-
}
|
|
183
|
-
case "push": {
|
|
184
|
-
const result = await run("/usr/bin/git", ["-C", dir, "push", "origin", config.gitopsBranch], { env: gitEnv(), timeoutMs: TIMEOUT_MS });
|
|
185
|
-
if (result.code !== 0) throw new ServiceError(`git push failed: ${result.stderr.trim()}`);
|
|
186
|
-
printRaw(result.stderr.trim() || "pushed");
|
|
187
|
-
return 0;
|
|
188
|
-
}
|
|
189
|
-
default:
|
|
190
|
-
throw new UsageError(`unknown git subcommand: ${sub ?? "(none)"}\n${HELP}`);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
85
|
async function federatedToken(config: ArcaneConfig): Promise<string> {
|
|
195
86
|
const key = parseServiceAccountKey(readSecret(SERVICE, "ZITADEL_KEY_JSON"));
|
|
196
87
|
const zitadelToken = await mintAccessToken({ key, domain: config.zitadelDomain, audienceProjectId: config.audience });
|
|
@@ -256,7 +147,7 @@ export async function command(args: string[]): Promise<number> {
|
|
|
256
147
|
return 0;
|
|
257
148
|
}
|
|
258
149
|
const config = readConfig<ArcaneConfig>(SERVICE, SHAPE);
|
|
259
|
-
if (args[0] === "git") return
|
|
150
|
+
if (args[0] === "git") return repoCommand(repoSpec(config), args.slice(1));
|
|
260
151
|
return passthrough(config, args);
|
|
261
152
|
}
|
|
262
153
|
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { readConfig } from "../lib/config.ts";
|
|
2
|
+
import { ServiceError, UsageError } from "../lib/errors.ts";
|
|
3
|
+
import { main } from "../lib/main.ts";
|
|
4
|
+
import { printJson, printRaw } from "../lib/output.ts";
|
|
5
|
+
import { redactSecrets } from "../lib/sanitize.ts";
|
|
6
|
+
import { readSecret } from "../lib/secrets.ts";
|
|
7
|
+
|
|
8
|
+
const SERVICE = "dns";
|
|
9
|
+
|
|
10
|
+
interface DnsConfig extends Record<string, string> {
|
|
11
|
+
technitiumPrimaryUrl: string;
|
|
12
|
+
technitiumSecondaryUrl: string;
|
|
13
|
+
/** Catalog zone new primary zones join, so the secondary receives them. */
|
|
14
|
+
technitiumCatalog: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SHAPE = { technitiumPrimaryUrl: "string", technitiumSecondaryUrl: "string", technitiumCatalog: "string" } as const;
|
|
18
|
+
|
|
19
|
+
const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
|
|
20
|
+
const TIMEOUT_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
export const NODES = ["primary", "secondary"] as const;
|
|
23
|
+
export type Node = (typeof NODES)[number];
|
|
24
|
+
|
|
25
|
+
const NAME = /^[A-Za-z0-9_.-]{1,253}$/;
|
|
26
|
+
const RECORD_TYPE = /^[A-Z0-9]{1,10}$/;
|
|
27
|
+
const PARAM_KEY = /^[a-zA-Z]{1,40}$/;
|
|
28
|
+
const STATS_TYPES = ["LastHour", "LastDay", "LastWeek", "LastMonth", "LastYear"];
|
|
29
|
+
const HEX_ID = /^[0-9a-f]{32}$/;
|
|
30
|
+
|
|
31
|
+
/** Technitium HTTP API reads, valid on both nodes. */
|
|
32
|
+
export const TECHNITIUM_READS: Readonly<Record<string, { args: [number, number]; help: string }>> = {
|
|
33
|
+
zones: { args: [0, 1], help: "zones [filter] list authoritative zones (filter: * and ? wildcards)" },
|
|
34
|
+
records: { args: [1, 2], help: "records <zone> [domain] all records of a zone, or those of one name" },
|
|
35
|
+
"zone-options": { args: [1, 1], help: "zone-options <zone> zone settings, including its catalog" },
|
|
36
|
+
resolve: { args: [1, 2], help: "resolve <domain> [type] query this server (type default A)" },
|
|
37
|
+
stats: { args: [0, 1], help: `stats [${STATS_TYPES.join("|")}] dashboard counters` },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Technitium writes; the primary only, the secondary receives zones through the catalog. */
|
|
41
|
+
export const TECHNITIUM_WRITES: Readonly<Record<string, { args: [number, number]; help: string }>> = {
|
|
42
|
+
"zone-create": { args: [1, 1], help: "zone-create <zone> Primary zone, member of the configured catalog" },
|
|
43
|
+
"zone-delete": { args: [1, 1], help: "zone-delete <zone> delete a zone and every record in it" },
|
|
44
|
+
"record-add": { args: [3, 40], help: "record-add <zone> <domain> <type> <key=value>... e.g. ipAddress=10.0.40.99 ttl=300" },
|
|
45
|
+
"record-update": { args: [3, 40], help: "record-update <zone> <domain> <type> <key=value>... e.g. ipAddress=10.0.40.99 newIpAddress=10.0.40.98" },
|
|
46
|
+
"record-delete": { args: [3, 40], help: "record-delete <zone> <domain> <type> <key=value>... e.g. ipAddress=10.0.40.99" },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const HELP = `dns specialist
|
|
50
|
+
|
|
51
|
+
technitium <primary|secondary> <read> [args]
|
|
52
|
+
${Object.values(TECHNITIUM_READS)
|
|
53
|
+
.map((r) => ` ${r.help}`)
|
|
54
|
+
.join("\n")}
|
|
55
|
+
technitium primary <write> [args] (the secondary follows through the catalog zone)
|
|
56
|
+
${Object.values(TECHNITIUM_WRITES)
|
|
57
|
+
.map((w) => ` ${w.help}`)
|
|
58
|
+
.join("\n")}
|
|
59
|
+
key=value pairs are the documented parameters of /api/zones/records/{add,update,delete}
|
|
60
|
+
cloudflare verify check the API token
|
|
61
|
+
cloudflare zones zones the token can see
|
|
62
|
+
cloudflare records <zone> [--type T] [--name fqdn]
|
|
63
|
+
cloudflare record-add <zone> <json> {"type":"A","name":"x.37pla.net","content":"35.237.66.101","ttl":300,"proxied":false}
|
|
64
|
+
cloudflare record-update <zone> <recordId> <json>
|
|
65
|
+
cloudflare record-delete <zone> <recordId>`;
|
|
66
|
+
|
|
67
|
+
export interface TechnitiumRequest {
|
|
68
|
+
path: string;
|
|
69
|
+
params: Record<string, string>;
|
|
70
|
+
write: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function assertName(value: string | undefined, what: string): string {
|
|
74
|
+
if (value === undefined || !NAME.test(value)) throw new UsageError(`${what} must be a DNS name`);
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseParams(pairs: string[]): Record<string, string> {
|
|
79
|
+
const params: Record<string, string> = {};
|
|
80
|
+
for (const pair of pairs) {
|
|
81
|
+
const eq = pair.indexOf("=");
|
|
82
|
+
if (eq <= 0) throw new UsageError(`expected key=value, got ${pair}`);
|
|
83
|
+
const key = pair.slice(0, eq);
|
|
84
|
+
if (!PARAM_KEY.test(key) || key === "token" || key === "node") throw new UsageError(`invalid parameter name: ${key}`);
|
|
85
|
+
params[key] = pair.slice(eq + 1);
|
|
86
|
+
}
|
|
87
|
+
return params;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Maps a technitium subcommand to a documented API path and its parameters. */
|
|
91
|
+
export function technitiumRequest(catalog: string, args: string[]): TechnitiumRequest {
|
|
92
|
+
const [name, ...rest] = args;
|
|
93
|
+
const spec = name === undefined ? undefined : (TECHNITIUM_READS[name] ?? TECHNITIUM_WRITES[name]);
|
|
94
|
+
if (!spec) throw new UsageError(`unknown technitium command: ${name ?? "(none)"}`);
|
|
95
|
+
if (rest.length < spec.args[0] || rest.length > spec.args[1]) {
|
|
96
|
+
throw new UsageError(`technitium ${name} takes ${spec.args[0]}${spec.args[1] > spec.args[0] ? ` to ${spec.args[1]}` : ""} argument(s)`);
|
|
97
|
+
}
|
|
98
|
+
switch (name) {
|
|
99
|
+
case "zones":
|
|
100
|
+
return { path: "/api/zones/list", params: rest[0] === undefined ? {} : { filterName: rest[0] }, write: false };
|
|
101
|
+
case "records": {
|
|
102
|
+
const zone = assertName(rest[0], "zone");
|
|
103
|
+
const domain = rest[1] === undefined ? zone : assertName(rest[1], "domain");
|
|
104
|
+
return { path: "/api/zones/records/get", params: { domain, zone, listZone: rest[1] === undefined ? "true" : "false" }, write: false };
|
|
105
|
+
}
|
|
106
|
+
case "zone-options":
|
|
107
|
+
return { path: "/api/zones/options/get", params: { zone: assertName(rest[0], "zone") }, write: false };
|
|
108
|
+
case "resolve": {
|
|
109
|
+
const type = rest[1] ?? "A";
|
|
110
|
+
if (!RECORD_TYPE.test(type)) throw new UsageError("type must be a record type such as A, AAAA, CNAME, TXT");
|
|
111
|
+
return { path: "/api/dnsClient/resolve", params: { server: "this-server", domain: assertName(rest[0], "domain"), type }, write: false };
|
|
112
|
+
}
|
|
113
|
+
case "stats": {
|
|
114
|
+
const type = rest[0] ?? "LastHour";
|
|
115
|
+
if (!STATS_TYPES.includes(type)) throw new UsageError(`stats type must be one of ${STATS_TYPES.join(", ")}`);
|
|
116
|
+
return { path: "/api/dashboard/stats/get", params: { type }, write: false };
|
|
117
|
+
}
|
|
118
|
+
case "zone-create":
|
|
119
|
+
return { path: "/api/zones/create", params: { zone: assertName(rest[0], "zone"), type: "Primary", catalog }, write: true };
|
|
120
|
+
case "zone-delete":
|
|
121
|
+
return { path: "/api/zones/delete", params: { zone: assertName(rest[0], "zone") }, write: true };
|
|
122
|
+
case "record-add":
|
|
123
|
+
case "record-update":
|
|
124
|
+
case "record-delete": {
|
|
125
|
+
const zone = assertName(rest[0], "zone");
|
|
126
|
+
const domain = assertName(rest[1], "domain");
|
|
127
|
+
if (!RECORD_TYPE.test(rest[2])) throw new UsageError("type must be a record type such as A, AAAA, CNAME, TXT");
|
|
128
|
+
const params = parseParams(rest.slice(3));
|
|
129
|
+
for (const fixed of ["zone", "domain", "type"]) {
|
|
130
|
+
if (fixed in params) throw new UsageError(`${fixed} is a positional argument`);
|
|
131
|
+
}
|
|
132
|
+
return { path: `/api/zones/records/${name.slice("record-".length)}`, params: { ...params, zone, domain, type: rest[2] }, write: true };
|
|
133
|
+
}
|
|
134
|
+
default:
|
|
135
|
+
throw new UsageError(`unknown technitium command: ${name}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface TechnitiumResponse {
|
|
140
|
+
status?: string;
|
|
141
|
+
errorMessage?: string;
|
|
142
|
+
response?: unknown;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function technitium(config: DnsConfig, args: string[]): Promise<number> {
|
|
146
|
+
const node = args[0];
|
|
147
|
+
if (!NODES.includes(node as Node)) throw new UsageError(`technitium <${NODES.join("|")}> <command> [args]`);
|
|
148
|
+
const request = technitiumRequest(config.technitiumCatalog, args.slice(1));
|
|
149
|
+
if (request.write && node !== "primary") throw new UsageError("writes go to the primary; the secondary follows through the catalog zone");
|
|
150
|
+
const base = node === "primary" ? config.technitiumPrimaryUrl : config.technitiumSecondaryUrl;
|
|
151
|
+
const token = readSecret(SERVICE, node === "primary" ? "TECHNITIUM_TOKEN" : "TECHNITIUM2_TOKEN");
|
|
152
|
+
const response = await fetch(new URL(request.path, base), {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
155
|
+
body: new URLSearchParams(request.params),
|
|
156
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
157
|
+
});
|
|
158
|
+
const text = await response.text();
|
|
159
|
+
let parsed: TechnitiumResponse;
|
|
160
|
+
try {
|
|
161
|
+
parsed = JSON.parse(text) as TechnitiumResponse;
|
|
162
|
+
} catch {
|
|
163
|
+
throw new ServiceError(`Technitium ${node} answered ${response.status} with a non-JSON body`);
|
|
164
|
+
}
|
|
165
|
+
if (parsed.status !== "ok") {
|
|
166
|
+
throw new ServiceError(`Technitium ${node}: ${parsed.status ?? response.status}${parsed.errorMessage ? `: ${parsed.errorMessage}` : ""}`);
|
|
167
|
+
}
|
|
168
|
+
printJson({ node, status: parsed.status, response: redactSecrets(parsed.response ?? null) });
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface CloudflareEnvelope {
|
|
173
|
+
success: boolean;
|
|
174
|
+
errors?: { code: number; message: string }[];
|
|
175
|
+
result?: unknown;
|
|
176
|
+
result_info?: { page?: number; total_pages?: number; count?: number; total_count?: number };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function cloudflareCall(method: string, path: string, body?: unknown): Promise<CloudflareEnvelope> {
|
|
180
|
+
const token = readSecret(SERVICE, "CLOUDFLARE_TOKEN");
|
|
181
|
+
const response = await fetch(`${CLOUDFLARE_API}${path}`, {
|
|
182
|
+
method,
|
|
183
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
184
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
185
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
186
|
+
});
|
|
187
|
+
let envelope: CloudflareEnvelope;
|
|
188
|
+
try {
|
|
189
|
+
envelope = (await response.json()) as CloudflareEnvelope;
|
|
190
|
+
} catch {
|
|
191
|
+
throw new ServiceError(`Cloudflare answered ${response.status} with a non-JSON body`);
|
|
192
|
+
}
|
|
193
|
+
if (!response.ok || !envelope.success) {
|
|
194
|
+
const detail = (envelope.errors ?? []).map((e) => `${e.code} ${e.message}`).join("; ");
|
|
195
|
+
throw new ServiceError(`Cloudflare ${method} ${path} failed (${response.status})${detail ? `: ${detail}` : ""}`);
|
|
196
|
+
}
|
|
197
|
+
return envelope;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface CloudflareZone {
|
|
201
|
+
id: string;
|
|
202
|
+
name: string;
|
|
203
|
+
status?: string;
|
|
204
|
+
paused?: boolean;
|
|
205
|
+
name_servers?: string[];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface CloudflareRecord {
|
|
209
|
+
id: string;
|
|
210
|
+
type: string;
|
|
211
|
+
name: string;
|
|
212
|
+
content: string;
|
|
213
|
+
ttl: number;
|
|
214
|
+
proxied?: boolean;
|
|
215
|
+
comment?: string | null;
|
|
216
|
+
modified_on?: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function compactRecord(record: CloudflareRecord): CloudflareRecord {
|
|
220
|
+
const { id, type, name, content, ttl, proxied, comment, modified_on } = record;
|
|
221
|
+
return { id, type, name, content, ttl, proxied, comment: comment ?? undefined, modified_on };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function zoneId(zone: string): Promise<string> {
|
|
225
|
+
const envelope = await cloudflareCall("GET", `/zones?name=${encodeURIComponent(assertName(zone, "zone"))}`);
|
|
226
|
+
const zones = envelope.result as CloudflareZone[];
|
|
227
|
+
if (zones.length !== 1) throw new UsageError(`zone ${zone} is not visible to the token`);
|
|
228
|
+
return zones[0].id;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function parseRecordFilters(args: string[]): { zone: string; query: URLSearchParams } {
|
|
232
|
+
const query = new URLSearchParams();
|
|
233
|
+
let zone: string | undefined;
|
|
234
|
+
for (let i = 0; i < args.length; i++) {
|
|
235
|
+
const a = args[i];
|
|
236
|
+
if (a === "--type" || a === "--name") {
|
|
237
|
+
const value = args[i + 1];
|
|
238
|
+
if (value === undefined) throw new UsageError(`${a} needs a value`);
|
|
239
|
+
if (a === "--type" && !RECORD_TYPE.test(value)) throw new UsageError("--type must be a record type");
|
|
240
|
+
if (a === "--name") assertName(value, "--name");
|
|
241
|
+
query.set(a.slice(2), value);
|
|
242
|
+
i++;
|
|
243
|
+
} else if (a.startsWith("-")) {
|
|
244
|
+
throw new UsageError(`unknown option ${a}`);
|
|
245
|
+
} else if (zone === undefined) {
|
|
246
|
+
zone = a;
|
|
247
|
+
} else {
|
|
248
|
+
throw new UsageError("cloudflare records <zone> [--type T] [--name fqdn]");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (zone === undefined) throw new UsageError("cloudflare records <zone> [--type T] [--name fqdn]");
|
|
252
|
+
return { zone: assertName(zone, "zone"), query };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseJsonObject(text: string | undefined, what: string): Record<string, unknown> {
|
|
256
|
+
if (text === undefined) throw new UsageError(`${what}: JSON body missing`);
|
|
257
|
+
let parsed: unknown;
|
|
258
|
+
try {
|
|
259
|
+
parsed = JSON.parse(text);
|
|
260
|
+
} catch {
|
|
261
|
+
throw new UsageError(`${what}: body must be JSON`);
|
|
262
|
+
}
|
|
263
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new UsageError(`${what}: body must be a JSON object`);
|
|
264
|
+
return parsed as Record<string, unknown>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function cloudflare(args: string[]): Promise<number> {
|
|
268
|
+
const [sub, ...rest] = args;
|
|
269
|
+
switch (sub) {
|
|
270
|
+
case "verify": {
|
|
271
|
+
const accountId = readSecret(SERVICE, "CLOUDFLARE_ACCOUNT_ID");
|
|
272
|
+
if (!HEX_ID.test(accountId)) throw new ServiceError("CLOUDFLARE_ACCOUNT_ID is not a 32-character hex id");
|
|
273
|
+
const envelope = await cloudflareCall("GET", `/accounts/${accountId}/tokens/verify`);
|
|
274
|
+
printJson(redactSecrets(envelope.result));
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
case "zones": {
|
|
278
|
+
const envelope = await cloudflareCall("GET", "/zones?per_page=50");
|
|
279
|
+
printJson((envelope.result as CloudflareZone[]).map(({ id, name, status, paused, name_servers }) => ({ id, name, status, paused, name_servers })));
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
case "records": {
|
|
283
|
+
const { zone, query } = parseRecordFilters(rest);
|
|
284
|
+
const id = await zoneId(zone);
|
|
285
|
+
query.set("per_page", "5000");
|
|
286
|
+
const records: CloudflareRecord[] = [];
|
|
287
|
+
for (let page = 1; ; page++) {
|
|
288
|
+
query.set("page", String(page));
|
|
289
|
+
const envelope = await cloudflareCall("GET", `/zones/${id}/dns_records?${query.toString()}`);
|
|
290
|
+
records.push(...(envelope.result as CloudflareRecord[]).map(compactRecord));
|
|
291
|
+
if (page >= (envelope.result_info?.total_pages ?? 1)) break;
|
|
292
|
+
}
|
|
293
|
+
printJson({ zone, zoneId: id, count: records.length, records });
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
case "record-add": {
|
|
297
|
+
if (rest.length !== 2) throw new UsageError("cloudflare record-add <zone> <json>");
|
|
298
|
+
const body = parseJsonObject(rest[1], "record-add");
|
|
299
|
+
const envelope = await cloudflareCall("POST", `/zones/${await zoneId(rest[0])}/dns_records`, body);
|
|
300
|
+
printJson(compactRecord(envelope.result as CloudflareRecord));
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
case "record-update": {
|
|
304
|
+
if (rest.length !== 3 || !HEX_ID.test(rest[1])) throw new UsageError("cloudflare record-update <zone> <recordId> <json>");
|
|
305
|
+
const body = parseJsonObject(rest[2], "record-update");
|
|
306
|
+
const envelope = await cloudflareCall("PATCH", `/zones/${await zoneId(rest[0])}/dns_records/${rest[1]}`, body);
|
|
307
|
+
printJson(compactRecord(envelope.result as CloudflareRecord));
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
case "record-delete": {
|
|
311
|
+
if (rest.length !== 2 || !HEX_ID.test(rest[1])) throw new UsageError("cloudflare record-delete <zone> <recordId>");
|
|
312
|
+
const envelope = await cloudflareCall("DELETE", `/zones/${await zoneId(rest[0])}/dns_records/${rest[1]}`);
|
|
313
|
+
printJson(envelope.result);
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
default:
|
|
317
|
+
throw new UsageError(`unknown cloudflare command: ${sub ?? "(none)"}\n${HELP}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function command(args: string[]): Promise<number> {
|
|
322
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
|
|
323
|
+
printRaw(HELP);
|
|
324
|
+
return 0;
|
|
325
|
+
}
|
|
326
|
+
const config = readConfig<DnsConfig>(SERVICE, SHAPE);
|
|
327
|
+
switch (args[0]) {
|
|
328
|
+
case "technitium":
|
|
329
|
+
return technitium(config, args.slice(1));
|
|
330
|
+
case "cloudflare":
|
|
331
|
+
return cloudflare(args.slice(1));
|
|
332
|
+
default:
|
|
333
|
+
throw new UsageError(`unknown command: ${args[0]}\n${HELP}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
|
338
|
+
await main(SERVICE, command);
|
|
339
|
+
}
|