DNS Publisher Names
DNS Publisher Names¶
Human-readable domain names instead of 64-character hex publisher ids. A domain like images.example.com can stand in for your publisher key everywhere a <publisher> appears: registry paths, ocictl pull, ocictl follow, ocictl seed, ocictl pin, and policy.toml.
1# instead of:
2podman pull --tls-verify=false 127.0.0.1:5050/3f9a…c1/app:1.0
3
4# you can say:
5podman pull --tls-verify=false 127.0.0.1:5050/images.example.com/app:1.0
The domain takes the same first-path-segment slot as <hex> — the loopback-registry trick is unchanged; only name resolution grows a branch.
How it works¶
A DNS zone claims a human name for a publisher key via a signed TXT record at _ocid.<zone>:
1_ocid.images.example.com. 300 IN TXT "v=ocid1 k=<64-hex> ts=<unix> [relay=<url>] [seed=<64-hex>] sig=<128-hex>"
The signature is made by the publisher key itself over the canonical payload ocid1 <zone> <k> <ts> [<relay>] [<seed>]. This proves mutual consent: the zone admin points the name at the key, and only the key's owner could have signed the record. A domain cannot be saddled with a key, and a key cannot claim a domain it wasn't published under.
The optional relay=/seed= fields are reachability hints so a name is enough to connect, not just to verify (see Connect by name). They are covered by the signature — DNS cannot invent or swap them — but they can go stale until the record is re-published, so peers treat them as best-effort. Records without hints are the original format and verify unchanged.
Since every release is already signed by the publisher key, the DNS record only has to convince you which key owns the name — everything published under it verifies itself.
Trust model¶
Plain DNS is spoofable, so trust is anchored locally:
| Mechanism | What it does |
|---|---|
| Signature verification | Every record is verified against the key it carries. Always. |
| Zone binding | The zone name is part of the signed bytes — a record cannot be replayed across zones. |
| Freshness window | Records older than dns_max_age_secs (default 30 days) are rejected. Limits replay of stale records. |
| TOFU pinning | The first verified resolution pins the key in $OCID_HOME/dns-pins.json. A later record for the same zone with a different key is rejected as a possible hijack. |
| Rotation escape hatch | ocictl dns-unpin <zone> drops the pin; the next resolve re-pins whatever the zone publishes. |
| Caching | Positive and negative lookups are cached briefly (TTL-capped). Errors are never cached — every pull re-checks a bad record. |
| Fall-through | A domain with no record (or a dotted self-name like my.app) falls through to the existing alias/self name rules. DNS adds a branch; it never breaks existing names. |
DNS is an untrusted lookup; the pins are the root.
For publishers: claim your name¶
Generate the signed record (fully offline — signs with your local identity):
1ocictl dns-record images.example.com
Output:
1Publish this record in the images.example.com zone:
2
3 _ocid.images.example.com. 300 IN TXT "v=ocid1 k=<your-hex> ts=1791503161 sig=<128-hex>"
4
5Once it propagates, pulls use the name instead of hex:
6
7 podman pull http://127.0.0.1:5050/images.example.com/<name>:<tag>
Publish that TXT line in your DNS zone (BIND, Cloudflare, etc.). No ocid infrastructure is involved — any DNS provider works.
To refresh the timestamp before it ages out (records older than 30 days are rejected), re-run ocictl dns-record and re-publish.
Reachability hints: --relay, --seed (optional)¶
By default the record proves identity only — connecting still needs a ticket, mDNS, or pkarr discovery. Two optional hints make ocictl connect <zone> work with nothing else:
1ocictl dns-record images.example.com \
2 --relay https://use1-1.relay.iroh.network \
3 --seed did:key:z6Mk… # a peer that gossips with you
--relay <url>— a relay server you are reachable through; peers use it as a dial hint.--seed <peer-id>— an introducer: a peer that is a member of your swarm topic. Peers dial it first, and gossip's address exchange carries your address to them through it.
Both are explicit opt-in and never auto-filled: your daemon's current relay can change at any time, and a URL baked into a 30-day record would go stale silently. Since the record needs monthly re-signing anyway (freshness), restating the hints then is free — re-run and re-publish whenever your relay or seed peer changes.
Records with hints exceed the 255-byte single TXT string; the command prints them as multiple quoted strings (standard multi-string TXT, RFC 7208 §3.1 concatenation):
1_ocid.images.example.com. 300 IN TXT "v=ocid1 k=… ts=… relay=… seed=…" "sig=…"
Zone requirements¶
The zone must be a syntactically valid DNS name:
- At least one dot (
images.example.com, notbuildah) - Lowercase hostname charset:
a-z,0-9,-,. - No leading/trailing hyphens in labels
- No all-numeric labels (so IP literals like
1.2.3.4are not treated as zones) - No trailing dot
- Labels 1–63 chars, total ≤ 253 chars
For consumers: pull, follow, and seed by name¶
Pull by domain¶
1podman pull --tls-verify=false 127.0.0.1:5050/images.example.com/app:1.0
The daemon resolves images.example.com → publisher key via DNS, verifies the signature, pins it (TOFU first sight), and fetches the release from that publisher on demand.
Connect by name¶
A DNS name is also a peer address — ocictl connect accepts it alongside tickets and bare keys:
1ocictl connect images.example.com
The daemon resolves and pins the name as above, then dials the publisher. If the record carries hints, they are used: the relay= URL becomes a dial hint, and the seed= introducer is dialed first — gossip's address exchange then carries the publisher's address to you through it. Without hints, the bare key goes to iroh's pkarr/DHT discovery. Either way, no ticket needs to have been exchanged.
ocid --peer images.example.com (bootstrap at startup) and POST /_ocid/peers {"peer": "images.example.com"} behave the same.
A note on relays: a pkarr record publishes the peer's relay URL only (iroh keeps IP addresses private), so the dialer must be able to use relays for internet-wide bare-key connects — keep relay = "default". A node with relay = "disabled" still reaches peers on its LAN (mDNS) and anyone whose ticket or DNS record carries a usable hint; it cannot dial through the relay a bare-key lookup returns.
Inspect a name¶
Needs the daemon running (it performs the DNS lookup):
1ocictl resolve images.example.com
1zone images.example.com
2publisher 3f9a…c1
3state pinned (verified, fresh, TOFU-pinned)
4relay https://use1-1.relay.iroh.network (signed hint)
5seed 9b21…77 (signed introducer hint)
6connect: ocictl connect images.example.com (no ticket needed)
7
8pulls via: podman pull <registry>/images.example.com/<name>:<tag>
(The relay/seed lines appear only when the record carries hints.)
States: pinned (existing pin confirmed), new (pinned now), no-record (falls through to name rules), disabled (dns = "off"), error (stale record, pin mismatch, bad signature — with detail).
Policy in domain form¶
Policy commands need the daemon running (it resolves and pins the domain first). Offline, use the publisher id or an alias instead:
1ocictl follow images.example.com # every image they publish
2ocictl seed images.example.com/app:1.0 # exactly this tag
3ocictl pin images.example.com/app:1.0 # always kept, never GC'd
policy.toml keeps the human-readable domain form and records the resolved key in a [dns] mapping table, so policy evaluation never needs DNS at load time:
1[[follow]]
2publisher = "images.example.com"
3
4[[seed]]
5ref = "images.example.com/app:1.0"
6
7[dns]
8images.example.com = "3f9a…c1"
Key rotation (deliberate)¶
If a publisher legitimately changes keys (lost key, org change). Both commands need the daemon running:
1ocictl dns-unpin images.example.com # drop the pin
2ocictl resolve images.example.com # re-pins the new key
If you did not expect a key change, do not unpin — treat it as a hijack attempt and investigate.
Configuration (config.toml)¶
1dns = "on" # default: on. "off" disables all DNS lookups.
2dns_nameserver = "10.0.0.53" # optional: custom resolver (IP literal, port defaults to 53).
3 # default: system resolver. Useful for air-gapped setups.
4dns_max_age_secs = 2592000 # default: 30 days. Records older than this are rejected.
HTTP API¶
The daemon exposes the same operations for tooling (e.g. the Podman Desktop extension):
| Method | Path | Description |
|---|---|---|
GET | /_ocid/dns/resolve?zone=<zone> | Resolve a zone. Never fails with HTTP error — the state field carries the outcome. |
POST | /_ocid/dns/unpin | Drop the TOFU pin. Body: {"zone": "<zone>"}. |
Example:
1curl -s "http://127.0.0.1:5050/_ocid/dns/resolve?zone=images.example.com" | jq .
1{
2 "zone": "images.example.com",
3 "state": "pinned",
4 "publisher": "3f9a…c1",
5 "detail": null
6}
Files on disk¶
| Path | Purpose |
|---|---|
$OCID_HOME/dns-pins.json | TOFU pin store: zone → pinned publisher key + pinned_at timestamp. Atomic writes. |
$OCID_HOME/policy.toml [dns] table | Zone → resolved key mapping, written by the daemon when a domain-form policy rule is saved. |
Resolution precedence¶
When the registry sees /v2/<first-segment>/<rest>/…, it tries in order:
- 64-hex → explicit publisher
- Domain with verified
_ocidrecord → publisher from the record (TOFU-pinned) - Image alias (whole repo matches an alias)
- Publisher alias (first segment matches an alias)
- Implicit self → your own publisher id
A domain that has no record (or DNS is disabled) falls through to steps 3–5 unchanged.
Security considerations¶
- Hijack detection: if a pinned zone suddenly resolves to a different key, resolution fails loudly. The error message names both keys.
- Stale records: a record signed more than
dns_max_age_secsago is rejected even if the signature is valid — forces periodic re-publishing. - Hints can go stale:
relay=/seed=are covered by the signature, so DNS cannot invent or swap them, but the values describe the state of the world when the record was signed. A relay that has since moved or a seed peer that has since gone offline simply fails as a dial candidate — peers fall back to pkarr/mDNS/tickets and never trust a hint over verified content (blobs and releases verify themselves regardless of how the connection was found). - No DNSSEC required: the signature is the security boundary, not DNSSEC. (Opportunistic DNSSEC validation may come later.)
- Offline after first sight: once pinned, the name resolves from the pin store. First contact always requires DNS.
- Pin store is local:
$OCID_HOME/dns-pins.jsonis per-machine. A fresh node pins on first verified resolution — there is no cross-node pin distribution (by design; pins are a local trust root).
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
state: error — "refusing to switch keys" | Zone now points to a different key than the pin | If intentional: ocictl dns-unpin <zone>. If not: possible hijack — investigate. |
state: error — "is stale" | Record older than dns_max_age_secs | Publisher re-runs ocictl dns-record and re-publishes. |
state: no-record | No _ocid TXT at the zone | Check dig TXT _ocid.<zone>; publish the record. |
state: disabled | dns = "off" in config.toml | Set dns = "on" and restart. |
Pull by domain → MANIFEST_UNKNOWN | Name resolved but the publisher has no such image | Check the image name/tag with the publisher. |
Pull by domain → NAME_INVALID | DNS record failed verification | See ocictl resolve <zone> for the detail. |
See also¶
- README.md — naming table, CLI reference
- DESIGN.md — architecture, trust boundaries, resolution diagram
- todos/02-dns-publisher-records.md — design notes, roadmap