package main import ( "bytes" "crypto/tls" "encoding/json" "errors" "flag" "fmt" "io/ioutil" "net" "net/http" "os" "os/exec" "regexp" "sort" "strconv" "strings" "time" ) const ( // the default API url defaultAPI = "https://localhost:8443" ) type ( // json definitions for the web API jsonArray []*jsonCommand jsonCommand struct { Method string `json:"method"` ID int `json:"id"` JSONRPC string `json:"jsonrpc"` Params jsonCommandParams `json:"params"` args []string } jsonCommandParams struct { Name string `json:"name"` Value string `json:"value"` TTL int `json:"ttl"` ForceReverse bool `json:"reverse"` Append bool `json:"append"` Comment string `json:"comment"` DryRun bool `json:"dry-run"` IgnoreError bool `json:"ignore-error"` Nonce string `json:"nonce"` Debug bool `json:"-"` } apiResponse struct { ID int `json:"id"` Result []struct { Changes string `json:"changes"` Comment string `json:"comment"` Result string `json:"result"` } `json:"result"` Error struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` } // structure for command line options command struct { args []string descr string options []string check func(*jsonCommand) bool } ) // global vars var ( dnsAPI string httpClient http.Client commands map[string]command generalFlags []string ) // Populate the generalFlags, commands and dnsAPI variables func init() { generalFlags = []string{"dry-run", "comment"} commands = map[string]command{ "a": { []string{"", ""}, "Create a A record, points to an IPv4. If the IP has no reverse, create it with the record value (won't apply for wildcard)", []string{"append", "reverse", "ttl"}, cmdCheckIPv4}, "aaaa": { []string{"", ""}, "Create a AAAA record, points to an IPv6. If the IP has no reverse, create it with the record value (won't apply for wildcard)", []string{"append", "reverse", "ttl"}, cmdCheckIPv6}, "cname": { []string{"", ""}, "Create a CNAME record, points to another name. If that name is managed by the DNS server, it must exist", []string{"ttl"}, nil}, "dname": { []string{"", ""}, "Create a DNAME record, points to another name. If that name is managed by the DNS server, it must exist", []string{"ttl"}, nil}, "caa": { []string{"", "", "", ""}, "Create a CAA record. If value contains spaces, it must be quoted", []string{"append", "ttl"}, cmdCheckCAA}, "srv": { []string{"<_service._proto.name>", "", "", "", ""}, "Create a SRV record. https://en.wikipedia.org/wiki/SRV_record", []string{"append", "ttl"}, cmdCheckSRV}, "txt": { []string{"", "<\"text\">"}, "Create a TXT record. No need to escape quotes, it will be done automatically on the server", []string{"append", "ttl"}, nil}, "mx": { []string{"", "", ""}, "Create a MX record. mail-server must exist", []string{"append", "ttl"}, cmdCheckMX}, "ns": { []string{"", ""}, "Create a NS record. dns-server must exist and must be an external server", []string{"append", "ttl"}, nil}, "ptr": { []string{"", ""}, "Add a PTR record. If the first argument is an IP, will convert it to an .arpa name", []string{"append", "ttl"}, cmdCheckPTR}, "delete": { []string{"", "[value]"}, "Remove the record. If value is not provided, remove all records of all types sharing the name. If value is specified, only remove that particuliar value. If a deleted record corresponds to a matching reverse, will remove the reverse too", []string{}, nil}, "ttl": { []string{"", "[value]", ""}, "Change the TTL of a record. If value is not provided, change the TTL of all types sharing the name. If value is specified, only change that particuliar TTL", []string{}, cmdCheckTTL}, "newzone": { []string{""}, "Add a new zone. The zone will be private for a private IPs reverse zone. NS and SOA options are automatically changed", []string{}, nil}, "search": { []string{""}, "Search the pdns database", []string{}, nil}, "dump": { []string{""}, "Display the zone", []string{}, nil}, "list": { []string{"[regexp]"}, "List zones, filter on regexp if provided", []string{}, nil}, "batch": { []string{""}, "Batch mode: file is a jsonRPC 2.0 complient json file with all commands you want to execute. Example : \n [\n { \"jsonrpc\": \"2.0\", \"id\": 0, \"method\": \"list\" },\n { \"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"newzone\", \"params\": { \"name\": \"example.com\", \"ignore-error\": true } },\n { \"jsonrpc\": \"2.0\", \"id\": 2, \"method\": \"a\", \"params\": {\n \"comment\": \"it's the fault\", \"name\": \"toto.example.com\",\n \"value\": \"192.0.2.1\" } }\n ]\n By default, an error will stop the batch. Use the boolean ignore-error to change the behaviour for a particular line. The comment and ttl switches are applied only if no explicit value is provided in a line", []string{"ttl"}, nil}, } // we can override the default API url (for dev/debug). In this case, // ignore the SSL dnsAPI = defaultAPI if os.Getenv("DNS_API") != "" { dnsAPI = os.Getenv("DNS_API") } // ignore security for localhost if strings.HasPrefix(dnsAPI, "https://localhost") { http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} } // set the http timeout httpClient.Timeout = time.Duration(60 * time.Second) flag.Usage = Usage } // cmdCheckIPv4 is the validation function for the "a" action. // Checks the 2nd argument is an IPv4 address func cmdCheckIPv4(j *jsonCommand) bool { ip := net.ParseIP(j.args[2]) return ip != nil && ip.To4() != nil } // cmdCheckIPv6 is the validation function for the "aaaa" action. // Checks the 2nd argument is an IPv6 address func cmdCheckIPv6(j *jsonCommand) bool { ip := net.ParseIP(j.args[2]) return ip != nil && ip.To4() == nil } // cmdCheckPTR is the validation function for the "reverse" action. // Checks the 1st argument is an IP or a valid .arpa name, // converts the first argument to the valid .arpa name if necessary, // checks that the second argument points back to the first func cmdCheckPTR(j *jsonCommand) bool { var ip net.IP j.args[1] = strings.Trim(j.args[1], ".") j.args[2] = strings.Trim(j.args[2], ".") if strings.HasSuffix(j.args[1], ".arpa") { ip = net.ParseIP(ptrToIP(j.args[1])) } else { ip = net.ParseIP(j.args[1]) } // if ip is not valid, stop here if ip == nil { fmt.Fprintf(os.Stderr, "Error: %s is not a valid IP or reverse\n\n", j.args[1]) return false } j.Params.Name = iPtoReverse(ip) strIP := ip.String() names, err := net.LookupHost(j.args[2]) if err != nil { fmt.Fprintf(os.Stderr, "Warning: %s must exist\n\n", j.args[2]) return true } cname, err := net.LookupCNAME(j.args[2]) cname = strings.Trim(cname, ".") if err == nil && cname != j.args[2] { fmt.Fprintf(os.Stderr, "Error: %s cannot be a CNAME\n\n", j.args[2]) return false } for _, n := range names { if n == strIP { return true } } fmt.Fprintf(os.Stderr, "Warning: %s must point to %s\n\n", j.args[2], strIP) return true } // cmdCheckCAA is the validation function for the "caa" action. func cmdCheckCAA(j *jsonCommand) bool { const q = "\"" var err error var tag int if tag, err = strconv.Atoi(j.args[2]); err != nil || tag < 0 || tag > 255 { return false } j.args[4] = strings.Trim(j.args[4], q) j.args[4] = strings.Replace(j.args[4], q, "\\\"", -1) j.args[4] = q + j.args[4] + q j.Params.Value = fmt.Sprintf("%d %s %s", tag, j.args[3], j.args[4]) return true } // cmdCheckSRV is the validation function for the "srv" action. // Checks that the arguments are valid, and put them all in arg[2] func cmdCheckSRV(j *jsonCommand) bool { var err error var prio, weight, port int validSRV := regexp.MustCompile("^_[a-z0-9]+\\._(tcp|udp|tls)[^ ]+$") if !validSRV.MatchString(j.args[1]) { return false } if prio, err = strconv.Atoi(j.args[2]); err != nil || prio < 0 { return false } if weight, err = strconv.Atoi(j.args[3]); err != nil || weight < 0 { return false } if port, err = strconv.Atoi(j.args[4]); err != nil || port < 1 { return false } j.Params.Value = fmt.Sprintf("%d %d %d %s", prio, weight, port, j.args[5]) return true } // cmdCheckMX is the validation function for the "mx" action. // Checks that the arguments are a weight and a mail servers func cmdCheckMX(j *jsonCommand) bool { var err error var prio int if prio, err = strconv.Atoi(j.args[2]); err != nil || prio < 0 { return false } j.Params.Value = fmt.Sprintf("%d %s", prio, j.args[3]) return true } // cmdCheckTTL is the validation function for the "ttl" action. func cmdCheckTTL(j *jsonCommand) bool { var err error // The args for this command are "", "[value]", "" (the // middle argument is optional). n := len(j.args) if j.Params.TTL, err = strconv.Atoi(j.args[n-1]); err != nil || j.Params.TTL < 0 { return false } // Default value if the optional argument is not given if n < 4 { j.Params.Value = "" } return true } // Output the usage of this specific command func (c *command) Usage(name string) string { ret := fmt.Sprintf(" %s [options] %s %s : \n %s\n Options:\n", os.Args[0], name, c.formatArgs(), c.formatDescr()) findSame := map[string]string{} alt := map[string]string{} for _, opt := range append(generalFlags, c.options...) { fl := flag.Lookup(opt) findSame[fl.Usage] = opt } flag.VisitAll(func(f *flag.Flag) { if opt, ok := findSame[f.Usage]; ok && opt != f.Name { alt[opt] = f.Name } }) for _, opt := range append(generalFlags, c.options...) { var short, arg, defValue string f := flag.Lookup(opt) if s, ok := alt[opt]; ok { short = fmt.Sprintf("-%s,", s) } if f.DefValue != "false" && f.DefValue != "true" { arg = "" defValue = fmt.Sprintf(", default is %s", f.DefValue) } if f.DefValue == "" { defValue += "\"\"" } ret += fmt.Sprintf(" %-3v --%-7v %-7v : %s%s\n", short, f.Name, arg, f.Usage, defValue) } return ret } // CheckArgs populate the json structure with the given arguments and check // they are valid func (c *command) CheckArgs(j *jsonCommand) bool { args := flag.Args() min := 1 for _, a := range c.args { if a[0] == '<' { min++ } } if len(args) > len(c.args)+1 { return false } if len(args) < min { return false } // per construction, there is always at least 2 arguments and the commands // in args if len(args) > 1 { j.Params.Name = args[1] } if len(args) > 2 { j.Params.Value = args[2] } // use the adapted check function. It can modify the structure if c.check != nil { return c.check(j) } return true } // SetDryRun set the dry-run flag on every entry func (ja jsonArray) SetDryRun() { for i := range ja { ja[i].Params.DryRun = true } } // SetNonce gets a nonce from the API, and stores it in the structure. // The nonce is valid for 10 min, to avoid replay attacks func (ja jsonArray) SetNonce() error { var valid = regexp.MustCompile(`^[A-Za-z0-9+/]*$`) req, err := http.NewRequest("GET", fmt.Sprintf("%s/nonce", dnsAPI), nil) if err != nil { return err } resp, err := httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() body, _ := ioutil.ReadAll(resp.Body) if valid.MatchString(string(body)) { ja[0].Params.Nonce = string(body) return nil } return errors.New("Cannot get Nonce : Invalid response") } // SetArgs copy the argument from the command line into the structure func (ja *jsonArray) SetArgs(j *jsonCommand, args []string) error { if len(args) < 1 { return errors.New("Not enough args") } cmd, ok := commands[args[0]] if !ok { return fmt.Errorf("Unknown command %s", args[0]) } j.Method = args[0] j.args = args j.JSONRPC = "2.0" j.ID = 1 // check the arguments if !cmd.CheckArgs(j) { Usage() } // evacuate the simple case first if args[0] != "batch" { *ja = append(*ja, j) return nil } batch, err := ioutil.ReadFile(args[1]) if err != nil { return err } if err := json.Unmarshal(batch, ja); err != nil { return err } for i := range *ja { (*ja)[i].JSONRPC = j.JSONRPC (*ja)[i].ID = i if (*ja)[i].Params.TTL == 0 { (*ja)[i].Params.TTL = j.Params.TTL } if (*ja)[i].Params.Comment == "" { (*ja)[i].Params.Comment = j.Params.Comment } if (*ja)[i].Params.DryRun == false { (*ja)[i].Params.DryRun = j.Params.DryRun } } return nil } // Usage print the usage for all actions, or just the one used func Usage() { fmt.Fprintln(flag.CommandLine.Output(), "Usage: ") if flag.NArg() >= 1 { name := flag.Arg(0) if cmd, ok := commands[name]; ok { fmt.Fprintf(flag.CommandLine.Output(), "%s\n\n", cmd.Usage(name)) os.Exit(1) } } s := []string{} for name := range commands { s = append(s, name) } sort.Strings(s) for _, name := range s { cmd := commands[name] fmt.Fprintf(flag.CommandLine.Output(), "%s\n\n", cmd.Usage(name)) } os.Exit(1) } // GetYubikey try to find a PGP Smartcard and return its ID func GetYubikey() (string, error) { out, err := exec.Command("gpg", "--card-status", "--with-colons").CombinedOutput() if err != nil { return "", err } for _, line := range strings.Split(string(out), "\n") { if strings.HasPrefix(line, "fpr:") { return strings.Split(line, ":")[1], nil } } return "", errors.New("Yubikey issue (is it plugged in?)") } // Sign runs the "gpg --clear-sign" command on the input. // If the key ID is empty, it will return the input address func Sign(payload []byte, gpgKey string) ([]byte, error) { if gpgKey == "" { return payload, nil } cmd := exec.Command("gpg", "--clearsign", "-u", gpgKey) stdin, err := cmd.StdinPipe() if err != nil { return payload, err } stdin.Write(payload) stdin.Close() out, err := cmd.CombinedOutput() if err != nil { return payload, err } return out, nil } // sendQuery sends the payload to the API server func sendQuery(payload []byte) ([]*apiResponse, error) { apiRespSimple := &apiResponse{} apiRespArray := []*apiResponse{} req, err := http.NewRequest("POST", fmt.Sprintf("%s/jsonrpc", dnsAPI), bytes.NewBuffer(payload)) if err != nil { return nil, err } req.Header.Set("Content-Type", "text/plain") resp, err := httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() s, _ := ioutil.ReadAll(resp.Body) if json.Unmarshal(s, &apiRespArray); len(apiRespArray) > 0 { return apiRespArray, nil } if err := json.Unmarshal(s, apiRespSimple); err != nil { return nil, err } return append(apiRespArray, apiRespSimple), nil } func main() { var j jsonCommand var jsonStruct jsonArray // parse the command line flag.BoolVar(&j.Params.ForceReverse, "reverse", false, "Force the creation of a reverse") flag.BoolVar(&j.Params.ForceReverse, "r", false, "Force the creation of a reverse") flag.BoolVar(&j.Params.DryRun, "dry-run", false, "Explain what the command would do, but do nothing") flag.BoolVar(&j.Params.DryRun, "n", false, "Explain what the command would do, but do nothing") flag.StringVar(&j.Params.Comment, "comment", "", "Add a comment to the operation") flag.StringVar(&j.Params.Comment, "c", "", "Add a comment to the operation") flag.IntVar(&j.Params.TTL, "ttl", 172800, "Specify the TTL") flag.IntVar(&j.Params.TTL, "t", 172800, "Specify the TTL") flag.BoolVar(&j.Params.Append, "append", false, "Append the value, don't replace the whole record") flag.BoolVar(&j.Params.Append, "a", false, "Append the value, don't replace the whole record") flag.BoolVar(&j.Params.Debug, "debug", false, "Add debug info") flag.BoolVar(&j.Params.Debug, "d", false, "Add debug info") flag.Parse() // copy and check the arguments if err := jsonStruct.SetArgs(&j, flag.Args()); err != nil { fmt.Fprintln(os.Stderr, err) Usage() } // now we have the right number of arguments, and a valid command // Add Nonce if err := jsonStruct.SetNonce(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(5) } // get the GPG Public Key from DM_GPG_KEY env var (usefull in case of several Yubikey) var gpgEnvKey = os.Getenv("DM_GPG_KEY") // select Key from env if define var gpgKey string if gpgEnvKey != "" { gpgKey = gpgEnvKey // or the Yubikey } else { // get the Yubikey Public Key gpgYubikey, err := GetYubikey() if err != nil { fmt.Fprintln(os.Stderr, "Warning, no Yubikey; forcing dry-run mode") jsonStruct.SetDryRun() } gpgKey = gpgYubikey } // Transform into json jsonStr, err := json.MarshalIndent(jsonStruct, " ", " ") if err != nil { fmt.Fprintln(os.Stderr, "panic") os.Exit(6) } // debug mode if j.Params.Debug { fmt.Println(string(jsonStr)) } // Sign signed, err := Sign(jsonStr, gpgKey) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(7) } // Send to the server ret, err := sendQuery(signed) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(9) } for _, response := range ret { if response.Error.Message != "" { fmt.Fprintln(os.Stderr, "; "+ strings.Replace(response.Error.Message, "\n", "\n; ", -1)+"\n") continue } for _, result := range response.Result { if strings.Trim(result.Comment, " \n") != "" { fmt.Fprintln(os.Stderr, "; "+ strings.Replace(result.Comment, "\n", "\n; ", -1)+"\n") if result.Result != "" { fmt.Fprintln(os.Stderr, "; "+result.Result) } } if result.Changes != "" { fmt.Println(result.Changes) } } } } // formatDescr outputs the description while adding CR when the line is too long func (c *command) formatDescr() string { max := 70 ret := "" current := 0 for _, word := range strings.Split(c.descr, " ") { if strings.Contains(word, "\n") { current = 0 ret += word + " " continue } if current+len(word) > max { ret += "\n " current = 0 } current += len(word + " ") ret += word + " " } ret = strings.Trim(ret, " ") return ret } // formatArgs concatenate the arguments for the Usage() function func (c *command) formatArgs() string { return strings.Join(c.args, " ") } // iPtoReverse calculates the reverse name associated with an IPv4 or IPv6 func iPtoReverse(ip net.IP) (arpa string) { const hexDigit = "0123456789abcdef" // code copied and adapted from the net library // ip can be 4 or 16 bytes long if ip.To4() != nil { if len(ip) == 16 { return uitoa(uint(ip[15])) + "." + uitoa(uint(ip[14])) + "." + uitoa(uint(ip[13])) + "." + uitoa(uint(ip[12])) + ".in-addr.arpa." } return uitoa(uint(ip[3])) + "." + uitoa(uint(ip[2])) + "." + uitoa(uint(ip[1])) + "." + uitoa(uint(ip[0])) + ".in-addr.arpa." } // Must be IPv6 buf := make([]byte, 0, len(ip)*4+len("ip6.arpa.")) // Add it, in reverse, to the buffer for i := len(ip) - 1; i >= 0; i-- { v := ip[i] buf = append(buf, hexDigit[v&0xF]) buf = append(buf, '.') buf = append(buf, hexDigit[v>>4]) buf = append(buf, '.') } // Append "ip6.arpa." and return (buf already has the final .) buf = append(buf, "ip6.arpa."...) return string(buf) } // Convert unsigned integer to decimal string. // code copied from the net library func uitoa(val uint) string { if val == 0 { // avoid string allocation return "0" } var buf [20]byte // big enough for 64bit value base 10 i := len(buf) - 1 for val >= 10 { q := val / 10 buf[i] = byte('0' + val - q*10) i-- val = q } // val < 10 buf[i] = byte('0' + val) return string(buf[i:]) } // ptrToIP converts a .arpa name to the corresponding IP func ptrToIP(s string) string { s = reverseParts(s) // reverse parts between dots (".") count := 0 ip := "" version := 4 for _, elt := range strings.Split(s, ".") { switch elt { case "": case "ip6": version = 6 case "in-addr": case "arpa": default: count++ ip += elt if version == 4 && count != 4 { ip += "." } if version == 6 && count%4 == 0 && count != 32 { ip += ":" } } } return ip } // reverse the part order on every member of the array func reverseParts(s string) string { parts := strings.Split(s, ".") for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { parts[i], parts[j] = parts[j], parts[i] } return strings.Join(parts, ".") }