Domain providers

Connect registrar and DNS-host API keys, import domains, manage external DNS, nameservers, DS records and expiry.

In preview providers version 0.1.0

Actions

29 actions, callable from the panel, the command palette and the API as POST /api/v1/a/<id>. Internal actions used between modules are not listed.

ActionWhat it doesRiskPreview
providers.catalog.list List supported providers (read) low No dry run
providers.connection.create Connect a provider medium No dry run
providers.connection.update Edit a connection medium No dry run
providers.connection.get Get a connection (read) low No dry run
providers.connection.list List connections (read) low No dry run
providers.connection.delete Remove a connection high No dry run
providers.connection.test Test a connection low No dry run
providers.custom.validate Validate a custom provider definition (read) low No dry run
providers.custom.save Save a custom provider medium No dry run
providers.custom.list List custom providers (read) low No dry run
providers.custom.get Get a custom provider (read) low No dry run
providers.custom.delete Delete a custom provider high No dry run
providers.domains.list List the domains a connection holds (read) low No dry run
providers.domains.import Import domains from a connection medium
providers.link.list List linked domains (read) low No dry run
providers.link.get Get a domain link (read) low No dry run
providers.link.set Link a domain to a connection, or change its DNS mode medium
providers.link.unlink Unlink a domain medium No dry run
providers.external.status Where does a domain's DNS live (read) low No dry run
providers.dns.record.list List records at the provider (read) low No dry run
providers.dns.record.set Set a record at the provider medium
providers.dns.record.unset Remove a record at the provider medium
providers.dns.sync Push the panel zone to the provider high
providers.nameservers.point Point the domain's nameservers to this server high
providers.dnssec.publish_ds Publish the DNSSEC DS record at the registrar high
providers.registrar.status Expiry, auto-renew, lock and privacy low No dry run
providers.drift.report Compare provider records with the panel zone (read) low No dry run
providers.policy.get Which providers may an account connect (read) low No dry run
providers.policy.set Limit the providers an account may connect medium No dry run

Permissions and limits

Permissions

  • providers.view See the provider catalog
  • providers.connection.view View provider connections
  • providers.connection.manage Connect, edit, test and remove provider connections
  • providers.custom.manage Define custom providers
  • providers.domain.view View linked domains, registrar status and drift
  • providers.domain.manage Import domains and change nameservers, DS and DNS mode
  • providers.record.manage Change DNS records held at a provider
  • providers.policy.manage Limit which providers an account may connect

Plan limits

  • providers.connections Provider connections

Engineering notes

Generated from modules/providers/docs.md at build d90e9e2. These are the notes the engineers keep next to the code: precise, technical, and honest about what is not done yet.

Customers (admin, reseller or hosting account) connect the API key of the place that holds their domain. The module then imports the domains, keeps DNS at the provider ("external mode") or moves it here, points nameservers at this server, publishes DNSSEC DS records, watches expiry and reports drift. It talks to provider APIs from the control plane only (no agent op).

