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.
| Action | What it does | Risk | Preview |
|---|---|---|---|
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.viewSee the provider catalogproviders.connection.viewView provider connectionsproviders.connection.manageConnect, edit, test and remove provider connectionsproviders.custom.manageDefine custom providersproviders.domain.viewView linked domains, registrar status and driftproviders.domain.manageImport domains and change nameservers, DS and DNS modeproviders.record.manageChange DNS records held at a providerproviders.policy.manageLimit which providers an account may connect
Plan limits
providers.connectionsProvider 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 orcustom), non-secretconfig, 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_untestedoverrides). Test = the adapter's cheapest read, classifiedok | auth_failed | rate_limited | provider_error | rejected | unreachable | blocked, with the provider's message redacted.providers.connections.recheckretests the 20 least recently checked every 15 min and emitsproviders.connection.failed/recovered. Delete is refused while domains are linked. Limitproviders.connections(default 5, not counted for admins). Per-account policyproviders.policy.set {principal_id, allowed[]}(admin) plus settingdefault_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,environmentproduction|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) staycustom_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.SetRRsetreplaces 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 declaredcredentials), staticheaders, and opsdomains,records,create,update?,delete,test?each withmethod,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). Optionstxt_quoted,keep_trailing_dots,name_format: relative|fqdn. Validation refuses unknown variables, credentials outsideauth,..////?/#in paths, forbidden headers (Host, Content-Length, Cookie, ...), a GET with a body, a non-https or privatebase_url, and definitions over 64 KB. A definition withoutupdatereplaces 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 settingsallow_private_targetsandallow_insecure_httprelax this for a lab or a self-hosted API; both default off. Errors drop the request URL, and every secret value andkey=style query parameter is replaced by••••. - Links (
domain_links): panel domain -> connection,mode:panel(DNS served here; the provider is only the registrar) orexternal(DNS served by the provider). Also cached registrar data (expiry, auto-renew, lock, privacy, nameservers) refreshed byproviders.registrar.refresh_allevery 6 h;providers.domain.expiringis sent once per threshold (settingexpiry_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)
- In
dns.on_domain_created(and wherever it would create/apply a zone) callproviders.external.status {domain}; whenexternal: truedo NOT create a zone on the node (the link already exists at that point). - 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 butproviders.external.statussays external, callproviders.dns.record.set|unset|listwith 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}. dns.acme_challenge.set|clear {domain, value}(internal): when no local zone holds the name, callproviders.acme_challenge.set|clear(same params; system actor). TXT TTL 60, several values coexist,*.stripped.- Domain deletion:
domains.deletedalready unlinks (subscriptionproviders.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. INWXtotp_secret) and plain settings are the catalogauth_fieldsthat the libdns struct really uses; fields libdns has no use for (namecheapusername, gandisharing_id, vercelteam_id, hehostname) are not accepted. Base-URL style fields of the libdns structs are never exposed. - Test-on-save:
ListZoneswhere it exists; otherwise a read of a zone that cannot exist (a rejected credential classifies asauth_failed, "zone not found" means the credential was accepted). Providers without ListZones cannot enumerate domains: import them by name (ZoneChecker.HasZonereads 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.DefaultTransportwith 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 isliveverified. 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.