diff --git a/main.go b/main.go index 766a01d..ff46abb 100644 --- a/main.go +++ b/main.go @@ -5,31 +5,25 @@ import ( "fmt" "io" "io/ioutil" + "ncddns/mod" + "ncddns/providers/gandi" + "ncddns/providers/namecheap" + "ncddns/providers/porkbun" "net/http" "os" "strings" ) -const ( - baseURL = "https://dynamicdns.park-your-domain.com/update?" -) - var ( byName = map[string]IPSetter{ - "namecheap": NameCheap{}, - "gandi": Gandi{}, + "namecheap": namecheap.Client{}, + "gandi": gandi.Client{}, + "porkbun": porkbun.Client{}, } ) -type IPSetRequest struct { - IP string - Domain string - Password string - Host string -} - type IPSetter interface { - IPSet([]IPSetRequest) error + IPSet([]mod.IPSetRequest) error } type DomainTuple struct { @@ -83,7 +77,7 @@ func main() { fatal("config", err) for _, cfgEntry := range cfg { fmt.Println(cfgEntry.Domain) - err = cfgEntry.Setter.IPSet([]IPSetRequest{ + err = cfgEntry.Setter.IPSet([]mod.IPSetRequest{ { IP: ip, Domain: cfgEntry.Domain, @@ -113,5 +107,5 @@ func IP() (string, error) { return "", fmt.Errorf("read body: %w", err) } - return string(body), nil + return strings.TrimSpace(string(body)), nil } diff --git a/mod/mod.go b/mod/mod.go new file mode 100644 index 0000000..da3385e --- /dev/null +++ b/mod/mod.go @@ -0,0 +1,51 @@ +package mod + +import ( + "encoding/json" + "flag" + "fmt" + "io" +) + +var ( + Verbose bool +) + +func init() { + flag.BoolVar(&Verbose, "v", false, "verbose") +} + +type IPSetRequest struct { + IP string + Domain string + Password string + Host string +} + +func MustJSON(v any) []byte { + bytes, err := json.Marshal(v) + if err != nil { + panic(err) + } + return bytes +} + +func TryBody(body io.Reader) string { + content, err := io.ReadAll(body) + if err != nil { + return fmt.Sprintf("failed reading body: %s", err.Error()) + } + return string(content) +} + +func Verboseln(args ...any) { + if Verbose { + fmt.Println(args...) + } +} + +func Verbosefln(format string, args ...any) { + if Verbose { + fmt.Printf(format+"\n", args...) + } +} diff --git a/gandi.go b/providers/gandi/gandi.go similarity index 87% rename from gandi.go rename to providers/gandi/gandi.go index 7c9a99a..2bc26b0 100644 --- a/gandi.go +++ b/providers/gandi/gandi.go @@ -1,7 +1,8 @@ -package main +package gandi import ( "fmt" + "ncddns/mod" "github.com/go-gandi/go-gandi" "github.com/go-gandi/go-gandi/config" @@ -25,9 +26,9 @@ var ( } ) -type Gandi struct{} +type Client struct{} -func (g Gandi) IPSet(reqs []IPSetRequest) error { +func (g Client) IPSet(reqs []mod.IPSetRequest) error { if len(reqs) == 0 { return nil } @@ -53,7 +54,7 @@ func (g Gandi) IPSet(reqs []IPSetRequest) error { return err } -func (Gandi) EnsureAllDomainsAreSame(reqs []IPSetRequest) error { +func (Client) EnsureAllDomainsAreSame(reqs []mod.IPSetRequest) error { first := reqs[0].Domain for _, dom := range reqs { if first != dom.Domain { diff --git a/namecheap.go b/providers/namecheap/namecheap.go similarity index 75% rename from namecheap.go rename to providers/namecheap/namecheap.go index fbaf096..991efdb 100644 --- a/namecheap.go +++ b/providers/namecheap/namecheap.go @@ -1,16 +1,21 @@ -package main +package namecheap import ( "fmt" "io" + "ncddns/mod" "net/http" "net/url" "os" ) -type NameCheap struct{} +const ( + baseURL = "https://dynamicdns.park-your-domain.com/update?" +) -func (nc NameCheap) IPSet(reqs []IPSetRequest) error { +type Client struct{} + +func (nc Client) IPSet(reqs []mod.IPSetRequest) error { for _, req := range reqs { if err := nc.SetSingle(req); err != nil { return fmt.Errorf("%s: %w", req.Domain, err) @@ -19,7 +24,7 @@ func (nc NameCheap) IPSet(reqs []IPSetRequest) error { return nil } -func (NameCheap) SetSingle(req IPSetRequest) error { +func (Client) SetSingle(req mod.IPSetRequest) error { params := url.Values{} params.Add("host", req.Host) params.Add("domain", req.Domain) diff --git a/providers/porkbun/model.go b/providers/porkbun/model.go new file mode 100644 index 0000000..f1d7925 --- /dev/null +++ b/providers/porkbun/model.go @@ -0,0 +1,36 @@ +package porkbun + +type EditRequest struct { + Request + Name string `json:"name"` + Type string `json:"type"` + IP string `json:"content"` + TTL string `json:"ttl"` +} + +func (EditRequest) FromRecord(r Record, keys Request) EditRequest { + return EditRequest{ + Request: keys, + Name: r.Name, + Type: r.Type, + IP: r.IP, + TTL: "300", + } +} + +type retrieveResponse struct { + Status string `json:"status"` + Records []Record `json:"records"` +} + +type Request struct { + APIKey string `json:"apikey"` + Secret string `json:"secretapikey"` +} + +type Record struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + IP string `json:"content"` +} diff --git a/providers/porkbun/porkbun.go b/providers/porkbun/porkbun.go new file mode 100644 index 0000000..1ad54b0 --- /dev/null +++ b/providers/porkbun/porkbun.go @@ -0,0 +1,97 @@ +package porkbun + +import ( + "bytes" + "encoding/json" + "fmt" + "ncddns/mod" + "net/http" + "strings" +) + +const ( + baseURL = "https://porkbun.com/api/json/v3" + retrieve = "/dns/retrieve/" + edit = "/dns/edit/" + keySplitter = ":" +) + +type Client struct { +} + +func (Client) retrieve(domain string, keys Request) ([]Record, error) { + url := baseURL + retrieve + domain + mod.Verboseln("POST", url) + resp, err := http.Post(url, "application/json", bytes.NewBuffer(mod.MustJSON(keys))) + if err != nil { + return nil, fmt.Errorf("POST retrieve: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("non-ok status code [%d]: %s", resp.StatusCode, mod.TryBody(resp.Body)) + } + res := retrieveResponse{} + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, fmt.Errorf("json: %w", err) + } + if res.Status != "SUCCESS" { + return nil, fmt.Errorf("non-SUCCESS status: %s", res.Status) + } + return res.Records, nil +} + +func (Client) edit(req EditRequest, id, domain string) error { + url := baseURL + edit + domain + "/" + id + mod.Verboseln("POST", url) + resp, err := http.Post(url, "application/json", bytes.NewReader(mod.MustJSON(req))) + if err != nil { + return fmt.Errorf("POST edit: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("non-ok status code [%d]: %s", resp.StatusCode, mod.TryBody(resp.Body)) + } + resp.Body.Close() + return nil +} + +func (c Client) IPSet(reqs []mod.IPSetRequest) error { + keyParts := strings.Split(reqs[0].Password, keySplitter) + if len(keyParts) != 2 { + panic(fmt.Sprintf("non-2 length split for password [%s]", reqs[0].Password)) + } + keys := Request{ + APIKey: keyParts[0], + Secret: keyParts[1], + } + var err error + domainRecords := map[string][]Record{} + for _, req := range reqs { + records, ok := domainRecords[req.Domain] + if !ok { + records, err = c.retrieve(req.Domain, keys) + if err != nil { + return fmt.Errorf("[%s]: retrieve: %w", req.Domain, err) + } + domainRecords[req.Domain] = records + } + for _, record := range records { + if record.Type != "A" && record.Type != "AAAA" { + mod.Verbosefln("Skipping [%s/%s/%s] due to type [%s]", record.IP, record.Name, record.IP, record.Type) + continue + } + mod.Verbosefln("Current IP for [%s/%s]: [%s], want [%s]", req.Domain, record.Type, record.IP, req.IP) + if req.IP == record.IP { + mod.Verboseln("Skipping...") + continue + } + edit := EditRequest{}.FromRecord(record, keys) + edit.IP = req.IP + + if err := c.edit(edit, record.ID, req.Domain); err != nil { + return fmt.Errorf("[%s]: edit: %w", req.Domain, err) + } + } + } + return nil +}