diff --git a/config.go b/config.go index 9caf176..24f9c02 100644 --- a/config.go +++ b/config.go @@ -16,8 +16,9 @@ type config struct { } type domain struct { - Type string `json:"type"` - Name string `json:"name"` + Type string `json:"type"` + Name string `json:"name"` + ZoneName string `json:"zoneName,omitempty"` // TODO: lograr que esto sea un coso de propiedades arbitrario Key string `json:"key"` } @@ -68,6 +69,16 @@ func LoadConfig(path string) (state State, err error) { Name: d.Name, NameServer: &nameservers.HeNet{HTTPClient: &state.HTTPClient, Password: d.Key}, }) + case "cloudflare v4 api": + if len(d.ZoneName) == 0 { + err = errors.New("cloudflare v4 api: missing zoneName property") + return + } + state.Domains = append(state.Domains, Domain{ + Name: d.Name, + NameServer: &nameservers.CloudflareV4{HTTPClient: &state.HTTPClient, Key: d.Key, + ZoneName: d.ZoneName}, + }) default: err = errors.New("I don't know the service type " + d.Type) return diff --git a/nameservers/cloudflare.go b/nameservers/cloudflare.go new file mode 100644 index 0000000..d945f3a --- /dev/null +++ b/nameservers/cloudflare.go @@ -0,0 +1,163 @@ +package nameservers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" +) + +type CloudflareV4 struct { + HTTPClient *http.Client + Key string + ZoneName string +} + +func (c *CloudflareV4) SetRecord(ctx context.Context, domain string, overrideIp string) (string, error) { + zoneId, err := c.apiGetZoneId(ctx) + if err != nil { + return "", err + } + recordId, err := c.apiGetDnsRecordId(ctx, zoneId, domain) + if err != nil { + return "", err + } + + content := overrideIp + if len(content) == 0 { + ip, err := c.getIp(ctx) + if err != nil { + return "", err + } + content = ip + } + + result, err := c.apiUpdateDnsRecord(ctx, zoneId, domain, recordId, content) + return result, err +} + +func (c *CloudflareV4) apiReq(ctx context.Context, path string, method string, reqBody io.Reader) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, method, "https://api.cloudflare.com"+path, reqBody) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+c.Key) + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return body, nil +} +func (c *CloudflareV4) apiGet(ctx context.Context, path string) ([]byte, error) { + return c.apiReq(ctx, path, "GET", nil) +} + +func (c *CloudflareV4) getIp(ctx context.Context) (string, error) { + // icanhazip.com es manejado por Cloudflare https://blog.apnic.net/2021/06/17/how-a-small-free-ip-tool-survived/ + req, err := http.NewRequestWithContext(ctx, "GET", "https://ipv4.icanhazip.com/", nil) + if err != nil { + return "", err + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return "", err + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(body), nil +} + +type apiZoneSearchResponse struct { + Result []struct { + Id string `json:"id"` + } `json:"result"` +} + +func (c *CloudflareV4) apiGetZoneId(ctx context.Context) (string, error) { + str, err := c.apiGet(ctx, "/client/v4/zones?name="+c.ZoneName) + if err != nil { + return "", err + } + var resp apiZoneSearchResponse + err = json.Unmarshal(str, &resp) + if err != nil { + return "", err + } + if len(resp.Result) != 1 { + return "", errors.New("expected result to be len()=1 but len is " + fmt.Sprintf("%d", len(resp.Result))) + } + return resp.Result[0].Id, nil +} + +type apiDnsRecordSearchResponse struct { + Result []struct { + Id string `json:"id"` + } `json:"result"` +} + +func (c *CloudflareV4) apiGetDnsRecordId(ctx context.Context, zoneId, domain string) (string, error) { + str, err := c.apiGet(ctx, fmt.Sprintf("/client/v4/zones/%s/dns_records?type=A&name=%s", zoneId, domain)) + if err != nil { + return "", err + } + var resp apiDnsRecordSearchResponse + err = json.Unmarshal(str, &resp) + if err != nil { + return "", err + } + if len(resp.Result) != 1 { + return "", errors.New("expected result to be len()=1 but len is " + fmt.Sprintf("%d", len(resp.Result))) + } + return resp.Result[0].Id, nil +} + +// https://developers.cloudflare.com/api/operations/dns-records-for-a-zone-patch-dns-record +type apiUpdateDnsRecordRequest struct { + Content string `json:"content"` + Name string `json:"name"` + Type string `json:"type"` +} +type apiUpdateDnsRecordResponse struct { + Result []struct { + Id string `json:"id"` + Content string `json:"content"` + } `json:"result"` +} + +func (c *CloudflareV4) apiUpdateDnsRecord(ctx context.Context, zoneId, zoneName, recordId, content string) (string, error) { + body, err := json.Marshal(apiUpdateDnsRecordRequest{ + Content: content, + Name: zoneName, + Type: "A", + }) + if err != nil { + return "", err + } + + str, err := c.apiReq(ctx, + fmt.Sprintf("/client/v4/zones/%s/dns_records/%s", zoneId, recordId), + "PATCH", bytes.NewReader(body)) + if err != nil { + return "", err + } + log.Printf("debug: [cloudflareV4(%s)] Response: %s", zoneName, string(body)) + var resp apiUpdateDnsRecordResponse + err = json.Unmarshal(str, &resp) + if err != nil { + return "", err + } + if len(resp.Result) != 1 { + return "", errors.New("expected result to be len()=1 but len is " + fmt.Sprintf("%d", len(resp.Result))) + } + return resp.Result[0].Content, nil +} diff --git a/readme.md b/readme.md index 4982561..564ce87 100644 --- a/readme.md +++ b/readme.md @@ -21,6 +21,12 @@ Create a config file: "type": "he.net ddns", "name": "pruebas.bat.ar", "key": "INSERT_KEY" + }, + { + "type": "cloudflare v4 api", + "name": "*.nulo.in", + "zoneName": "nulo.in", + "key": "INSERT_KEY" // https://dash.cloudflare.com/profile/api-tokens } ] }