Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Unifi Network Server running remotely #65

Merged
merged 39 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e5f7364
task(fix): modify auth url
aki-luxor Oct 3, 2024
4f0254e
task(fix): fix workflow
aki-luxor Oct 3, 2024
9c2fc25
Merge pull request #1 from aki263/task/fix-auth-url
aki263 Oct 3, 2024
e560d56
Update release.yaml
aki263 Oct 3, 2024
2db9826
Update release.yaml
aki263 Oct 3, 2024
576d1c1
task(fix): add logging
aki-luxor Oct 3, 2024
767e7af
task(add): add debug msg
aki263 Oct 3, 2024
791ca2b
task(fix): add more log
aki263 Oct 3, 2024
1050188
task(fix): fix url
aki263 Oct 3, 2024
8f04b2e
task(fix): add more debug
aki263 Oct 3, 2024
1869c0d
task(fix): add more debug
aki263 Oct 3, 2024
1cc866c
task(fix): remove double
aki263 Oct 3, 2024
e8b5bc5
before change
aki-luxor Oct 16, 2024
e8d4eb2
added new code
aki-luxor Oct 16, 2024
003b4bc
Merge remote-tracking branch 'origin/main'
aki-luxor Oct 16, 2024
f645bb8
check
aki-luxor Oct 16, 2024
8ea8ed5
fix port
aki-luxor Oct 16, 2024
20bb902
update readme
aki-luxor Oct 16, 2024
36985a2
task(fix): restore original workflow
aki263 Oct 16, 2024
6f60fd1
task(fix): address comments.
aki263 Oct 16, 2024
a159781
remove extra files
aki-luxor Oct 16, 2024
3324dcb
remove extra files
aki-luxor Oct 16, 2024
45fb92d
chore: test if controller type is supported when parsing config for u…
kashalls Oct 16, 2024
77712c0
chore: clean up readme and add tables for configuration options
kashalls Oct 16, 2024
a57dd7b
task(fix): change env variable to UNIFI_CONTROLLER_TYPE
aki263 Oct 16, 2024
f8b795e
chore: match vars to the name of the option types
kashalls Oct 16, 2024
681d990
chore: fix new line on release.yaml
kashalls Oct 16, 2024
b220737
chore: remove extra debug logs
kashalls Oct 16, 2024
9529a12
chore: do not dump cookies
kashalls Oct 16, 2024
af738bc
chore: placing logs a little better
kashalls Oct 16, 2024
9681bd5
fix: dry
kashalls Oct 16, 2024
f6389c2
chore: move external flag to bool
kashalls Oct 16, 2024
35be807
chore: way too much logging
kashalls Oct 16, 2024
127c9b0
chore: remove too much debug
kashalls Oct 16, 2024
9aa75e8
chore: remove logs for bubble up errors
kashalls Oct 16, 2024
2aaad6c
fix: formatting
kashalls Oct 16, 2024
09b0910
chore: update copypasta example + rename vars for understandability
kashalls Oct 16, 2024
85227d8
chore: remove excessive logging
kashalls Oct 16, 2024
f225892
chore: don't log if we have cookies every request
kashalls Oct 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -47,7 +47,7 @@
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_USER
valueFrom:
secretKeyRef:
Expand All @@ -58,8 +58,10 @@
secretKeyRef:
name: external-dns-unifi-secret
key: password
- name: LOG_LEVEL
value: debug
# - name: LOG_LEVEL
# value: debug

# Apply additional configurations here as needed.
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 running on official 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
145 changes: 120 additions & 25 deletions internal/unifi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,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"
unifiLoginPathGateway = "%s/api/auth/login"
unifiLoginPathStandalone = "%s/api/login"
unifiRecordPathGateway = "%s/proxy/network/v2/api/site/%s/static-dns/%s"
unifiRecordPathStandalone = "%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 +53,15 @@ func newUnifiClient(config *Config) (*httpClient, error) {
},
Jar: jar,
},
ClientURLs: &ClientURLs{
Login: unifiLoginPathGateway,
Records: unifiRecordPathGateway,
},
}

if config.ExternalController {
client.ClientURLs.Login = unifiLoginPathStandalone
client.ClientURLs.Records = unifiRecordPathStandalone
}

if err := client.login(); err != nil {
Expand All @@ -68,10 +85,11 @@ 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 {
log.Error("Login request failed", zap.Error(err))
return err
}

Expand All @@ -80,56 +98,109 @@ func (c *httpClient) login() error {
// Check if the login was successful
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
log.Error("login failed", zap.String("status", resp.Status), zap.String("response", string(respBody)))
log.Error("Login failed",
zap.String("status", resp.Status),
zap.String("response", string(respBody)))
return fmt.Errorf("login failed: %s", resp.Status)
}

// Retrieve CSRF token from the response headers
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))
log.Debug("Making request", zap.String("method", method), zap.String("path", path))

