1 Commits
Author SHA1 Message Date
Michael Wesemann 061181d1ac [mike@mwxm4] 2026-09-22 15:23:45 +02:00
4 changed files with 226 additions and 1 deletions
+157
View File
@@ -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
+12
View File
@@ -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
+56
View File
@@ -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
View File
@@ -1 +1 @@
2.5.0
2.5.1