diff --git a/docs/iac-dns-providers.md b/docs/iac-dns-providers.md new file mode 100644 index 00000000..d6aa2f8e --- /dev/null +++ b/docs/iac-dns-providers.md @@ -0,0 +1,154 @@ +# IaC DNS providers + +The `infra.dns` resource type is implemented by multiple plugins. +Each provider has different capabilities and auth requirements. + +## Provider matrix + +| Provider | Plugin | Auth | CIDR allowlist | Bulk ops | Status | +|----------|--------|------|----------------|----------|--------| +| DigitalOcean | [workflow-plugin-digitalocean](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) | API token | n/a | yes (idempotent record list) | verified | +| Namecheap | [workflow-plugin-namecheap](https://github.com/GoCodeAlone/workflow-plugin-namecheap) | API user + key + single client IP | NO — single-IP only | yes (`setHosts` is full-replace) | experimental | +| Hover | [workflow-plugin-hover](https://github.com/GoCodeAlone/workflow-plugin-hover) | username + password + TOTP (browser-flow) | n/a | per-record (no batch API) | experimental | + +## Configuration shape (all providers) + +```yaml +modules: + - name: + type: iac.provider. + config: + # provider-specific auth keys; see each plugin's README + ... + + - name: iac-state + type: iac.state + config: + backend: + +resources: + - name: + type: infra.dns + config: + provider: + domain: example.com + records: + - type: A + name: '@' # apex, or subdomain + data: 203.0.113.10 + ttl: 1800 + - type: CNAME + name: www + data: example.com. + ttl: 1800 + - type: MX + name: '@' + data: mail.example.com. + mx: 10 # MX priority + ttl: 3600 + - type: TXT + name: '_acme-challenge' + data: "abc123" + ttl: 60 +``` + +## Per-provider notes + +### DigitalOcean + +- Best for the GoCodeAlone reference stack (App Platform droplets + resolved by name; built-in DNS). +- Token must have full read+write scope. + +### Namecheap + +**Allowlist gotcha:** every IP that hits `api.namecheap.com` must be +explicitly whitelisted at Profile → Tools → Namecheap API Access. +Namecheap does NOT support CIDR. CI runners with rotating outbound +IPs need either: + +1. A NAT gateway with a static egress IP. +2. A bastion host that proxies the API call. + +The plugin's Config.Validate refuses CIDR strings outright so the +failure is detected at boot, not at apply time. + +`setHosts` is a full-replace API: the plugin reads existing records +first and merges only the diff. Concurrent applies against the same +zone can lose writes — serialize with `wfctl infra apply`'s +single-pass guarantee. + +### Hover + +Hover has no official API. The plugin mimics the browser-side auth +used by [pjslauta/hover-dyn-dns](https://github.com/pjslauta/hover-dyn-dns): + +1. GET `/signin` → parse CSRF `_token`. +2. POST `/signin` with username + password + token. +3. GET `/signin/totp` → fresh `_token`. +4. POST `/signin/totp` with RFC 6238 code + token. +5. Subsequent `/api/dns/...` requests carry the session cookie. + +**TOTP**: provide the base32 seed (shown when you enabled 2FA in +Hover). Codes are generated in-process via pure-Go HMAC-SHA1. + +**Failure modes**: + +- Hover may serve a CAPTCHA challenge on suspicious logins. The + plugin doesn't solve CAPTCHAs; log in manually from the same IP + once to seed trust, OR use a static egress IP. +- Hover's signin HTML can change. The plugin fails loud with + `CSRF token not found at /signin` when the regex stops matching. +- No batch API — record edits are per-call. Don't use Hover for + zones with > ~50 records. + +## Dynamic DNS + +For dynamic IPs (home labs, mobile workstations), pair any DNS +provider with the `infra.dyndns` module: + +```yaml +- name: home-dns + type: infra.dyndns + config: + provider: namecheap # any iac.provider.* module name + domain: gocodealone.tech + record_name: home + poll_interval: 5m + detect_via: [icanhazip, ifconfig.me, ipify] + quorum: 2 # 2-of-3 must agree before update fires +``` + +The daemon: + +1. Polls each detector in parallel. +2. Requires `quorum` of them to return the same IP. +3. Compares to last-known IP. +4. Calls the provider's `UpdateRecord` on change. +5. Exponential backoff on consecutive failures (capped at 1h). + +Per-record `detect_via` lets you trade redundancy for fewer +outbound calls; private LANs without internet access can supply +a custom detector via the plugin SDK. + +## Secret management + +Each provider declares its required secrets in plugin.json's +`required_secrets[]`. To set them all at once: + +```sh +wfctl secrets setup --plugin workflow-plugin-namecheap \ + --scope org --org GoCodeAlone + +wfctl secrets setup --plugin workflow-plugin-hover \ + --scope env --env production +``` + +See `docs/wfctl-secrets-scopes.md` for the scope flag matrix. + +## Provider plan + +The full DNS provider plan (Namecheap + Hover + dyndns + scoped +secret-set) is tracked in `docs/plans/2026-05-20-dns-providers.md` +(workflow#735) — caveman SPEC format with 20 tasks, 16 constraints, +18 invariants. diff --git a/docs/wfctl-secrets-scopes.md b/docs/wfctl-secrets-scopes.md new file mode 100644 index 00000000..abdbf7de --- /dev/null +++ b/docs/wfctl-secrets-scopes.md @@ -0,0 +1,121 @@ +# wfctl secrets — GitHub scope reference + +`wfctl secrets set` and `wfctl secrets setup --plugin` both write to +one of three GitHub secret destinations. Each requires a different +PAT scope and exposes different visibility controls. + +| Scope | URL prefix | PAT scopes | Visibility flags | +|-------|------------|-----------|------------------| +| `repo` (default) | `/repos/{owner}/{repo}/actions/secrets/{name}` | `repo` | — | +| `env` | `/repos/{owner}/{repo}/environments/{env}/secrets/{name}` | `repo`, `workflow` | — | +| `org` | `/orgs/{org}/actions/secrets/{name}` | `admin:org` | `--visibility {all,selected,private}` | + +## Default (repo) scope + +Backwards-compatible. Reads the repo from `secrets.config.repo` in +`app.yaml`: + +```sh +wfctl secrets set MY_TOKEN --value "abc123" +# → PUT /repos/GoCodeAlone/example/actions/secrets/MY_TOKEN +``` + +Pipes are honored: + +```sh +echo -n "abc123" | wfctl secrets set MY_TOKEN +``` + +If stdin is a TTY and neither `--value` nor `--from-file` is set, +the value is read with `term.ReadPassword` (masked). + +## Environment scope + +Writes to a repo's GitHub Actions environment. Requires the env to +already exist (create it once in the repo's Settings → Environments +panel). + +```sh +wfctl secrets set STRIPE_KEY \ + --scope env --env production \ + --value "sk_live_..." +# → PUT /repos/GoCodeAlone/example/environments/production/secrets/STRIPE_KEY +``` + +Repo is still resolved from `app.yaml`'s `secrets.config.repo`. + +## Organization scope + +Writes a secret that any selected repo can pull. Bypasses `app.yaml` +since org secrets are out-of-band of repo config. The PAT in +`$GITHUB_TOKEN` (or `--token-env`) MUST carry `admin:org`. + +```sh +# All repos in the org can pull this secret. +wfctl secrets set SHARED_API \ + --scope org --org GoCodeAlone \ + --visibility all \ + --value "$(openssl rand -hex 32)" + +# Only private + internal repos can pull. +wfctl secrets set INTERNAL_API \ + --scope org --org GoCodeAlone \ + --visibility private \ + --value "..." + +# Only the listed repo IDs can pull. (selected_repository_ids +# are populated programmatically via a follow-up; CLI accepts +# them in a future flag.) +wfctl secrets set CI_SECRET \ + --scope org --org GoCodeAlone \ + --visibility selected \ + --value "..." +``` + +## Plugin-driven setup + +If you're configuring a plugin that declares `required_secrets[]` in +its `plugin.json` (workflow-plugin-namecheap, workflow-plugin-hover, +etc.), use the interactive setup flow: + +```sh +wfctl secrets setup --plugin workflow-plugin-hover \ + --scope org --org GoCodeAlone --visibility all +``` + +This: + +1. Reads `data/plugins/workflow-plugin-hover/plugin.json`. +2. Iterates `required_secrets[]`. +3. Prompts for each (masked iff `sensitive: true`). +4. Writes each to the chosen GH scope. + +Pipe a value list to skip the prompt loop in CI: + +```sh +printf 'alice\nhunter2\nJBSWY3DPEHPK3PXP\n' | \ + wfctl secrets setup --plugin workflow-plugin-hover \ + --scope org --org GoCodeAlone +``` + +## PAT scope cheat sheet + +| Token use | Required scopes | +|-----------|-----------------| +| Repo secrets | `repo` (or fine-grained `Actions:write` + `Secrets:write`) | +| Env secrets | `repo` + `workflow` | +| Org secrets | `admin:org` (classic PAT) — fine-grained PATs cannot manage org secrets as of GH API v2022-11-28 | + +## Troubleshooting + +- **`HTTP 403: Resource not accessible by integration`** — missing + PAT scope. Most often `admin:org` for `--scope=org`. +- **`HTTP 422: secret value cannot be empty`** — the prompted value + was empty. The setup flow skips empty values; check terminal + echo settings. +- **`improperly encrypted secret`** — local clock skew vs GitHub or + a truncated public key. Re-run; the encryption nonce is per-call. +- **Org secret missing from a repo's Actions environment** — check + visibility. `selected` requires the repo ID to be in + `selected_repository_ids`; CLI accepts the list via a follow-up + flag.