Model

  • Connection (providers.connection.*): provider (catalog id or custom), non-secret config, secrets in the vault (conn/<id>, JSON of field -> value; the row keeps only ••••last4). Owner = "" (administrator), reseller id or hosting account id; a user sees only their own, an admin sees all. Saved only after a test call succeeds (save_untested overrides). Test = the adapter's cheapest read, classified ok | auth_failed | rate_limited | provider_error | rejected | unreachable | blocked, with the provider's message redacted. providers.connections.recheck retests the 20 least recently checked every 15 min and emits providers.connection.failed/recovered. Delete is refused while domains are linked. Limit providers.connections (default 5, not counted for admins). Per-account policy providers.policy.set {principal_id, allowed[]} (admin) plus setting default_allowed; plans map to accounts in the accounts module, so "per plan" = set it for the accounts of the plan (follow-up: package-level default).
  • Adapters (26 native: 4 hand-written below, 22 through libdns, see "libdns adapters"): Cloudflare (token), Hostinger (token; shapes from the owner's hpanel-dns client, validate before PUT, overwrite:true = RRset), GoDaddy (key+secret, environment production|ote; registrar info + nameservers), Porkbun (apikey+secretapikey in the POST body; registrar info, nameservers, DS). The 8 catalog providers without a libdns package or API client (dynadot, hover, openprovider, resellerclub, enom, constellix, ns1, hostinger-style gaps) stay custom_only: an admin defines them as a custom provider. Records are exchanged in the panel's canonical form (dnsx.Record: relative names, "quoted" TXT, 10 host. MX); each adapter converts to its wire format. SetRRset replaces all values of name+type, idempotently (id-based APIs: keep/update/delete/create).
  • Custom provider (providers.custom.save, admin): a JSON definition interpreted by the module, never code. base_url, auth (none | bearer | header | basic | query, values {cred.<key>} over the declared credentials), static headers, and ops domains, records, create, update?, delete, test? each with method, path, query, body (templates over {zone} {zone_id} {record_id} {name} {fqdn} {type} {value} {ttl} {priority}; an exact {ttl}/{priority} becomes a JSON number), items (dot path to the array), map (id, name, type, ttl, content, priority, expires), pagination (page | offset | cursor, max 200 pages). Options txt_quoted, keep_trailing_dots, name_format: relative|fqdn. Validation refuses unknown variables, credentials outside auth, ..////?/# in paths, forbidden headers (Host, Content-Length, Cookie, ...), a GET with a body, a non-https or private base_url, and definitions over 64 KB. A definition without update replaces by delete + create.
  • SSRF guard (ssrf.go, used by every outbound call): https only; no credentials in the URL; the host is resolved by the guard, EVERY address must be public (loopback, RFC 1918, link-local incl. 169.254.169.254, CGNAT, multicast, unspecified, IPv4-mapped / NAT64 / 6to4 forms judged by the embedded IPv4) and the connection is made to the checked address (no second lookup, so no DNS rebinding); redirects are re-checked, must stay https and may not leave the original host (custom headers would travel); max 3 redirects; no environment proxy; dial 5 s / TLS 10 s / headers 15 s / request 20 s; response capped at 4 MB. Admin-only settings allow_private_targets and allow_insecure_http relax this for a lab or a self-hosted API; both default off. Errors drop the request URL, and every secret value and key= style query parameter is replaced by ••••.
  • Links (domain_links): panel domain -> connection, mode: panel (DNS served here; the provider is only the registrar) or external (DNS served by the provider). Also cached registrar data (expiry, auto-renew, lock, privacy, nameservers) refreshed by providers.registrar.refresh_all every 6 h; providers.domain.expiring is sent once per threshold (setting expiry_warn_days, default 60/30/14/7/1; re-armed after a renewal).

Actions

catalog.list, connection.create|update|get|list|delete|test, custom.validate|save|list|get|delete, domains.list (what the provider holds, marked linked / on this server), domains.import (dry-run: per-domain plan), link.list|get|set|unlink, external.status, dns.record.list|set|unset, dns.sync (push panel zone to provider; prune), nameservers.point, dnssec.publish_ds, registrar.status, drift.report, policy.get|set, internal acme_challenge.set|clear. Every push has a dry-run Plan with real before/after values. Import order: the link is written before domains.create, so the dns module sees external when it reacts to domains.created; panel mode then hands the provider's records (apex NS and SOA left out) to dns.zone.import as a BIND file, waiting for the zone the dns module creates asynchronously.

Contract with the dns module (what dns must do for external mode; the dns module is not edited here)

  1. In dns.on_domain_created (and wherever it would create/apply a zone) call providers.external.status {domain}; when external: true do NOT create a zone on the node (the link already exists at that point).
  2. In dns.record.set|unset|list (and everything routed through them: mail SPF/DKIM/DMARC/MX, web A/AAAA), if the zone has no local zone but providers.external.status says external, call providers.dns.record.set|unset|list with the SAME params (and the same actor, dry-run flag included). Result shape matches: {zone, changed, applied, changes[], provider}; the list returns {zone, records[], provider}.
  3. dns.acme_challenge.set|clear {domain, value} (internal): when no local zone holds the name, call providers.acme_challenge.set|clear (same params; system actor). TXT TTL 60, several values coexist, *. stripped.
  4. Domain deletion: domains.deleted already unlinks (subscription providers.on_domain_deleted); provider records are left untouched.

Verified

Unit tests: SSRF refusals (blocked ranges incl. mapped/NAT64/6to4, URL forms, hostile DNS answer incl. one bad address among good ones, redirects to http / another host / loop, oversize body, timeout), key redaction, custom definition validation (19 bad definitions), custom adapter against a TLS mock with pagination, Cloudflare adapter against a mock, manifest contract. Lab run: see docs/tasks/w7/w7-02-providers-module.handover.md.

Not done / next

Native adapters for the other catalog providers (Namecheap XML with IP allow-list, Gandi, Name.com, Dynadot, Route 53 SigV4, ...); libdns/lego adapters (needs go.mod); provider-side DNSSEC for external zones; domain register/transfer/availability/pricing; web UI; sharing an admin or reseller connection with the accounts below it; per-plan default policy; Cloudflare batch endpoint for big imports.

libdns adapters (w7-03)

cp/libdns.go wraps any libdns provider (GetRecords + SetRecords + DeleteRecords, ListZones when it has one) as a DNSAdapter; cp/libdns_providers.go maps each provider's connection fields to its struct. Native via libdns (22): namecheap, namecom, namesilo, gandi, ovh, hetzner, digitalocean, route53, gcloud, azure, linode, ionos, dnsimple, desec, bunny, netlify, vercel, infomaniak, inwx, dynu, cloudns, he (TXT only). Records CRUD only: registrar features (nameservers, DS, expiry) exist only where hand-written (godaddy, porkbun). Notes:

  • Fields: required secrets, optional secrets (providerDef.Optional, e.g. INWX totp_secret) and plain settings are the catalog auth_fields that the libdns struct really uses; fields libdns has no use for (namecheap username, gandi sharing_id, vercel team_id, he hostname) are not accepted. Base-URL style fields of the libdns structs are never exposed.
  • Test-on-save: ListZones where it exists; otherwise a read of a zone that cannot exist (a rejected credential classifies as auth_failed, "zone not found" means the credential was accepted). Providers without ListZones cannot enumerate domains: import them by name (ZoneChecker.HasZone reads the zone). Google's service-account JSON is written to a 0600 temp file for the duration of one operation only.
  • SSRF: libdns packages build their own HTTP clients against fixed provider hosts. The plugin process replaces http.DefaultTransport with the guard's dial rules (public addresses only, no proxy); SDK-based packages (Route 53, Google, Azure, Linode) use their own transport and reach only their provider's hosts. Errors are redacted of every secret value.
  • Catalog: every provider carries tested {adapter, status, live}; nothing is live verified. Built-in four = mock-tested (adapters_test.go, adapters2_test.go: Cloudflare, Hostinger, GoDaddy incl. registrar calls); libdns ones = wrapper unit-tested over an in-memory libdns provider plus a build/field check against the catalog; the provider packages themselves are not run against mocks.

dns delegation (done in w7-03, the contract above is implemented)

dns.on_domain_created skips the node zone for external domains; dns.record.set|unset|list on a name with no local zone but an external link delegate to providers.dns.record.* (same actor and params); dns.acme_challenge.set|clear delegate to providers.acme_challenge.* when no local zone holds the name. Known limit: the SDK's Host.Call cannot forward the dry-run flag, so a dry run of an external set/unset returns a one-line plan (the exact diff is computed at apply). Follow-up: SDK change to pass DryRun through Host.Call.