do_dns
Manage DigitalOcean DNS zones and records from the CLI: list zones, list/add/get/update/delete records with SRV/MX/CAA/TXT support.
Downloads: 8 ยท ID: 5ae10c56bc1b42d44d000000
Manage DigitalOcean DNS zones and records from the CLI: list zones, list/add/get/update/delete records with SRV/MX/CAA/TXT support.
Downloads: 8 ยท ID: 5ae10c56bc1b42d44d000000
<!-- FILE: do_dns_skill.md -->
# DigitalOcean DNS Skill
Manage DNS zones and records hosted on DigitalOcean via their API v2.
Stdlib-only (urllib) โ no pip/uv required. Good for quick zone/record edits.
**Fast to use once your token is set:**
```bash
python3 do_dns.py list-zones # see your zones
python3 do_dns.py records example.com # list records in a zone
python3 do_dns.py add example.com --type A --name www --data 203.0.113.10
```
## Token (one-time setup)
Grab an API token from the DO control panel: **API โ Tokens โ Generate New
Token** (write scope needed to create/delete records). The token lives in
`~/.secrets` under `DIGITALOCEAN_TOKEN=`. Env vars
`DIGITALOCEAN_TOKEN` / `DIGITALOCEAN_API_TOKEN` override it if set.
If the script says "no DigitalOcean token", add the line to `~/.secrets`:
```
DIGITALOCEAN_TOKEN=your_token_here
```
(chmod 600 โ the file should already be private.)
## Commands
| Command | What it does |
|---------|--------------|
| `list-zones` | List all domains/zones on the account |
| `records <zone>` | List all records in a zone (with record ids) |
| `get <zone> <id>` | Show one record by id |
| `add <zone> --type T --name N --data V [...]` | Create a record |
| `update <zone> <id> [--data ...] [--ttl ...]` | Edit a record (PATCH) |
| `delete <zone> <id> --yes` | Delete a record (needs explicit --yes) |
### `add` / `update` record fields
Common: `--type` (A, AAAA, CNAME, TXT, MX, SRV, NS, CAAโฆ), `--name` (hostname,
or `@` for the zone root), `--data` (the value).
Optional extra fields:
| Flag | Applies to | Meaning |
|------|-----------|---------|
| `--ttl N` | all (required for SRV) | seconds |
| `--priority N` | MX, SRV | preference value |
| `--port N` | SRV | port |
| `--weight N` | SRV | weight |
| `--flags N` | CAA | 0 or 128 |
| `--tag S` | CAA | issue / issuewild / iodef |
`update` is a PATCH: only the fields you pass change; omit the rest.
## Examples
```bash
# Point www at an IP
do_dns.py add example.com --type A --name www --data 203.0.113.10
# Add/verify an SPF TXT record (quote the value!)
do_dns.py add example.com --type TXT --name @ --data "v=spf1 include:_spf.example.com ~all"
# Mail record
do_dns.py add example.com --type MX --name @ --data mail.example.com --priority 10
# Fix the destination of an existing record by id
do_dns.py update example.com 123456789 --data 203.0.113.11
# Remove a record
do_dns.py delete example.com 123456789 --yes
```
## Notes / gotchas
- **Record names are relative to the zone**, so for `mail.example.com` inside
zone `example.com` you pass `--name mail`. Use `@` for the apex (some tools
use empty-string name for the root; DO accepts `@`).
- **Change propagation** is near-instant at the authoritative edge, but caches
respect TTL โ lower TTL before a planned cutover if you need it fast.
- Records are listed with their numeric **record id** first โ that's the handle
for `get`/`update`/`delete`.
- Delete is the only destructive op and always requires `--yes`.
- Pagination is handled for you (200/request), so you'll see every zone/record.
## Files
- `do_dns.py` โ the one-file CLI (stdlib only, no uv).
- `do_dns_skill.md` โ this doc.
<!-- FILE: do_dns.py -->
#!/usr/bin/env python3
"""DigitalOcean DNS manager.
Lists domains/zones, lists/adds/updates/deletes DNS records on DigitalOcean via
their API v2. Stdlib-only (urllib), so no pip/uv needed.
Token resolution (in order):
1. $DIGITALOCEAN_TOKEN env var
2. $DIGITALOCEAN_API_TOKEN env var
3. `DIGITALOCEAN_TOKEN=` line in ~/.secrets (or ~/.secrets)
Examples:
do_dns.py list-zones
do_dns.py records example.com
do_dns.py add example.com --type A --name www --data 203.0.113.10
do_dns.py add example.com --type CNAME --name mail --data mailgun.example.com
do_dns.py add example.com --type TXT --name _dmarc --data "v=DMARC1; p=quarantine"
do_dns.py add example.com --type MX --name @ --data mx1.example.com --priority 10
do_dns.py get example.com 123456789
do_dns.py update example.com 123456789 --data 203.0.113.11
do_dns.py delete example.com 123456789 --yes
"""
import argparse, json, os, sys, urllib.request, urllib.error
from pathlib import Path
API = "https://api.digitalocean.com/v2"
# Record types that carry an extra integer field (all optional from DO's side)
PRIORITY_TYPES = {"MX", "SRV"} # priority
PORT_TYPES = {"SRV"} # port
WEIGHT_TYPES = {"SRV"} # weight
FLAG_TYPES = {"CAA"} # flags (CAA: 0/128)
TAG_TYPES = {"CAA"} # tag (CAA: issue/issuewild/iodef)
TTL_TYPES = {"SRV"} # SRV requires a TTL per DO docs
def _read_secrets():
"""Read KEY=VALUE lines from the secrets file(s). Merges ~/.secrets and
~/.secrets so either location works; later file wins."""
paths = [Path.home() / ".secrets", Path.home() / ".secrets"]
secrets = {}
for path in paths:
if path.exists():
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
secrets[k.strip()] = v.strip()
return secrets
def _token():
tok = os.environ.get("DIGITALOCEAN_TOKEN") or os.environ.get("DIGITALOCEAN_API_TOKEN")
if not tok:
tok = _read_secrets().get("DIGITALOCEAN_TOKEN")
if not tok:
sys.stderr.write("ERROR: no DigitalOcean token. Set DIGITALOCEAN_TOKEN env var,\n"
" or add 'DIGITALOCEAN_TOKEN=<token>' to ~/.secrets\n")
sys.exit(1)
return tok
def _request(method, path, body=None):
"""Perform an API call. Returns parsed JSON (or [] for 204-on-delete)."""
url = API + path
data = json.dumps(body).encode() if body is not None else None
headers = {
"Authorization": f"Bearer {_token()}",
"Content-Type": "application/json",
"User-Agent": "pengy-do-dns/1.0",
}
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
detail = ""
try:
err = json.loads(e.read().decode())
detail = ": " + json.dumps(err)
except Exception:
pass
sys.stderr.write(f"ERROR: HTTP {e.code} {detail}\n")
sys.exit(1)
except urllib.error.URLError as e:
sys.stderr.write(f"ERROR: network failure: {e.reason}\n")
sys.exit(1)
def list_zones(args):
zones = []
path = "/domains?per_page=200"
while path:
data = _request("GET", path)
zones.extend(data.get("domains", []))
links = data.get("links", {}).get("pages", {}).get("next")
path = links if links else None
if not zones:
print("No zones on this account.")
return
for z in zones:
print(f"{z['name']:<40} ttl={z.get('ttl', '?')} zone_file_size={len(z.get('zone_file', ''))}")
print(f"\n{len(zones)} zone(s)")
def _record_brief(r):
meta = ""
if r["type"] in PRIORITY_TYPES and r.get("priority"):
meta += f" pri={r['priority']}"
if r["type"] in PORT_TYPES and r.get("port"):
meta += f" port={r['port']}"
if r["type"] in WEIGHT_TYPES and r.get("weight"):
meta += f" weight={r['weight']}"
if r["type"] in FLAG_TYPES and r.get("flags") is not None:
meta += f" flags={r['flags']}"
if r["type"] in TAG_TYPES and r.get("tag"):
meta += f" tag={r['tag']}"
if r.get("ttl"):
meta += f" ttl={r['ttl']}"
return f"{r['id']:<12} {r['type']:<6} {r['name']:<35} {r['data']}{meta}"
def records(args):
path = f"/domains/{args.zone}/records?per_page=200"
recs = []
while path:
data = _request("GET", path)
recs.extend(data.get("domain_records", []))
links = data.get("links", {}).get("pages", {}).get("next")
path = links if links else None
if not recs:
print(f"No records in zone '{args.zone}' (or zone not found).")
return
for r in recs:
print(_record_brief(r))
print(f"\n{len(recs)} record(s)")
def _build_payload(args, existing=False):
"""Assemble the DO record body from CLI args."""
body = {}
if getattr(args, "type", None):
body["type"] = args.type.upper()
if getattr(args, "name", None):
body["name"] = args.name
if getattr(args, "data", None):
body["data"] = args.data
if getattr(args, "ttl", None) is not None:
body["ttl"] = args.ttl
if getattr(args, "priority", None) is not None:
body["priority"] = args.priority
if getattr(args, "port", None) is not None:
body["port"] = args.port
if getattr(args, "weight", None) is not None:
body["weight"] = args.weight
if getattr(args, "flags", None) is not None:
body["flags"] = args.flags
if getattr(args, "tag", None) is not None:
body["tag"] = args.tag
return body
def add(args):
body = _build_payload(args)
# DO requires name for non-NS records be the hostname; '@' -> root handled by DO
for req in ("type", "name", "data"):
if req not in body:
sys.stderr.write(f"ERROR: missing required field '--{req}'\n")
sys.exit(1)
data = _request("POST", f"/domains/{args.zone}/records", body)
r = data.get("domain_record", {})
print(f"Created record {r.get('id')}: " + _record_brief(r))
def get(args):
data = _request("GET", f"/domains/{args.zone}/records/{args.record_id}")
print(_record_brief(data.get("domain_record", {})))
def update(args):
body = _build_payload(args)
if not body:
print("Nothing to update โ pass at least one --name/--data/--ttl/--priority/etc.")
return
data = _request("PATCH", f"/domains/{args.zone}/records/{args.record_id}", body)
print("Updated record id: " + _record_brief(data.get("domain_record", {})))
def delete(args):
if not args.yes:
sys.stderr.write("Refusing to delete without --yes. Re-run with --yes to confirm.\n")
sys.exit(1)
_request("DELETE", f"/domains/{args.zone}/records/{args.record_id}")
print(f"Deleted record {args.record_id} from zone '{args.zone}'.")
def main():
p = argparse.ArgumentParser(prog="do_dns.py", description="Manage DigitalOcean DNS records")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("list-zones", help="List domains/zones")
pr = sub.add_parser("records", help="List records in a zone")
pr.add_argument("zone")
pa = sub.add_parser("add", help="Add a record")
pa.add_argument("zone")
pa.add_argument("--type", required=True)
pa.add_argument("--name", required=True)
pa.add_argument("--data", required=True)
pa.add_argument("--ttl", type=int, default=None)
pa.add_argument("--priority", type=int, default=None)
pa.add_argument("--port", type=int, default=None)
pa.add_argument("--weight", type=int, default=None)
pa.add_argument("--flags", type=int, default=None)
pa.add_argument("--tag", default=None)
pg = sub.add_parser("get", help="Get a single record")
pg.add_argument("zone")
pg.add_argument("record_id")
pu = sub.add_parser("update", help="Update a record")
pu.add_argument("zone")
pu.add_argument("record_id")
pu.add_argument("--type")
pu.add_argument("--name")
pu.add_argument("--data")
pu.add_argument("--ttl", type=int, default=None)
pu.add_argument("--priority", type=int, default=None)
pu.add_argument("--port", type=int, default=None)
pu.add_argument("--weight", type=int, default=None)
pu.add_argument("--flags", type=int, default=None)
pu.add_argument("--tag", default=None)
pd = sub.add_parser("delete", help="Delete a record")
pd.add_argument("zone")
pd.add_argument("record_id")
pd.add_argument("--yes", action="store_true")
args = p.parse_args()
{"list-zones": list_zones, "records": records, "add": add,
"get": get, "update": update, "delete": delete}[args.cmd](args)
if __name__ == "__main__":
main()