aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--.gitlab-ci.yml24
-rw-r--r--Dockerfile15
-rw-r--r--README.md75
-rw-r--r--go.mod3
-rw-r--r--nccli.go372
6 files changed, 491 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8c6c22b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+nccli
+*.env
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..09211e8
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,24 @@
+image: docker:latest
+services:
+ - docker:dind
+
+stages:
+ - build
+
+variables:
+ IMAGE: registry.gitlab.com/kdam0/nccli
+ DOCKER_CLI_EXPERIMENTAL: enabled
+
+before_script:
+ - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
+
+build:
+ stage: build
+ script:
+ - docker build --build-arg opts="CGO_ENABLED=0 GOARCH=amd64" --pull -t $IMAGE:amd64 .
+ - docker push $IMAGE:amd64
+ - docker build --build-arg opts="GOARCH=arm GOARM=7" --pull -t $IMAGE:arm32v7 .
+ - docker push $IMAGE:arm32v7
+ - docker manifest create $IMAGE $IMAGE:amd64 $IMAGE:arm32v7
+ - docker manifest annotate $IMAGE $IMAGE:arm32v7 --os linux --arch arm
+ - docker manifest push $IMAGE
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1dd55e5
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+FROM golang:alpine as builder
+
+RUN apk update && apk add git && apk add tzdata && apk add ca-certificates
+
+COPY . $GOPATH/src/gitlab.com/kdam0/nccli
+WORKDIR $GOPATH/src/gitlab.com/kdam0/nccli
+
+RUN go get -d -v
+ARG opts
+RUN env ${opts} go build -o /bin/nccli
+
+FROM scratch
+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
+COPY --from=builder /bin/nccli /bin/nccli
+CMD ["/bin/nccli"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1c70495
--- /dev/null
+++ b/README.md
@@ -0,0 +1,75 @@
+# Namecheap CLI
+
+A quick script to list/add/remove/update DNS records for a Namecheap managed domain. It does not use any non-standard Go libraries.
+
+## Why
+
+At the time of writing, there is no simple way to add DNS records for my Namecheap domain from the cli, since strictly "adding" is not supported. Instead, we have to first get all the current records and include the new one as part of the [setHosts](https://www.namecheap.com/support/api/methods/domains-dns/set-hosts/) method. This would involve a bunch of XML parsing which is beyond my bash-fu.
+
+## Pre-Requisites
+
+You must setup an API Access Key, and whitelist your access IP. Follow instructions for [Enabling API Access](https://www.namecheap.com/support/api/intro/).
+
+## Usage
+
+Available sub-commands:
+* `list`: show all DNS records currently configured.
+* `add`: add a DNS record.
+* `update`: update an existing DNS record.
+* `remove`: remove existing DNS record(s). You will be prompted for which record(s) you want to remove.
+
+### Docker
+
+```bash
+docker run --rm -it --name nctest \
+ -e API_USER="[ your namecheap user ]"
+ -e API_KEY="[ your namecheap api access key ]"
+ -e ACCESS_IP="[ your namecheap api whitelisted IP ]"
+ -e DOMAIN_MAIN="[ your namecheap managed domain ]"
+ -e DOMAIN_END="[ your namecheap managed domain's ending (net, or com, or xyz etc.) ]"
+ registry.gitlab.com/kdam0/nccli:[your architecture]
+ /bin/nccli add -h
+```
+Outputs:
+```bash
+Usage of add:
+ -name string
+ Name of DNS record.
+ -priority int
+ Value of the DNS record.
+ -ttl int
+ TTL of the DNS record. (default 1800)
+ -type string
+ Type of DNS record.
+ -v Verbosity. More output. Default=false.
+ -value string
+ Value of the DNS record.
+```
+Images are available for the following architectures:
+* linux/amd64
+* linux/arm32v7
+
+### Non-Docker
+1. Create `nccli.env` file with content:
+```bash
+API_USER="[ your namecheap user ]"
+API_KEY="[ your namecheap api access key ]"
+ACCESS_IP="[ your namecheap api whitelisted IP ]"
+DOMAIN_MAIN="[ your namecheap managed domain ]"
+DOMAIN_END="[ your namecheap managed domain's ending (net, or com, or xyz etc.) ]"
+```
+
+2. Build:
+```bash
+go build nccli.go
+```
+
+3. Activate env:
+```bash
+source nccli.env
+```
+
+4. Run:
+```bash
+./nccli -h
+```
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..22f687d
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module gitlab.com/kdam0/nccli
+
+go 1.18
diff --git a/nccli.go b/nccli.go
new file mode 100644
index 0000000..4059bd1
--- /dev/null
+++ b/nccli.go
@@ -0,0 +1,372 @@
+package main
+
+import (
+ "bufio"
+ "encoding/xml"
+ "flag"
+ "fmt"
+ "io/ioutil"
+ "log"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+)
+
+var (
+ verbose = false
+ url string
+)
+
+// 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 //
+
+// A helper to check if a env var is defined. If not defined, show error.
+// Args: key - the env var to check for
+// Returns if exists, the value of the var
+func getEnv(key string) string {
+ value := os.Getenv(key)
+ if len(value) == 0 {
+ log.Fatalf("Env var %s is required and missing.", key)
+ }
+ if verbose {
+ fmt.Println(key, value)
+ }
+ return value
+}
+
+func loadConfig() {
+ url = "https://api.namecheap.com/xml.response?ApiUser=" + getEnv("API_USER") +
+ "&ApiKey=" + getEnv("API_KEY") +
+ "&UserName=" + getEnv("API_USER") +
+ "&ClientIp=" + getEnv("ACCESS_IP") +
+ "&SLD=" + getEnv("DOMAIN_MAIN") +
+ "&TLD=" + getEnv("DOMAIN_END")
+}
+
+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("================================")
+}
+
+func makeReq(endPoint string, extraParams ...string) []byte {
+
+ apiUrl := url + "&Command=namecheap.domains.dns." + endPoint
+ for _, param := range extraParams {
+ apiUrl += param
+ }
+
+ if verbose {
+ fmt.Println("Using api url:", apiUrl)
+ }
+
+ var (
+ response *http.Response
+ err error
+ )
+ switch endPoint {
+ case "gethosts":
+ response, err = http.Get(apiUrl)
+ case "sethosts":
+ response, err = http.Post(apiUrl, "application/xml", nil)
+ default:
+ log.Fatalln("Error: Invalid request type specified.")
+ }
+
+ if err != nil {
+ log.Fatalln(err)
+ }
+
+ responseData, err := ioutil.ReadAll(response.Body)
+ if err != nil {
+ log.Fatalln(err)
+ }
+ return responseData
+}
+
+func normalizeUserChoices(userChoicesStrs []string, maxVal int) []int {
+ var chosenInts []int
+ for _, choice := range userChoicesStrs {
+ if len(strings.TrimSpace(choice)) == 0 {
+ continue
+ }
+ val, err := strconv.Atoi(choice)
+ if err != nil {
+ log.Fatalln("Error: Bad user input detected. Please only enter numbers.")
+ }
+ // validate
+ if (val >= 0) && (val < maxVal) {
+ chosenInts = append(chosenInts, val)
+ } else {
+ log.Fatalln("Error: Bad user input detected. One of the numbers is not correct.")
+ }
+ }
+ return chosenInts
+}
+
+// If an existing (Name, Type) exists => update the first match...
+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
+ }
+ }
+}
+
+func updateNamecheapRecords(urlEncodedHosts string) {
+ // make the api request to namecheap
+ responseData := makeReq("sethosts", urlEncodedHosts)
+ var responseObj SetResponseObj
+ xml.Unmarshal(responseData, &responseObj)
+
+ // validate
+ if responseObj.Status != "OK" {
+ log.Fatalln(string(responseData))
+ log.Fatalln("Error: Api request failed. Exiting.")
+ }
+ if !responseObj.DNSResultObj.IsSuccess {
+ log.Fatalln(string(responseData))
+ log.Fatalln("Error: Something went wrong updating DNS records for:", responseObj.DNSResultObj.Domain)
+ }
+ if verbose {
+ fmt.Println("Success updating DNS records for:", responseObj.DNSResultObj.Domain)
+ }
+}
+
+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
+}
+
+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)
+ }
+ // split by ","
+ 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 //
+
+func getAllRecords() []Host {
+ responseData := makeReq("gethosts")
+ var responseObj GetResponseObj
+ xml.Unmarshal(responseData, &responseObj)
+
+ // validate
+ if responseObj.Status != "OK" {
+ log.Fatalln(string(responseData))
+ log.Fatalln("Error: Api request failed. Exiting.")
+ }
+ return responseObj.Hosts
+}
+
+func addRecord(rName string, rType string, rAddress string, rMXPref int, rTTL int) {
+ // we need to get the records before we can add to it.
+ var hostRecords = getAllRecords()
+ if verbose {
+ logRecords(hostRecords)
+ }
+
+ hostRecords = append(hostRecords, Host{
+ Name: rName,
+ Type: rType,
+ Address: rAddress,
+ MXPref: rMXPref,
+ TTL: rTTL,
+ })
+
+ // encode all hosts into a url
+ urlEncodedHosts := urlEncodeHosts(hostRecords)
+ updateNamecheapRecords(urlEncodedHosts)
+}
+
+func removeRecord() {
+ // we need to get the records before we can remove one
+ var hostRecords = getAllRecords()
+ logRecords(hostRecords)
+
+ // get user input for which ones to delete
+ userChoicesStrs := userInputHandler()
+ userChoicesInts := normalizeUserChoices(userChoicesStrs, len(hostRecords))
+
+ // contains 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)
+ }
+ }
+
+ // encode all hosts into a url
+ urlEncodedHosts := urlEncodeHosts(newHostRecords)
+ updateNamecheapRecords(urlEncodedHosts)
+}
+
+func updateRecord(rName string, rType string, rAddress string, rMXPref int, rTTL int) {
+ // we need to get the records before we can update it.
+ var hostRecords = getAllRecords()
+ if verbose {
+ logRecords(hostRecords)
+ }
+
+ updateHostRecordSet(
+ hostRecords,
+ Host{
+ Name: rName,
+ Type: rType,
+ Address: rAddress,
+ MXPref: rMXPref,
+ TTL: rTTL,
+ },
+ )
+
+ // encode all hosts into a url
+ urlEncodedHosts := urlEncodeHosts(hostRecords)
+ updateNamecheapRecords(urlEncodedHosts)
+}
+
+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:])
+ loadConfig()
+ logRecords(getAllRecords())
+ case "add":
+ addCmd.Parse(os.Args[2:])
+ if *addName == "" || *addType == "" || *addAddress == "" {
+ log.Fatalln(addUpdateErrorMsg)
+ }
+ loadConfig()
+ addRecord(*addName, *addType, *addAddress, *addMXPref, *addTTL)
+ case "remove":
+ removeCmd.Parse(os.Args[2:])
+ loadConfig()
+ removeRecord()
+ case "update":
+ updateCmd.Parse(os.Args[2:])
+ if *updateName == "" || *updateType == "" || *updateAddress == "" {
+ log.Fatalln(addUpdateErrorMsg)
+ }
+ loadConfig()
+ updateRecord(*updateName, *updateType, *updateAddress, *updateMXPref, *updateTTL)
+ default:
+ log.Fatalln(subCmdErrorMsg)
+ }
+}