Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1a103b65a | ||
|
|
061181d1ac |
@@ -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
|
||||
@@ -58,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, "")
|
||||
|
||||
@@ -84,6 +86,10 @@ 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"))
|
||||
@@ -133,6 +139,11 @@ 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)
|
||||
@@ -440,6 +451,152 @@ func showtxt(name string) { // -------------------------------------------------
|
||||
|
||||
|
||||
|
||||
// ================================================================================================== 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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =============================================================================================== CERTBOT HOOKS
|
||||
|
||||
func certbotauth() { // ---------------------------------------------------------------------- certbot auth hook
|
||||
|
||||
@@ -43,11 +43,18 @@ type answer struct {
|
||||
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"`
|
||||
|
||||
@@ -66,6 +73,11 @@ type jaddr struct { // ---------------------------------------------------------
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -227,3 +228,58 @@ func TestConfirmWithoutYStopsTheRun(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.5.0
|
||||
2.5.1
|
||||
|
||||
Reference in New Issue
Block a user