Refactor architecture: extract reconciler, add interfaces, reduce complexity, improve test coverage
Architecture: - Extract reconcileNetworking into internal/reconcile package with 7 consumer-side interfaces - Add locked modifyState helper to fix state.yaml read-modify-write race condition - Extract CrowdSec and VPN handler groups with interfaces documenting dependency surface - Replace raw map[string]any YAML manipulation with typed AddDHCPStaticLease/RemoveDHCPStaticLease - Extract Cloudflare API functions into cfClient struct, eliminating repeated auth boilerplate Complexity reduction: - haproxy.GenerateWithOpts: 50 → 6 (extracted 8 focused helpers) - reconcile.Reconcile: 42 → 11 (extracted buildRoutes, buildDNSEntries, writeHAProxyConfig) - AutheliaUpdateConfig: 25 → 10 (extracted enableAuthelia/disableAuthelia) Test coverage improvements: - reconcile: 4.5% → 56.8% (stub-based tests for route building, DNS entries, orchestration) - dnsfilter: 10.7% → 45.6% (Manager.Compile, AddList, ToggleList, custom entries) - config: 67.3% → 89.8% (DHCP static lease mutation tests)
This commit is contained in:
@@ -146,7 +146,7 @@ func (rn *Runner) run(ctx context.Context) {
|
||||
}
|
||||
|
||||
// checkAndUpdate fetches fresh params, the current public IP, and syncs all records.
|
||||
// The updateCloudflareRecord function short-circuits when the A record already matches,
|
||||
// The updateRecord method short-circuits when the A record already matches,
|
||||
// so there's no wasted API calls when nothing has changed.
|
||||
func (rn *Runner) checkAndUpdate() {
|
||||
rn.mu.Lock()
|
||||
@@ -170,10 +170,12 @@ func (rn *Runner) checkAndUpdate() {
|
||||
|
||||
slog.Debug("DDNS: syncing records", "component", "ddns", "ip", ip, "records", p.Records)
|
||||
|
||||
cf := &cfClient{apiToken: p.APIToken}
|
||||
|
||||
var lastErr string
|
||||
records := make([]RecordStatus, 0, len(p.Records))
|
||||
for _, record := range p.Records {
|
||||
if err := updateCloudflareRecord(p.APIToken, record, ip); err != nil {
|
||||
if err := cf.updateRecord(record, ip); err != nil {
|
||||
lastErr = fmt.Sprintf("updating %s: %v", record, err)
|
||||
slog.Error("DDNS: failed to update record", "component", "ddns", "record", record, "error", err)
|
||||
records = append(records, RecordStatus{Name: record, OK: false, Error: err.Error()})
|
||||
@@ -238,6 +240,13 @@ func ipFromURL(url string) (string, error) {
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
// --- Cloudflare API client ---
|
||||
|
||||
// cfClient wraps the Cloudflare API token and provides DNS record operations.
|
||||
type cfClient struct {
|
||||
apiToken string
|
||||
}
|
||||
|
||||
// cloudflareRecord is used to find the record ID for a given hostname
|
||||
type cloudflareRecord struct {
|
||||
ID string `json:"id"`
|
||||
@@ -246,114 +255,72 @@ type cloudflareRecord struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// updateCloudflareRecord updates a single A record via the Cloudflare API.
|
||||
// The record name must be a fully qualified domain name (e.g. "cloud.payne.io").
|
||||
// If a CNAME already exists at that name (e.g. from a router DDNS service), it is
|
||||
// deleted first so the A record can be created without conflict.
|
||||
func updateCloudflareRecord(apiToken, recordName, ip string) error {
|
||||
// Extract zone name: last two parts of the FQDN (e.g. "payne.io" from "cloud.payne.io")
|
||||
// do executes an authenticated Cloudflare API request and checks the status.
|
||||
func (c *cfClient) do(method, url string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// updateRecord updates a single A record via the Cloudflare API.
|
||||
// If a CNAME already exists at that name, it is deleted first.
|
||||
func (c *cfClient) updateRecord(recordName, ip string) error {
|
||||
parts := strings.Split(recordName, ".")
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("invalid record name: %s", recordName)
|
||||
}
|
||||
zoneName := strings.Join(parts[len(parts)-2:], ".")
|
||||
|
||||
// Get zone ID
|
||||
zoneID, err := getCloudflareZoneID(apiToken, zoneName)
|
||||
zoneID, err := c.getZoneID(zoneName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting zone ID for %s: %w", zoneName, err)
|
||||
}
|
||||
|
||||
// Check for existing A record — patch it if found
|
||||
recordID, currentIP, err := getCloudflareRecordID(apiToken, zoneID, recordName, "A")
|
||||
recordID, currentIP, err := c.getRecordID(zoneID, recordName, "A")
|
||||
if err == nil {
|
||||
if currentIP == ip {
|
||||
return nil // already up to date
|
||||
}
|
||||
return patchCloudflareRecord(apiToken, zoneID, recordID, recordName, ip)
|
||||
return c.patchRecord(zoneID, recordID, recordName, ip)
|
||||
}
|
||||
|
||||
// No A record. Remove any CNAME that would conflict with A record creation
|
||||
// (common when migrating from a router-based DDNS service like GL.iNet).
|
||||
if cnameID, _, cnameErr := getCloudflareRecordID(apiToken, zoneID, recordName, "CNAME"); cnameErr == nil {
|
||||
// No A record. Remove any CNAME that would conflict.
|
||||
if cnameID, _, cnameErr := c.getRecordID(zoneID, recordName, "CNAME"); cnameErr == nil {
|
||||
slog.Info("DDNS: removing CNAME before creating A record", "component", "ddns", "record", recordName)
|
||||
if err := deleteCloudflareRecord(apiToken, zoneID, cnameID); err != nil {
|
||||
if err := c.deleteRecord(zoneID, cnameID); err != nil {
|
||||
return fmt.Errorf("removing CNAME for %s: %w", recordName, err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("DDNS: creating A record", "component", "ddns", "record", recordName)
|
||||
return createCloudflareRecord(apiToken, zoneID, recordName, ip)
|
||||
return c.createRecord(zoneID, recordName, ip)
|
||||
}
|
||||
|
||||
// createCloudflareRecord creates a new A record via the Cloudflare API
|
||||
func createCloudflareRecord(apiToken, zoneID, recordName, ip string) error {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"type": "A",
|
||||
"name": recordName,
|
||||
"content": ip,
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPost,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID),
|
||||
bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating record: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// patchCloudflareRecord updates an existing A record via the Cloudflare API
|
||||
func patchCloudflareRecord(apiToken, zoneID, recordID, recordName, ip string) error {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"type": "A",
|
||||
"name": recordName,
|
||||
"content": ip,
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPatch,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
|
||||
bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating record: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCloudflareZoneID(apiToken, zoneName string) (string, error) {
|
||||
req, _ := http.NewRequest(http.MethodGet,
|
||||
"https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
func (c *cfClient) getZoneID(zoneName string) (string, error) {
|
||||
resp, err := c.do(http.MethodGet, "https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
@@ -368,22 +335,14 @@ func getCloudflareZoneID(apiToken, zoneName string) (string, error) {
|
||||
return result.Result[0].ID, nil
|
||||
}
|
||||
|
||||
func getCloudflareRecordID(apiToken, zoneID, recordName, recordType string) (id, currentContent string, err error) {
|
||||
req, _ := http.NewRequest(http.MethodGet,
|
||||
func (c *cfClient) getRecordID(zoneID, recordName, recordType string) (id, currentContent string, err error) {
|
||||
resp, err := c.do(http.MethodGet,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, recordName), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", "", fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Result []cloudflareRecord `json:"result"`
|
||||
}
|
||||
@@ -396,21 +355,36 @@ func getCloudflareRecordID(apiToken, zoneID, recordName, recordType string) (id,
|
||||
return result.Result[0].ID, result.Result[0].Content, nil
|
||||
}
|
||||
|
||||
func deleteCloudflareRecord(apiToken, zoneID, recordID string) error {
|
||||
req, _ := http.NewRequest(http.MethodDelete,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
|
||||
nil)
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
func (c *cfClient) createRecord(zoneID, recordName, ip string) error {
|
||||
body, _ := json.Marshal(map[string]string{"type": "A", "name": recordName, "content": ip})
|
||||
resp, err := c.do(http.MethodPost,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID),
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating record: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
func (c *cfClient) patchRecord(zoneID, recordID, recordName, ip string) error {
|
||||
body, _ := json.Marshal(map[string]string{"type": "A", "name": recordName, "content": ip})
|
||||
resp, err := c.do(http.MethodPatch,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating record: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cfClient) deleteRecord(zoneID, recordID string) error {
|
||||
resp, err := c.do(http.MethodDelete,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting record: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user