dns
A command line front for the infoblox grid. It adds and removes host records, aliases, txt and mx records, hands out the next free address, writes dhcp options, restarts the grid, stands in as a certbot hook, and answers in one line of json when a script is asking.
One binary and nothing else: no runtime to install, no configuration file to write, no login to keep somewhere. The credentials are sealed into the program and unsealed once, and the program updates itself from the gitea releases.
$ dns -f mail
mail1.fhi.mpg.de 141.14.130.21 (00:1b:21:3c:4d:5e)
mail2.fhi.mpg.de 141.14.130.22
mailgate.fhi.mpg.de 141.14.128.9
Contents
- Installing · The first run · Names
- Host records · Aliases · Txt records · Mx records
- Dhcp options · The grid · Certbot
- Json for scripts · Options
- Keeping current · Building · The login
- Files · Exit status · The source
Installing
Every release carries a binary per platform — darwin and linux, amd64 and
arm64. Take the one for the machine from
the releases, name it dns and put
it in the path:
curl -Lo dns https://git.fhi.mpg.de/mike/dns/releases/download/2.5.1/dns-darwin-arm64
chmod 755 dns
mv dns ~/bin/
From there it keeps itself current — see Keeping current.
The first run
Two things have to be right before anything happens at all.
The network. dns runs from 141.14.128.0/20 and nowhere else. From another
address it says access violation, permission denied and stops — before the
login, before the grid is touched.
The login. It sits sealed inside the binary, under a passphrase shared among
the people who use dns. The first run asks for it once, and writes the login to
~/.dnsrc, encrypted and mode 0600; every run after that reads that file and
asks nothing.
$ dns -s myhost
? passphrase **********
OK: credentials unsealed to /Users/mike/.dnsrc
{
"_ref": "record:host/ZG5zLmhvc3Q...",
"ipv4addrs": [ ... ],
"name": "myhost.fhi.mpg.de",
...
}
A run with nobody sitting there — cron, the certbot hooks — never asks. It says what is missing and stops. So run dns once by hand on every machine that is going to use it, under the account that will be running it.
Names
A name without a dot is completed with .fhi.mpg.de: -s myhost and
-s myhost.fhi.mpg.de ask the same question. A name with a dot anywhere in it
is taken as it stands, which is how a host, an alias or a mail server outside
the default domain is named.
Host records
dns -a myhost # with the next free address
dns -a myhost -i 141.14.130.17 # with that one
dns -a myhost -i 141.14.130.17 -m 00:1b:21:3c:4d:5e # and a dhcp reservation
dns -s myhost # the record, as infoblox holds it
dns -f mail # every host whose name contains that
dns -i 141.14.130.17 # what sits on that address
dns -l # every free address in the network
dns -d myhost # remove it (asks first)
Without -i the next free address in 141.14.128.0/20 is taken, and the answer
says which one it was.
A mac address turns the record into a dhcp reservation, and a reservation only takes effect once the grid has restarted — so that restart happens by itself. The record is in either way: a restart that goes wrong comes back as a warning beside the answer, not as an answer of its own.
Aliases
dns -q myhost # the aliases the record carries
dns -q myhost -a www # add one
dns -q myhost -d www # take one away (asks)
dns -q myhost -D # take all of them away (asks)
Aliases belong to the host record, so they go in by rewriting the list on it. The list is kept sorted and without duplicates.
Txt records
dns -t _dmarc.fhi.mpg.de -a "v=DMARC1; p=none" # add
dns -t _dmarc.fhi.mpg.de # show
dns -t _dmarc.fhi.mpg.de -D # remove (asks)
One name can carry several txt records. -D removes all of them, and every one
is tried before anything is said about it: one that will not go is no reason to
leave the rest standing.
Mx records
The name of an mx record is the domain the mail is addressed to, not a host, and the preference decides in which order several of them are tried, lowest first.
dns -M fhi.mpg.de -a mail1 -p 10 # mail for fhi.mpg.de goes to mail1.fhi.mpg.de
dns -M fhi.mpg.de -a mail2 -p 20 # second in line
dns -M fhi.mpg.de -a mail1 -p 30 # the same server again: moved, not doubled
dns -M fhi.mpg.de -a mx.provider.com # a server outside the domain
dns -M fhi.mpg.de # what the domain has, lowest preference first
dns -M fhi.mpg.de -d mail2 # take one out (asks)
dns -M fhi.mpg.de -D # take all of them out (asks)
-a both adds and changes, because the mail server is what a single record is
addressed by. One that is already on the domain has its preference moved:
infoblox would otherwise take the second one — same domain, same server, another
preference — and the domain would end up with two records where one was meant.
Without -p nothing on an existing record is touched; the default of 10 is for
a record that is being created. A preference runs from 0 to 65535, and 0 is a
preference like any other.
$ dns -M fhi.mpg.de
Mx records for 'fhi.mpg.de'
10 mail1.fhi.mpg.de
20 mail2.fhi.mpg.de
Dhcp options
dns -o support/xtest.json -i 141.14.130.17
The file goes to the address record as it stands — the two in support/ are the
ones in use, for netboot and for opsi. The grid is restarted afterwards.
The grid
dns -r
RESTART_IF_NEEDED, all services, the members one after another. It is the same restart the dhcp operations do by themselves.
Certbot
-c and -x are the two hooks of a dns-01 challenge. They read
CERTBOT_DOMAIN and CERTBOT_VALIDATION from the environment, and write and
remove the _acme-challenge.<domain> txt record:
certbot certonly --manual --preferred-challenges dns \
--manual-auth-hook "dns -c" --manual-cleanup-hook "dns -x" \
-d fhi.mpg.de -d '*.fhi.mpg.de'
The auth hook waits ten seconds after writing, so that the record has spread before letsencrypt looks for it. The cleanup hook removes every txt record of that name, which is what a run that was interrupted earlier leaves behind.
Two things to watch: ~/.dnsrc has to exist for the account certbot runs as —
nothing here can ask for a passphrase — and these two hooks, unlike everything
else, end the run with 1 when they fail, because certbot has to notice.
Json for scripts
-j puts one json object on stdout and nothing else: no colours, no sentences,
no questions. Whatever the operation, the answer has the same shape, and so does
everything that can go wrong before it — the network check, the login, the
service, infoblox itself.
$ dns -M fhi.mpg.de -j
{"ok":true,"action":"showmx","name":"fhi.mpg.de","mxs":[{"mx":"mail1.fhi.mpg.de","preference":10},{"mx":"mail2.fhi.mpg.de","preference":20}],"count":2}
$ dns -a myhost -j
{"ok":true,"action":"addhost","name":"myhost.fhi.mpg.de","ip":"141.14.130.17"}
$ dns -s nothere -j ; echo "exit $?"
{"ok":false,"action":"showhost","error":"host 'nothere.fhi.mpg.de' not found"}
exit 1
A script cannot answer a question, so -j never asks one: -y stands in for
the answer, and an operation that would have asked and did not get it says so
rather than going ahead.
$ dns -d myhost -j ; echo "exit $?"
{"ok":false,"action":"delhost","error":"confirmation required, add -y"}
exit 1
Only what an operation has to say is in the answer; the rest stays out. Lists
and counts are always written, even when they are empty — "mxs":[],"count":0
is an answer, and a script should not have to tell it from a missing key.
| field | |
|---|---|
ok |
whether it did what it was asked |
action |
which operation is answering |
error, detail |
what went wrong, and more about it |
warning |
the operation went through, something beside it did not |
name, ip, mac, alias, text, file, mx, preference |
what it worked on |
aliases, texts, ips, hosts, mxs |
lists |
record |
the infoblox record, nested as an object |
count |
how many the list holds |
version, build, toolbox |
from -v |
The actions are addhost, delhost, showhost, find, showip,
listunused, setoptions, gridrestart, addalias, delalias, delaliases,
showaliases, addtxt, deltxt, showtxt, addmx, changemx, delmx,
delmxs, showmx, certbotauth, certbotclean, version — and dns, for
what goes wrong before any operation is reached.
-a on an mx record answers addmx when the record was created and changemx
when one that was already there was used, so that a script can tell the two
apart.
--seal, --update and --check-update keep their prose. They are maintenance
done by hand, and nobody is parsing them.
Options
-a <hostname> [-i <ip>] [-m <mac] add host record
-o <json file> -i <ip> write option from json file to ip record
-s <hostname> show host record
-i <ip> show ip record
-f <hostname> search for host names
-d <hostname> delete host record
-q <hostname> -a <alias> add alias to host record
-q <hostname> -d <alias> remove alias from host record
-q <hostname> -D remove aliases from host record
-q <hostname> show aliases for host record
-t <record name> -a <text> add text record
-t <record name> -D remove text record
-t <record name> show text record
-M <domain> -a <server> [-p <n>] add or change mx record, preference n (default 10)
-M <domain> -d <server> remove mx record
-M <domain> -D remove all mx records
-M <domain> show mx records
-r restart infoblox grid
-l list unused ip addresses
-c run as certbot auth hook
-x run as certbot cleanup hook
-y supress interactive mode, alwayes answer 'yes'
-j answer with one line of json, for scripts
(not --seal, --update, --check-update)
--seal encrypt an infoblox login into a block for creds.go
--check-update look for a newer release
--update download and install the newest release
-v show version
-h show help
Keeping current
dns --check-update # look
dns --update # fetch the newest release and replace this file
Beside that, an ordinary run looks by itself, at most once a day and never in the foreground: it reads a note left in the cache directory, and if that note is stale it starts a background run whose answer the next call finds waiting. When there is a newer version, a line at the end of the run says so.
Nothing of this happens in a pipe, in a cron job or in a json run, and
DNS_NO_UPDATE_CHECK=1 turns it off everywhere.
An update is only put in place after the download has been run once with -v
and answered: a truncated or wrong-platform file never replaces the one that
works.
Building
build.sh owns the build. It steps the patch version, builds every platform
with that one version in it, and writes the number back to version.txt, which
therefore always says what the binaries in ./bin carry.
./build.sh # 2.5.1 -> 2.5.2, all four platforms
PLATFORMS="linux/amd64" ./build.sh # just the one
VERSION=2.6.0 ./build.sh # a minor or major step, named outright
The names in ./bin — dns-<goos>-<goarch> — are what --update looks for in
a release, so a release has to carry exactly those files, under a tag that is
the bare version number. With mgsh that is:
mgsh push 'what changed' # commit and push to the git server
mgsh pushremote # mirror to the public server
mgsh release 2.5.2 # tag and publish the release there
bin/dns is a symlink to the build for this machine. build.go carries a build
counter that shows up next to the version in -v; nothing in build.sh touches
it.
The login
SEALED in creds.go holds user and password under a passphrase — AES-256-GCM,
the key derived with argon2id, so that guessing the passphrase from a copy of
the binary stays expensive. ~/.dnsrc is encrypted as well, under a key the
program carries, which is what keeps the password out of a backup or a synced
home directory. The file stays 0600, and dns says so when it is not.
This keeps the credentials out of the repository and out of plain sight on disk.
It is not a vault: whoever knows the passphrase has the login, and so has
whoever holds ~/.dnsrc together with a copy of dns.
Rotating the infoblox password:
dns --seal # asks for user, password and the passphrase
Paste the line it prints into creds.go, rebuild, release, and remove the stale
~/.dnsrc wherever one exists — the next run unseals it afresh.
Files
~/.dnsrc |
the login, encrypted, 0600. Delete it and the next run asks for the passphrase again |
<cache>/dns/update.json |
when it last looked for a release, and what it found |
<cache> is ~/Library/Caches on darwin and ~/.cache on linux.
Exit status
0 when the run did what it was asked. 1 when a json run did not, and 1 in both modes for the things that stop a run before it starts — the network check, a missing login, no service to be found — and for the certbot hooks.
An operation that goes wrong in the ordinary mode says ERROR: and still ends
with 0. That is how dns has always behaved and what has been built around it
lives off; a script that wants to know should use -j, where a failure is
always 1.
The source
dns.go |
the options and every operation |
json.go |
the json answer, and how a run ends either way |
creds.go |
the sealed login, ~/.dnsrc, --seal |
selfupdate.go |
--update and --check-update, written to be copied into other programs |
tools.go |
the toolbox: printing, colours, prompts, the small helpers |
build.go |
the build counter |
build.sh |
the build, the version, the names a release needs |
support/ |
two dhcp option files that are in use |
mwx'2026