// Convert body to bytes for logging and reuse
var bodyBytes []byte
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Little worried about reading the body here, I tried doing it last time and it messed up exdns

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not very comfortable with GO so if you think this can cause trouble, please remove it.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not very comfortable with GO so if you think this can cause trouble, please remove it.

It's fine, we will test and verify it functions.

if body != nil {
bodyBytes, _ = io.ReadAll(body)
body = bytes.NewReader(bodyBytes)
log.Debug("Request body", zap.String("body", string(bodyBytes)))
}

req, err := http.NewRequest(method, path, body)
if err != nil {
log.Error("Failed to create request", zap.Error(err))
return nil, err
}
// Set the required headers
if body != nil {
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(bodyBytes)))
}
// Dynamically set the Host header
parsedURL, err := url.Parse(path)
if err != nil {
log.Error("Failed to parse URL", zap.Error(err))
return nil, err
}
req.Host = parsedURL.Host

log.Debug("Request host", zap.String("host", req.Host))

c.setHeaders(req)

// Log all request headers
for name, values := range req.Header {
for _, value := range values {
log.Debug("Request header", zap.String("name", name), zap.String("value", value))
}
}

resp, err := c.Client.Do(req)
if err != nil {
log.Error("Request failed", zap.Error(err))
return nil, err
}

// Log all response headers
for name, values := range resp.Header {
for _, value := range values {
log.Debug("Response header", zap.String("name", name), zap.String("value", value))
}
}

// Log response body
respBody, _ := io.ReadAll(resp.Body)
log.Debug("Response body", zap.String("body", string(respBody)))
resp.Body = io.NopCloser(bytes.NewBuffer(respBody)) // Restore the response body for further use

if csrf := resp.Header.Get("X-CSRF-Token"); csrf != "" {
c.csrf = csrf
log.Debug("Updated CSRF token", zap.String("token", c.csrf))
}

log.Debug(fmt.Sprintf("response code from %s request to %s: %d", method, path, resp.StatusCode))
log.Debug("Response received",
zap.String("method", method),
zap.String("path", path),
zap.Int("statusCode", 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
}
}

if resp.StatusCode != http.StatusOK {
log.Error("Request was not successful",
zap.String("method", method),
zap.String("path", path),
zap.Int("statusCode", resp.StatusCode))
return nil, fmt.Errorf("%s request to %s was not successful: %d", method, path, resp.StatusCode)
}

Expand All @@ -138,56 +209,72 @@ func (c *httpClient) doRequest(method, path string, body io.Reader) (*http.Respo

// GetEndpoints retrieves the list of DNS records from the UniFi controller.
func (c *httpClient) GetEndpoints() ([]DNSRecord, error) {
log.Debug("Getting endpoints")

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 {
log.Error("Failed to get endpoints", zap.Error(err))
return nil, err
}
defer resp.Body.Close()

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)))
for _, record := range records {
log.Debug("Record", zap.Any("record", record))
}

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{
log.Debug("Creating endpoint", zap.String("key", endpoint.DNSName))

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 {
log.Error("Failed to marshal record", zap.Error(err))
return nil, err
}
log.Debug("Creating record", zap.String("record", string(jsonBody)))

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 {
log.Error("Failed to create endpoint", zap.Error(err))
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 {
log.Error("Failed to decode response", zap.Error(err))
return nil, err
}

log.Debug(fmt.Sprintf("created record: %+v", record))
log.Debug("Created record", zap.Any("record", createdRecord))

return &record, nil
return &createdRecord, nil
}

// DeleteEndpoint deletes a DNS record from the UniFi controller.
Expand All @@ -197,11 +284,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,18 +300,20 @@ 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
}

for _, r := range records {
if r.Key == key && r.RecordType == recordType {
log.Debug("Found matching record", zap.Any("record", r))
return &r, nil
}
}

return nil, err
return nil, fmt.Errorf("record not found: %s", key)
}

// setHeaders sets the headers for the HTTP request.
Expand All @@ -234,8 +326,11 @@ func (c *httpClient) setHeaders(req *http.Request) {
// 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))))
cookies := c.Client.Jar.Cookies(parsedURL)
log.Debug("Request cookies",
zap.String("url", req.URL.String()),
zap.Int("cookieCount", len(cookies)))
} else {
log.Debug(fmt.Sprintf("Requesting %s", req.URL))
log.Debug("No cookie jar available", zap.String("url", req.URL.String()))
}
}
Loading