3 Commits
Author SHA1 Message Date
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
6 changed files with 819 additions and 150 deletions
+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