Skip to content

Commit

Permalink
chore: introduce support for standalone unifi network servers (#65)
Browse files Browse the repository at this point in the history
* task(fix): modify auth url

* task(fix): fix workflow

* Update release.yaml

Signed-off-by: aki263 <aakash@nappinggeek.com>

* Update release.yaml

Signed-off-by: aki263 <aakash@nappinggeek.com>

* task(fix): add logging

* task(add): add debug msg

* task(fix): add more log

* task(fix): fix url

* task(fix): add more debug

* task(fix): add more debug

* task(fix): remove double

* before change

* added new code

* check

* fix port

* update readme

* task(fix): restore original workflow

* task(fix): address comments.

* remove extra files

* remove extra files

* chore: test if controller type is supported when parsing config for unifi

* chore: clean up readme and add tables for configuration options

Very helpful for people who want to get started using it instead of DM-ing me.

* task(fix): change env variable to UNIFI_CONTROLLER_TYPE

* chore: match vars to the name of the option types

* chore: fix new line on release.yaml

* chore: remove extra debug logs

* chore: do not dump cookies

* chore: placing logs a little better

* fix: dry

* chore: move external flag to bool

* chore: way too much logging

* chore: remove too much debug

* chore: remove logs for bubble up errors

* fix: formatting

* chore: update copypasta example + rename vars for understandability

* chore: remove excessive logging

* chore: don't log if we have cookies every request

---------

Signed-off-by: aki263 <aakash@nappinggeek.com>
Co-authored-by: Aakash <aakash.tewari@luxor.tech>
Co-authored-by: Jordan Jones <jordpjones@gmail.com>
  • Loading branch information
3 people authored Oct 18, 2024
1 parent 5e8a491 commit 86386ba
Show file tree
Hide file tree
Showing 6 changed files with 96 additions and 45 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
.idea
###
.private/
external-dns-unifi-webhook
external-dns-unifi-webhook
.DS_Store
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@

```yaml
fullnameOverride: external-dns-unifi
logLevel: debug
logLevel: &logLevel debug
provider:
name: webhook
webhook:
Expand All @@ -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:
Expand All @@ -59,7 +61,7 @@
name: external-dns-unifi-secret
key: password
- name: LOG_LEVEL
value: debug
value: *logLevel
livenessProbe:
httpGet:
path: /healthz
Expand Down Expand Up @@ -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

<div align="center">
Expand Down
81 changes: 47 additions & 34 deletions internal/unifi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
}
}
Expand All @@ -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 {
Expand All @@ -150,44 +165,46 @@ 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 {
return nil, err
}
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.
Expand All @@ -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
}

Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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))
}
}
8 changes: 8 additions & 0 deletions internal/unifi/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
}
Expand Down
11 changes: 6 additions & 5 deletions internal/unifi/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 1 addition & 2 deletions pkg/webhook/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 86386ba

Please sign in to comment.