Muppy Builtin DNS¶
muppy.io's DNS setup is unusual. The platform holds the API keys of the real DNS providers — OVH, Cloudflare, Scaleway — and gives its customers DNS domains under them. Handing out the provider keys instead would give away the whole account, every zone included.
Muppy Builtin DNS (MBD) is how a Muppy instance gives DNS domains to others without giving away its keys: it publishes one subdomain over a signed REST API, and the other side configures that subdomain as a normal DNS domain and creates records in it, without ever seeing the backend. The feature is open at two levels:
- Any Muppy customer can use the MBD Client as an HTTP DNS API — their instance creates records under a subdomain that muppy.io serves.
- The MBD Server is open too, for a customer who cascades Muppy instances (Muppy Enterprise puts no limit on the number of Muppy servers): their own Muppy publishes subdomains to their downstream instances the same way.
MBD is a pair of DNS Provider values on a DNS Domain: MBD Server on the side that owns the zone, MBD Client on the side that consumes it.
MBD Client ──HTTPS, AWS SigV4──▶ MBD Server ──provider API──▶ OVH / Cloudflare
acme.example.com acme.example.com example.com
An MBD Server publishes one subdomain¶
Create a DNS Domain named after the subdomain you want to publish, and set its DNS Provider
to Muppy Builtin DNS Server - MBD. Fill the MBD Server tab:
| Field | What it does |
|---|---|
| Backend DNS Domain | the connected domain the records are really written to. The picker offers the domains that can host a delegated sub-zone: an OVH zone, or a Cloudflare zone with Advanced Certificate Manager (ACM) on. Names under a zone that cannot would get no certificate. |
| AWS SigV4 Token | the credential clients sign with. Create Token mints one with its Access Key ID and Secret already set. |
| Records Quota | how many active records this domain accepts, across all its clients. Default 100. |
| MBD Status | Active or Suspended. |
The name must be exactly one label under the backend domain — three components in all. With
a backend of example.com, acme.example.com is valid and a.b.example.com is not. Records
are written to the backend as subdomains of it, and the sync reads them back by rebuilding that
name; a deeper name produces records the sync never recognises.
An MBD Server owns no zone of its own. Every create, update and delete is delegated to the backend domain, so the records also show up there.
Connect checks the whole chain before marking the domain connected: the backend is set, is itself connected and answers; the name has the right shape; a SigV4 token is set. It fails loudly, and writes nothing when it fails.
The token is a shared credential, not a per-client one
Whoever holds it may create, update and delete records on every subdomain of the domain, and one token can authorize several MBD Server domains at once. Regenerating its keys silently breaks every client holding the old pair. Issue a separate token per client you do not want to disturb together.
Suspended denies every API request for the domain until it is set back to Active. Records already created are left untouched — clients stop being able to change anything, they do not lose anything.
An MBD Client consumes one¶
On the consuming instance, create a DNS Domain with the same name as the server's domain
and set its DNS Provider to Muppy Builtin DNS Client - MBD. Fill the MBD Client tab:
| Field | What it does |
|---|---|
| MBD Server URL | scheme and host only, https://…. Muppy appends the API paths itself. |
| AWS SigV4 Token | a local token carrying the Access Key ID and Secret the server operator handed you. Paste them; do not regenerate them. |
| AWS Region / AWS Service Name | signing labels, defaults mpy-eur-1 and muppy-client. Leave them alone unless the server operator asks otherwise. |
| Proxied by Default (via MBD Server) | asks the server to serve every A, AAAA and CNAME record of this domain through Cloudflare (the orange cloud), TTL pinned to Automatic. Only a server whose backend is a Cloudflare zone honours it; TXT records, hence DNS-01 challenges, are never proxied. Turn it on for the domain that carries public URLs behind Cloudflare, off for a domain reached by IP. Each record then shows Proxied as the server reports it, at creation and at every Sync. |
https only — a single plain-http call burns the token
The first request that reaches an MBD Server over http:// marks the token compromised,
permanently. There is no shortcut for a development box: reach a local server through its
public https URL.
Connect lists the domain's records. That one call proves three things at once — the signature is valid, the token is authorized on this domain, and the domain is Active and connected on the server. It never raises: the outcome is written to the form — the state badge, and a red banner carrying the server's own error text when it failed. Test Connection to MBD Server runs the same call and reports in a toast, changing nothing.
Connecting also mints the HTTP Basic token a Let's Encrypt client needs and ticks Use Muppy as 'Let's Encrypt' DNS Provider, so the domain is immediately usable for DNS-01 challenges — see Let's Encrypt Certificates (DNS-01).
Credential Expiry¶
Credential Expiry on the DNS Domain shows what the server reports about the token you signed with, refreshed at every connection check. Unknown means nobody has successfully asked yet — it is deliberately distinct from Perpetual, which is the server explicitly stating the token has no lifetime.
The credential lives on the server, and so does the risk. A client cannot see the token record, cannot renew it and cannot tell how many other domains it also serves. An expiring or regenerated token is repaired on the server side, then the new Access Key ID and Secret are handed over again.
Protocol¶
Every verb is served on one URL:
https://<mbd-server-host>/mpy/dns/v1/records
Authentication is AWS Signature V4 — the signing scheme only. No AWS service is involved
anywhere in MBD. region and service are free-form labels: the server reads both back from
the Credential header you sent and recomputes the signature from them, so any consistent pair
works. Sign with the Access Key ID and Secret the server operator issued.
Authorization is per token and domain. A signature that verifies still gets Access denied
for a domain the token is not bound to.
| Verb | Body / query | Does |
|---|---|---|
POST |
JSON body: domain, name, record_type, value, ttl (default 3600), optional proxied |
creates a record |
GET |
query string: domain, optional record_type, optional name |
lists the active records |
PUT |
JSON body: domain, name, record_type, and target and/or ttl and/or proxied |
updates an existing record |
DELETE |
JSON body: domain, name, record_type |
deletes a record |
name is the subdomain part alone, empty for the domain itself. POST names the record value
value, PUT names it target — they are the same thing under two keys.
proxied asks for the record to be served through Cloudflare. It is honoured only when the
server's backend is a Cloudflare zone, and only on A, AAAA and CNAME records; anything
else answers status: error before a record is written. A proxied record has its TTL pinned to
1 (Cloudflare's Automatic), whatever ttl says, and keeps it on later updates: a PUT
carrying ttl alone never unproxies a record, only "proxied": false does. Send the key only
when it is true — an older server rejects any unknown key, false included.
Creating a record, with the SigV4 headers omitted:
{
"domain": "acme.example.com",
"name": "www",
"record_type": "A",
"value": "203.0.113.10",
"ttl": 3600
}
A listing answers:
{
"status": "success",
"records": [
{
"id": 42,
"sub_domain": "www",
"fqdn": "www.acme.example.com",
"record_type": "A",
"target": "203.0.113.10",
"ttl": 3600,
"description": "",
"proxied": false
}
],
"count": 1,
"quota_used": 1,
"quota_max": 100,
"credential": {
"access_key": "MBD Server - acme.example.com/id=7",
"lifetime": "limited",
"expires": "2027-01-31T09:00:00Z"
}
}
Every failure answers with the same two keys, whatever the verb:
{
"status": "error",
"error": "Access denied to domain 'acme.example.com'"
}
GET carries the failure in the HTTP status too — 400 for a refusal, 500 for an internal
error. The other three verbs always answer 200 and put the outcome in status, so read
status rather than the status code.
Custom hostnames¶
A second resource, on its own path:
https://<mbd-server-host>/mpy/dns/v1/custom-hostnames
It declares a customer's own name in front of one of the domain's public names — the shape Cloudflare calls Cloudflare for SaaS. The customer points a CNAME at their public name, the backend issues a certificate for their name and connects to the record behind it.
| Verb | Body / query | Does |
|---|---|---|
POST |
JSON body: domain, hostname, origin |
declares hostname, served from origin |
GET |
query string: domain, optional hostname, optional force_refresh |
lists them, refreshing the ones still in flight — or all of them, with force_refresh |
DELETE |
JSON body: domain, hostname |
removes it, and the certificate with it |
hostname is the customer's own fully qualified name; origin is a name of the delegated
domain, and it must already be proxied.
Its own path rather than new keys on /records, deliberately. The body of a request is
mapped onto the route's arguments, so a server that predates a key answers an error naming a
Python type; one that predates a whole route answers a plain 404, which a client can turn
into a sentence. That is the difference between a puzzling failure and a clear one.
Two guards, and only two. The origin must be an existing, proxied record of the domain the token is bound to. The name is not checked at all: it lives outside any delegation by construction, and only the backend's own domain-control validation proves the client holds it — a guard here would refuse every legitimate declaration.
A quota, defaulting to zero. Each custom hostname is a certificate the backend bills the operator, so the operator grants a number per domain and the default forbids them entirely. A name still waiting for its certificate holds a slot.
A declaration answers:
{
"status": "success",
"custom_hostname_id": 42,
"hostname": "erp.acme.com",
"origin": "erp-prod.acme.example.com",
"cname_target": "erp-prod.acme.example.com",
"state": "pending",
"provider_state": "pending",
"ssl_state": "initializing",
"last_error": "",
"quota_used": 1,
"quota_max": 2
}
state is Muppy's own reading and the only one to branch on: pending, active, error or
deleted. It turns active when the certificate is issued, not when the name is first
seen. provider_state and ssl_state are the backend's own words, passed through unchanged.
last_error is populated for as long as the customer's CNAME is not in place. That is the
normal wait, not a failure — state stays pending throughout.
Declaring the same name twice with the same origin succeeds twice and creates one; with a different origin it is refused. A name the backend already holds is adopted rather than duplicated.
Polling. A client refreshes a pending declaration by GET. Muppy does this on a cron that
enqueues nothing while nothing is pending — and, like every queued job, it needs a running IMQ
worker. The Check button on the object and on the Traefik Application does the same thing
synchronously when one is not available.
A plain GET refreshes only the declarations still in flight, because asking the backend about
a settled one costs a call for nothing. force_refresh asks about all of them, and that is
what Check sends: active is settled from the tenant's side only. A customer who withdraws
their CNAME moves the name out from under the declaration at the backend, and a listing that
skips settled names would report it healthy forever. A cron polls cheaply; a person asking
whether something is still true gets the truth.
One instance serving itself will see the poll retry once
A Muppy that is both the MBD Server and one of its own MBD Clients — a development box
usually — has the client's GET reaching a route inside the same database. The worker's
transaction and the route's own session then write the same rows, PostgreSQL refuses to
serialise them, and the message retries. The retry succeeds and nothing is lost; it is
what a retry is for. Separate instances, which is the normal shape, never meet it.
Compatibility¶
A field absent from a response means the server does not report it — never that the value is empty. MBD servers differ in age, and reading silence as a value turns an older server into a false statement.
For credential specifically:
| What you get | What it means |
|---|---|
no credential object |
the server does not report credential lifetimes. Unknown. |
credential with "expires": null and "lifetime": "perpetual" |
the server states the token has no expiry. |
credential with an ISO 8601 expires |
the token expires then. Compute soon / expired yourself. |
The same rule holds for proxied in a listing: a record without the key comes from a server
that does not report proxying, not from a record served directly. A server that does report it
reads the value on its backend record, so it reflects what Cloudflare holds, false on any
backend that cannot proxy.
Signed examples with your own keys¶
The MBD Client tab generates ready-to-run curl and Python examples for all four verbs,
signed with the credentials that domain actually holds. Tick Show API Examples with Real
Credentials to reveal them, alongside the environment variables for a shell client. Use those
rather than transcribing the shapes above — they carry your host, your domain and your keys.