package main import ( "bufio" "encoding/xml" "flag" "fmt" "io" "log" "net/http" "os" "strconv" "strings" ) var ( verbose = false ) // Based on: https://www.namecheap.com/support/api/methods/domains-dns/set-hosts/ type SetResponseObj struct { XMLName xml.Name `xml:"ApiResponse"` // Based on: https://www.namecheap.com/support/api/intro/ Status string `xml:"Status,attr"` Type string `xml:"RequestedCommand"` DNSResultObj DNSResultObj `xml:"CommandResponse>DomainDNSSetHostsResult"` } type DNSResultObj struct { XMLName xml.Name `xml:"DomainDNSSetHostsResult"` Domain string `xml:"Domain,attr"` IsSuccess bool `xml:"IsSuccess,attr"` } // Based on: https://www.namecheap.com/support/api/methods/domains-dns/get-hosts/ type GetResponseObj struct { XMLName xml.Name `xml:"ApiResponse"` // Based on: https://www.namecheap.com/support/api/intro/ Status string `xml:"Status,attr"` Type string `xml:"RequestedCommand"` Hosts []Host `xml:"CommandResponse>DomainDNSGetHostsResult>host"` } type Host struct { //XMLName xml.Name `xml:"host"` Type string `xml:"Type,attr"` Name string `xml:"Name,attr"` Address string `xml:"Address,attr"` MXPref int `xml:"MXPref,attr"` TTL int `xml:"TTL,attr"` } // Helper functions // // Check if a env var is defined. If not defined, return error. // Args: key - the env var to check for. // Returns if exists, the value of the var. func getEnv(key string) (string, error) { value := os.Getenv(key) if len(value) == 0 { return value, fmt.Errorf("Env var %s is required and missing.", key) } if verbose { fmt.Println(key, value) } return value, nil } // Load ENV vars into the apiUrl var. // Returns nil if config loaded successfully, error otherwise. func loadConfig() (string, error) { keys := [5]string{"API_USER", "API_KEY", "ACCESS_IP", "DOMAIN_MAIN", "DOMAIN_END"} for _, key := range keys { _, err := getEnv(key) if err != nil { return "", err } } user, _ := getEnv("API_USER") apiKey, _ := getEnv("API_KEY") accessIP, _ := getEnv("ACCESS_IP") domainMain, _ := getEnv("DOMAIN_MAIN") domainEnd, _ := getEnv("DOMAIN_END") apiUrl := "https://api.namecheap.com/xml.response?ApiUser=" + user + "&ApiKey=" + apiKey + "&UserName=" + user + "&ClientIp=" + accessIP + "&SLD=" + domainMain + "&TLD=" + domainEnd return apiUrl, nil } // Print host recrods to stdout. func logRecords(hosts []Host) { fmt.Println("================================") fmt.Println("Number of DNS records:", len(hosts)) for i, host := range hosts { fmt.Println( "[", i, "]", host.Type, host.Name, host.Address, host.MXPref, host.TTL, ) } fmt.Println("================================") } // Make the API request to Namecheap. // Args: endPoint - the api method to call. // // extraParams (optional) - more params to add to the apiUrl. // // Returns the response byte stream. func makeReq(baseUrl string, endPoint string, extraParams ...string) ([]byte, error) { methodUrl := baseUrl + "&Command=namecheap.domains.dns." + endPoint for _, param := range extraParams { methodUrl += param } if verbose { fmt.Println("Using api url:", methodUrl) } var ( response *http.Response err error ) switch endPoint { case "gethosts": response, err = http.Get(methodUrl) case "sethosts": response, err = http.Post(methodUrl, "application/xml", nil) default: return nil, fmt.Errorf("Error: Invalid request type specified.") } if err != nil { return nil, err } responseData, err := io.ReadAll(response.Body) if err != nil { return nil, err } return responseData, nil } // Converts user input strings into ints. Errors if input is wrong. // Args: userChoicesStrs - slice of strings corresponding to user choices. // // maxVal - the maximum int value allowed as input. // // Returns the slice of integers. func normalizeUserChoices(userChoicesStrs []string, maxVal int) ([]int, error) { var chosenInts []int for _, choice := range userChoicesStrs { text := strings.TrimSpace(choice) if len(text) == 0 { continue } val, err := strconv.Atoi(text) if err != nil { return nil, fmt.Errorf("Error: Bad input detected: %s. Please only enter numbers.", text) } // validate if (val >= 0) && (val < maxVal) { chosenInts = append(chosenInts, val) } else { return nil, fmt.Errorf("Error: Bad user input detected. The number %d is out of range.", val) } } return chosenInts, nil } // Updates the hostRecords set with new data. // If an existing (Name, Type) exists, update the first match. // Args: hostRecords - slice of Host corresponding to DNS records. // // newHost - the new values to set for the DNS record. func updateHostRecordSet(hostRecords []Host, newHost Host) { for i, host := range hostRecords { if (host.Name == newHost.Name) && (host.Type == newHost.Type) { hostRecords[i].Address = newHost.Address hostRecords[i].MXPref = newHost.MXPref hostRecords[i].TTL = newHost.TTL } } } // Performs the API calls that updates Namecheap in any way, // and handles the resulting response validation. // Args: urlEncodedHosts - params consisting of DNS records. func updateNamecheapRecords(apiUrl string, urlEncodedHosts string) error { // make the api request to namecheap responseData, err := makeReq(apiUrl, "sethosts", urlEncodedHosts) if err != nil { return err } var responseObj SetResponseObj xml.Unmarshal(responseData, &responseObj) // validate switch responseObj.Status { case "OK": case "ERROR": return fmt.Errorf("Error: Api request failed.") default: return fmt.Errorf("Error while parsing XML response.") } if !responseObj.DNSResultObj.IsSuccess { return fmt.Errorf("Error: Something went wrong updating DNS records for %s", responseObj.DNSResultObj.Domain) } if verbose { fmt.Println("Success updating DNS records for:", responseObj.DNSResultObj.Domain) } return nil } // Build a string with all DNS records. // Args: hosts - slice of Host corresponding to DNS records. // Returns all DNS records in the format expected by the API. func urlEncodeHosts(hosts []Host) string { var res string for i, host := range hosts { res += fmt.Sprintf("&HostName%d=%s&RecordType%d=%s&Address%d=%s&MXPref%d=%d&TTL%d=%d", i+1, host.Name, i+1, host.Type, i+1, host.Address, i+1, host.MXPref, i+1, host.TTL, ) } return res } // Prompt for user input regarding removal of records, and // handles user flow. // Returns a slice of strings corresponding to user selections. func userInputHandler() []string { var chosen []string for { fmt.Println("Please enter the number next to the DNS record you want to remove: ") fmt.Println("Each number should be seperated by a space:") scanner := bufio.NewScanner(os.Stdin) scanner.Scan() err := scanner.Err() if err != nil { log.Fatal(err) } text := strings.TrimSpace(scanner.Text()) if len(text) == 0 { fmt.Println("Nothing chosen. Exiting.") os.Exit(0) } chosen = strings.Split(text, " ") fmt.Println("You chose", chosen, ". Are you sure you want to remove these? Y/n:") scanner2 := bufio.NewScanner(os.Stdin) scanner2.Scan() err2 := scanner2.Err() if err2 != nil { log.Fatal(err2) } confirm := scanner2.Text() if confirm == "Y" { break } } return chosen } // Main functions // // Get all existing DNS records. // Returns a slice of Host corresponding to DNS records. func getAllRecords(apiUrl string) ([]Host, error) { responseData, err := makeReq(apiUrl, "gethosts") if err != nil { return nil, err } var responseObj GetResponseObj xml.Unmarshal(responseData, &responseObj) // validate switch responseObj.Status { case "OK": return responseObj.Hosts, nil case "ERROR": return nil, fmt.Errorf("Error: Api request failed. Exiting.") default: return nil, fmt.Errorf("Error while parsing XML response.") } } // Add a DNS record. // Args: rName - the name of the DNS record. // // rType - the type of the DNS record. // rAddress - the value of the DNS record. // rMXPref - the priority of the DNS record. // rTTL - the ttl of the DNS record. func addRecord(apiUrl string, rName string, rType string, rAddress string, rMXPref int, rTTL int) error { // we need to get the records before we can add to it. hostRecords, err := getAllRecords(apiUrl) if err != nil { return err } if verbose { logRecords(hostRecords) } hostRecords = append(hostRecords, Host{ Name: rName, Type: rType, Address: rAddress, MXPref: rMXPref, TTL: rTTL, }) urlEncodedHosts := urlEncodeHosts(hostRecords) err = updateNamecheapRecords(apiUrl, urlEncodedHosts) if err != nil { return err } return nil } // Remove DNS record(s). // Will prompt for user input. func removeRecord(apiUrl string) error { // we need to get the records before we can remove one hostRecords, err := getAllRecords(apiUrl) if err != nil { return err } logRecords(hostRecords) // get user input for which ones to delete userChoicesStrs := userInputHandler() userChoicesInts, err := normalizeUserChoices(userChoicesStrs, len(hostRecords)) if err != nil { return err } // build the desired set of host records. var newHostRecords []Host for i, host := range hostRecords { found := false for _, rmIdx := range userChoicesInts { if i == rmIdx { found = true break } } if !found { newHostRecords = append(newHostRecords, host) } } urlEncodedHosts := urlEncodeHosts(newHostRecords) err = updateNamecheapRecords(apiUrl, urlEncodedHosts) if err != nil { return err } return nil } // Update DNS record. // Args: rName - the name of the DNS record. // // rType - the type of the DNS record. // rAddress - the value of the DNS record. // rMXPref - the priority of the DNS record. // rTTL - the ttl of the DNS record. func updateRecord(apiUrl string, rName string, rType string, rAddress string, rMXPref int, rTTL int) error { // we need to get the records before we can update it. hostRecords, err := getAllRecords(apiUrl) if err != nil { return err } if verbose { logRecords(hostRecords) } updateHostRecordSet( hostRecords, Host{ Name: rName, Type: rType, Address: rAddress, MXPref: rMXPref, TTL: rTTL, }, ) urlEncodedHosts := urlEncodeHosts(hostRecords) err = updateNamecheapRecords(apiUrl, urlEncodedHosts) if err != nil { return err } return nil } func main() { // Subcommads: list, add, remove, update // define cli args for the "list" subcommand: listCmd := flag.NewFlagSet("list", flag.ExitOnError) listCmd.BoolVar(&verbose, "v", false, "Verbosity. More output. Default=false.") // define cli args for the "add" subcommand: addCmd := flag.NewFlagSet("add", flag.ExitOnError) addName := addCmd.String("name", "", "Name of DNS record.") addType := addCmd.String("type", "", "Type of DNS record.") addAddress := addCmd.String("value", "", "Value of the DNS record.") addMXPref := addCmd.Int("priority", 0, "Value of the DNS record.") addTTL := addCmd.Int("ttl", 1800, "TTL of the DNS record.") addCmd.BoolVar(&verbose, "v", false, "Verbosity. More output. Default=false.") // define cli args for the "list" subcommand: removeCmd := flag.NewFlagSet("remove", flag.ExitOnError) removeCmd.BoolVar(&verbose, "v", false, "Verbosity. More output. Default=false.") // define cli args for the "update" subcommand: updateCmd := flag.NewFlagSet("update", flag.ExitOnError) updateName := updateCmd.String("name", "", "Name of DNS record.") updateType := updateCmd.String("type", "", "Type of DNS record.") updateAddress := updateCmd.String("value", "", "Value of the DNS record.") updateMXPref := updateCmd.Int("priority", 0, "Value of the DNS record.") updateTTL := updateCmd.Int("ttl", 1800, "TTL of the DNS record.") updateCmd.BoolVar(&verbose, "v", false, "Verbosity. More output. Default=false.") subCmdErrorMsg := "Error: Expected one of: list, add, remove, or update subcommands." addUpdateErrorMsg := "Error: Missing options. Please use -h to see the list of options." if len(os.Args) < 2 { log.Fatalln(subCmdErrorMsg) } switch os.Args[1] { case "list": listCmd.Parse(os.Args[2:]) apiUrl, err := loadConfig() if err != nil { log.Fatalln(err) } hostRecords, err := getAllRecords(apiUrl) if err != nil { log.Fatalln(err) } logRecords(hostRecords) case "add": addCmd.Parse(os.Args[2:]) if *addName == "" || *addType == "" || *addAddress == "" { log.Fatalln(addUpdateErrorMsg) } apiUrl, err := loadConfig() if err != nil { log.Fatalln(err) } err = addRecord(apiUrl, *addName, *addType, *addAddress, *addMXPref, *addTTL) if err != nil { log.Fatalln(err) } case "remove": removeCmd.Parse(os.Args[2:]) apiUrl, err := loadConfig() if err != nil { log.Fatalln(err) } err = removeRecord(apiUrl) if err != nil { log.Fatalln(err) } case "update": updateCmd.Parse(os.Args[2:]) if *updateName == "" || *updateType == "" || *updateAddress == "" { log.Fatalln(addUpdateErrorMsg) } apiUrl, err := loadConfig() if err != nil { log.Fatalln(err) } err = updateRecord(apiUrl, *updateName, *updateType, *updateAddress, *updateMXPref, *updateTTL) if err != nil { log.Fatalln(err) } default: log.Fatalln(subCmdErrorMsg) } }