Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86e2579396 | ||
|
|
57c63ee8ed | ||
|
|
485d030f7a | ||
|
|
0cc7b9d3da | ||
|
|
4488787971 |
@@ -11,19 +11,30 @@
|
||||
// nothing. A run without a terminal — the certbot hooks, cron — never asks: it
|
||||
// says what is missing and stops.
|
||||
//
|
||||
// ~/.dnsrc is encrypted too, under FILEKEY, which the program carries and
|
||||
// nobody is asked for. It is the same AES-256-GCM, and the file opens on every
|
||||
// machine dns runs on, so cron and the hooks notice nothing. What it buys is
|
||||
// that the password no longer stands in the clear in a backup, in a synced home
|
||||
// directory or on a screen someone else is looking at. A file from before this,
|
||||
// plain JSON, is still read, and written back encrypted on the next run.
|
||||
//
|
||||
// Rotating the infoblox password means 'dns --seal', pasting the line it
|
||||
// prints into this file, rebuilding, and removing the stale ~/.dnsrc wherever
|
||||
// one exists.
|
||||
//
|
||||
// What this is not: whoever knows the passphrase has the login, and so has
|
||||
// whoever can read ~/.dnsrc. It keeps the credentials out of the repository and
|
||||
// out of the binary. It is not a vault.
|
||||
// whoever holds ~/.dnsrc together with a copy of dns — FILEKEY is in every one
|
||||
// of them, and prising it out is an afternoon's work, not a cluster's. That is
|
||||
// why the file stays 0600. It keeps the credentials out of the repository and
|
||||
// out of plain sight on disk. It is not a vault.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -37,8 +48,23 @@ import (
|
||||
|
||||
var SEALED = "B/RvQRI1EfziN3EoEY0obzrVeQsIMeN1QzBiR4Pl8PaygMUqlK1vggmbObjceeF+tmW3npiuXAvp93R18u9bejlv5M/3qL5Ix3fOpi+L5p3x90oXni7fhlPtc9Z3"
|
||||
|
||||
// The key ~/.dnsrc is written under. Thirty-two random bytes, so there is
|
||||
// nothing to guess and no reason to slow a guesser down: hkdf, not argon2, and
|
||||
// every run opens the file in microseconds instead of a third of a second.
|
||||
//
|
||||
// A build may put another one in its place with -ldflags "-X main.FILEKEY=...".
|
||||
// Files the earlier builds wrote then no longer open, and dns says so and asks
|
||||
// for the passphrase again — which a cron run cannot do, so a key changed under
|
||||
// a running installation is changed for the hooks as well.
|
||||
var FILEKEY = "mphYib5GBHwMnKE0F3of3V8+rpS4ayUlXvaMncaZ3wE="
|
||||
|
||||
const CREDSFILE = ".dnsrc"
|
||||
|
||||
// The first bytes of an encrypted ~/.dnsrc. It tells the file apart from the
|
||||
// plain JSON of older versions, and leaves room to tell it apart from whatever
|
||||
// a later version writes should FILEKEY ever have to change.
|
||||
const FILETAG = "dnsrc1:"
|
||||
|
||||
// argon2id, the second of the two settings RFC 9106 recommends: 64 MB and three
|
||||
// passes. It costs a fraction of a second here and makes an offline run through
|
||||
// a list of likely passphrases expensive on hardware built for it.
|
||||
@@ -62,19 +88,33 @@ type credentials struct {
|
||||
func getcreds() (string, string) { // ----------------------------------- the login, from ~/.dnsrc or the passphrase
|
||||
path:=credspath()
|
||||
|
||||
if c,err:=readcreds(path); err==nil { return c.User,c.Pass }
|
||||
c,plain,err:=readcreds(path)
|
||||
if err==nil {
|
||||
if (plain) { // written before ~/.dnsrc was encrypted: put it away properly
|
||||
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
|
||||
}
|
||||
|
||||
// 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) && !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()
|
||||
c=askpassphrase()
|
||||
|
||||
if err:=writecreds(path,c); err!=nil {
|
||||
PE("cannot write "+path,err.Error()) // the login still works for this one run
|
||||
@@ -122,14 +162,37 @@ func sealcmd() { // --------------------------------------------- 'dns --seal':
|
||||
|
||||
// ===================================================================================================== THE BLOCK
|
||||
|
||||
func seal(c credentials, pass string) (string, error) { // ------------------------------------- encrypt the login
|
||||
// The block in creds.go and the file in the home directory are the same thing
|
||||
// twice, encrypted the same way and differing only in which key opens them:
|
||||
// lock and unlock do the work, and what is handed in decides whether that is
|
||||
// the shared passphrase or FILEKEY.
|
||||
|
||||
func seal(c credentials, pass string) (string, error) { // ------------------- encrypt the login for creds.go
|
||||
return lock(c,func(salt []byte) (cipher.AEAD,error) { return credsgcm(pass,salt) })
|
||||
}
|
||||
|
||||
func unseal(blob string, pass string) (credentials, error) { // ------------- decrypt the login from creds.go
|
||||
return unlock(blob,"the sealed block",func(salt []byte) (cipher.AEAD,error) { return credsgcm(pass,salt) })
|
||||
}
|
||||
|
||||
func lockcreds(c credentials) (string, error) { // -------------------------- encrypt the login for ~/.dnsrc
|
||||
blob,err:=lock(c,filegcm)
|
||||
if err!=nil { return "",err }
|
||||
return FILETAG+blob,nil
|
||||
}
|
||||
|
||||
func opencreds(blob string) (credentials, error) { // ---------------------- decrypt the login from ~/.dnsrc
|
||||
return unlock(strings.TrimPrefix(blob,FILETAG),"the credentials",filegcm)
|
||||
}
|
||||
|
||||
func lock(c credentials, keyed func([]byte) (cipher.AEAD, error)) (string, error) { // -------- encrypt the login
|
||||
plain,err:=json.Marshal(c)
|
||||
if err!=nil { return "",err }
|
||||
|
||||
salt:=make([]byte,SALTLEN)
|
||||
if _,err:=rand.Read(salt); err!=nil { return "",err }
|
||||
|
||||
gcm,err:=credsgcm(pass,salt)
|
||||
gcm,err:=keyed(salt)
|
||||
if err!=nil { return "",err }
|
||||
|
||||
nonce:=make([]byte,gcm.NonceSize())
|
||||
@@ -145,27 +208,28 @@ func seal(c credentials, pass string) (string, error) { // ---------------------
|
||||
return base64.StdEncoding.EncodeToString(out),nil
|
||||
}
|
||||
|
||||
func unseal(blob string, pass string) (credentials, error) { // -------------------------------- decrypt the login
|
||||
func unlock(blob string, what string, keyed func([]byte) (cipher.AEAD, error)) (credentials, error) { // - decrypt
|
||||
var c credentials
|
||||
|
||||
raw,err:=base64.StdEncoding.DecodeString(strings.TrimSpace(blob))
|
||||
if err!=nil { return c,errors.New("the sealed block is not valid base64") }
|
||||
if err!=nil { return c,errors.New(what+" is not valid base64") }
|
||||
|
||||
gcm,err:=credsgcm(pass,raw[:min(SALTLEN,len(raw))])
|
||||
gcm,err:=keyed(raw[:min(SALTLEN,len(raw))])
|
||||
if err!=nil { return c,err }
|
||||
|
||||
if (len(raw) < SALTLEN+gcm.NonceSize()+gcm.Overhead()) {
|
||||
return c,errors.New("the sealed block is too short")
|
||||
return c,errors.New(what+" is too short")
|
||||
}
|
||||
nonce:=raw[SALTLEN : SALTLEN+gcm.NonceSize()]
|
||||
|
||||
// A wrong passphrase derives a wrong key, and the tag does not check out —
|
||||
// the same error a block someone has meddled with produces.
|
||||
// A wrong key — a mistyped passphrase, a FILEKEY that has moved on — and the
|
||||
// tag does not check out: the same error a block someone has meddled with
|
||||
// produces.
|
||||
plain,err:=gcm.Open(nil,nonce,raw[SALTLEN+gcm.NonceSize():],nil)
|
||||
if err!=nil { return c,errors.New("cannot open the sealed block") }
|
||||
if err!=nil { return c,errors.New("cannot open "+what) }
|
||||
|
||||
if err:=json.Unmarshal(plain,&c); err!=nil { return c,err }
|
||||
if (c.User=="" || c.Pass=="") { return c,errors.New("the sealed block holds no login") }
|
||||
if (c.User=="" || c.Pass=="") { return c,errors.New(what+" holds no login") }
|
||||
return c,nil
|
||||
}
|
||||
|
||||
@@ -176,35 +240,54 @@ func credsgcm(pass string, salt []byte) (cipher.AEAD, error) { // --------------
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
func filegcm(salt []byte) (cipher.AEAD, error) { // -------------------------------------- FILEKEY and salt to a key
|
||||
key,err:=hkdf.Key(sha256.New,[]byte(FILEKEY),salt,CREDSFILE,KEYLEN)
|
||||
if err!=nil { return nil,err }
|
||||
block,err:=aes.NewCipher(key)
|
||||
if err!=nil { return nil,err }
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ====================================================================================================== ~/.DNSRC
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func readcreds(path string) (credentials, error) { // ------------------------------------------------- read it
|
||||
// The second return says the file was still the plain JSON of an older dns.
|
||||
// The login in it is good, and getcreds writes it back encrypted; refusing it
|
||||
// would strand a cron run on a file it could perfectly well use.
|
||||
func readcreds(path string) (credentials, bool, error) { // ------------------------------------ read it, either form
|
||||
var c credentials
|
||||
|
||||
st,err:=os.Stat(path)
|
||||
if err!=nil { return c,err }
|
||||
if (st.Mode().Perm()&0o077 != 0) { PE(path+" can be read by others",SF("chmod 600 %s",path)) }
|
||||
if err!=nil { return c,false,err }
|
||||
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,err }
|
||||
if err!=nil { return c,false,err }
|
||||
txt:=strings.TrimSpace(string(b))
|
||||
|
||||
if err:=json.Unmarshal(b,&c); err!=nil { return c,err }
|
||||
if (c.User=="" || c.Pass=="") { return c,errors.New("no login in "+path) }
|
||||
return c,nil
|
||||
if (strings.HasPrefix(txt,FILETAG)) {
|
||||
c,err=opencreds(txt)
|
||||
return c,false,err
|
||||
}
|
||||
|
||||
if (!strings.HasPrefix(txt,"{")) { return c,false,errors.New(path+" is not a credentials file") }
|
||||
|
||||
if err:=json.Unmarshal([]byte(txt),&c); err!=nil { return c,true,err }
|
||||
if (c.User=="" || c.Pass=="") { return c,true,errors.New("no login in "+path) }
|
||||
return c,true,nil
|
||||
}
|
||||
|
||||
func writecreds(path string, c credentials) error { // ----------------------------------------------- write it
|
||||
b,err:=json.MarshalIndent(c,""," ")
|
||||
func writecreds(path string, c credentials) error { // ------------------------------------------ write it, encrypted
|
||||
blob,err:=lockcreds(c)
|
||||
if err!=nil { return err }
|
||||
b=append(b,'\n')
|
||||
b:=[]byte(blob+"\n")
|
||||
|
||||
// Alongside first, then renamed: nobody comes upon half a file, and the login
|
||||
// is never on disk readable by others, not even for a moment — CreateTemp
|
||||
|
||||
+155
-5
@@ -2,8 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -83,24 +85,172 @@ func TestCredsFileIsPrivate(t *testing.T) {
|
||||
t.Errorf("mode is %04o, want 0600", perm)
|
||||
}
|
||||
|
||||
got, err := readcreds(path)
|
||||
got, plain, err := readcreds(path)
|
||||
if err != nil {
|
||||
t.Fatalf("readcreds: %v", err)
|
||||
}
|
||||
if got != testCreds {
|
||||
t.Errorf("got %+v, want %+v", got, testCreds)
|
||||
}
|
||||
if plain {
|
||||
t.Error("a file dns just wrote was taken for an old plaintext one")
|
||||
}
|
||||
|
||||
// Nothing written, nothing to read: the first run has to fall through to the
|
||||
// passphrase rather than come back with an empty login.
|
||||
if _, err := readcreds(filepath.Join(t.TempDir(), ".dnsrc")); err == nil {
|
||||
if _, _, err := readcreds(filepath.Join(t.TempDir(), ".dnsrc")); err == nil {
|
||||
t.Error("a missing file was accepted")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{"user":"","password":""}`), 0o600); err != nil {
|
||||
}
|
||||
|
||||
// What lands on disk must not read out the login, and must not be the plain
|
||||
// JSON of before — that is the whole point of the exercise.
|
||||
func TestCredsFileIsEncrypted(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".dnsrc")
|
||||
|
||||
if err := writecreds(path, testCreds); err != nil {
|
||||
t.Fatalf("writecreds: %v", err)
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(b), FILETAG) {
|
||||
t.Errorf("the file does not begin with %q", FILETAG)
|
||||
}
|
||||
for _, s := range []string{testCreds.User, testCreds.Pass, `"password"`} {
|
||||
if bytesContains(b, []byte(s)) {
|
||||
t.Errorf("%q stands in the clear in the file", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Two writes of the same login differ: salt and nonce are fresh each time.
|
||||
first := string(b)
|
||||
if err := writecreds(path, testCreds); err != nil {
|
||||
t.Fatalf("writecreds: %v", err)
|
||||
}
|
||||
if b, _ = os.ReadFile(path); string(b) == first {
|
||||
t.Error("two writes of the same login are identical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredsFileRejects(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// A byte turned over in the ciphertext, a file that is not one of ours, and
|
||||
// an encrypted file holding nothing: none of them may pass as a login.
|
||||
blob, err := lockcreds(testCreds)
|
||||
if err != nil {
|
||||
t.Fatalf("lockcreds: %v", err)
|
||||
}
|
||||
raw, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(blob, FILETAG))
|
||||
raw[len(raw)-1] ^= 0x01
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"tampered": FILETAG + base64.StdEncoding.EncodeToString(raw),
|
||||
"foreign": "just some text someone put here",
|
||||
"empty": "",
|
||||
"nologin": `{"user":"","password":""}`,
|
||||
} {
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(body+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := readcreds(path); err == nil {
|
||||
t.Errorf("a %s file was accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The file an older dns wrote is still read, and flagged so getcreds writes it
|
||||
// back encrypted. Anything else would stop the certbot hooks on a home
|
||||
// directory that has not seen an interactive run yet.
|
||||
func TestCredsFileFromBefore(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".dnsrc")
|
||||
|
||||
b, err := json.Marshal(testCreds)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readcreds(path); err == nil {
|
||||
t.Error("a file without a login was accepted")
|
||||
if err := os.WriteFile(path, append(b, '\n'), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, plain, err := readcreds(path)
|
||||
if err != nil {
|
||||
t.Fatalf("readcreds: %v", err)
|
||||
}
|
||||
if got != testCreds {
|
||||
t.Errorf("got %+v, want %+v", got, testCreds)
|
||||
}
|
||||
if !plain {
|
||||
t.Error("a plaintext file was not reported as one")
|
||||
}
|
||||
|
||||
// And once written back it opens as an encrypted one, with the same login.
|
||||
if err := writecreds(path, got); err != nil {
|
||||
t.Fatalf("writecreds: %v", err)
|
||||
}
|
||||
got, plain, err = readcreds(path)
|
||||
if err != nil || got != testCreds || plain {
|
||||
t.Errorf("after rewriting: %+v, plain %v, err %v", got, plain, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole way through, on a home directory holding a file from an older dns:
|
||||
// the login comes back, the file is encrypted afterwards, and the next run —
|
||||
// the one from cron, which can ask nobody anything — reads it again.
|
||||
func TestGetcredsEncryptsWhatItFinds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
path := filepath.Join(dir, CREDSFILE)
|
||||
|
||||
b, err := json.Marshal(testCreds)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(b, '\n'), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if user, pass := getcreds(); user != testCreds.User || pass != testCreds.Pass {
|
||||
t.Fatalf("got %q/%q, want %q/%q", user, pass, testCreds.User, testCreds.Pass)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(string(raw), FILETAG) {
|
||||
t.Fatal("the file was not written back encrypted")
|
||||
}
|
||||
|
||||
if user, pass := getcreds(); user != testCreds.User || pass != testCreds.Pass {
|
||||
t.Errorf("second run: got %q/%q, want %q/%q", user, pass, testCreds.User, testCreds.Pass)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockcredsRoundtrip(t *testing.T) {
|
||||
blob, err := lockcreds(testCreds)
|
||||
if err != nil {
|
||||
t.Fatalf("lockcreds: %v", err)
|
||||
}
|
||||
got, err := opencreds(blob)
|
||||
if err != nil {
|
||||
t.Fatalf("opencreds: %v", err)
|
||||
}
|
||||
if got != testCreds {
|
||||
t.Errorf("got %+v, want %+v", got, testCreds)
|
||||
}
|
||||
|
||||
// A different FILEKEY does not open it — the file is worth something only
|
||||
// together with the binary that wrote it.
|
||||
old := FILEKEY
|
||||
FILEKEY = "c29tZXRoaW5nIGVsc2UgZW50aXJlbHksIHRoaXJ0eSB0d28="
|
||||
_, err = opencreds(blob)
|
||||
FILEKEY = old
|
||||
if err == nil {
|
||||
t.Error("another FILEKEY opened the 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","","")
|
||||
@@ -88,6 +89,8 @@ func main() { // ===============================================================
|
||||
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 +100,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 +113,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
|
||||
}
|
||||
|
||||
@@ -131,63 +139,70 @@ func main() { // ===============================================================
|
||||
|
||||
} 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 +215,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 +235,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 +286,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 +331,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 +347,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 +371,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 +394,48 @@ 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)) }
|
||||
}
|
||||
|
||||
|
||||
@@ -396,42 +447,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 +493,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 +582,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 +643,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
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// =========================================================================== 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"`
|
||||
|
||||
Aliases *[]string `json:"aliases,omitempty"`
|
||||
Texts *[]string `json:"texts,omitempty"`
|
||||
IPs *[]string `json:"ips,omitempty"`
|
||||
Hosts *[]jhost `json:"hosts,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"`
|
||||
}
|
||||
|
||||
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
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -6,6 +6,11 @@
|
||||
// that block it brings no names that do not begin with "selfUpdate" or
|
||||
// "update".
|
||||
//
|
||||
// One thing to know before copying it on: this copy does not verify the TLS
|
||||
// certificate of the gitea it talks to — see updateClient at the foot of the
|
||||
// file, where it says what that costs. That is a decision taken for this estate
|
||||
// and not a property of the file; anywhere else, take it out again.
|
||||
//
|
||||
// It assumes the layout build.sh produces: one release per version, whose tag
|
||||
// is the bare number (2.1.6, a leading "v" is allowed), holding one asset
|
||||
// "<name>-<goos>-<goarch>" each — that is, exactly the files from ./bin. Under
|
||||
@@ -16,6 +21,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -394,7 +400,22 @@ func (u selfUpdater) apiBase() (string, error) {
|
||||
|
||||
// One time limit for all of it: the look costs a few hundred milliseconds, the
|
||||
// download a few megabytes — both may hang, but not forever.
|
||||
var updateClient = &http.Client{Timeout: 5 * time.Minute}
|
||||
//
|
||||
// The certificate is deliberately not verified. Machines in this estate that
|
||||
// carry no current trust store cannot build a path to the chain the gitea
|
||||
// serves, and fixing that on each of them was not the road taken. Be clear
|
||||
// about the price: anyone able to place themselves in the network path between
|
||||
// this program and the gitea can hand it any binary at all, and install() will
|
||||
// put that binary in place of the running one and it will be run. The probe
|
||||
// afterwards is no guard against this — a hostile binary prints whatever
|
||||
// version string is asked of it; the probe catches a truncated download, not a
|
||||
// substituted one. What is left protecting the update is the network itself.
|
||||
var updateClient = &http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
|
||||
func updateGet(ctx context.Context, target string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
2.4.0
|
||||
2.5.0
|
||||
|
||||
Reference in New Issue
Block a user