4 Commits
Author SHA1 Message Date
Michael Wesemann f1a103b65a [mike@mwxm4] mx records, and a readme 2026-09-22 15:39:09 +02:00
Michael Wesemann 061181d1ac [mike@mwxm4] 2026-09-22 15:23:45 +02:00
Michael Wesemann 86e2579396 [mike@mwxm4] 2026-09-16 10:12:23 +02:00
Michael Wesemann 57c63ee8ed [mike@mwxm4] 'json output added' 2026-09-16 09:18:12 +02:00
7 changed files with 1207 additions and 150 deletions
+388
View File
@@ -0,0 +1,388 @@
<img src="gozilla.png" width="120" align="right" alt="">
# 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](#installing) · [The first run](#the-first-run) · [Names](#names)
- [Host records](#host-records) · [Aliases](#aliases) · [Txt records](#txt-records) · [Mx records](#mx-records)
- [Dhcp options](#dhcp-options) · [The grid](#the-grid) · [Certbot](#certbot)
- [Json for scripts](#json-for-scripts) · [Options](#options)
- [Keeping current](#keeping-current) · [Building](#building) · [The login](#the-login)
- [Files](#files) · [Exit status](#exit-status) · [The source](#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](https://git.fhi.mpg.de/mike/dns/releases), name it `dns` and put
it in the path:
```sh
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](#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
```sh
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
```sh
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
```sh
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.
```sh
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
```sh
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
```sh
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:
```sh
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
```sh
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.
```sh
./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:
```sh
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:
```sh
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
+12 -9
View File
@@ -91,7 +91,10 @@ func getcreds() (string, string) { // ----------------------------------- the lo
c,plain,err:=readcreds(path)
if err==nil {
if (plain) { // written before ~/.dnsrc was encrypted: put it away properly
if err:=writecreds(path,c); err!=nil { PE("cannot encrypt "+path,err.Error()) } else { PO(path+" is now encrypted") }
err:=writecreds(path,c)
if (!jsonmode()) { // a machine gets the answer to what it asked, nothing else
if err!=nil { PE("cannot encrypt "+path,err.Error()) } else { PO(path+" is now encrypted") }
}
}
return c.User,c.Pass
}
@@ -99,16 +102,16 @@ func getcreds() (string, string) { // ----------------------------------- the lo
// A file that is there but will not open — meddled with, truncated, written
// by a build with a different FILEKEY — is worth saying out loud before the
// passphrase is asked for and the file written afresh.
if (!os.IsNotExist(err)) { PE("cannot read "+path,err.Error()) }
if (!os.IsNotExist(err) && !jsonmode()) { PE("cannot read "+path,err.Error()) }
if (SEALED=="") {
PE("this build carries no credentials","run 'dns --seal' and paste the line into creds.go")
os.Exit(1)
Fatal("this build carries no credentials","run 'dns --seal' and paste the line into creds.go")
}
if (!oninteractive()) {
PE("no credentials in "+path,"run dns once by hand to unseal them")
os.Exit(1)
// A json run asks nothing either: the passphrase prompt would land in the
// middle of the answer, and whoever is reading it cannot type.
if (!oninteractive() || jsonmode()) {
Fatal("no credentials in "+path,"run dns once by hand to unseal them")
}
c=askpassphrase()
@@ -251,7 +254,7 @@ func filegcm(salt []byte) (cipher.AEAD, error) { // ----------------------------
func credspath() string { // -------------------------------------------------------------------- where the file is
home,err:=os.UserHomeDir()
if err!=nil { PE("cannot find the home directory",err.Error()); os.Exit(1) }
if err!=nil { Fatal("cannot find the home directory",err.Error()) }
return filepath.Join(home,CREDSFILE)
}
@@ -263,7 +266,7 @@ func readcreds(path string) (credentials, bool, error) { // --------------------
st,err:=os.Stat(path)
if err!=nil { return c,false,err }
if (st.Mode().Perm()&0o077 != 0) { PE(path+" can be read by others",SF("chmod 600 %s",path)) }
if (st.Mode().Perm()&0o077 != 0 && !jsonmode()) { PE(path+" can be read by others",SF("chmod 600 %s",path)) }
b,err:=os.ReadFile(path)
if err!=nil { return c,false,err }
+372 -138
View File
@@ -42,6 +42,7 @@ var opt_y *bool
func main() { // ========================================================================================== MAIN
opt_y = flag.Bool("y",false,"")
opt_j = flag.Bool("j",false,"")
opt_a := flag.String("a","","") // option setup
opt_o := flag.String("o","","")
@@ -57,6 +58,8 @@ func main() { // ===============================================================
opt_i := flag.String("i","","")
opt_q := flag.String("q","","")
opt_t := flag.String("t","","")
opt_M := flag.String("M","","")
opt_p := flag.Int("p",10,"")
opt_h := flag.Bool("h", false, "")
opt_v := flag.Bool("v", false, "")
@@ -83,11 +86,17 @@ func main() { // ===============================================================
P(Cw(" -t <record name> -a <text> add text record"))
P(Cw(" -t <record name> -D remove text record"))
P(Cw(" -t <record name> show text record"))
P(Cw(" -M <domain> -a <server> [-p <n>] add or change mx record, preference n (default 10)"))
P(Cw(" -M <domain> -d <server> remove mx record"))
P(Cw(" -M <domain> -D remove all mx records"))
P(Cw(" -M <domain> show mx records"))
P(Cw(" -r restart infoblox grid"))
P(Cw(" -l list unused ip addresses"))
P(Cw(" -c run as certbot auth hook"))
P(Cw(" -x run as certbot cleanup hook"))
P(Cw(" -y supress interactive mode, alwayes answer 'yes'"))
P(Cw(" -j answer with one line of json, for scripts"))
P(Cw(" (not --seal, --update, --check-update)"))
P(Cw(" --seal encrypt an infoblox login into a block for creds.go"))
P(Cw(" --check-update look for a newer release"))
P(Cw(" --update download and install the newest release"))
@@ -97,6 +106,11 @@ func main() { // ===============================================================
}
flag.Parse()
// From here on an answer is a json object, the ones that end the run // json.go
// included — checkaccess, getcreds and findservice all reach the caller
// through Fatal.
if (*opt_j) { Fatal=jfatal }
// These run before checkaccess, getcreds and findservice: none of them needs // no service,
// the infoblox service, a login or the right network. Sealing a login is done // no login,
// wherever it is convenient, and selfupdate.go probes a fresh download with // no net check
@@ -105,7 +119,7 @@ func main() { // ===============================================================
} else if (*opt_update) { runupdate(selfUpdate.install); return
} else if (*opt_checkupdate) { runupdate(selfUpdate.check); return
} else if (*opt_seal) { sealcmd(); return
} else if (*opt_v) { info(); return
} else if (*opt_v) { showversion(); return
} else if (*opt_h) { flag.Usage(); return
}
@@ -125,69 +139,81 @@ func main() { // ===============================================================
} else if (*opt_t!="" && *opt_D) { deltxt(*opt_t)
} else if (*opt_t!="" ) { showtxt(*opt_t)
} else if (*opt_M!="" && *opt_a!="") { addmx(*opt_M,*opt_a,*opt_p,Isflagpassed("p"))
} else if (*opt_M!="" && *opt_d!="") { delmx(*opt_M,*opt_d)
} else if (*opt_M!="" && *opt_D) { delmx(*opt_M,"")
} else if (*opt_M!="" ) { showmx(*opt_M)
} else if (*opt_a!="") { addhost(*opt_a,*opt_i,*opt_m)
} else if (*opt_d!="") { delhost(*opt_d)
} else if (*opt_s!="") { showhost(*opt_s)
} else if (*opt_o!="") { setoptions(*opt_o,*opt_i)
} else if (*opt_f!="") { find(*opt_f)
} else if (*opt_i!="") { showip(*opt_i)
} else if (*opt_r) { gridrestart()
} else if (*opt_l) { listunused()
} else if (*opt_i!="") { showip(*opt_i)
} else if (*opt_r) { restart()
} else if (*opt_l) { listunused()
} else if (*opt_c) { certbotauth()
} else if (*opt_x) { certbotclean()
} else if (*opt_j) { fail("dns","no operation given")
} else { flag.Usage()
}
// Costs nothing: the hint comes from the note in the cache, and the asking
// happens once a day at most, in the background.
if hint:=selfUpdate.daily(); hint!="" { fmt.Fprintln(os.Stderr,Cy(hint)) }
// happens once a day at most, in the background. A json run is left alone
// with it: nobody there is going to update anything.
if (!*opt_j) {
if hint:=selfUpdate.daily(); hint!="" { fmt.Fprintln(os.Stderr,Cy(hint)) }
}
}
// ================================================================================================ HOST RECORDS
func addhost(name string, ip string, mac string) { // ------------------------------------------ add host record
if (ip=="") { ip=nextip() }
if (ip=="") {
ip=nextip()
if (ip=="") { fail("addhost","no free ip address available"); return }
}
data:=`{"name":"`+hn(name)+`","ipv4addrs":[{"ipv4addr":"`+ip+`"}]}`
if (len(mac)>0) { data=`{"name":"`+hn(name)+`","ipv4addrs":[{"ipv4addr":"`+ip+`","mac":"`+mac+`"}]}` }
body:=request("POST", URL+"record:host", data)
if gjson.Get(body, "Error").Exists() {
PE(gjson.Get(body, "Error").String())
} else {
if (mac!="") {
gridrestart()
PO("host '"+hn(name)+"' added with IP '"+ip+"' and MAC '"+mac+"'")
} else {
PO("host '"+hn(name)+"' added with IP '"+ip+"'")
}
}
if gjson.Get(body, "Error").Exists() { fail("addhost",gjson.Get(body, "Error").String()); return }
// The restart is the grid's business, not the record's: it has been added
// either way, so a restart that goes wrong is a warning beside the answer
// and not an answer of its own.
warn:=""
msg:="host '"+hn(name)+"' added with IP '"+ip+"'"
if (mac!="") {
warn=gridrestart()
msg="host '"+hn(name)+"' added with IP '"+ip+"' and MAC '"+mac+"'"
}
done(answer{Action: "addhost", Name: hn(name), IP: ip, MAC: mac, Warning: warn},msg)
}
func delhost(name string) { // --------------------------------------------------------------- delete host record
body:=request("GET", URL+"record:host?name="+hn(name),"")
gj := gjson.Parse(body).Array()
if (len(gj)==1) {
fref := gjson.Get(body, "0._ref").String()
fname := gjson.Get(body, "0.name").String()
ans:=Yesno("remove host record '"+fname+"'",false,*opt_y);
if (len(gj)!=1) { fail("delhost","host record not found"); return }
if (ans) {
exedelete(fref)
PO("host '"+hn(name)+"' deleted")
}
} else {
PE("host record not found")
}
}
fref := gjson.Get(body, "0._ref").String()
fname := gjson.Get(body, "0.name").String()
if (!confirm("delhost","remove host record '"+fname+"'")) { return }
if e:=exedelete(fref); e!="" { fail("delhost",e); return }
done(answer{Action: "delhost", Name: hn(name)},"host '"+hn(name)+"' deleted")
}
func showhost(name string) { // --------------------------------------------------------------- show host record
@@ -200,17 +226,19 @@ func showhost(name string) { // ------------------------------------------------
body:=request("GET", URL+"record:host?name="+hn(name)+"&_return_fields="+FIELDS, "")
gj := gjson.Parse(body).Array()
if (len(gj)==1) {
prettyjson(gj[0].String())
} else {
PE("host '"+hn(name)+"' not found")
}
}
if (len(gj)!=1) { fail("showhost","host '"+hn(name)+"' not found"); return }
if (jsonmode()) {
done(answer{Action: "showhost", Name: hn(name), Record: json.RawMessage(gj[0].Raw)},"")
return
}
prettyjson(gj[0].String())
}
func find(name string) { // ------------------------------------------------------------------- find host record
body:=request("GET", URL+"record:host?name~="+name, "")
max:=0
gj := gjson.Parse(body).Array()
for _, v := range gj {
@@ -218,6 +246,20 @@ func find(name string) { // ----------------------------------------------------
if (len(name)>max) { max=len(name)}
}
if (jsonmode()) {
hosts:=[]jhost{}
for _, v := range gj {
h:=jhost{Name: gjson.Get(v.String(), "name").String(), IPs: []jaddr{}}
for _, i := range gjson.Get(v.String(), "ipv4addrs").Array() {
h.IPs=append(h.IPs,jaddr{IP: gjson.Get(i.String(), "ipv4addr").String(),
MAC: gjson.Get(i.String(), "mac").String()})
}
hosts=append(hosts,h)
}
done(answer{Action: "find", Name: name, Hosts: &hosts, Count: ptr(len(hosts))},"")
return
}
for _, v := range gj {
name := gjson.Get(v.String(), "name").String()
ips := gjson.Get(v.String(), "ipv4addrs").Array()
@@ -255,21 +297,28 @@ func showip (ip string) { // ---------------------------------------------------
body:=request("GET", URL+"record:host_ipv4addr?ipv4addr="+ip+"&_return_fields%2B="+FIELDS, "")
gj := gjson.Parse(body).Array()
if (len(gj)==1) {
prettyjson(gj[0].String())
} else {
PE("host '"+ip+"' not found")
}
}
if (len(gj)!=1) { fail("showip","host '"+ip+"' not found"); return }
if (jsonmode()) {
done(answer{Action: "showip", IP: ip, Record: json.RawMessage(gj[0].Raw)},"")
return
}
prettyjson(gj[0].String())
}
func listunused() { // ---------------------------------------------------------------- list unused ip addresses
body:=request("GET", URL+"ipv4address?network="+CIDR+"&status=UNUSED", "")
gj := gjson.Parse(body).Array()
for _, v := range gj {
ip := gjson.Get(v.String(), "ip_address").String()
P(ip)
ips:=[]string{}
for _, v := range gj { ips=append(ips,gjson.Get(v.String(), "ip_address").String()) }
if (jsonmode()) {
done(answer{Action: "listunused", IPs: &ips, Count: ptr(len(ips))},"")
return
}
for _, ip := range ips { P(ip) }
}
func nextip() string { // -------------------------------------------------------------------- find next free IP
@@ -293,6 +342,12 @@ func getaliases(host string) []string { // -------------------------------------
func showaliases(host string) { // ---------------------------------------------------------------- show aliases
aliases:=getaliases(host)
if (jsonmode()) {
done(answer{Action: "showaliases", Name: hn(host), Aliases: &aliases, Count: ptr(len(aliases))},"")
return
}
if (len(aliases)>0) {
PF("%s '%s'\n",Cwb("Aliases for"),Cwb(hn(host)))
for _, a := range aliases { PF(" %s\n",Cw(a)) }
@@ -303,11 +358,13 @@ func showaliases(host string) { // ---------------------------------------------
func alias(mode int, host string, alias string) { // --------------------- alias (0=add, 1=delete, 2=delete all)
action:=[]string{"addalias","delalias","delaliases"}[mode]
ref:=hostref(host)
ans:=true
if (mode==1) { ans=Yesno("remove aliases '"+hn(alias)+"' from host record '"+hn(host)+"'",false,*opt_y) }
if (mode==2) { ans=Yesno("remove ALL aliases from host record '"+hn(host)+"'",false,*opt_y) }
if (mode==1) { ans=confirm(action,"remove aliases '"+hn(alias)+"' from host record '"+hn(host)+"'") }
if (mode==2) { ans=confirm(action,"remove ALL aliases from host record '"+hn(host)+"'") }
if (!ans) { return }
aliases:=getaliases(host)
@@ -325,19 +382,20 @@ func alias(mode int, host string, alias string) { // --------------------- alias
data,_:=json.Marshal(map[string][]string{"aliases": aliases})
body:=request("PUT",URL+ref,string(data))
if gjson.Get(body,"Error").Exists() {
PE(gjson.Get(body,"Error").String())
} else {
if (mode==1) {
PO("alias '"+hn(alias)+"' removed from host record '"+hn(host)+"'")
} else if (mode==2) {
PO("all aliases removed from host record '"+hn(host)+"'")
} else {
PO("alias '"+hn(alias)+"' added to host '"+hn(host)+"'")
}
}
if gjson.Get(body,"Error").Exists() { fail(action,gjson.Get(body,"Error").String()); return }
msg:="alias '"+hn(alias)+"' added to host '"+hn(host)+"'"
if (mode==1) { msg="alias '"+hn(alias)+"' removed from host record '"+hn(host)+"'" }
if (mode==2) { msg="all aliases removed from host record '"+hn(host)+"'" }
// 'aliases' is what the record carries now, which is the thing a script that
// just changed it wants to see. No alias is named when all of them went:
// hn("") would invent one out of the default domain.
a:=answer{Action: action, Name: hn(host), Aliases: &aliases, Count: ptr(len(aliases))}
if (alias!="") { a.Alias=hn(alias) }
done(a,msg)
}
@@ -347,44 +405,194 @@ func alias(mode int, host string, alias string) { // --------------------- alias
func addtxt(name string, txt string) { // ------------------------------------------------------- add txt record
body:=request("POST",URL+"record:txt", `{"name":"`+name+`","text":"`+txt+`"}`)
if gjson.Get(body, "Error").Exists() {
PE(gjson.Get(body, "Error").String())
} else {
PO("txt record '"+name+"' added with txt '"+txt+"'")
}
if gjson.Get(body, "Error").Exists() { fail("addtxt",gjson.Get(body, "Error").String()); return }
done(answer{Action: "addtxt", Name: name, Text: txt},"txt record '"+name+"' added with txt '"+txt+"'")
}
func deltxt(name string) { // ---------------------------------------------------------------- delete txt record
refs:=txtrefs(name)
n:=len(refs)
if (n>0) {
ans:=Yesno("remove txt record '"+name+"'",false,*opt_y);
if (ans) {
for _, ref := range refs {
exedelete(ref)
}
}
} else {
PE("txt record not found")
return
if (len(refs)==0) { fail("deltxt","txt record not found"); return }
if (!confirm("deltxt","remove txt record '"+name+"'")) { return }
// Every one of them is tried before anything is said about it: a name can
// carry several records, and one that will not go is no reason to leave the
// rest standing.
bad:=""
for _, ref := range refs {
if e:=exedelete(ref); e!="" && bad=="" { bad=e }
}
if (bad!="") { fail("deltxt",bad); return }
done(answer{Action: "deltxt", Name: name, Count: ptr(len(refs))},"") // has never said anything, still does not
}
func showtxt(name string) { // ----------------------------------------------------------------- show txt record
data:=request("GET", URL+"record:txt?name="+hn(name), "")
gj:=gjson.Parse(data).Array()
n:=len(gj)
if (n>0) {
PF("%s '%s'\n",Cwb("Txt records for"),Cwb(name))
for i := 0; i < n; i++ {
PF(" %s\n",Cw(gj[i].Get("text")))
}
} else {
PE("txt record '"+hn(name)+"' not found")
if (n==0) { fail("showtxt","txt record '"+hn(name)+"' not found"); return }
texts:=[]string{}
for i := 0; i < n; i++ { texts=append(texts,gj[i].Get("text").String()) }
if (jsonmode()) {
done(answer{Action: "showtxt", Name: hn(name), Texts: &texts, Count: ptr(n)},"")
return
}
PF("%s '%s'\n",Cwb("Txt records for"),Cwb(name))
for _, t := range texts { PF(" %s\n",Cw(t)) }
}
// ================================================================================================== MX RECORDS
//
// The name of an mx record is the domain the mail is addressed to, not a host:
// 'dns -M fhi.mpg.de -a mail1 -p 10' says that mail for fhi.mpg.de goes to
// mail1.fhi.mpg.de, and the preference decides in which order several of them
// are tried, lowest first.
//
// A domain carries one record per mail server, so the server is what a single
// record is addressed by: -a puts one in or moves it to another preference, -d
// takes that one out, -D takes all of them out.
type mxrec struct { // ------------------------------------------------------------------ one mx record, as read
ref string
mx string
pref int
}
func getmx(name string) []mxrec { // -------------------------------------------- get the mx records of a domain
body:=request("GET", URL+"record:mx?name="+hn(name)+"&_return_fields=mail_exchanger,preference", "")
recs:=[]mxrec{}
for _, v := range gjson.Parse(body).Array() {
recs=append(recs,mxrec{ref: v.Get("_ref").String(),
mx: v.Get("mail_exchanger").String(),
pref: int(v.Get("preference").Int())})
}
// Lowest preference first, the order the mail servers are tried in. An equal
// pair is settled by the name, so that two runs read the same.
slices.SortFunc(recs, func(a mxrec, b mxrec) int {
if (a.pref!=b.pref) { return a.pref-b.pref }
return strings.Compare(a.mx,b.mx)
})
return recs
}
func jmxs(recs []mxrec) []jmx { // -------------------------------------------- the same list, for a json answer
l:=[]jmx{}
for _, r := range recs { l=append(l,jmx{MX: r.mx, Pref: r.pref}) }
return l
}
func showmx(name string) { // ------------------------------------------------------------------ show mx records
recs:=getmx(name)
// A domain without mail is an ordinary state and not a failure, so this one
// answers the empty list rather than an error, the way the aliases do.
if (jsonmode()) {
l:=jmxs(recs)
done(answer{Action: "showmx", Name: hn(name), MXs: &l, Count: ptr(len(l))},"")
return
}
if (len(recs)==0) {
PF("%s '%s'\n",Cwb("No mx records found for"),Cwb(hn(name)))
return
}
PF("%s '%s'\n",Cwb("Mx records for"),Cwb(hn(name)))
for _, r := range recs { PF(" %s %s\n",Cw(SF("%5d",r.pref)),Cw(r.mx)) }
}
func addmx(name string, mx string, pref int, given bool) { // ----------------------- add or change an mx record
if (given && (pref<0 || pref>65535)) { fail("addmx","preference has to be between 0 and 65535"); return }
// The mail server is what the record is addressed by, so one that is already
// there is changed instead of added a second time: infoblox would take the
// second one — same domain, same server, another preference — and the domain
// would end up with two records where one was meant.
old:=[]mxrec{}
for _, r := range getmx(name) { if (r.mx==hn(mx)) { old=append(old,r) } }
if (len(old)>1) {
fail("addmx","'"+hn(mx)+"' is on '"+hn(name)+"' more than once, remove it first with -d")
return
}
if (len(old)==1) {
// Without -p there is nothing to change: the preference on the record is
// the one that was asked for, not the default of the option.
if (!given || old[0].pref==pref) {
done(answer{Action: "changemx", Name: hn(name), MX: hn(mx), Pref: ptr(old[0].pref)},
"mx record '"+hn(mx)+"' on '"+hn(name)+"' unchanged, preference "+Itoa(old[0].pref))
return
}
body:=request("PUT", URL+old[0].ref, `{"preference":`+Itoa(pref)+`}`)
if gjson.Get(body,"Error").Exists() { fail("changemx",gjson.Get(body,"Error").String()); return }
done(answer{Action: "changemx", Name: hn(name), MX: hn(mx), Pref: ptr(pref)},
"mx record '"+hn(mx)+"' on '"+hn(name)+"' changed from preference "+Itoa(old[0].pref)+
" to "+Itoa(pref))
return
}
data:=`{"name":"`+hn(name)+`","mail_exchanger":"`+hn(mx)+`","preference":`+Itoa(pref)+`}`
body:=request("POST", URL+"record:mx", data)
if gjson.Get(body,"Error").Exists() { fail("addmx",gjson.Get(body,"Error").String()); return }
done(answer{Action: "addmx", Name: hn(name), MX: hn(mx), Pref: ptr(pref)},
"mx record '"+hn(mx)+"' added to '"+hn(name)+"' with preference "+Itoa(pref))
}
func delmx(name string, mx string) { // --------------------------------- delete mx records ("" for all of them)
action:="delmx"
if (mx=="") { action="delmxs" }
gone:=[]mxrec{}
for _, r := range getmx(name) { if (mx=="" || r.mx==hn(mx)) { gone=append(gone,r) } }
if (len(gone)==0) {
if (mx=="") { fail(action,"no mx records found for '"+hn(name)+"'"); return }
fail(action,"mx record '"+hn(mx)+"' not found on '"+hn(name)+"'")
return
}
ask:="remove mx record '"+hn(mx)+"' from '"+hn(name)+"'"
if (mx=="") { ask="remove ALL mx records from '"+hn(name)+"'" }
if (!confirm(action,ask)) { return }
// As with the txt records, every one of them is tried before anything is
// said about it: one that will not go is no reason to leave the rest
// standing.
bad:=""
for _, r := range gone {
if e:=exedelete(r.ref); e!="" && bad=="" { bad=e }
}
if (bad!="") { fail(action,bad); return }
msg:="mx record '"+hn(mx)+"' removed from '"+hn(name)+"'"
if (mx=="") { msg="all mx records removed from '"+hn(name)+"'" }
// No server is named when all of them went: hn("") would invent one out of
// the default domain.
a:=answer{Action: action, Name: hn(name), Count: ptr(len(gone))}
if (mx!="") { a.MX=hn(mx) }
done(a,msg)
}
@@ -396,42 +604,44 @@ func certbotauth() { // --------------------------------------------------------
validation := os.Getenv("CERTBOT_VALIDATION")
if domain == "" || validation == "" {
PE("Error: CERTBOT_DOMAIN or CERTBOT_VALIDATION environment variables missing")
os.Exit(1)
fail("certbotauth","CERTBOT_DOMAIN or CERTBOT_VALIDATION environment variables missing")
os.Exit(1) // fail ends a json run by itself, this one ends the other
}
name := "_acme-challenge." + domain
body := request("POST", URL+"record:txt", `{"name":"`+name+`","text":"`+validation+`"}`)
if gjson.Get(body, "Error").Exists() {
PE(gjson.Get(body, "Error").String())
os.Exit(1)
} else {
PO("Certbot auth: TXT record '" + name + "' added")
time.Sleep(10 * time.Second)
fail("certbotauth",gjson.Get(body, "Error").String())
os.Exit(1)
}
done(answer{Action: "certbotauth", Name: name, Text: validation},"Certbot auth: TXT record '"+name+"' added")
time.Sleep(10 * time.Second)
}
func certbotclean() { // ------------------------------------------------------------------ certbot cleanup hook
domain := os.Getenv("CERTBOT_DOMAIN")
if domain == "" {
PE("Error: CERTBOT_DOMAIN environment variable missing")
os.Exit(1)
fail("certbotclean","CERTBOT_DOMAIN environment variable missing")
os.Exit(1) // fail ends a json run by itself, this one ends the other
}
name := "_acme-challenge." + domain
refs := txtrefs(name)
n := len(refs)
if n > 0 {
for _, ref := range refs {
exedelete(ref)
}
PO("certbot cleanup: " + SF("%d", n) + " txt record(s) for '" + name + "' removed")
} else {
PO("certbot cleanup: no txt records found for '" + name + "' to delete")
bad:=""
for _, ref := range refs {
if e:=exedelete(ref); e!="" && bad=="" { bad=e } // all of them go, whatever one of them has to say
}
if (bad!="") { fail("certbotclean",bad); return }
msg:="certbot cleanup: no txt records found for '" + name + "' to delete"
if (n>0) { msg="certbot cleanup: " + SF("%d", n) + " txt record(s) for '" + name + "' removed" }
done(answer{Action: "certbotclean", Name: name, Count: ptr(n)},msg)
}
@@ -440,37 +650,49 @@ func certbotclean() { // -------------------------------------------------------
func setoptions(file string, ip string) { // ------------------------------------------------- set options to ip
ref:=ipref(ip)
filecontent,_ := os.ReadFile(file)
// Unread until now: an unreadable file went to infoblox as an empty body and
// took whatever was on the record with it.
filecontent,err := os.ReadFile(file)
if err!=nil { fail("setoptions","cannot read "+file,err.Error()); return }
body:=request("PUT",URL+ref, string(filecontent))
if gjson.Get(body, "Error").Exists() {
PE(gjson.Get(body, "Error").String())
} else {
gridrestart()
PO("options added to ip '"+ip+"'")
}
if gjson.Get(body, "Error").Exists() { fail("setoptions",gjson.Get(body, "Error").String()); return }
warn:=gridrestart()
done(answer{Action: "setoptions", IP: ip, File: file, Warning: warn},"options added to ip '"+ip+"'")
}
func runupdate(task func(io.Writer) error) { // ----------------------------------- run a task from selfupdate.go
if err:=task(os.Stdout); err!=nil { PE(err.Error()); os.Exit(1) }
}
func gridrestart() { // ----------------------------------------------------------------------- restart infoblox
// Both of these are steps inside other operations as much as operations in
// their own right, so they say nothing themselves: they hand back what went
// wrong, empty when nothing did, and whoever called decides whether that is
// the answer or a remark beside it.
func gridrestart() string { // ----------------------------------------------------------------- restart infoblox
ref:=gridref()
body:=request("POST", URL+ref+"?_function=restartservices",
`{"restart_option":"RESTART_IF_NEEDED","service_option":"ALL",`+
`"member_order":"SEQUENTIALLY", "sequential_delay":1}`)
if gjson.Get(string(body), "Error").Exists() { PE(gjson.Get(string(body), "Error").String()) }
return gjson.Get(string(body), "Error").String()
}
func exedelete(ref string) { // ------------------------------------------------------------ delete by reference
func restart() { // ----------------------------------------------------------------------- -r: restart the grid
if e:=gridrestart(); e!="" { fail("gridrestart",e); return }
done(answer{Action: "gridrestart"},"") // has never said anything, still does not
}
func exedelete(ref string) string { // ----------------------------------------------------- delete by reference
body:=request("DELETE", URL+ref, "")
if gjson.Get(body, "Error").Exists() { PE(gjson.Get(body, "Error").String()) }
}
return gjson.Get(body, "Error").String()
}
@@ -517,23 +739,30 @@ func request(method string, url string, data string) (string) { // -------------
if data != "" { bdata = bytes.NewReader([]byte(data)) }
req, err := http.NewRequest(method,url,bdata)
if err != nil { PE(err.Error()); os.Exit(1) }
if err != nil { Fatal(err.Error()) }
req.SetBasicAuth(US,PW)
req.Header.Set("Content-Type","application/json")
resp,err:=client.Do(req)
if err != nil { PE(err.Error()); os.Exit(1) }
if err != nil { Fatal(err.Error()) }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
PE(SF("Unexpected http status code: %d",resp.StatusCode))
}
body, err := io.ReadAll(resp.Body)
if err != nil { PE(err.Error()); os.Exit(1) }
if err != nil { Fatal(err.Error()) }
// The body is read first now: infoblox says what it did not like in there,
// and that is the better message of the two. A json run that gets neither a
// good status nor an explanation has nothing left to report and stops.
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if (!jsonmode()) {
PE(SF("Unexpected http status code: %d",resp.StatusCode))
} else if (!gjson.Get(string(body), "Error").Exists()) {
Fatal(SF("unexpected http status code: %d",resp.StatusCode))
}
}
return string(body)
}
@@ -571,11 +800,16 @@ func findservice() { // --------------------------------------------------------
}
if (URL=="") {
why:=[]string{}
for i,url := range URLS { why=append(why,SF("%s: %s",url,MSG[i])) }
if (jsonmode()) { Fatal("no working service found",strings.Join(why,"; ")) }
PE("No working service found")
for i,url := range URLS { PE(SF("%s: %s",url,MSG[i])) }
for _, w := range why { PE(w) }
os.Exit(1)
}
}
}
func prettyjson(str string) { // ------------------------------------------------------------------- format json
var prettyJSON bytes.Buffer
+141
View File
@@ -0,0 +1,141 @@
// =========================================================================== machine readable answers (mwx'2026)
//
// -j puts one json object on stdout and nothing else: no colours, no sentences,
// no questions. A script reads the object, looks at "ok" and takes the fields
// it needs; whoever only looks at the exit status finds the same answer there,
// 0 or 1.
//
// One run, one object. The operations that otherwise print line after line say
// the same thing in a list, and everything that can go wrong before the answer
// — the network check, the login, the service, infoblox itself — comes back in
// that same shape. That is what Fatal is redirected for.
//
// A question cannot be answered by a script, so -j never asks one: -y is the
// answer, and an operation that would have asked and did not get it says so
// rather than going ahead.
//
// --seal, --update and --check-update keep their prose. They are maintenance
// done by hand, and nobody is parsing them.
package main
import (
"encoding/json"
"os"
)
var opt_j *bool
// One struct for all of them rather than one apiece: an operation fills in what
// it has to say and omitempty keeps the rest out of the answer. The lists and
// the count are pointers so that 'none at all' can still be written as an empty
// list or a nought — a script should not have to tell a missing key from one
// that is genuinely empty.
type answer struct {
OK bool `json:"ok"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
Detail string `json:"detail,omitempty"`
Warning string `json:"warning,omitempty"`
Name string `json:"name,omitempty"`
IP string `json:"ip,omitempty"`
MAC string `json:"mac,omitempty"`
Alias string `json:"alias,omitempty"`
Text string `json:"text,omitempty"`
File string `json:"file,omitempty"`
MX string `json:"mx,omitempty"`
// A preference of 0 is a preference like any other, so this one is a pointer
// too: omitempty would drop it and the answer would read as if the record had
// none.
Pref *int `json:"preference,omitempty"`
Aliases *[]string `json:"aliases,omitempty"`
Texts *[]string `json:"texts,omitempty"`
IPs *[]string `json:"ips,omitempty"`
Hosts *[]jhost `json:"hosts,omitempty"`
MXs *[]jmx `json:"mxs,omitempty"`
Record json.RawMessage `json:"record,omitempty"`
Count *int `json:"count,omitempty"`
Version string `json:"version,omitempty"`
Build string `json:"build,omitempty"`
Toolbox string `json:"toolbox,omitempty"`
}
type jhost struct { // ------------------------------------------------------------------ one hit of a host search
Name string `json:"name"`
IPs []jaddr `json:"ips"`
}
type jaddr struct { // ------------------------------------------------------------------ one address of such a hit
IP string `json:"ip"`
MAC string `json:"mac,omitempty"`
}
type jmx struct { // ------------------------------------------------------------------- one mx record of a domain
MX string `json:"mx"`
Pref int `json:"preference"`
}
func jsonmode() bool { return opt_j!=nil && *opt_j } // ------------------------------- is this a run for a machine
func ptr[T any](v T) *T { return &v } // ----------------------------- something present, even when it is empty
// ==================================================================================================== ANSWERING
func done(a answer, msg string) { // ----------------------------------- an operation that did what it was asked
a.OK=true
if (jsonmode()) { jprint(a); return }
if (a.Warning!="") { PE(a.Warning) }
if (msg!="") { PO(msg) } // no sentence: the operations that never had one keep quiet
}
// Not the end of the run in the ordinary mode — saying so and carrying on is
// what dns has always done, and what has been built around it lives off the
// exit status it gets today. A json run does end here, with 1: a script must
// not have to tell an empty answer from a failed one.
func fail(action string, msg ...string) {
if (jsonmode()) {
jprint(failed(action,msg))
os.Exit(1)
}
PE(msg...)
}
func jfatal(msg ...string) { // ------------------------------- what Fatal does in a json run: the object, and out
jprint(failed("dns",msg))
os.Exit(1)
}
func failed(action string, msg []string) answer { // ----------------------------- message and detail, as PE takes them
a:=answer{Action: action, Error: msg[0]}
if (len(msg)>1) { a.Detail=msg[1] }
return a
}
func jprint(a answer) { // -------------------------------------------------------------------- the object, one line
b,err:=json.Marshal(a)
if err!=nil { PE(err.Error()); os.Exit(1) }
P(string(b))
}
// The one place a run can still stop and wait. In json mode -y stands in for
// the answer, and without it the operation does not happen: doing it anyway
// unasked is not a decision this program gets to make for the caller.
func confirm(action string, msg string) bool {
if (!jsonmode()) { return Yesno(msg,false,*opt_y) }
if (*opt_y) { return true }
fail(action,"confirmation required, add -y")
return false
}
func showversion() { // ------------------------------------------------------------------------ -v, either way
if (!jsonmode()) { info(); return }
done(answer{Action: "version", Version: version, Build: build, Toolbox: tbversion},"")
}
// ========================================================================================================== END
+285
View File
@@ -0,0 +1,285 @@
package main
import (
"encoding/json"
"io"
"os"
"os/exec"
"slices"
"strings"
"testing"
)
// Runs f with stdout on a pipe and hands back what it wrote. P, PO and PE all
// reach for os.Stdout when they are called, so swapping it here is enough.
func capture(t *testing.T, f func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
f()
w.Close()
os.Stdout = old
b, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func asjson(t *testing.T, f func()) map[string]any {
t.Helper()
old := opt_j
opt_j = ptr(true)
out := capture(t, f)
opt_j = old
if n := strings.Count(strings.TrimSpace(out), "\n"); n != 0 {
t.Fatalf("one run has to say one line, said %d:\n%s", n+1, out)
}
var m map[string]any
if err := json.Unmarshal([]byte(out), &m); err != nil {
t.Fatalf("not json: %v\n%s", err, out)
}
return m
}
func TestAnswerIsOneObject(t *testing.T) {
m := asjson(t, func() {
done(answer{Action: "addhost", Name: "host.fhi.mpg.de", IP: "141.14.128.5"}, "host added")
})
if m["ok"] != true || m["action"] != "addhost" {
t.Errorf("ok/action wrong: %+v", m)
}
if m["name"] != "host.fhi.mpg.de" || m["ip"] != "141.14.128.5" {
t.Errorf("payload wrong: %+v", m)
}
// The sentence belongs to the other mode, and what was never filled in has
// no business in the answer.
for _, k := range []string{"mac", "error", "warning", "count", "aliases"} {
if _, there := m[k]; there {
t.Errorf("%q should not be in the answer: %+v", k, m)
}
}
if strings.Contains(strings.ToLower(m["action"].(string)), "host added") {
t.Error("the sentence leaked into the json")
}
}
// Nothing found is an answer too: an empty list must not turn into a missing
// key, or a script cannot tell 'none' from 'this operation does not say'.
func TestEmptyListStaysAList(t *testing.T) {
m := asjson(t, func() {
done(answer{Action: "showaliases", Name: "host", Aliases: ptr([]string{}), Count: ptr(0)}, "")
})
al, there := m["aliases"]
if !there {
t.Fatalf("aliases missing: %+v", m)
}
if l, isl := al.([]any); !isl || len(l) != 0 {
t.Errorf("aliases is %#v, want []", al)
}
if c, there := m["count"]; !there || c.(float64) != 0 {
t.Errorf("count is %#v, want 0", m["count"])
}
}
// The infoblox record goes in as a record, not as a string holding one.
func TestRecordNestsAsAnObject(t *testing.T) {
m := asjson(t, func() {
done(answer{Action: "showhost", Record: json.RawMessage(`{"name":"a","ttl":300}`)}, "")
})
rec, isobj := m["record"].(map[string]any)
if !isobj {
t.Fatalf("record is %#v, want an object", m["record"])
}
if rec["name"] != "a" || rec["ttl"].(float64) != 300 {
t.Errorf("record wrong: %+v", rec)
}
}
func TestFailedCarriesMessageAndDetail(t *testing.T) {
a := failed("delhost", []string{"host record not found"})
if a.OK || a.Action != "delhost" || a.Error != "host record not found" || a.Detail != "" {
t.Errorf("got %+v", a)
}
a = failed("dns", []string{"no working service found", "ddi1: down; ddi2: down"})
if a.Detail != "ddi1: down; ddi2: down" {
t.Errorf("detail lost: %+v", a)
}
}
// A warning is what the answer carries when the record went in but the grid
// restart did not: still ok, and still said.
func TestWarningRidesAlong(t *testing.T) {
m := asjson(t, func() {
done(answer{Action: "addhost", Name: "h", IP: "1.2.3.4", MAC: "aa:bb", Warning: "grid busy"}, "added")
})
if m["ok"] != true || m["warning"] != "grid busy" {
t.Errorf("got %+v", m)
}
}
// The ordinary run keeps its sentences, and says the warning before them, the
// way it always has.
func TestHumanModeStillTalks(t *testing.T) {
old := opt_j
opt_j = ptr(false)
out := capture(t, func() {
done(answer{Action: "addhost", Name: "h", Warning: "grid busy"}, "host 'h' added")
})
opt_j = old
if !strings.Contains(out, "host 'h' added") {
t.Errorf("the sentence is gone: %q", out)
}
if !strings.Contains(out, "grid busy") {
t.Errorf("the warning is gone: %q", out)
}
if strings.Contains(out, `"ok"`) {
t.Errorf("json leaked into the ordinary run: %q", out)
}
if strings.Index(out, "grid busy") > strings.Index(out, "host 'h' added") {
t.Error("the warning has to come before the sentence")
}
// An operation that never had a sentence keeps quiet.
opt_j = ptr(false)
out = capture(t, func() { done(answer{Action: "gridrestart"}, "") })
opt_j = old
if out != "" {
t.Errorf("said %q, should have said nothing", out)
}
}
// -y is the answer in a json run. Without it the caller gets told, and the
// operation does not happen — that path ends the run, so it is not exercised
// here; this is the half that has to go through.
func TestConfirmTakesY(t *testing.T) {
oldj, oldy := opt_j, opt_y
opt_j, opt_y = ptr(true), ptr(true)
defer func() { opt_j, opt_y = oldj, oldy }()
if out := capture(t, func() {
if !confirm("delhost", "remove host record 'h'") {
t.Error("-y was not taken as the answer")
}
}); out != "" {
t.Errorf("a json run must not ask anything: %q", out)
}
}
func TestVersionAnswers(t *testing.T) {
m := asjson(t, showversion)
if m["ok"] != true || m["action"] != "version" || m["version"] != version {
t.Errorf("got %+v", m)
}
if m["build"] != build || m["toolbox"] != tbversion {
t.Errorf("build/toolbox wrong: %+v", m)
}
}
// The other half of confirm ends the run, so it needs a run of its own. This is
// the one that must not go wrong: a json call that would have asked, and got no
// -y, has to come back with a refusal and delete nothing.
func TestConfirmWithoutYStopsTheRun(t *testing.T) {
if os.Getenv("DNS_TEST_CONFIRM") == "1" {
opt_j, opt_y = ptr(true), ptr(false)
confirm("delhost", "remove host record 'h'")
os.Stdout.WriteString("CARRIED ON\n") // must never be reached
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestConfirmWithoutYStopsTheRun")
cmd.Env = append(os.Environ(), "DNS_TEST_CONFIRM=1")
out, err := cmd.Output()
if strings.Contains(string(out), "CARRIED ON") {
t.Fatal("a json run went past a question it could not ask")
}
var code int
if ee, is := err.(*exec.ExitError); is {
code = ee.ExitCode()
}
if code != 1 {
t.Errorf("exit %d, want 1 (err %v, out %q)", code, err, out)
}
var m map[string]any
line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0]
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatalf("no json answer: %v\n%s", err, out)
}
if m["ok"] != false || m["action"] != "delhost" {
t.Errorf("got %+v", m)
}
if e, _ := m["error"].(string); !strings.Contains(e, "-y") {
t.Errorf("the refusal has to name -y: %q", e)
}
}
// Preference 0 is a preference like any other — the answer has to carry it, and
// the list of exchangers has to stay a list when a domain has none.
func TestMXAnswerKeepsPreferenceZero(t *testing.T) {
m := asjson(t, func() {
done(answer{Action: "addmx", Name: "fhi.mpg.de", MX: "mail1.fhi.mpg.de", Pref: ptr(0)}, "added")
})
p, there := m["preference"]
if !there {
t.Fatalf("preference missing: %+v", m)
}
if p.(float64) != 0 {
t.Errorf("preference is %#v, want 0", p)
}
if m["mx"] != "mail1.fhi.mpg.de" || m["action"] != "addmx" {
t.Errorf("got %+v", m)
}
m = asjson(t, func() {
done(answer{Action: "showmx", Name: "fhi.mpg.de", MXs: ptr([]jmx{}), Count: ptr(0)}, "")
})
if l, isl := m["mxs"].([]any); !isl || len(l) != 0 {
t.Errorf("mxs is %#v, want []", m["mxs"])
}
// And a run that never touched an mx record must not mention one.
m = asjson(t, func() { done(answer{Action: "addhost", Name: "h", IP: "1.2.3.4"}, "added") })
for _, k := range []string{"mx", "mxs", "preference"} {
if _, there := m[k]; there {
t.Errorf("%q should not be in the answer: %+v", k, m)
}
}
}
// The order the mail servers are tried in is the answer's order: lowest
// preference first, equal ones by name.
func TestMXListIsSortedByPreference(t *testing.T) {
recs := []mxrec{{mx: "b.fhi.mpg.de", pref: 20}, {mx: "c.fhi.mpg.de", pref: 10},
{mx: "a.fhi.mpg.de", pref: 20}}
slices.SortFunc(recs, func(a mxrec, b mxrec) int {
if a.pref != b.pref {
return a.pref - b.pref
}
return strings.Compare(a.mx, b.mx)
})
want := []jmx{{MX: "c.fhi.mpg.de", Pref: 10}, {MX: "a.fhi.mpg.de", Pref: 20},
{MX: "b.fhi.mpg.de", Pref: 20}}
if got := jmxs(recs); !slices.Equal(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
+8 -2
View File
@@ -33,11 +33,17 @@ var LR = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// Updating oneself lives in selfupdate.go — one file per program, configured at
// its head, driven by the --update and --check-update options.
// What the end of a run that cannot go on looks like. The default says it and
// stops; a program that answers in something other than prose — dns -j — puts
// its own here before anything can fail. Whatever is put here has to stop the
// run: nothing that calls Fatal expects to get control back.
var Fatal func(msg ...string) = func(msg ...string) { PE(msg...); os.Exit(1) }
func checkaccess(NETS []string) { // ------------------------------------------------- check ip net based access
match:=0;
for _, validnet := range NETS {
addrs, err := net.InterfaceAddrs()
if err != nil { PE("Error getting addresses"); os.Exit(1) }
if err != nil { Fatal("Error getting addresses") }
_, ipNet, err := net.ParseCIDR(validnet)
for _, address := range addrs {
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
@@ -48,7 +54,7 @@ func checkaccess(NETS []string) { // -------------------------------------------
}
}
if (match==0) { PE("access violation, permission denied"); os.Exit(1) }
if (match==0) { Fatal("access violation, permission denied") }
}
+1 -1
View File
@@ -1 +1 @@
2.4.4
2.5.1