diff --git a/.gitignore b/.gitignore index 83c5afc..6abe6c2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .idea ### .private/ -external-dns-unifi-webhook \ No newline at end of file +external-dns-unifi-webhook +.DS_Store diff --git a/README.md b/README.md index 0d7f660..1575d80 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ ```yaml fullnameOverride: external-dns-unifi - logLevel: debug + logLevel: &logLevel debug provider: name: webhook webhook: @@ -47,7 +47,9 @@ tag: main # replace with a versioned release tag env: - name: UNIFI_HOST - value: https://192.168.1.1 # replace with the address to your UniFi router + value: https://192.168.1.1 # replace with the address to your UniFi router/controller + - name: UNIFI_EXTERNAL_CONTROLLER + value: false - name: UNIFI_USER valueFrom: secretKeyRef: @@ -59,7 +61,7 @@ name: external-dns-unifi-secret key: password - name: LOG_LEVEL - value: debug + value: *logLevel livenessProbe: httpGet: path: /healthz @@ -87,6 +89,33 @@ helm install external-dns-unifi external-dns/external-dns -f external-dns-unifi-values.yaml --version 1.14.3 -n external-dns ``` +## Configuration + +### Unifi Controller Configuration + +| Environment Variable | Description | Default Value | +|-----------------------------|---------------------------------------------------------------------|---------------| +| `UNIFI_USER` | Username for the Unifi Controller (must be provided). | N/A | +| `UNIFI_SKIP_TLS_VERIFY` | Whether to skip TLS verification (true or false). | `true` | +| `UNIFI_SITE` | Unifi Site Identifier (used in multi-site installations) | `default` | +| `UNIFI_PASS` | Password for the Unifi Controller (must be provided). | N/A | +| `UNIFI_HOST` | Host of the Unifi Controller (must be provided). | N/A | +| `UNIFI_EXTERNAL_CONTROLLER` | Whether your controller is supported by official Ubiquiti hardware. | `false` | +| `LOG_LEVEL` | Change the verbosity of logs (used when making a bug report) | `info` | + +### Server Configuration + +| Environment Variable | Description | Default Value | +|----------------------------------|------------------------------------------------------------------|---------------| +| `SERVER_HOST` | The host address where the server listens. | `localhost` | +| `SERVER_PORT` | The port where the server listens. | `8888` | +| `SERVER_READ_TIMEOUT` | Duration the server waits before timing out on read operations. | N/A | +| `SERVER_WRITE_TIMEOUT` | Duration the server waits before timing out on write operations. | N/A | +| `DOMAIN_FILTER` | List of domains to include in the filter. | Empty | +| `EXCLUDE_DOMAIN_FILTER` | List of domains to exclude from filtering. | Empty | +| `REGEXP_DOMAIN_FILTER` | Regular expression for filtering domains. | Empty | +| `REGEXP_DOMAIN_FILTER_EXCLUSION` | Regular expression for excluding domains from the filter. | Empty | + ## ⭐ Stargazers
diff --git a/internal/unifi/client.go b/internal/unifi/client.go index 6f1f934..dfb943d 100644 --- a/internal/unifi/client.go +++ b/internal/unifi/client.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "net/http/cookiejar" - "net/url" "github.com/kashalls/external-dns-provider-unifi/cmd/webhook/init/log" "golang.org/x/net/publicsuffix" @@ -17,16 +16,24 @@ import ( "go.uber.org/zap" ) +type ClientURLs struct { + Login string + Records string +} + // httpClient is the DNS provider client. type httpClient struct { *Config *http.Client - csrf string + csrf string + ClientURLs *ClientURLs } const ( - unifiLoginPath = "%s/api/auth/login" - unifiRecordPath = "%s/proxy/network/v2/api/site/%s/static-dns/%s" + unifiLoginPath = "%s/api/auth/login" + unifiLoginPathExternal = "%s/api/login" + unifiRecordPath = "%s/proxy/network/v2/api/site/%s/static-dns/%s" + unifiRecordPathExternal = "%s/v2/api/site/%s/static-dns/%s" ) // newUnifiClient creates a new DNS provider client and logs in to store cookies. @@ -45,6 +52,15 @@ func newUnifiClient(config *Config) (*httpClient, error) { }, Jar: jar, }, + ClientURLs: &ClientURLs{ + Login: unifiLoginPath, + Records: unifiRecordPath, + }, + } + + if config.ExternalController { + client.ClientURLs.Login = unifiLoginPathExternal + client.ClientURLs.Records = unifiRecordPathExternal } if err := client.login(); err != nil { @@ -68,7 +84,7 @@ func (c *httpClient) login() error { // Perform the login request resp, err := c.doRequest( http.MethodPost, - FormatUrl(unifiLoginPath, c.Config.Host), + FormatUrl(c.ClientURLs.Login, c.Config.Host), bytes.NewBuffer(jsonBody), ) if err != nil { @@ -88,14 +104,10 @@ func (c *httpClient) login() error { if csrf := resp.Header.Get("x-csrf-token"); csrf != "" { c.csrf = resp.Header.Get("x-csrf-token") } - return nil } -// doRequest makes an HTTP request to the UniFi controller. func (c *httpClient) doRequest(method, path string, body io.Reader) (*http.Response, error) { - log.Debug(fmt.Sprintf("making %s request to %s", method, path)) - req, err := http.NewRequest(method, path, body) if err != nil { return nil, err @@ -112,19 +124,22 @@ func (c *httpClient) doRequest(method, path string, body io.Reader) (*http.Respo c.csrf = csrf } - log.Debug(fmt.Sprintf("response code from %s request to %s: %d", method, path, resp.StatusCode)) - // If the status code is 401, re-login and retry the request if resp.StatusCode == http.StatusUnauthorized { - log.Debug("Received 401 Unauthorized, re-login required") + log.Debug("received 401 unauthorized, attempting to re-login") if err := c.login(); err != nil { + log.Error("re-login failed", zap.Error(err)) return nil, err } // Update the headers with new CSRF token c.setHeaders(req) + // Retry the request + log.Debug("retrying request after re-login") + resp, err = c.Client.Do(req) if err != nil { + log.Error("Retry request failed", zap.Error(err)) return nil, err } } @@ -140,7 +155,7 @@ func (c *httpClient) doRequest(method, path string, body io.Reader) (*http.Respo func (c *httpClient) GetEndpoints() ([]DNSRecord, error) { resp, err := c.doRequest( http.MethodGet, - FormatUrl(unifiRecordPath, c.Config.Host, c.Config.Site), + FormatUrl(c.ClientURLs.Records, c.Config.Host, c.Config.Site), nil, ) if err != nil { @@ -150,29 +165,33 @@ func (c *httpClient) GetEndpoints() ([]DNSRecord, error) { var records []DNSRecord if err = json.NewDecoder(resp.Body).Decode(&records); err != nil { + log.Error("Failed to decode response", zap.Error(err)) return nil, err } - log.Debug(fmt.Sprintf("retrieved records: %+v", records)) + log.Debug("retrieved records", zap.Int("count", len(records))) return records, nil } // CreateEndpoint creates a new DNS record in the UniFi controller. func (c *httpClient) CreateEndpoint(endpoint *endpoint.Endpoint) (*DNSRecord, error) { - jsonBody, err := json.Marshal(DNSRecord{ + record := DNSRecord{ Enabled: true, Key: endpoint.DNSName, RecordType: endpoint.RecordType, TTL: endpoint.RecordTTL, Value: endpoint.Targets[0], - }) + } + + jsonBody, err := json.Marshal(record) if err != nil { return nil, err } + resp, err := c.doRequest( http.MethodPost, - FormatUrl(unifiRecordPath, c.Config.Host, c.Config.Site), + FormatUrl(c.ClientURLs.Records, c.Config.Host, c.Config.Site), bytes.NewReader(jsonBody), ) if err != nil { @@ -180,14 +199,12 @@ func (c *httpClient) CreateEndpoint(endpoint *endpoint.Endpoint) (*DNSRecord, er } defer resp.Body.Close() - var record DNSRecord - if err = json.NewDecoder(resp.Body).Decode(&record); err != nil { + var createdRecord DNSRecord + if err = json.NewDecoder(resp.Body).Decode(&createdRecord); err != nil { return nil, err } - log.Debug(fmt.Sprintf("created record: %+v", record)) - - return &record, nil + return &createdRecord, nil } // DeleteEndpoint deletes a DNS record from the UniFi controller. @@ -197,11 +214,14 @@ func (c *httpClient) DeleteEndpoint(endpoint *endpoint.Endpoint) error { return err } - if _, err = c.doRequest( + deleteURL := FormatUrl(c.ClientURLs.Records, c.Config.Host, c.Config.Site, lookup.ID) + + _, err = c.doRequest( http.MethodDelete, - FormatUrl(unifiRecordPath, c.Config.Host, c.Config.Site, lookup.ID), + deleteURL, nil, - ); err != nil { + ) + if err != nil { return err } @@ -210,6 +230,7 @@ func (c *httpClient) DeleteEndpoint(endpoint *endpoint.Endpoint) error { // lookupIdentifier finds the ID of a DNS record in the UniFi controller. func (c *httpClient) lookupIdentifier(key, recordType string) (*DNSRecord, error) { + log.Debug("Looking up identifier", zap.String("key", key), zap.String("recordType", recordType)) records, err := c.GetEndpoints() if err != nil { return nil, err @@ -221,7 +242,7 @@ func (c *httpClient) lookupIdentifier(key, recordType string) (*DNSRecord, error } } - return nil, err + return nil, fmt.Errorf("record not found: %s", key) } // setHeaders sets the headers for the HTTP request. @@ -230,12 +251,4 @@ func (c *httpClient) setHeaders(req *http.Request) { req.Header.Set("X-CSRF-Token", c.csrf) req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json; charset=utf-8") - - // Log the request URL and cookies - if c.Client.Jar != nil { - parsedURL, _ := url.Parse(req.URL.String()) - log.Debug(fmt.Sprintf("Requesting %s cookies: %d", req.URL, len(c.Client.Jar.Cookies(parsedURL)))) - } else { - log.Debug(fmt.Sprintf("Requesting %s", req.URL)) - } } diff --git a/internal/unifi/provider.go b/internal/unifi/provider.go index 33b1b65..863918e 100644 --- a/internal/unifi/provider.go +++ b/internal/unifi/provider.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/kashalls/external-dns-provider-unifi/cmd/webhook/init/log" + "go.uber.org/zap" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" @@ -62,13 +64,19 @@ func (p *Provider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { // ApplyChanges applies a given set of changes in the DNS provider. func (p *Provider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { for _, endpoint := range append(changes.UpdateOld, changes.Delete...) { + log.Debug("deleting endpoint", zap.String("name", endpoint.DNSName), zap.String("type", endpoint.RecordType)) + if err := p.client.DeleteEndpoint(endpoint); err != nil { + log.Error("failed to delete endpoint", zap.String("name", endpoint.DNSName), zap.String("type", endpoint.RecordType), zap.Error(err)) return err } } for _, endpoint := range append(changes.Create, changes.UpdateNew...) { + log.Debug("creating endpoint", zap.String("name", endpoint.DNSName), zap.String("type", endpoint.RecordType)) + if _, err := p.client.CreateEndpoint(endpoint); err != nil { + log.Error("failed to create endpoint", zap.String("name", endpoint.DNSName), zap.String("type", endpoint.RecordType), zap.Error(err)) return err } } diff --git a/internal/unifi/types.go b/internal/unifi/types.go index 391bc0b..b4c536a 100644 --- a/internal/unifi/types.go +++ b/internal/unifi/types.go @@ -4,11 +4,12 @@ import "sigs.k8s.io/external-dns/endpoint" // Config represents the configuration for the UniFi API. type Config struct { - Host string `env:"UNIFI_HOST,notEmpty"` - User string `env:"UNIFI_USER,notEmpty"` - Password string `env:"UNIFI_PASS,notEmpty"` - Site string `env:"UNIFI_SITE" envDefault:"default"` - SkipTLSVerify bool `env:"UNIFI_SKIP_TLS_VERIFY" envDefault:"true"` + Host string `env:"UNIFI_HOST,notEmpty"` + User string `env:"UNIFI_USER,notEmpty"` + Password string `env:"UNIFI_PASS,notEmpty"` + Site string `env:"UNIFI_SITE" envDefault:"default"` + ExternalController bool `env:"UNIFI_EXTERNAL_CONTROLLER" envDefault:"false"` + SkipTLSVerify bool `env:"UNIFI_SKIP_TLS_VERIFY" envDefault:"true"` } // Login represents a login request to the UniFi API. diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go index 36461d3..d61a04c 100644 --- a/pkg/webhook/webhook.go +++ b/pkg/webhook/webhook.go @@ -97,7 +97,6 @@ func (p *Webhook) Records(w http.ResponseWriter, r *http.Request) { return } - requestLog(r).Debug("requesting records") ctx := r.Context() records, err := p.provider.Records(ctx) if err != nil { @@ -106,7 +105,6 @@ func (p *Webhook) Records(w http.ResponseWriter, r *http.Request) { return } - requestLog(r).With(zap.Int("count", len(records))).Debug("returning records") w.Header().Set(contentTypeHeader, string(mediaTypeVersion1)) w.Header().Set(varyHeader, contentTypeHeader) err = json.NewEncoder(w).Encode(records) @@ -145,6 +143,7 @@ func (p *Webhook) ApplyChanges(w http.ResponseWriter, r *http.Request) { zap.Int("delete", len(changes.Delete)), ).Debug("requesting apply changes") if err := p.provider.ApplyChanges(ctx, &changes); err != nil { + requestLog(r).Error("error when applying changes", zap.Error(err)) w.Header().Set(contentTypeHeader, contentTypePlaintext) w.WriteHeader(http.StatusInternalServerError) return