From e3a48e251f7db52df07811dad6417d63a6ade58b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:20:30 +0200 Subject: [PATCH 01/13] fix(route53): synchronize hosted zone cache access The zone cache map was read and written from concurrent request handlers without synchronization. Guard it with a sync.RWMutex and run the test suite with the race detector as part of the CI task. Co-authored-by: Claude --- internal/provider/provider_aws_route53.go | 9 ++++++- .../provider/provider_aws_route53_test.go | 26 +++++++++++++++++++ mise.toml | 9 +++++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/internal/provider/provider_aws_route53.go b/internal/provider/provider_aws_route53.go index cdaeaca..b6afaae 100644 --- a/internal/provider/provider_aws_route53.go +++ b/internal/provider/provider_aws_route53.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "strings" + "sync" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -25,6 +26,7 @@ type Route53API interface { // AwsRoute53Client represents an AWS Route53 client type AwsRoute53Client struct { client Route53API + zoneMu sync.RWMutex zoneCache map[string]string // domain -> hostedZoneId cache } @@ -182,7 +184,10 @@ func (c *AwsRoute53Client) Close(ctx context.Context) error { // getHostedZoneID retrieves the hosted zone ID for a domain func (c *AwsRoute53Client) getHostedZoneID(ctx context.Context, domain string) (string, error) { // Check cache first - if zoneID, exists := c.zoneCache[domain]; exists { + c.zoneMu.RLock() + zoneID, exists := c.zoneCache[domain] + c.zoneMu.RUnlock() + if exists { logger.Debug("AWS Route53: Using cached zone ID for domain %s", domain) return zoneID, nil } @@ -209,7 +214,9 @@ func (c *AwsRoute53Client) getHostedZoneID(ctx context.Context, domain string) ( zoneID := aws.ToString(zone.Id) // Strip /hostedzone/ prefix if present zoneID = strings.TrimPrefix(zoneID, "/hostedzone/") + c.zoneMu.Lock() c.zoneCache[domain] = zoneID + c.zoneMu.Unlock() return zoneID, nil } } diff --git a/internal/provider/provider_aws_route53_test.go b/internal/provider/provider_aws_route53_test.go index f9098c8..c05daf3 100644 --- a/internal/provider/provider_aws_route53_test.go +++ b/internal/provider/provider_aws_route53_test.go @@ -3,6 +3,7 @@ package provider import ( "context" "fmt" + "sync" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -160,3 +161,28 @@ func TestAwsRoute53Client_UpdateRecord(t *testing.T) { t.Fatalf("expected 1 change call, got %d", changeCalls) } } + +func TestAwsRoute53Client_ConcurrentZoneLookup(t *testing.T) { + mockAPI := &mockRoute53API{ + ListHostedZonesFunc: func(ctx context.Context, params *route53.ListHostedZonesInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { + return &route53.ListHostedZonesOutput{HostedZones: []types.HostedZone{{ + Id: aws.String("/hostedzone/ZONE123"), + Name: aws.String("example.com."), + }}}, nil + }, + } + client := NewAwsRoute53ClientWithMock(mockAPI) + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + zoneID, err := client.getHostedZoneID(context.Background(), "example.com") + if err != nil || zoneID != "ZONE123" { + t.Errorf("unexpected result: %q, %v", zoneID, err) + } + }() + } + wg.Wait() +} diff --git a/mise.toml b/mise.toml index ed1cb7a..3deff18 100644 --- a/mise.toml +++ b/mise.toml @@ -70,6 +70,11 @@ ko build . --bare --sbom=none --platform=linux/amd64,linux/arm64,linux/arm/v7 -- description = "Run all tests" run = "go test ./..." +[tasks."test:race"] +description = "Run all tests with the race detector (requires cgo)" +run = "go test -race ./..." +env = { CGO_ENABLED = "1" } + [tasks."test:verbose"] description = "Run all tests with verbose output" run = "go test -v ./..." @@ -110,13 +115,13 @@ run = "rm -rf homeddns homeddns-linux-* cdk-dynamic-dns dyndns *.tar dist/" # CI/CD tasks [tasks.ci] description = "Run full CI pipeline (verify, test, build, validate)" -depends = ["tidy", "test", "build", "build:ko", "release:validate"] +depends = ["tidy", "test", "test:race", "build", "build:ko", "release:validate"] run = """ echo "✅ CI pipeline completed successfully" echo "" echo "Summary:" echo " - Dependencies verified and tidied" -echo " - All tests passed" +echo " - All tests passed (including race detector)" echo " - Binary built successfully" echo " - Ko image built locally" echo " - GoReleaser config validated" From 4b8952fa401f457ec8f6156e81bacb37bec56269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:21:25 +0200 Subject: [PATCH 02/13] feat: respond with nochg when the record is already up to date Provider.UpdateRecord now reports whether a change was sent, so the handler can answer "nochg " as documented instead of always "good". GetRecord returns provider.ErrRecordNotFound for missing records. Co-authored-by: Claude --- cmd/update.go | 7 +- internal/handler/dyndns.go | 18 ++-- internal/handler/dyndns_test.go | 82 +++++++++++++++++++ internal/provider/provider.go | 9 +- internal/provider/provider_aws_route53.go | 14 ++-- .../provider/provider_aws_route53_test.go | 39 ++++++++- internal/provider/provider_netcup_ccp.go | 12 +-- internal/provider/provider_netcup_ccp_test.go | 12 +-- 8 files changed, 167 insertions(+), 26 deletions(-) create mode 100644 internal/handler/dyndns_test.go diff --git a/cmd/update.go b/cmd/update.go index 1099d95..e128b00 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -39,10 +39,15 @@ func RunUpdate(hostname, recordType string, config *Config) error { TTL: config.DefaultTTL, } - if err := p.UpdateRecord(context.Background(), config.Domain, record); err != nil { + changed, err := p.UpdateRecord(context.Background(), config.Domain, record) + if err != nil { return fmt.Errorf("failed to update DNS record: %w", err) } + if !changed { + logger.Info("%s record for %s already points to %s, no update needed", recordType, hostname, publicIP) + return nil + } logger.Info("Successfully updated %s record for %s to %s", recordType, hostname, publicIP) return nil } diff --git a/internal/handler/dyndns.go b/internal/handler/dyndns.go index f153c5e..d8add7f 100644 --- a/internal/handler/dyndns.go +++ b/internal/handler/dyndns.go @@ -85,12 +85,19 @@ func (h *DynDNSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { logger.Debug("Updating DNS: hostname=%s, domain=%s, subdomain=%s, ip=%s", hostname, domain, subdomain, ipAddress) - if err := h.updateDNS(ctx, domain, subdomain, ipAddress); err != nil { + changed, err := h.updateDNS(ctx, domain, subdomain, ipAddress) + if err != nil { logger.Error("Error updating DNS for %s: %v", hostname, err) h.respond(w, "911", ipAddress, isStandardFormat) return } + if !changed { + logger.Info("%s already points to %s, no update needed", hostname, ipAddress) + h.respond(w, "nochg", ipAddress, isStandardFormat) + return + } + logger.Info("Successfully updated %s to %s", hostname, ipAddress) h.respond(w, "good", ipAddress, isStandardFormat) } @@ -177,7 +184,7 @@ func (h *DynDNSHandler) extractIP(r *http.Request) string { } // updateDNS updates the DNS record via the configured provider -func (h *DynDNSHandler) updateDNS(ctx context.Context, domain, subdomain, ipAddress string) error { +func (h *DynDNSHandler) updateDNS(ctx context.Context, domain, subdomain, ipAddress string) (bool, error) { // Determine record type recordType := "A" if strings.Contains(ipAddress, ":") { @@ -196,11 +203,12 @@ func (h *DynDNSHandler) updateDNS(ctx context.Context, domain, subdomain, ipAddr } // Update the record via provider - if err := h.config.Provider.UpdateRecord(ctx, domain, record); err != nil { - return fmt.Errorf("update DNS record: %w", err) + changed, err := h.config.Provider.UpdateRecord(ctx, domain, record) + if err != nil { + return false, fmt.Errorf("update DNS record: %w", err) } - return nil + return changed, nil } // buildHostname builds a full hostname from subdomain and domain diff --git a/internal/handler/dyndns_test.go b/internal/handler/dyndns_test.go new file mode 100644 index 0000000..3deec5f --- /dev/null +++ b/internal/handler/dyndns_test.go @@ -0,0 +1,82 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/markussiebert/homeddns/internal/provider" +) + +// fakeProvider is an in-memory provider keyed by "name/type". +type fakeProvider struct { + mu sync.Mutex + records map[string]string + updates []provider.DNSRecord +} + +func newFakeProvider() *fakeProvider { + return &fakeProvider{records: make(map[string]string)} +} + +func (f *fakeProvider) Name() string { return "fake" } + +func (f *fakeProvider) GetRecord(ctx context.Context, domain, hostname, recordType string) (*provider.DNSRecord, error) { + f.mu.Lock() + defer f.mu.Unlock() + value, ok := f.records[hostname+"/"+recordType] + if !ok { + return nil, provider.ErrRecordNotFound + } + return &provider.DNSRecord{Name: hostname, Type: recordType, Value: value}, nil +} + +func (f *fakeProvider) UpdateRecord(ctx context.Context, domain string, record *provider.DNSRecord) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + key := record.Name + "/" + record.Type + if f.records[key] == record.Value { + return false, nil + } + f.records[key] = record.Value + f.updates = append(f.updates, *record) + return true, nil +} + +func (f *fakeProvider) Close(ctx context.Context) error { return nil } + +func serve(h http.Handler, target string, configure func(*http.Request)) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, target, nil) + req.RemoteAddr = "1.2.3.4:5555" + if configure != nil { + configure(req) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func body(rec *httptest.ResponseRecorder) string { + return strings.TrimSpace(rec.Body.String()) +} + +func TestServeHTTP_NochgWhenUnchanged(t *testing.T) { + fake := newFakeProvider() + h := NewDynDNSHandler(Config{Provider: fake}) + + rec := serve(h, "/nic/update?hostname=home.example.com&myip=1.2.3.4", nil) + if got := body(rec); got != "good 1.2.3.4" { + t.Fatalf("first update: got %q", got) + } + + rec = serve(h, "/nic/update?hostname=home.example.com&myip=1.2.3.4", nil) + if got := body(rec); got != "nochg 1.2.3.4" { + t.Fatalf("second update: got %q", got) + } + if len(fake.updates) != 1 { + t.Fatalf("expected 1 provider update, got %d", len(fake.updates)) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index cb8986f..f2cbd92 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -2,10 +2,14 @@ package provider import ( "context" + "errors" "fmt" "sort" ) +// ErrRecordNotFound is returned by GetRecord when no matching record exists. +var ErrRecordNotFound = errors.New("record not found") + // DNSRecordSet represents a DNS zone's record set type DNSRecordSet struct { DNSRecords []DNSRecord `json:"dnsrecords"` @@ -23,8 +27,11 @@ type DNSRecord struct { // Provider defines the interface that all DNS providers must implement. type Provider interface { Name() string + // GetRecord returns ErrRecordNotFound if the record does not exist. GetRecord(ctx context.Context, domain, hostname, recordType string) (*DNSRecord, error) - UpdateRecord(ctx context.Context, domain string, record *DNSRecord) error + // UpdateRecord creates or updates a record. changed is false if the + // record already had the requested value and no update was sent. + UpdateRecord(ctx context.Context, domain string, record *DNSRecord) (changed bool, err error) Close(ctx context.Context) error } diff --git a/internal/provider/provider_aws_route53.go b/internal/provider/provider_aws_route53.go index b6afaae..327ae20 100644 --- a/internal/provider/provider_aws_route53.go +++ b/internal/provider/provider_aws_route53.go @@ -98,12 +98,12 @@ func (c *AwsRoute53Client) GetRecord(ctx context.Context, domain, hostname, reco // Check if we found the record if len(result.ResourceRecordSets) == 0 { - return nil, fmt.Errorf("record not found") + return nil, ErrRecordNotFound } recordSet := result.ResourceRecordSets[0] if aws.ToString(recordSet.Name) != fqdn || string(recordSet.Type) != recordType { - return nil, fmt.Errorf("record not found") + return nil, ErrRecordNotFound } // Extract value @@ -121,13 +121,13 @@ func (c *AwsRoute53Client) GetRecord(ctx context.Context, domain, hostname, reco } // UpdateRecord updates or creates a DNS record -func (c *AwsRoute53Client) UpdateRecord(ctx context.Context, domain string, record *DNSRecord) error { +func (c *AwsRoute53Client) UpdateRecord(ctx context.Context, domain string, record *DNSRecord) (bool, error) { logger.Debug("AWS Route53: Updating record for domain=%s, name=%s, type=%s, value=%s", domain, record.Name, record.Type, record.Value) // Get hosted zone ID zoneID, err := c.getHostedZoneID(ctx, domain) if err != nil { - return fmt.Errorf("get hosted zone: %w", err) + return false, fmt.Errorf("get hosted zone: %w", err) } // Ensure hostname ends with a dot for Route53 @@ -138,7 +138,7 @@ func (c *AwsRoute53Client) UpdateRecord(ctx context.Context, domain string, reco if err == nil && existing.Value == record.Value { logger.Debug("AWS Route53: Record already up to date") // Record exists and is already up to date - return nil + return false, nil } // Prepare the resource record @@ -169,11 +169,11 @@ func (c *AwsRoute53Client) UpdateRecord(ctx context.Context, domain string, reco _, err = c.client.ChangeResourceRecordSets(ctx, input) if err != nil { - return fmt.Errorf("change resource record sets: %w", err) + return false, fmt.Errorf("change resource record sets: %w", err) } logger.Info("AWS Route53: Successfully updated record %s to %s", record.Name, record.Value) - return nil + return true, nil } // Close cleans up resources (no-op for Route53) diff --git a/internal/provider/provider_aws_route53_test.go b/internal/provider/provider_aws_route53_test.go index c05daf3..ed965ed 100644 --- a/internal/provider/provider_aws_route53_test.go +++ b/internal/provider/provider_aws_route53_test.go @@ -152,11 +152,14 @@ func TestAwsRoute53Client_UpdateRecord(t *testing.T) { } client := NewAwsRoute53ClientWithMock(mockAPI) - err := client.UpdateRecord(context.Background(), domain, record) + changed, err := client.UpdateRecord(context.Background(), domain, record) if err != nil { t.Fatalf("expected no error, got %v", err) } + if !changed { + t.Fatalf("expected record to be reported as changed") + } if changeCalls != 1 { t.Fatalf("expected 1 change call, got %d", changeCalls) } @@ -186,3 +189,37 @@ func TestAwsRoute53Client_ConcurrentZoneLookup(t *testing.T) { } wg.Wait() } + +func TestAwsRoute53Client_UpdateRecordUnchanged(t *testing.T) { + mockAPI := &mockRoute53API{ + ListHostedZonesFunc: func(ctx context.Context, params *route53.ListHostedZonesInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { + return &route53.ListHostedZonesOutput{HostedZones: []types.HostedZone{{ + Id: aws.String("/hostedzone/ZONE123"), + Name: aws.String("example.com."), + }}}, nil + }, + ListResourceRecordSetsFunc: func(ctx context.Context, params *route53.ListResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { + return &route53.ListResourceRecordSetsOutput{ResourceRecordSets: []types.ResourceRecordSet{{ + Name: aws.String("test.example.com."), + Type: types.RRTypeA, + TTL: aws.Int64(60), + ResourceRecords: []types.ResourceRecord{{Value: aws.String("192.0.2.2")}}, + }}}, nil + }, + ChangeResourceRecordSetsFunc: func(ctx context.Context, params *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { + t.Fatalf("unexpected change call for unchanged record") + return nil, nil + }, + } + + client := NewAwsRoute53ClientWithMock(mockAPI) + changed, err := client.UpdateRecord(context.Background(), "example.com", &DNSRecord{ + Name: "test.example.com", Type: "A", Value: "192.0.2.2", TTL: 60, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if changed { + t.Fatalf("expected record to be reported as unchanged") + } +} diff --git a/internal/provider/provider_netcup_ccp.go b/internal/provider/provider_netcup_ccp.go index fd19e5f..1bfca66 100644 --- a/internal/provider/provider_netcup_ccp.go +++ b/internal/provider/provider_netcup_ccp.go @@ -512,11 +512,11 @@ func (c *NetcupClient) GetRecord(ctx context.Context, domain, hostname, recordTy } } - return nil, fmt.Errorf("record not found") + return nil, ErrRecordNotFound } // UpdateRecord updates or creates a DNS record -func (c *NetcupClient) UpdateRecord(ctx context.Context, domain string, record *DNSRecord) error { +func (c *NetcupClient) UpdateRecord(ctx context.Context, domain string, record *DNSRecord) (bool, error) { logger.Debug("Netcup: Updating record for domain=%s, name=%s, type=%s, value=%s", domain, record.Name, record.Type, record.Value) // Extract subdomain from hostname @@ -525,7 +525,7 @@ func (c *NetcupClient) UpdateRecord(ctx context.Context, domain string, record * // Get all existing records records, err := c.InfoDNSRecords(ctx, domain) if err != nil { - return fmt.Errorf("get DNS records: %w", err) + return false, fmt.Errorf("get DNS records: %w", err) } // Find existing record @@ -543,7 +543,7 @@ func (c *NetcupClient) UpdateRecord(ctx context.Context, domain string, record * if existingRecord != nil && existingRecord.Destination == record.Value { logger.Debug("Netcup: Record already up to date") // Already up to date - return nil + return false, nil } // Prepare record for update @@ -561,11 +561,11 @@ func (c *NetcupClient) UpdateRecord(ctx context.Context, domain string, record * // Update the record if err := c.UpdateDNSRecords(ctx, domain, updatedRecords); err != nil { - return fmt.Errorf("update DNS record: %w", err) + return false, fmt.Errorf("update DNS record: %w", err) } logger.Info("Netcup: Successfully updated record %s to %s", record.Name, record.Value) - return nil + return true, nil } // Close logs out and cleans up resources diff --git a/internal/provider/provider_netcup_ccp_test.go b/internal/provider/provider_netcup_ccp_test.go index 0540b91..f188ff4 100644 --- a/internal/provider/provider_netcup_ccp_test.go +++ b/internal/provider/provider_netcup_ccp_test.go @@ -129,8 +129,7 @@ func TestNetcupProvider_GetRecord(t *testing.T) { // Test case: Record not found _, err = client.GetRecord(context.Background(), "example.com", "notfound.example.com", "A") - assert.Error(t, err) - assert.Equal(t, "record not found", err.Error()) + assert.IsError(t, err, ErrRecordNotFound) } func TestNetcupProvider_UpdateRecord(t *testing.T) { @@ -140,12 +139,13 @@ func TestNetcupProvider_UpdateRecord(t *testing.T) { client := NewNetcupClient("user", "key", "pass").WithEndpoint(mockServer.server.URL) // Test case: Update existing record - err := client.UpdateRecord(context.Background(), "example.com", &DNSRecord{ + changed, err := client.UpdateRecord(context.Background(), "example.com", &DNSRecord{ Name: "www.example.com", Type: "A", Value: "2.2.2.2", }) assert.NoError(t, err) + assert.True(t, changed) // Verify the record was updated record, err := client.GetRecord(context.Background(), "example.com", "www.example.com", "A") @@ -153,12 +153,13 @@ func TestNetcupProvider_UpdateRecord(t *testing.T) { assert.Equal(t, "2.2.2.2", record.Value) // Test case: Create new record - err = client.UpdateRecord(context.Background(), "example.com", &DNSRecord{ + changed, err = client.UpdateRecord(context.Background(), "example.com", &DNSRecord{ Name: "new.example.com", Type: "A", Value: "3.3.3.3", }) assert.NoError(t, err) + assert.True(t, changed) // Verify the record was created record, err = client.GetRecord(context.Background(), "example.com", "new.example.com", "A") @@ -170,12 +171,13 @@ func TestNetcupProvider_UpdateRecord(t *testing.T) { initialRequestCount := len(mockServer.requests) mockServer.mu.Unlock() - err = client.UpdateRecord(context.Background(), "example.com", &DNSRecord{ + changed, err = client.UpdateRecord(context.Background(), "example.com", &DNSRecord{ Name: "new.example.com", Type: "A", Value: "3.3.3.3", }) assert.NoError(t, err) + assert.False(t, changed) // Verify no update API call was made mockServer.mu.Lock() From 6184922248239a585afa48743812eb4c7eb52af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:22:23 +0200 Subject: [PATCH 03/13] fix(route53): resolve hosted zones by longest suffix match The hosted zone was looked up by the last two labels of the hostname, which breaks for multi-label TLDs (example.co.uk) and delegated subdomain zones. Pick the public hosted zone with the longest name that is a suffix of the hostname instead, ignore private zones, and allow pinning the zone via ROUTE53_ZONE_ID. Also normalize Route53's \052 escape for wildcard names so the unchanged check works for *.example.com records. Co-authored-by: Claude --- internal/provider/provider_aws_route53.go | 121 ++++++++++++------ .../provider/provider_aws_route53_test.go | 96 ++++++++++++++ 2 files changed, 181 insertions(+), 36 deletions(-) diff --git a/internal/provider/provider_aws_route53.go b/internal/provider/provider_aws_route53.go index 327ae20..0c7f4a5 100644 --- a/internal/provider/provider_aws_route53.go +++ b/internal/provider/provider_aws_route53.go @@ -6,6 +6,7 @@ package provider import ( "context" "fmt" + "os" "strings" "sync" @@ -26,8 +27,9 @@ type Route53API interface { // AwsRoute53Client represents an AWS Route53 client type AwsRoute53Client struct { client Route53API + zoneID string // fixed hosted zone ID (ROUTE53_ZONE_ID); skips zone discovery when set zoneMu sync.RWMutex - zoneCache map[string]string // domain -> hostedZoneId cache + zoneCache map[string]string // hostname -> hostedZoneId cache } // NewAwsRoute53Client creates a new Route53 client @@ -57,6 +59,12 @@ func NewAwsRoute53ClientWithConfig(cfg aws.Config) *AwsRoute53Client { } } +// WithZoneID pins the client to a hosted zone and disables zone discovery. +func (c *AwsRoute53Client) WithZoneID(zoneID string) *AwsRoute53Client { + c.zoneID = strings.TrimPrefix(zoneID, "/hostedzone/") + return c +} + // NewAwsRoute53ClientWithMock creates a new Route53 client with a mock API for testing func NewAwsRoute53ClientWithMock(mock Route53API) *AwsRoute53Client { return &AwsRoute53Client{ @@ -70,20 +78,42 @@ func (c *AwsRoute53Client) Name() string { return "aws_route53" } -// GetRecord retrieves a specific DNS record +// GetRecord retrieves a specific DNS record. +// The hosted zone is determined from the hostname; the domain argument is +// only used for logging. func (c *AwsRoute53Client) GetRecord(ctx context.Context, domain, hostname, recordType string) (*DNSRecord, error) { logger.Debug("AWS Route53: Getting record for domain=%s, hostname=%s, type=%s", domain, hostname, recordType) // Get hosted zone ID - zoneID, err := c.getHostedZoneID(ctx, domain) + zoneID, err := c.getHostedZoneID(ctx, hostname) if err != nil { return nil, fmt.Errorf("get hosted zone: %w", err) } + recordSet, err := c.findRecordSet(ctx, zoneID, hostname, recordType) + if err != nil { + return nil, err + } + + // Extract value + var value string + if len(recordSet.ResourceRecords) > 0 { + value = aws.ToString(recordSet.ResourceRecords[0].Value) + } + + return &DNSRecord{ + Name: hostname, + Type: recordType, + Value: value, + TTL: int(aws.ToInt64(recordSet.TTL)), + }, nil +} + +// findRecordSet returns the record set with exactly the given name and type. +func (c *AwsRoute53Client) findRecordSet(ctx context.Context, zoneID, hostname, recordType string) (*types.ResourceRecordSet, error) { // Ensure hostname ends with a dot for Route53 fqdn := c.ensureTrailingDot(hostname) - // List resource record sets input := &route53.ListResourceRecordSetsInput{ HostedZoneId: aws.String(zoneID), StartRecordName: aws.String(fqdn), @@ -96,28 +126,21 @@ func (c *AwsRoute53Client) GetRecord(ctx context.Context, domain, hostname, reco return nil, fmt.Errorf("list record sets: %w", err) } - // Check if we found the record if len(result.ResourceRecordSets) == 0 { return nil, ErrRecordNotFound } recordSet := result.ResourceRecordSets[0] - if aws.ToString(recordSet.Name) != fqdn || string(recordSet.Type) != recordType { + if !strings.EqualFold(normalizeRecordName(aws.ToString(recordSet.Name)), normalizeRecordName(fqdn)) || string(recordSet.Type) != recordType { return nil, ErrRecordNotFound } + return &recordSet, nil +} - // Extract value - var value string - if len(recordSet.ResourceRecords) > 0 { - value = aws.ToString(recordSet.ResourceRecords[0].Value) - } - - return &DNSRecord{ - Name: hostname, - Type: recordType, - Value: value, - TTL: int(aws.ToInt64(recordSet.TTL)), - }, nil +// normalizeRecordName converts Route53's octal escape for "*" back to the +// literal character so wildcard names compare equal. +func normalizeRecordName(name string) string { + return strings.ReplaceAll(name, `\052`, "*") } // UpdateRecord updates or creates a DNS record @@ -125,7 +148,7 @@ func (c *AwsRoute53Client) UpdateRecord(ctx context.Context, domain string, reco logger.Debug("AWS Route53: Updating record for domain=%s, name=%s, type=%s, value=%s", domain, record.Name, record.Type, record.Value) // Get hosted zone ID - zoneID, err := c.getHostedZoneID(ctx, domain) + zoneID, err := c.getHostedZoneID(ctx, record.Name) if err != nil { return false, fmt.Errorf("get hosted zone: %w", err) } @@ -181,21 +204,28 @@ func (c *AwsRoute53Client) Close(ctx context.Context) error { return nil } -// getHostedZoneID retrieves the hosted zone ID for a domain -func (c *AwsRoute53Client) getHostedZoneID(ctx context.Context, domain string) (string, error) { +// getHostedZoneID returns the hosted zone for a hostname: the public zone +// with the longest name that is a suffix of the hostname. A zone ID +// configured via ROUTE53_ZONE_ID takes precedence. +func (c *AwsRoute53Client) getHostedZoneID(ctx context.Context, hostname string) (string, error) { + if c.zoneID != "" { + return c.zoneID, nil + } + + hostname = strings.ToLower(strings.TrimSuffix(hostname, ".")) + // Check cache first c.zoneMu.RLock() - zoneID, exists := c.zoneCache[domain] + zoneID, exists := c.zoneCache[hostname] c.zoneMu.RUnlock() if exists { - logger.Debug("AWS Route53: Using cached zone ID for domain %s", domain) + logger.Debug("AWS Route53: Using cached zone ID for %s", hostname) return zoneID, nil } - logger.Debug("AWS Route53: Looking up hosted zone ID for domain %s", domain) + logger.Debug("AWS Route53: Looking up hosted zone ID for %s", hostname) - // Ensure domain ends with a dot - domainWithDot := c.ensureTrailingDot(domain) + var bestName, bestID string // List hosted zones manually for mockability var marker *string @@ -210,14 +240,16 @@ func (c *AwsRoute53Client) getHostedZoneID(ctx context.Context, domain string) ( } for _, zone := range output.HostedZones { - if aws.ToString(zone.Name) == domainWithDot { - zoneID := aws.ToString(zone.Id) - // Strip /hostedzone/ prefix if present - zoneID = strings.TrimPrefix(zoneID, "/hostedzone/") - c.zoneMu.Lock() - c.zoneCache[domain] = zoneID - c.zoneMu.Unlock() - return zoneID, nil + if zone.Config != nil && zone.Config.PrivateZone { + continue + } + zoneName := strings.ToLower(strings.TrimSuffix(aws.ToString(zone.Name), ".")) + if hostname != zoneName && !strings.HasSuffix(hostname, "."+zoneName) { + continue + } + if len(zoneName) > len(bestName) { + bestName = zoneName + bestID = strings.TrimPrefix(aws.ToString(zone.Id), "/hostedzone/") } } @@ -227,7 +259,15 @@ func (c *AwsRoute53Client) getHostedZoneID(ctx context.Context, domain string) ( marker = output.NextMarker } - return "", fmt.Errorf("hosted zone for domain %s not found", domain) + if bestID == "" { + return "", fmt.Errorf("no public hosted zone found for %s", hostname) + } + + logger.Debug("AWS Route53: Using hosted zone %s (%s) for %s", bestName, bestID, hostname) + c.zoneMu.Lock() + c.zoneCache[hostname] = bestID + c.zoneMu.Unlock() + return bestID, nil } // ensureTrailingDot ensures the hostname ends with a dot @@ -244,6 +284,15 @@ func init() { // NewRoute53Provider creates a new AWS Route53 provider // It uses the default AWS SDK configuration chain (env vars, ~/.aws/credentials, IAM roles, etc.) +// If ROUTE53_ZONE_ID is set, all records are written to that hosted zone. func NewRoute53Provider(ctx context.Context, config interface{}) (Provider, error) { - return NewAwsRoute53Client(ctx) + client, err := NewAwsRoute53Client(ctx) + if err != nil { + return nil, err + } + if zoneID := os.Getenv("ROUTE53_ZONE_ID"); zoneID != "" { + logger.Info("AWS Route53: Using fixed hosted zone %s (ROUTE53_ZONE_ID)", zoneID) + client.WithZoneID(zoneID) + } + return client, nil } diff --git a/internal/provider/provider_aws_route53_test.go b/internal/provider/provider_aws_route53_test.go index ed965ed..cc23893 100644 --- a/internal/provider/provider_aws_route53_test.go +++ b/internal/provider/provider_aws_route53_test.go @@ -223,3 +223,99 @@ func TestAwsRoute53Client_UpdateRecordUnchanged(t *testing.T) { t.Fatalf("expected record to be reported as unchanged") } } + +func zonesMock(zones ...types.HostedZone) *mockRoute53API { + return &mockRoute53API{ + ListHostedZonesFunc: func(ctx context.Context, params *route53.ListHostedZonesInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { + return &route53.ListHostedZonesOutput{HostedZones: zones}, nil + }, + } +} + +func zone(id, name string, private bool) types.HostedZone { + return types.HostedZone{ + Id: aws.String("/hostedzone/" + id), + Name: aws.String(name), + Config: &types.HostedZoneConfig{PrivateZone: private}, + } +} + +func TestAwsRoute53Client_ZoneDiscovery(t *testing.T) { + mockAPI := zonesMock( + zone("COUK", "example.co.uk.", false), + zone("COM", "example.com.", false), + zone("DYN", "dyn.example.com.", false), + zone("PRIVATE", "home.example.com.", true), + zone("OTHER", "ample.com.", false), + ) + + testCases := []struct { + hostname string + expected string + }{ + {"home.example.co.uk", "COUK"}, + {"example.com", "COM"}, + {"www.example.com", "COM"}, + {"a.dyn.example.com", "DYN"}, + {"*.dyn.example.com", "DYN"}, + {"dyn.example.com.", "DYN"}, + {"home.example.com", "COM"}, // private zone is ignored + } + + client := NewAwsRoute53ClientWithMock(mockAPI) + for _, tc := range testCases { + t.Run(tc.hostname, func(t *testing.T) { + zoneID, err := client.getHostedZoneID(context.Background(), tc.hostname) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if zoneID != tc.expected { + t.Fatalf("expected zone %s, got %s", tc.expected, zoneID) + } + }) + } + + if _, err := client.getHostedZoneID(context.Background(), "example.org"); err == nil { + t.Fatalf("expected error for hostname without zone") + } +} + +func TestAwsRoute53Client_FixedZoneID(t *testing.T) { + mockAPI := &mockRoute53API{} // ListHostedZones must not be called + client := NewAwsRoute53ClientWithMock(mockAPI).WithZoneID("/hostedzone/FIXED") + + zoneID, err := client.getHostedZoneID(context.Background(), "home.example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if zoneID != "FIXED" { + t.Fatalf("expected FIXED, got %s", zoneID) + } +} + +func TestAwsRoute53Client_WildcardUnchanged(t *testing.T) { + mockAPI := zonesMock(zone("ZONE123", "example.com.", false)) + mockAPI.ListResourceRecordSetsFunc = func(ctx context.Context, params *route53.ListResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { + return &route53.ListResourceRecordSetsOutput{ResourceRecordSets: []types.ResourceRecordSet{{ + Name: aws.String(`\052.example.com.`), + Type: types.RRTypeAaaa, + TTL: aws.Int64(60), + ResourceRecords: []types.ResourceRecord{{Value: aws.String("2001:db8::1")}}, + }}}, nil + } + mockAPI.ChangeResourceRecordSetsFunc = func(ctx context.Context, params *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { + t.Fatalf("unexpected change call for unchanged wildcard record") + return nil, nil + } + + client := NewAwsRoute53ClientWithMock(mockAPI) + changed, err := client.UpdateRecord(context.Background(), "example.com", &DNSRecord{ + Name: "*.example.com", Type: "AAAA", Value: "2001:db8::1", TTL: 60, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if changed { + t.Fatalf("expected wildcard record to be reported as unchanged") + } +} From cd5bb6d1f2588ecb990d7fc4fd93c456751ddf2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:25:13 +0200 Subject: [PATCH 04/13] feat: explicit IPv6 support with address validation - Parse addresses with net/netip: zone IDs are rejected and IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) are set as A records. The record type is derived from Addr.Is4() instead of a string check. - Accept myipv6 in addition to myip; if both are given, A and AAAA are set and the response is "good ,". - Reject loopback, unspecified, multicast, link-local, RFC 1918, CGNAT, ULA and documentation addresses with 911 unless ALLOW_PRIVATE_IPS=true. - Only honor X-Forwarded-For if the peer is listed in TRUSTED_PROXIES, read it right to left and accept entries with ports and brackets. - Optionally delete the record of the other address family after a single-family update (CLEANUP_STALE_RECORD=true). Providers gain DeleteRecord, which only deletes if the value still matches. Co-authored-by: Claude --- cmd/config.go | 42 ++++- cmd/server.go | 10 +- internal/clientip/clientip.go | 142 +++++++++++++++ internal/clientip/clientip_test.go | 154 ++++++++++++++++ internal/handler/dyndns.go | 165 +++++++++++++----- internal/handler/dyndns_test.go | 129 ++++++++++++++ internal/provider/provider.go | 3 + internal/provider/provider_aws_route53.go | 43 +++++ .../provider/provider_aws_route53_test.go | 41 +++++ internal/provider/provider_netcup_ccp.go | 25 +++ internal/provider/provider_netcup_ccp_test.go | 46 ++++- 11 files changed, 742 insertions(+), 58 deletions(-) create mode 100644 internal/clientip/clientip.go create mode 100644 internal/clientip/clientip_test.go diff --git a/cmd/config.go b/cmd/config.go index 02d7d32..54ee03d 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -3,25 +3,36 @@ package cmd import ( "encoding/json" "fmt" + "net/netip" "os" "strconv" "strings" + "github.com/markussiebert/homeddns/internal/clientip" "github.com/markussiebert/homeddns/internal/logger" "github.com/markussiebert/homeddns/internal/util" ) // Config represents the application configuration type Config struct { - Port int - Username string - Password string - Provider string - Domain string - DefaultTTL int - SSL bool - CertFile string - KeyFile string + Port int + Username string + Password string + Provider string + Domain string + DefaultTTL int + SSL bool + CertFile string + KeyFile string + TrustedProxies []netip.Prefix + AllowPrivateIPs bool + CleanupStaleRecords bool +} + +// envBool reports whether an environment variable is set to "true" or "1". +func envBool(key string) bool { + value := strings.ToLower(os.Getenv(key)) + return value == "true" || value == "1" } func LoadHomeAssistantConfig() error { @@ -197,6 +208,19 @@ func LoadConfig() (*Config, error) { config.KeyFile = "/ssl/privkey.pem" // Home Assistant default } + // IP handling + if proxies := os.Getenv("TRUSTED_PROXIES"); proxies != "" { + prefixes, err := clientip.ParsePrefixes(proxies) + if err != nil { + return nil, logger.Errorf("invalid TRUSTED_PROXIES: %w", err) + } + config.TrustedProxies = prefixes + logger.Debug("Trusted proxies: %v", prefixes) + } + config.AllowPrivateIPs = envBool("ALLOW_PRIVATE_IPS") + config.CleanupStaleRecords = envBool("CLEANUP_STALE_RECORD") + logger.Debug("IP config: allow_private_ips=%v, cleanup_stale_record=%v", config.AllowPrivateIPs, config.CleanupStaleRecords) + logger.Debug("SSL config: enabled=%v, certfile=%s, keyfile=%s", config.SSL, config.CertFile, config.KeyFile) // Validate SSL certificate files exist if SSL is enabled diff --git a/cmd/server.go b/cmd/server.go index c9a926c..745fb6b 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -10,6 +10,7 @@ import ( "time" "github.com/markussiebert/homeddns/internal/auth" + "github.com/markussiebert/homeddns/internal/clientip" "github.com/markussiebert/homeddns/internal/handler" "github.com/markussiebert/homeddns/internal/logger" "github.com/markussiebert/homeddns/internal/provider" @@ -29,9 +30,14 @@ func RunServer(port int, config *Config) error { logger.Info("Using DNS provider: %s", p.Name()) + clientIPResolver := clientip.Resolver{TrustedProxies: config.TrustedProxies} + dyndnsHandler := handler.NewDynDNSHandler(handler.Config{ - Provider: p, - DefaultTTL: config.DefaultTTL, + Provider: p, + DefaultTTL: config.DefaultTTL, + ClientIP: clientIPResolver, + AllowPrivateIPs: config.AllowPrivateIPs, + CleanupStaleRecords: config.CleanupStaleRecords, }) authMiddleware := auth.Middleware(auth.Config{ diff --git a/internal/clientip/clientip.go b/internal/clientip/clientip.go new file mode 100644 index 0000000..887cb9d --- /dev/null +++ b/internal/clientip/clientip.go @@ -0,0 +1,142 @@ +// Package clientip parses and validates the IP addresses used as DynDNS +// update targets and determines the address of the requesting client. +package clientip + +import ( + "fmt" + "net/http" + "net/netip" + "strings" +) + +// nonPublicPrefixes are ranges that are not covered by the netip.Addr +// helpers used in IsPublic but cannot be a valid DynDNS target either. +var nonPublicPrefixes = []netip.Prefix{ + netip.MustParsePrefix("100.64.0.0/10"), // CGNAT shared address space (RFC 6598) + netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1 (RFC 5737) + netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2 (RFC 5737) + netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3 (RFC 5737) + netip.MustParsePrefix("2001:db8::/32"), // IPv6 documentation (RFC 3849) + netip.MustParsePrefix("3fff::/20"), // IPv6 documentation (RFC 9637) +} + +// ParseAddr parses a single IP address as sent in a myip/myipv6 parameter. +// Surrounding brackets are accepted, zone IDs are rejected and +// IPv4-mapped IPv6 addresses are converted to plain IPv4. +func ParseAddr(value string) (netip.Addr, error) { + value = strings.TrimSpace(value) + if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") { + value = value[1 : len(value)-1] + } + addr, err := netip.ParseAddr(value) + if err != nil { + return netip.Addr{}, fmt.Errorf("invalid IP address %q: %w", value, err) + } + if addr.Zone() != "" { + return netip.Addr{}, fmt.Errorf("IP address %q must not contain a zone ID", value) + } + return addr.Unmap(), nil +} + +// parseHostAddr parses an address that may carry a port, as found in +// RemoteAddr or X-Forwarded-For ("1.2.3.4:80", "[2001:db8::1]:1234"). +func parseHostAddr(value string) (netip.Addr, error) { + value = strings.TrimSpace(value) + if addrPort, err := netip.ParseAddrPort(value); err == nil { + return ParseAddr(addrPort.Addr().String()) + } + return ParseAddr(value) +} + +// IsPublic reports whether addr is usable as a public DynDNS target. +// Loopback, unspecified, multicast, link-local, private (RFC 1918, ULA), +// CGNAT and documentation addresses are rejected. +func IsPublic(addr netip.Addr) bool { + addr = addr.Unmap() + if !addr.IsValid() || addr.IsLoopback() || addr.IsUnspecified() || addr.IsMulticast() || + addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsPrivate() { + return false + } + for _, prefix := range nonPublicPrefixes { + if prefix.Contains(addr) { + return false + } + } + return true +} + +// ParsePrefixes parses a comma-separated list of CIDR prefixes or plain IP +// addresses (treated as single-host prefixes). +func ParsePrefixes(list string) ([]netip.Prefix, error) { + var prefixes []netip.Prefix + for _, entry := range strings.Split(list, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if strings.Contains(entry, "/") { + prefix, err := netip.ParsePrefix(entry) + if err != nil { + return nil, fmt.Errorf("invalid prefix %q: %w", entry, err) + } + prefixes = append(prefixes, prefix.Masked()) + continue + } + addr, err := ParseAddr(entry) + if err != nil { + return nil, err + } + prefixes = append(prefixes, netip.PrefixFrom(addr, addr.BitLen())) + } + return prefixes, nil +} + +// Resolver determines the client address of a request. +type Resolver struct { + // TrustedProxies lists the peers whose X-Forwarded-For header is honored. + TrustedProxies []netip.Prefix +} + +// FromRequest returns the address of the client that sent the request. +// X-Forwarded-For is only evaluated if the direct peer is a trusted proxy; +// it is then read from right to left, skipping trusted proxies, so a client +// cannot spoof its address by prepending entries. +func (r Resolver) FromRequest(req *http.Request) (netip.Addr, error) { + remote, err := parseHostAddr(req.RemoteAddr) + if err != nil { + return netip.Addr{}, fmt.Errorf("parse remote address: %w", err) + } + if !r.isTrusted(remote) { + return remote, nil + } + + var entries []string + for _, header := range req.Header.Values("X-Forwarded-For") { + entries = append(entries, strings.Split(header, ",")...) + } + if len(entries) == 0 { + return remote, nil + } + + var addr netip.Addr + for i := len(entries) - 1; i >= 0; i-- { + addr, err = parseHostAddr(entries[i]) + if err != nil { + return netip.Addr{}, fmt.Errorf("parse X-Forwarded-For entry: %w", err) + } + if !r.isTrusted(addr) { + return addr, nil + } + } + // Every hop is a trusted proxy; the leftmost entry is the origin. + return addr, nil +} + +func (r Resolver) isTrusted(addr netip.Addr) bool { + for _, prefix := range r.TrustedProxies { + if prefix.Contains(addr) { + return true + } + } + return false +} diff --git a/internal/clientip/clientip_test.go b/internal/clientip/clientip_test.go new file mode 100644 index 0000000..a066fad --- /dev/null +++ b/internal/clientip/clientip_test.go @@ -0,0 +1,154 @@ +package clientip + +import ( + "net/http/httptest" + "net/netip" + "testing" +) + +func TestParseAddr(t *testing.T) { + testCases := []struct { + input string + expected string + wantErr bool + }{ + {input: "1.2.3.4", expected: "1.2.3.4"}, + {input: " 2a01:4f8::1 ", expected: "2a01:4f8::1"}, + {input: "[2a01:4f8::1]", expected: "2a01:4f8::1"}, + {input: "::ffff:1.2.3.4", expected: "1.2.3.4"}, + {input: "fe80::1%eth0", wantErr: true}, + {input: "1.2.3.4:80", wantErr: true}, + {input: "example.com", wantErr: true}, + {input: "", wantErr: true}, + } + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + addr, err := ParseAddr(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got %s", addr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if addr.String() != tc.expected { + t.Fatalf("expected %s, got %s", tc.expected, addr) + } + }) + } +} + +func TestIsPublic(t *testing.T) { + testCases := map[string]bool{ + "1.2.3.4": true, + "2a01:4f8::1": true, + "::ffff:1.2.3.4": true, + "127.0.0.1": false, + "::1": false, + "0.0.0.0": false, + "::": false, + "224.0.0.1": false, + "ff02::1": false, + "169.254.1.1": false, + "fe80::1": false, + "10.1.2.3": false, + "172.16.0.1": false, + "192.168.1.1": false, + "100.64.0.1": false, + "100.127.255.255": false, + "100.128.0.1": true, + "fc00::1": false, + "fd12:3456::1": false, + "192.0.2.1": false, + "198.51.100.1": false, + "203.0.113.1": false, + "2001:db8::1": false, + "3fff::1": false, + "::ffff:10.0.0.1": false, + "::ffff:100.64.0.1": false, + } + for input, expected := range testCases { + if got := IsPublic(netip.MustParseAddr(input)); got != expected { + t.Errorf("IsPublic(%s) = %v, expected %v", input, got, expected) + } + } +} + +func TestParsePrefixes(t *testing.T) { + prefixes, err := ParsePrefixes("10.0.0.0/8, 192.168.1.5 ,fd00::/8,::1,") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"10.0.0.0/8", "192.168.1.5/32", "fd00::/8", "::1/128"} + if len(prefixes) != len(expected) { + t.Fatalf("expected %v, got %v", expected, prefixes) + } + for i, prefix := range prefixes { + if prefix.String() != expected[i] { + t.Fatalf("expected %v, got %v", expected, prefixes) + } + } + + if _, err := ParsePrefixes("10.0.0.0/33"); err == nil { + t.Fatalf("expected error for invalid prefix") + } + if _, err := ParsePrefixes("not-an-ip"); err == nil { + t.Fatalf("expected error for invalid address") + } +} + +func TestResolverFromRequest(t *testing.T) { + resolver := Resolver{TrustedProxies: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("fd00::/8"), + }} + + testCases := []struct { + name string + resolver Resolver + remoteAddr string + xff []string + expected string + wantErr bool + }{ + {name: "remote IPv4", remoteAddr: "1.2.3.4:5555", expected: "1.2.3.4"}, + {name: "remote IPv6", remoteAddr: "[2a01:4f8::1]:5555", expected: "2a01:4f8::1"}, + {name: "remote IPv4-mapped", remoteAddr: "[::ffff:1.2.3.4]:5555", expected: "1.2.3.4"}, + {name: "XFF ignored without trusted proxies", remoteAddr: "1.2.3.4:5555", xff: []string{"5.6.7.8"}, expected: "1.2.3.4"}, + {name: "XFF ignored from untrusted peer", resolver: resolver, remoteAddr: "1.2.3.4:5555", xff: []string{"5.6.7.8"}, expected: "1.2.3.4"}, + {name: "XFF from trusted peer", resolver: resolver, remoteAddr: "10.0.0.1:5555", xff: []string{"5.6.7.8"}, expected: "5.6.7.8"}, + {name: "XFF rightmost untrusted wins", resolver: resolver, remoteAddr: "10.0.0.1:5555", xff: []string{"6.6.6.6, 5.6.7.8, 10.0.0.2"}, expected: "5.6.7.8"}, + {name: "XFF IPv6 with port and brackets", resolver: resolver, remoteAddr: "[fd00::1]:5555", xff: []string{"[2a01:4f8::1]:1234"}, expected: "2a01:4f8::1"}, + {name: "XFF bare IPv6", resolver: resolver, remoteAddr: "10.0.0.1:5555", xff: []string{"2a01:4f8::1"}, expected: "2a01:4f8::1"}, + {name: "XFF IPv4 with port", resolver: resolver, remoteAddr: "10.0.0.1:5555", xff: []string{"5.6.7.8:80"}, expected: "5.6.7.8"}, + {name: "XFF multiple headers", resolver: resolver, remoteAddr: "10.0.0.1:5555", xff: []string{"5.6.7.8", "10.0.0.3"}, expected: "5.6.7.8"}, + {name: "XFF all trusted", resolver: resolver, remoteAddr: "10.0.0.1:5555", xff: []string{"10.0.0.5, 10.0.0.6"}, expected: "10.0.0.5"}, + {name: "XFF invalid entry", resolver: resolver, remoteAddr: "10.0.0.1:5555", xff: []string{"garbage"}, wantErr: true}, + {name: "XFF zone ID", resolver: resolver, remoteAddr: "10.0.0.1:5555", xff: []string{"fe80::1%eth0"}, wantErr: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = tc.remoteAddr + for _, value := range tc.xff { + req.Header.Add("X-Forwarded-For", value) + } + addr, err := tc.resolver.FromRequest(req) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got %s", addr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if addr.String() != tc.expected { + t.Fatalf("expected %s, got %s", tc.expected, addr) + } + }) + } +} diff --git a/internal/handler/dyndns.go b/internal/handler/dyndns.go index d8add7f..b318b81 100644 --- a/internal/handler/dyndns.go +++ b/internal/handler/dyndns.go @@ -2,13 +2,15 @@ package handler import ( "context" + "errors" "fmt" - "net" "net/http" + "net/netip" "net/url" "strings" "time" + "github.com/markussiebert/homeddns/internal/clientip" "github.com/markussiebert/homeddns/internal/logger" "github.com/markussiebert/homeddns/internal/provider" ) @@ -17,6 +19,13 @@ import ( type Config struct { Provider provider.Provider DefaultTTL int + // ClientIP determines the client address when no myip/myipv6 is given. + ClientIP clientip.Resolver + // AllowPrivateIPs disables the rejection of non-public addresses. + AllowPrivateIPs bool + // CleanupStaleRecords removes the record of the other address family + // (A when setting AAAA and vice versa) if only one family is updated. + CleanupStaleRecords bool } // DynDNSHandler handles DynDNS update requests @@ -70,36 +79,49 @@ func (h *DynDNSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } logger.Debug("Split hostname: domain=%s, subdomain=%s", domain, subdomain) - // Get IP address - ipAddress := h.extractIP(r) - if ipAddress == "" { - logger.Warn("Failed to extract valid IP address from request") + // Get IP addresses + addresses, err := h.extractIPs(r) + if err != nil { + logger.Warn("Rejecting update for %s from %s: %v", hostname, r.RemoteAddr, err) h.respond(w, "911", "", isStandardFormat) return } - logger.Debug("Extracted IP address: %s", ipAddress) + logger.Debug("Extracted IP addresses: %v", addresses) - // Update DNS record + // Update DNS records ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() - logger.Debug("Updating DNS: hostname=%s, domain=%s, subdomain=%s, ip=%s", hostname, domain, subdomain, ipAddress) + anyChanged := false + ipStrings := make([]string, 0, len(addresses)) + for _, addr := range addresses { + ipStrings = append(ipStrings, addr.String()) + } + ipList := strings.Join(ipStrings, ",") + + for _, addr := range addresses { + logger.Debug("Updating DNS: hostname=%s, domain=%s, subdomain=%s, ip=%s", hostname, domain, subdomain, addr) + changed, err := h.updateDNS(ctx, domain, subdomain, addr) + if err != nil { + logger.Error("Error updating DNS for %s: %v", hostname, err) + h.respond(w, "911", ipList, isStandardFormat) + return + } + anyChanged = anyChanged || changed + } - changed, err := h.updateDNS(ctx, domain, subdomain, ipAddress) - if err != nil { - logger.Error("Error updating DNS for %s: %v", hostname, err) - h.respond(w, "911", ipAddress, isStandardFormat) - return + if h.config.CleanupStaleRecords && len(addresses) == 1 { + h.cleanupStaleRecord(ctx, domain, h.buildHostname(subdomain, domain), recordType(addresses[0])) } - if !changed { - logger.Info("%s already points to %s, no update needed", hostname, ipAddress) - h.respond(w, "nochg", ipAddress, isStandardFormat) + if !anyChanged { + logger.Info("%s already points to %s, no update needed", hostname, ipList) + h.respond(w, "nochg", ipList, isStandardFormat) return } - logger.Info("Successfully updated %s to %s", hostname, ipAddress) - h.respond(w, "good", ipAddress, isStandardFormat) + logger.Info("Successfully updated %s to %s", hostname, ipList) + h.respond(w, "good", ipList, isStandardFormat) } // extractHostname extracts the hostname from the request @@ -153,52 +175,78 @@ func (h *DynDNSHandler) splitHostname(hostname string) (domain, subdomain string return domain, subdomain } -// extractIP extracts the IP address from the request -func (h *DynDNSHandler) extractIP(r *http.Request) string { - // Check query parameter first - if ip := r.URL.Query().Get("myip"); ip != "" { - if net.ParseIP(ip) != nil { - return ip +// extractIPs returns the addresses to set, at most one per address family. +// Sources in order of precedence: myip/myipv6 query parameters, then the +// client address (X-Forwarded-For from trusted proxies, else RemoteAddr). +func (h *DynDNSHandler) extractIPs(r *http.Request) ([]netip.Addr, error) { + query := r.URL.Query() + var ipv4, ipv6 netip.Addr + + for _, param := range []string{"myip", "myipv6"} { + value := query.Get(param) + if value == "" { + continue + } + addr, err := clientip.ParseAddr(value) + if err != nil { + return nil, fmt.Errorf("%s: %w", param, err) + } + if param == "myipv6" && addr.Is4() { + return nil, fmt.Errorf("myipv6: %s is not an IPv6 address", addr) } + target := &ipv6 + if addr.Is4() { + target = &ipv4 + } + if target.IsValid() { + return nil, fmt.Errorf("%s: more than one address of the same family", param) + } + *target = addr } - // Fall back to request source IP - ip := r.RemoteAddr - if host, _, err := net.SplitHostPort(ip); err == nil { - ip = host + if !ipv4.IsValid() && !ipv6.IsValid() { + addr, err := h.config.ClientIP.FromRequest(r) + if err != nil { + return nil, fmt.Errorf("determine client address: %w", err) + } + if addr.Is4() { + ipv4 = addr + } else { + ipv6 = addr + } } - // Handle X-Forwarded-For header - if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { - ips := strings.Split(forwarded, ",") - if len(ips) > 0 { - ip = strings.TrimSpace(ips[0]) + var addresses []netip.Addr + for _, addr := range []netip.Addr{ipv4, ipv6} { + if !addr.IsValid() { + continue + } + if !h.config.AllowPrivateIPs && !clientip.IsPublic(addr) { + return nil, fmt.Errorf("%s is not a public address (set ALLOW_PRIVATE_IPS=true to allow)", addr) } + addresses = append(addresses, addr) } + return addresses, nil +} - if net.ParseIP(ip) != nil { - return ip +// recordType returns the DNS record type for an address. +func recordType(addr netip.Addr) string { + if addr.Is4() { + return "A" } - - return "" + return "AAAA" } // updateDNS updates the DNS record via the configured provider -func (h *DynDNSHandler) updateDNS(ctx context.Context, domain, subdomain, ipAddress string) (bool, error) { - // Determine record type - recordType := "A" - if strings.Contains(ipAddress, ":") { - recordType = "AAAA" - } - +func (h *DynDNSHandler) updateDNS(ctx context.Context, domain, subdomain string, addr netip.Addr) (bool, error) { // Build full hostname hostname := h.buildHostname(subdomain, domain) // Prepare DNS record record := &provider.DNSRecord{ Name: hostname, - Type: recordType, - Value: ipAddress, + Type: recordType(addr), + Value: addr.String(), TTL: h.config.DefaultTTL, } @@ -211,6 +259,31 @@ func (h *DynDNSHandler) updateDNS(ctx context.Context, domain, subdomain, ipAddr return changed, nil } +// cleanupStaleRecord deletes the record of the other address family for +// hostname, e.g. an outdated A record after switching to IPv6-only updates. +// Failures are logged and do not fail the update. +func (h *DynDNSHandler) cleanupStaleRecord(ctx context.Context, domain, hostname, keepType string) { + staleType := "AAAA" + if keepType == "AAAA" { + staleType = "A" + } + + existing, err := h.config.Provider.GetRecord(ctx, domain, hostname, staleType) + if errors.Is(err, provider.ErrRecordNotFound) { + return + } + if err != nil { + logger.Warn("Could not check for stale %s record of %s: %v", staleType, hostname, err) + return + } + + if err := h.config.Provider.DeleteRecord(ctx, domain, existing); err != nil { + logger.Warn("Could not delete stale %s record of %s: %v", staleType, hostname, err) + return + } + logger.Info("Deleted stale %s record of %s (was %s)", staleType, hostname, existing.Value) +} + // buildHostname builds a full hostname from subdomain and domain func (h *DynDNSHandler) buildHostname(subdomain, domain string) string { if subdomain == "@" || subdomain == "" { diff --git a/internal/handler/dyndns_test.go b/internal/handler/dyndns_test.go index 3deec5f..31cfb70 100644 --- a/internal/handler/dyndns_test.go +++ b/internal/handler/dyndns_test.go @@ -4,10 +4,12 @@ import ( "context" "net/http" "net/http/httptest" + "net/netip" "strings" "sync" "testing" + "github.com/markussiebert/homeddns/internal/clientip" "github.com/markussiebert/homeddns/internal/provider" ) @@ -46,6 +48,17 @@ func (f *fakeProvider) UpdateRecord(ctx context.Context, domain string, record * return true, nil } +func (f *fakeProvider) DeleteRecord(ctx context.Context, domain string, record *provider.DNSRecord) error { + f.mu.Lock() + defer f.mu.Unlock() + key := record.Name + "/" + record.Type + if f.records[key] != record.Value { + return provider.ErrRecordNotFound + } + delete(f.records, key) + return nil +} + func (f *fakeProvider) Close(ctx context.Context) error { return nil } func serve(h http.Handler, target string, configure func(*http.Request)) *httptest.ResponseRecorder { @@ -80,3 +93,119 @@ func TestServeHTTP_NochgWhenUnchanged(t *testing.T) { t.Fatalf("expected 1 provider update, got %d", len(fake.updates)) } } + +func TestServeHTTP_AddressSelection(t *testing.T) { + trusted := clientip.Resolver{TrustedProxies: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("fd00::/8"), + }} + + testCases := []struct { + name string + target string + remoteAddr string + xff string + resolver clientip.Resolver + allowPriv bool + expected string + records map[string]string + }{ + {name: "myip IPv4", target: "/nic/update?hostname=home.example.com&myip=1.2.3.4", + expected: "good 1.2.3.4", records: map[string]string{"home.example.com/A": "1.2.3.4"}}, + {name: "myip IPv6", target: "/nic/update?hostname=home.example.com&myip=2a01:4f8::1", + expected: "good 2a01:4f8::1", records: map[string]string{"home.example.com/AAAA": "2a01:4f8::1"}}, + {name: "IPv4-mapped IPv6 becomes A", target: "/nic/update?hostname=home.example.com&myip=::ffff:1.2.3.4", + expected: "good 1.2.3.4", records: map[string]string{"home.example.com/A": "1.2.3.4"}}, + {name: "IPv6 with zone ID rejected", target: "/nic/update?hostname=home.example.com&myip=2a01:4f8::1%25eth0", + expected: "911"}, + {name: "invalid myip rejected", target: "/nic/update?hostname=home.example.com&myip=not-an-ip", + expected: "911"}, + {name: "myip and myipv6", target: "/nic/update?hostname=home.example.com&myip=1.2.3.4&myipv6=2a01:4f8::1", + expected: "good 1.2.3.4,2a01:4f8::1", + records: map[string]string{"home.example.com/A": "1.2.3.4", "home.example.com/AAAA": "2a01:4f8::1"}}, + {name: "myipv6 with IPv4 rejected", target: "/nic/update?hostname=home.example.com&myipv6=1.2.3.4", + expected: "911"}, + {name: "two IPv6 addresses rejected", target: "/nic/update?hostname=home.example.com&myip=2a01:4f8::1&myipv6=2a01:4f8::2", + expected: "911"}, + {name: "CGNAT rejected", target: "/nic/update?hostname=home.example.com&myip=100.64.1.2", expected: "911"}, + {name: "RFC 1918 rejected", target: "/nic/update?hostname=home.example.com&myip=192.168.1.10", expected: "911"}, + {name: "ULA rejected", target: "/nic/update?hostname=home.example.com&myip=fd12:3456::1", expected: "911"}, + {name: "link-local rejected", target: "/nic/update?hostname=home.example.com&myip=fe80::1", expected: "911"}, + {name: "loopback rejected", target: "/nic/update?hostname=home.example.com&myip=::1", expected: "911"}, + {name: "documentation rejected", target: "/nic/update?hostname=home.example.com&myip=2001:db8::1", expected: "911"}, + {name: "private allowed when enabled", target: "/nic/update?hostname=home.example.com&myip=192.168.1.10", + allowPriv: true, expected: "good 192.168.1.10", records: map[string]string{"home.example.com/A": "192.168.1.10"}}, + {name: "RemoteAddr IPv6", target: "/nic/update?hostname=home.example.com", remoteAddr: "[2a01:4f8::1]:443", + expected: "good 2a01:4f8::1", records: map[string]string{"home.example.com/AAAA": "2a01:4f8::1"}}, + {name: "RemoteAddr private rejected", target: "/nic/update?hostname=home.example.com", remoteAddr: "10.1.2.3:443", + expected: "911"}, + {name: "XFF ignored from untrusted peer", target: "/nic/update?hostname=home.example.com", + remoteAddr: "5.6.7.8:443", xff: "1.2.3.4", + expected: "good 5.6.7.8", records: map[string]string{"home.example.com/A": "5.6.7.8"}}, + {name: "XFF from trusted proxy, multiple entries with IPv6", target: "/nic/update?hostname=home.example.com", + remoteAddr: "10.0.0.1:443", xff: "9.9.9.9, [2a01:4f8::1]:1234, 10.0.0.2", resolver: trusted, + expected: "good 2a01:4f8::1", records: map[string]string{"home.example.com/AAAA": "2a01:4f8::1"}}, + {name: "myip takes precedence over XFF", target: "/nic/update?hostname=home.example.com&myip=1.2.3.4", + remoteAddr: "10.0.0.1:443", xff: "5.6.7.8", resolver: trusted, + expected: "good 1.2.3.4", records: map[string]string{"home.example.com/A": "1.2.3.4"}}, + {name: "UniFi path format", target: "/home.example.com?myip=1.2.3.4", + expected: "good", records: map[string]string{"home.example.com/A": "1.2.3.4"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fake := newFakeProvider() + h := NewDynDNSHandler(Config{Provider: fake, ClientIP: tc.resolver, AllowPrivateIPs: tc.allowPriv}) + rec := serve(h, tc.target, func(r *http.Request) { + if tc.remoteAddr != "" { + r.RemoteAddr = tc.remoteAddr + } + if tc.xff != "" { + r.Header.Set("X-Forwarded-For", tc.xff) + } + }) + if got := body(rec); got != tc.expected { + t.Fatalf("expected %q, got %q", tc.expected, got) + } + if len(fake.records) != len(tc.records) { + t.Fatalf("expected records %v, got %v", tc.records, fake.records) + } + for key, value := range tc.records { + if fake.records[key] != value { + t.Fatalf("expected records %v, got %v", tc.records, fake.records) + } + } + }) + } +} + +func TestServeHTTP_CleanupStaleRecord(t *testing.T) { + for _, enabled := range []bool{false, true} { + fake := newFakeProvider() + fake.records["home.example.com/A"] = "5.6.7.8" + fake.records["other.example.com/A"] = "5.6.7.8" + h := NewDynDNSHandler(Config{Provider: fake, CleanupStaleRecords: enabled}) + + rec := serve(h, "/nic/update?hostname=home.example.com&myipv6=2a01:4f8::1", nil) + if got := body(rec); got != "good 2a01:4f8::1" { + t.Fatalf("enabled=%v: got %q", enabled, got) + } + _, hasA := fake.records["home.example.com/A"] + if hasA == enabled { + t.Fatalf("enabled=%v: stale A record present=%v", enabled, hasA) + } + if _, ok := fake.records["other.example.com/A"]; !ok { + t.Fatalf("enabled=%v: unrelated record was deleted", enabled) + } + } +} + +func TestServeHTTP_CleanupSkippedForDualUpdate(t *testing.T) { + fake := newFakeProvider() + h := NewDynDNSHandler(Config{Provider: fake, CleanupStaleRecords: true}) + + serve(h, "/nic/update?hostname=home.example.com&myip=1.2.3.4&myipv6=2a01:4f8::1", nil) + if len(fake.records) != 2 { + t.Fatalf("expected both records, got %v", fake.records) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f2cbd92..836c205 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -32,6 +32,9 @@ type Provider interface { // UpdateRecord creates or updates a record. changed is false if the // record already had the requested value and no update was sent. UpdateRecord(ctx context.Context, domain string, record *DNSRecord) (changed bool, err error) + // DeleteRecord deletes the record with the given name and type if it + // still has the given value. + DeleteRecord(ctx context.Context, domain string, record *DNSRecord) error Close(ctx context.Context) error } diff --git a/internal/provider/provider_aws_route53.go b/internal/provider/provider_aws_route53.go index 0c7f4a5..5d1d562 100644 --- a/internal/provider/provider_aws_route53.go +++ b/internal/provider/provider_aws_route53.go @@ -199,6 +199,49 @@ func (c *AwsRoute53Client) UpdateRecord(ctx context.Context, domain string, reco return true, nil } +// DeleteRecord deletes a DNS record if it still has the given value +func (c *AwsRoute53Client) DeleteRecord(ctx context.Context, domain string, record *DNSRecord) error { + logger.Debug("AWS Route53: Deleting record for domain=%s, name=%s, type=%s, value=%s", domain, record.Name, record.Type, record.Value) + + zoneID, err := c.getHostedZoneID(ctx, record.Name) + if err != nil { + return fmt.Errorf("get hosted zone: %w", err) + } + + recordSet, err := c.findRecordSet(ctx, zoneID, record.Name, record.Type) + if err != nil { + return err + } + + found := false + for _, rr := range recordSet.ResourceRecords { + if aws.ToString(rr.Value) == record.Value { + found = true + break + } + } + if !found { + return ErrRecordNotFound + } + + // Route53 requires the delete to match the existing record set exactly. + input := &route53.ChangeResourceRecordSetsInput{ + HostedZoneId: aws.String(zoneID), + ChangeBatch: &types.ChangeBatch{ + Changes: []types.Change{{ + Action: types.ChangeActionDelete, + ResourceRecordSet: recordSet, + }}, + }, + } + if _, err := c.client.ChangeResourceRecordSets(ctx, input); err != nil { + return fmt.Errorf("change resource record sets: %w", err) + } + + logger.Info("AWS Route53: Deleted record %s %s %s", record.Name, record.Type, record.Value) + return nil +} + // Close cleans up resources (no-op for Route53) func (c *AwsRoute53Client) Close(ctx context.Context) error { return nil diff --git a/internal/provider/provider_aws_route53_test.go b/internal/provider/provider_aws_route53_test.go index cc23893..7512b54 100644 --- a/internal/provider/provider_aws_route53_test.go +++ b/internal/provider/provider_aws_route53_test.go @@ -2,6 +2,7 @@ package provider import ( "context" + "errors" "fmt" "sync" "testing" @@ -319,3 +320,43 @@ func TestAwsRoute53Client_WildcardUnchanged(t *testing.T) { t.Fatalf("expected wildcard record to be reported as unchanged") } } + +func TestAwsRoute53Client_DeleteRecord(t *testing.T) { + existing := types.ResourceRecordSet{ + Name: aws.String("home.example.com."), + Type: types.RRTypeA, + TTL: aws.Int64(300), + ResourceRecords: []types.ResourceRecord{{Value: aws.String("192.0.2.1")}}, + } + mockAPI := zonesMock(zone("ZONE123", "example.com.", false)) + mockAPI.ListResourceRecordSetsFunc = func(ctx context.Context, params *route53.ListResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { + return &route53.ListResourceRecordSetsOutput{ResourceRecordSets: []types.ResourceRecordSet{existing}}, nil + } + var deleted *types.ResourceRecordSet + mockAPI.ChangeResourceRecordSetsFunc = func(ctx context.Context, params *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { + change := params.ChangeBatch.Changes[0] + if change.Action != types.ChangeActionDelete { + t.Fatalf("unexpected action: %s", change.Action) + } + deleted = change.ResourceRecordSet + return &route53.ChangeResourceRecordSetsOutput{}, nil + } + client := NewAwsRoute53ClientWithMock(mockAPI) + + // Value mismatch: nothing is deleted + err := client.DeleteRecord(context.Background(), "", &DNSRecord{Name: "home.example.com", Type: "A", Value: "192.0.2.9"}) + if !errors.Is(err, ErrRecordNotFound) { + t.Fatalf("expected ErrRecordNotFound, got %v", err) + } + if deleted != nil { + t.Fatalf("unexpected delete") + } + + err = client.DeleteRecord(context.Background(), "", &DNSRecord{Name: "home.example.com", Type: "A", Value: "192.0.2.1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if deleted == nil || aws.ToInt64(deleted.TTL) != 300 || aws.ToString(deleted.Name) != "home.example.com." { + t.Fatalf("delete must match the existing record set exactly, got %+v", deleted) + } +} diff --git a/internal/provider/provider_netcup_ccp.go b/internal/provider/provider_netcup_ccp.go index 1bfca66..e0aaf57 100644 --- a/internal/provider/provider_netcup_ccp.go +++ b/internal/provider/provider_netcup_ccp.go @@ -568,6 +568,31 @@ func (c *NetcupClient) UpdateRecord(ctx context.Context, domain string, record * return true, nil } +// DeleteRecord deletes a DNS record if it still has the given value +func (c *NetcupClient) DeleteRecord(ctx context.Context, domain string, record *DNSRecord) error { + logger.Debug("Netcup: Deleting record for domain=%s, name=%s, type=%s, value=%s", domain, record.Name, record.Type, record.Value) + + subdomain := c.extractSubdomain(record.Name, domain) + + records, err := c.InfoDNSRecords(ctx, domain) + if err != nil { + return fmt.Errorf("get DNS records: %w", err) + } + + for _, r := range records { + if r.Hostname == subdomain && r.Type == record.Type && r.Destination == record.Value { + r.Delete = true + if err := c.UpdateDNSRecords(ctx, domain, []netcupDNSRecord{r}); err != nil { + return fmt.Errorf("delete DNS record: %w", err) + } + logger.Info("Netcup: Deleted record %s %s %s", record.Name, record.Type, record.Value) + return nil + } + } + + return ErrRecordNotFound +} + // Close logs out and cleans up resources func (c *NetcupClient) Close(ctx context.Context) error { return c.Logout(ctx) diff --git a/internal/provider/provider_netcup_ccp_test.go b/internal/provider/provider_netcup_ccp_test.go index f188ff4..b94d71b 100644 --- a/internal/provider/provider_netcup_ccp_test.go +++ b/internal/provider/provider_netcup_ccp_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "slices" "sync" "testing" "time" @@ -75,7 +76,7 @@ func newMockNetcupAPIServer() *mockNetcupAPIServer { err := json.Unmarshal(paramBytes, ¶ms) assert.NoError(&testing.T{}, err) mock.mu.Lock() - mock.records = params.DNSRecordSet.DNSRecords + mock.applyUpdate(params.DNSRecordSet.DNSRecords) mock.mu.Unlock() resp = APIResponse{ Status: "success", @@ -102,6 +103,28 @@ func newMockNetcupAPIServer() *mockNetcupAPIServer { return mock } +// applyUpdate replaces the record set, or removes the records flagged with +// deleterecord if the request only contains deletions. +func (m *mockNetcupAPIServer) applyUpdate(records []netcupDNSRecord) { + var deleteIDs []string + for _, r := range records { + if r.Delete { + deleteIDs = append(deleteIDs, r.ID) + } + } + if len(deleteIDs) == 0 { + m.records = records + return + } + var kept []netcupDNSRecord + for _, r := range m.records { + if !slices.Contains(deleteIDs, r.ID) { + kept = append(kept, r) + } + } + m.records = kept +} + func (m *mockNetcupAPIServer) marshalRecords() string { m.mu.Lock() defer m.mu.Unlock() @@ -245,3 +268,24 @@ func TestNetcupProvider_NewNetcupProvider(t *testing.T) { _, err = NewNetcupProvider(context.Background(), nil) assert.Error(t, err) } + +func TestNetcupProvider_DeleteRecord(t *testing.T) { + mockServer := newMockNetcupAPIServer() + defer mockServer.Close() + + client := NewNetcupClient("user", "key", "pass").WithEndpoint(mockServer.server.URL) + + // Value mismatch: nothing is deleted + err := client.DeleteRecord(context.Background(), "example.com", &DNSRecord{Name: "www.example.com", Type: "A", Value: "9.9.9.9"}) + assert.IsError(t, err, ErrRecordNotFound) + + err = client.DeleteRecord(context.Background(), "example.com", &DNSRecord{Name: "www.example.com", Type: "A", Value: "1.1.1.1"}) + assert.NoError(t, err) + + _, err = client.GetRecord(context.Background(), "example.com", "www.example.com", "A") + assert.IsError(t, err, ErrRecordNotFound) + + // Other records are untouched + _, err = client.GetRecord(context.Background(), "example.com", "example.com", "A") + assert.NoError(t, err) +} From 8c5ba68989a6fef6c4fd0b83543676dfdbf0c9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:26:08 +0200 Subject: [PATCH 05/13] feat: restrict updatable hostnames via ALLOWED_HOSTNAMES DOMAIN was required but never checked in the server path, so any authenticated client could update any hostname the provider manages. - ALLOWED_HOSTNAMES (comma-separated, "*.suffix" patterns allowed) rejects all other hostnames with 403 "nohost". - DOMAIN is now only required by the update command. If set, the server uses it as the zone for hostnames within it instead of assuming the last two labels. - Accept DNS_PROVIDER=route53 as an alias for aws_route53, as used in the documentation and the Home Assistant add-on schema. Co-authored-by: Claude --- cmd/config.go | 28 +++++++++--- cmd/config_test.go | 75 +++++++++++++++++++++++++++++++++ cmd/server.go | 5 +++ cmd/update.go | 4 ++ internal/handler/dyndns.go | 47 ++++++++++++++++++++- internal/handler/dyndns_test.go | 69 ++++++++++++++++++++++++++++++ 6 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 cmd/config_test.go diff --git a/cmd/config.go b/cmd/config.go index 54ee03d..9ea4413 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -24,11 +24,25 @@ type Config struct { SSL bool CertFile string KeyFile string + AllowedHostnames []string TrustedProxies []netip.Prefix AllowPrivateIPs bool CleanupStaleRecords bool } +// parseHostnameList parses a comma-separated list of hostnames or +// "*.suffix" patterns into normalized lowercase entries. +func parseHostnameList(list string) []string { + var hostnames []string + for _, entry := range strings.Split(list, ",") { + entry = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(entry), ".")) + if entry != "" { + hostnames = append(hostnames, entry) + } + } + return hostnames +} + // envBool reports whether an environment variable is set to "true" or "1". func envBool(key string) bool { value := strings.ToLower(os.Getenv(key)) @@ -149,18 +163,22 @@ func LoadConfig() (*Config, error) { if provider := os.Getenv("DNS_PROVIDER"); provider != "" { logger.Debug("Reading DNS_PROVIDER from env: %s", provider) config.Provider = strings.ToLower(provider) + if config.Provider == "route53" { + config.Provider = "aws_route53" // alias + } logger.Debug("Set DNS provider to: %s", config.Provider) } else { logger.Debug("DNS_PROVIDER not set, using default: %s", config.Provider) } - // Domain - config.Domain = os.Getenv("DOMAIN") - if config.Domain == "" { - return nil, logger.Errorf("DOMAIN is required") - } + // Domain (required by the update command, optional for the server) + config.Domain = strings.ToLower(strings.TrimSuffix(os.Getenv("DOMAIN"), ".")) logger.Debug("Domain: %s", config.Domain) + // Hostname authorization + config.AllowedHostnames = parseHostnameList(os.Getenv("ALLOWED_HOSTNAMES")) + logger.Debug("Allowed hostnames: %v", config.AllowedHostnames) + // TTL if ttl := os.Getenv("DNS_TTL"); ttl != "" { logger.Debug("Reading DNS_TTL from env: %s", ttl) diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..7ee3d1a --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "testing" +) + +func setBaseEnv(t *testing.T) { + t.Setenv("ADDON_OPTIONS_PATH", "") + t.Setenv("AUTH_USERNAME", "dyndns") + t.Setenv("AUTH_PASSWORD", "secret") + t.Setenv("DOMAIN", "") + t.Setenv("DNS_PROVIDER", "") + t.Setenv("ALLOWED_HOSTNAMES", "") + t.Setenv("TRUSTED_PROXIES", "") + t.Setenv("ALLOW_PRIVATE_IPS", "") + t.Setenv("CLEANUP_STALE_RECORD", "") + t.Setenv("SSL", "") +} + +func TestLoadConfig_DomainOptional(t *testing.T) { + setBaseEnv(t) + t.Setenv("DNS_PROVIDER", "aws_route53") + + config, err := LoadConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if config.Domain != "" { + t.Fatalf("expected empty domain, got %q", config.Domain) + } + + if err := RunUpdate("home.example.com", "A", config); err == nil { + t.Fatalf("expected update command to require DOMAIN") + } +} + +func TestLoadConfig_Route53Alias(t *testing.T) { + setBaseEnv(t) + t.Setenv("DNS_PROVIDER", "Route53") + + config, err := LoadConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if config.Provider != "aws_route53" { + t.Fatalf("expected aws_route53, got %q", config.Provider) + } +} + +func TestLoadConfig_IPOptions(t *testing.T) { + setBaseEnv(t) + t.Setenv("ALLOWED_HOSTNAMES", " Home.Example.com., *.dyn.example.com ,") + t.Setenv("TRUSTED_PROXIES", "10.0.0.0/8,::1") + t.Setenv("ALLOW_PRIVATE_IPS", "true") + t.Setenv("CLEANUP_STALE_RECORD", "1") + + config, err := LoadConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(config.AllowedHostnames) != 2 || config.AllowedHostnames[0] != "home.example.com" || config.AllowedHostnames[1] != "*.dyn.example.com" { + t.Fatalf("unexpected allowed hostnames: %v", config.AllowedHostnames) + } + if len(config.TrustedProxies) != 2 { + t.Fatalf("unexpected trusted proxies: %v", config.TrustedProxies) + } + if !config.AllowPrivateIPs || !config.CleanupStaleRecords { + t.Fatalf("expected boolean options to be enabled") + } + + t.Setenv("TRUSTED_PROXIES", "not-a-cidr") + if _, err := LoadConfig(); err == nil { + t.Fatalf("expected error for invalid TRUSTED_PROXIES") + } +} diff --git a/cmd/server.go b/cmd/server.go index 745fb6b..16a7cc8 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -29,12 +29,17 @@ func RunServer(port int, config *Config) error { } logger.Info("Using DNS provider: %s", p.Name()) + if len(config.AllowedHostnames) == 0 { + logger.Warn("ALLOWED_HOSTNAMES is not set: authenticated clients can update any hostname the provider manages") + } clientIPResolver := clientip.Resolver{TrustedProxies: config.TrustedProxies} dyndnsHandler := handler.NewDynDNSHandler(handler.Config{ Provider: p, DefaultTTL: config.DefaultTTL, + Domain: config.Domain, + AllowedHostnames: config.AllowedHostnames, ClientIP: clientIPResolver, AllowPrivateIPs: config.AllowPrivateIPs, CleanupStaleRecords: config.CleanupStaleRecords, diff --git a/cmd/update.go b/cmd/update.go index e128b00..286638f 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -12,6 +12,10 @@ import ( ) func RunUpdate(hostname, recordType string, config *Config) error { + if config.Domain == "" { + return fmt.Errorf("DOMAIN is required for the update command") + } + publicIP, err := getPublicIP() if err != nil { return fmt.Errorf("failed to get public IP: %w", err) diff --git a/internal/handler/dyndns.go b/internal/handler/dyndns.go index b318b81..49fc97d 100644 --- a/internal/handler/dyndns.go +++ b/internal/handler/dyndns.go @@ -19,6 +19,11 @@ import ( type Config struct { Provider provider.Provider DefaultTTL int + // Domain, if set, is used as the zone for hostnames within it. + Domain string + // AllowedHostnames restricts which hostnames may be updated. Entries + // are exact names or "*.suffix" patterns; empty allows all hostnames. + AllowedHostnames []string // ClientIP determines the client address when no myip/myipv6 is given. ClientIP clientip.Resolver // AllowPrivateIPs disables the rejection of non-public addresses. @@ -71,6 +76,12 @@ func (h *DynDNSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { hostname = h.normalizeHostname(hostname) logger.Debug("Normalized hostname: %s", hostname) + if !h.hostnameAllowed(hostname) { + logger.Warn("Hostname %s is not in ALLOWED_HOSTNAMES, rejecting request from %s", hostname, r.RemoteAddr) + h.respondStatus(w, http.StatusForbidden, "nohost", "", isStandardFormat) + return + } + domain, subdomain := h.splitHostname(hostname) if domain == "" { logger.Warn("Failed to split hostname '%s' into domain and subdomain", hostname) @@ -152,10 +163,38 @@ func (h *DynDNSHandler) normalizeHostname(hostname string) string { return strings.ToLower(hostname) } -// splitHostname splits a hostname into domain and subdomain +// hostnameAllowed reports whether hostname matches AllowedHostnames. +func (h *DynDNSHandler) hostnameAllowed(hostname string) bool { + if len(h.config.AllowedHostnames) == 0 { + return true + } + for _, allowed := range h.config.AllowedHostnames { + if suffix, isPattern := strings.CutPrefix(allowed, "*"); isPattern { + if strings.HasPrefix(suffix, ".") && len(hostname) > len(suffix) && strings.HasSuffix(hostname, suffix) { + return true + } + } else if hostname == allowed { + return true + } + } + return false +} + +// splitHostname splits a hostname into domain and subdomain. +// If the configured Domain contains the hostname, it is used as the domain; +// otherwise the last two labels are assumed to be the domain. // Example: "test.example.com" -> domain="example.com", subdomain="test" // Example: "*.example.com" -> domain="example.com", subdomain="*" func (h *DynDNSHandler) splitHostname(hostname string) (domain, subdomain string) { + if configured := strings.ToLower(h.config.Domain); configured != "" { + if hostname == configured { + return configured, "@" + } + if prefix, ok := strings.CutSuffix(hostname, "."+configured); ok && prefix != "" { + return configured, prefix + } + } + parts := strings.Split(hostname, ".") if len(parts) < 2 { return "", "" @@ -294,7 +333,13 @@ func (h *DynDNSHandler) buildHostname(subdomain, domain string) string { // respond sends a DynDNS response func (h *DynDNSHandler) respond(w http.ResponseWriter, status, ip string, standardFormat bool) { + h.respondStatus(w, http.StatusOK, status, ip, standardFormat) +} + +// respondStatus sends a DynDNS response with the given HTTP status code +func (h *DynDNSHandler) respondStatus(w http.ResponseWriter, code int, status, ip string, standardFormat bool) { w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(code) if standardFormat { // Standard format includes IP diff --git a/internal/handler/dyndns_test.go b/internal/handler/dyndns_test.go index 31cfb70..f4f7448 100644 --- a/internal/handler/dyndns_test.go +++ b/internal/handler/dyndns_test.go @@ -209,3 +209,72 @@ func TestServeHTTP_CleanupSkippedForDualUpdate(t *testing.T) { t.Fatalf("expected both records, got %v", fake.records) } } + +func TestServeHTTP_AllowedHostnames(t *testing.T) { + allowed := []string{"home.example.com", "*.dyn.example.com"} + testCases := []struct { + hostname string + allowed bool + }{ + {"home.example.com", true}, + {"HOME.example.com", true}, + {"a.dyn.example.com", true}, + {"a.b.dyn.example.com", true}, + {"*.dyn.example.com", true}, + {"dyn.example.com", false}, + {"evil-dyn.example.com", false}, + {"other.example.com", false}, + {"home.example.com.evil.org", false}, + } + + for _, tc := range testCases { + t.Run(tc.hostname, func(t *testing.T) { + fake := newFakeProvider() + h := NewDynDNSHandler(Config{Provider: fake, AllowedHostnames: allowed}) + rec := serve(h, "/nic/update?hostname="+tc.hostname+"&myip=1.2.3.4", nil) + if tc.allowed { + if rec.Code != http.StatusOK || body(rec) != "good 1.2.3.4" { + t.Fatalf("expected update, got %d %q", rec.Code, body(rec)) + } + return + } + if rec.Code != http.StatusForbidden || body(rec) != "nohost" { + t.Fatalf("expected 403 nohost, got %d %q", rec.Code, body(rec)) + } + if len(fake.updates) != 0 { + t.Fatalf("unexpected provider update") + } + }) + } + + // UniFi path format is checked as well + h := NewDynDNSHandler(Config{Provider: newFakeProvider(), AllowedHostnames: allowed}) + if rec := serve(h, "/other.example.com?myip=1.2.3.4", nil); rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for UniFi format, got %d", rec.Code) + } +} + +func TestSplitHostname(t *testing.T) { + testCases := []struct { + configured string + hostname string + domain string + subdomain string + }{ + {"", "test.example.com", "example.com", "test"}, + {"", "example.com", "example.com", "@"}, + {"", "*.example.com", "example.com", "*"}, + {"example.co.uk", "home.example.co.uk", "example.co.uk", "home"}, + {"example.co.uk", "example.co.uk", "example.co.uk", "@"}, + {"example.co.uk", "home.other.org", "other.org", "home"}, + {"example.com", "a.b.example.com", "example.com", "a.b"}, + } + for _, tc := range testCases { + h := NewDynDNSHandler(Config{Domain: tc.configured}) + domain, subdomain := h.splitHostname(tc.hostname) + if domain != tc.domain || subdomain != tc.subdomain { + t.Errorf("splitHostname(%q) with DOMAIN=%q = %q, %q; expected %q, %q", + tc.hostname, tc.configured, domain, subdomain, tc.domain, tc.subdomain) + } + } +} From 9dc53fbc27e8608c088a9d6345bad6edd0124900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:26:52 +0200 Subject: [PATCH 06/13] fix(auth): constant-time credential check and failed-login limit Compare SHA-256 digests of username and password with subtle.ConstantTimeCompare and evaluate both to avoid timing side channels. Reject further requests with 429 after 10 failed logins from the same client address within 15 minutes (standard library only, no new dependency). The client address honors TRUSTED_PROXIES. Co-authored-by: Claude --- cmd/server.go | 1 + internal/auth/auth.go | 146 +++++++++++++++++++++++++++++++------ internal/auth/auth_test.go | 95 ++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 24 deletions(-) create mode 100644 internal/auth/auth_test.go diff --git a/cmd/server.go b/cmd/server.go index 16a7cc8..324a11f 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -48,6 +48,7 @@ func RunServer(port int, config *Config) error { authMiddleware := auth.Middleware(auth.Config{ Username: config.Username, Password: config.Password, + ClientIP: clientIPResolver, }) mux := http.NewServeMux() diff --git a/internal/auth/auth.go b/internal/auth/auth.go index d088f29..84d38fa 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,65 +1,163 @@ package auth import ( + "crypto/sha256" + "crypto/subtle" "encoding/base64" + "fmt" "net/http" + "net/netip" "strings" + "sync" + "time" + + "github.com/markussiebert/homeddns/internal/clientip" + "github.com/markussiebert/homeddns/internal/logger" +) + +const ( + // maxFailedAttempts is the number of failed logins per client address + // within failureWindow after which further requests are rejected. + maxFailedAttempts = 10 + failureWindow = 15 * time.Minute + // pruneThreshold is the number of tracked addresses above which expired + // entries are removed. + pruneThreshold = 1024 ) // Config represents authentication configuration type Config struct { Username string Password string + // ClientIP determines the address used for rate limiting failed logins. + ClientIP clientip.Resolver } // Middleware creates a basic auth middleware func Middleware(config Config) func(http.Handler) http.Handler { + limiter := newFailureLimiter(time.Now) + return middleware(config, limiter) +} + +func middleware(config Config, limiter *failureLimiter) func(http.Handler) http.Handler { + expectedUsername := sha256.Sum256([]byte(config.Username)) + expectedPassword := sha256.Sum256([]byte(config.Password)) + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Extract basic auth credentials - auth := r.Header.Get("Authorization") - if auth == "" { - unauthorized(w) - return - } - - // Parse "Basic " - const prefix = "Basic " - if !strings.HasPrefix(auth, prefix) { - unauthorized(w) - return - } + // An unparseable address falls back to the zero address, which + // then acts as a shared bucket. + client, _ := config.ClientIP.FromRequest(r) - decoded, err := base64.StdEncoding.DecodeString(auth[len(prefix):]) - if err != nil { - unauthorized(w) + if limiter.blocked(client) { + logger.Warn("Too many failed login attempts from %s, rejecting request", client) + w.Header().Set("Retry-After", fmt.Sprintf("%d", int(failureWindow.Seconds()))) + http.Error(w, "429 Too Many Requests", http.StatusTooManyRequests) return } - // Split username:password - credentials := string(decoded) - parts := strings.SplitN(credentials, ":", 2) - if len(parts) != 2 { + username, password, ok := parseBasicAuth(r.Header.Get("Authorization")) + if !ok { unauthorized(w) return } - username, password := parts[0], parts[1] - - // Verify credentials - if username != config.Username || password != config.Password { + // Compare fixed-length digests in constant time, and evaluate + // both comparisons to not reveal which one failed. + usernameDigest := sha256.Sum256([]byte(username)) + passwordDigest := sha256.Sum256([]byte(password)) + usernameMatch := subtle.ConstantTimeCompare(usernameDigest[:], expectedUsername[:]) + passwordMatch := subtle.ConstantTimeCompare(passwordDigest[:], expectedPassword[:]) + if usernameMatch&passwordMatch != 1 { + limiter.recordFailure(client) + logger.Warn("Failed login attempt from %s", client) unauthorized(w) return } // Authentication successful + limiter.reset(client) next.ServeHTTP(w, r) }) } } +// parseBasicAuth extracts the credentials from a "Basic " header +func parseBasicAuth(header string) (username, password string, ok bool) { + const prefix = "Basic " + if !strings.HasPrefix(header, prefix) { + return "", "", false + } + + decoded, err := base64.StdEncoding.DecodeString(header[len(prefix):]) + if err != nil { + return "", "", false + } + + // Split username:password + return strings.Cut(string(decoded), ":") +} + func unauthorized(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="DynDNS"`) w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("401 Unauthorized\n")) } + +// failureLimiter counts failed logins per client address in fixed windows. +type failureLimiter struct { + mu sync.Mutex + now func() time.Time + failures map[netip.Addr]*failureCount +} + +type failureCount struct { + count int + windowStart time.Time +} + +func newFailureLimiter(now func() time.Time) *failureLimiter { + return &failureLimiter{now: now, failures: make(map[netip.Addr]*failureCount)} +} + +// blocked reports whether addr exceeded the failed attempts in the current window. +func (l *failureLimiter) blocked(addr netip.Addr) bool { + l.mu.Lock() + defer l.mu.Unlock() + entry, ok := l.failures[addr] + if !ok { + return false + } + if l.now().Sub(entry.windowStart) >= failureWindow { + delete(l.failures, addr) + return false + } + return entry.count >= maxFailedAttempts +} + +func (l *failureLimiter) recordFailure(addr netip.Addr) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + + if len(l.failures) > pruneThreshold { + for key, entry := range l.failures { + if now.Sub(entry.windowStart) >= failureWindow { + delete(l.failures, key) + } + } + } + + entry, ok := l.failures[addr] + if !ok || now.Sub(entry.windowStart) >= failureWindow { + l.failures[addr] = &failureCount{count: 1, windowStart: now} + return + } + entry.count++ +} + +func (l *failureLimiter) reset(addr netip.Addr) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.failures, addr) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..ca0971f --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,95 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func newTestHandler(now func() time.Time) http.Handler { + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + limiter := newFailureLimiter(now) + return middleware(Config{Username: "dyndns", Password: "secret"}, limiter)(ok) +} + +func request(h http.Handler, remoteAddr, username, password string, withAuth bool) int { + req := httptest.NewRequest(http.MethodGet, "/nic/update", nil) + req.RemoteAddr = remoteAddr + if withAuth { + req.SetBasicAuth(username, password) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code +} + +func TestMiddleware_Credentials(t *testing.T) { + h := newTestHandler(time.Now) + testCases := []struct { + name string + username string + password string + withAuth bool + expected int + }{ + {"valid", "dyndns", "secret", true, http.StatusOK}, + {"wrong password", "dyndns", "wrong", true, http.StatusUnauthorized}, + {"wrong username", "admin", "secret", true, http.StatusUnauthorized}, + {"password prefix", "dyndns", "secre", true, http.StatusUnauthorized}, + {"password with colon", "dyndns", "secret:extra", true, http.StatusUnauthorized}, + {"empty", "", "", true, http.StatusUnauthorized}, + {"missing header", "", "", false, http.StatusUnauthorized}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if got := request(h, "1.2.3.4:5555", tc.username, tc.password, tc.withAuth); got != tc.expected { + t.Fatalf("expected %d, got %d", tc.expected, got) + } + }) + } +} + +func TestMiddleware_RateLimitsFailedLogins(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + h := newTestHandler(func() time.Time { return now }) + + for i := 0; i < maxFailedAttempts; i++ { + if got := request(h, "1.2.3.4:5555", "dyndns", "wrong", true); got != http.StatusUnauthorized { + t.Fatalf("attempt %d: expected 401, got %d", i, got) + } + } + + // Blocked, even with valid credentials + if got := request(h, "1.2.3.4:5555", "dyndns", "secret", true); got != http.StatusTooManyRequests { + t.Fatalf("expected 429, got %d", got) + } + // Other addresses are not affected + if got := request(h, "[2a01:4f8::1]:5555", "dyndns", "secret", true); got != http.StatusOK { + t.Fatalf("expected 200 for other address, got %d", got) + } + + // Window expires + now = now.Add(failureWindow) + if got := request(h, "1.2.3.4:5555", "dyndns", "secret", true); got != http.StatusOK { + t.Fatalf("expected 200 after window, got %d", got) + } +} + +func TestMiddleware_SuccessResetsFailures(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + h := newTestHandler(func() time.Time { return now }) + + for i := 0; i < maxFailedAttempts-1; i++ { + request(h, "1.2.3.4:5555", "dyndns", "wrong", true) + } + request(h, "1.2.3.4:5555", "dyndns", "secret", true) + if got := request(h, "1.2.3.4:5555", "dyndns", "wrong", true); got != http.StatusUnauthorized { + t.Fatalf("expected 401 after reset, got %d", got) + } + if got := request(h, "1.2.3.4:5555", "dyndns", "secret", true); got != http.StatusOK { + t.Fatalf("expected 200, got %d", got) + } +} From c4164fe344d09f5a1eb1bc6e7677b2780a627d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:27:34 +0200 Subject: [PATCH 07/13] docs: fix provider names and password handling in examples and add-on - .env.local.example used DNS_PROVIDER=netcup and AUTH_PASSWORD_HASH; the code expects netcup_ccp and a plain AUTH_PASSWORD. - Home Assistant add-on: allow aws_route53 (route53 kept as alias), make domain optional, replace the bcrypt hash instructions and document that LAN clients must send myip. Co-authored-by: Claude --- .env.local.example | 21 ++++++++++++++------- homeassistant/DOCS.md | 10 ++++++---- homeassistant/README.md | 15 +++++++++------ homeassistant/config.yaml | 4 ++-- homeassistant/translations/en.yaml | 10 +++++----- mise.toml | 2 +- 6 files changed, 37 insertions(+), 25 deletions(-) diff --git a/.env.local.example b/.env.local.example index eb2a4fa..681c2b6 100644 --- a/.env.local.example +++ b/.env.local.example @@ -14,18 +14,24 @@ SSL=false CERTFILE=/path/to/fullchain.pem KEYFILE=/path/to/privkey.pem -# Authentication +# Authentication (plain text password, compared in constant time) AUTH_USERNAME=dyndns -# Generate hash: echo "your-password" | docker run --rm -i ghcr.io/markussiebert/homeddns:latest hash-password -# IMPORTANT: bcrypt hashes contain $ characters - use the hash directly in .env files (no quotes needed) -# For shell: use SINGLE quotes like AUTH_PASSWORD_HASH='$2a$10$...' to prevent $ expansion -AUTH_PASSWORD_HASH=$2a$10$VpADQ4ns1gr1LbHZr/2/f.LdrKT8chhHUJVoMyjOv1A3Y5msQQJVi +AUTH_PASSWORD=your-secure-password -# DNS Provider (netcup or route53) -DNS_PROVIDER=netcup +# DNS Provider (netcup_ccp or aws_route53; default: netcup_ccp) +DNS_PROVIDER=netcup_ccp +# Optional for the server, required for the update command DOMAIN=example.com DNS_TTL=60 +# Hostname authorization (comma-separated, *.suffix allowed; empty = all) +# ALLOWED_HOSTNAMES=home.example.com,*.dyn.example.com + +# IP handling +# TRUSTED_PROXIES=10.0.0.0/8,fd00::/8 +# ALLOW_PRIVATE_IPS=false +# CLEANUP_STALE_RECORD=false + # Netcup Credentials NETCUP_CUSTOMER_NUMBER=123456 NETCUP_API_KEY=your-api-key @@ -35,3 +41,4 @@ NETCUP_API_PASSWORD=your-api-password # AWS_ACCESS_KEY_ID=your-access-key # AWS_SECRET_ACCESS_KEY=your-secret-key # AWS_REGION=us-east-1 +# ROUTE53_ZONE_ID=Z0123456789ABCDEFGHIJ diff --git a/homeassistant/DOCS.md b/homeassistant/DOCS.md index 2263161..121fd5d 100644 --- a/homeassistant/DOCS.md +++ b/homeassistant/DOCS.md @@ -76,9 +76,8 @@ Example configuration for AWS Route53: ```yaml auth_username: "dyndns" -auth_password: "$2a$10$VpADQ4ns1gr1LbHZr/2/f.LdrKT8chhHUJVoMyjOv1A3Y5msQQJVi" -dns_provider: "route53" -domain: "example.com" +auth_password: "your-secure-password" +dns_provider: "aws_route53" dns_ttl: 60 aws_access_key_id: "AKIAIOSFODNN7EXAMPLE" aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" @@ -97,7 +96,9 @@ Most modern routers support DynDNS. Here's how to set it up: **UniFi Devices:** - Service: `custom` -- Server: `homeassistant.local:8053/%h` +- Server: `homeassistant.local:8053/nic/update?hostname=%h&myip=%i` + +**Note:** When the router talks to Home Assistant over the LAN, its source address is private and is rejected with `911`. The router therefore has to send the public address via `myip` (or `myipv6`). **Fritz!Box:** - Update-URL: `http://homeassistant.local:8053/nic/update?hostname=&myip=` @@ -136,6 +137,7 @@ If you're using Nginx Proxy Manager or another reverse proxy: ### Common Issues **"911" Error Response** +- The IP address is not public (private, CGNAT, link-local, ...). Send the public address via `myip`/`myipv6` - Check your DNS provider credentials - Verify the domain exists in your DNS provider account - Check the add-on logs for detailed error messages diff --git a/homeassistant/README.md b/homeassistant/README.md index b89a6b2..4026f49 100644 --- a/homeassistant/README.md +++ b/homeassistant/README.md @@ -49,7 +49,7 @@ When Home Assistant launches the add-on (or you run the Docker image without CLI ```yaml auth_username: "dyndns" -auth_password: "$2a$10$..." # Paste the hash you generated above +auth_password: "your-secure-password" # plain text, not a hash dns_provider: "netcup_ccp" domain: "example.com" dns_ttl: 60 @@ -80,7 +80,7 @@ netcup_api_password: "your-api-password" For AWS Route53: ```yaml -dns_provider: "route53" +dns_provider: "aws_route53" aws_access_key_id: "AKIAIOSFODNN7EXAMPLE" aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" aws_region: "us-east-1" @@ -97,8 +97,8 @@ aws_region: "us-east-1" |--------|----------|---------|-------------| | `auth_username` | Yes | `dyndns` | Username for Basic Authentication | | `auth_password` | Yes | - | Your password for Basic Authentication | -| `dns_provider` | Yes | `netcup_ccp` | DNS provider (`netcup_ccp` or `route53`) | -| `domain` | Yes | - | Your domain name (e.g., `example.com`) | +| `dns_provider` | Yes | `netcup_ccp` | DNS provider (`netcup_ccp` or `aws_route53`; `route53` is accepted as an alias) | +| `domain` | No | - | Zone for hostnames within it (e.g., `example.co.uk`). Without it, Netcup uses the last two labels and Route53 discovers the zone | | `dns_ttl` | No | `60` | DNS record TTL in seconds (30-86400) | | `port` | No | `8053` | HTTP server port | | `netcup_customer_number` | For Netcup | - | Your Netcup customer number | @@ -106,7 +106,7 @@ aws_region: "us-east-1" | `netcup_api_password` | For Netcup | - | Netcup API password | | `aws_access_key_id` | For Route53 | - | AWS access key ID | | `aws_secret_access_key` | For Route53 | - | AWS secret access key | -| `aws_region` | For Route53 | `us-east-1` | AWS region | +| `aws_region` | For Route53 | - | AWS region for the SDK (Route53 itself is global, e.g. `us-east-1`) | ## Usage @@ -124,6 +124,8 @@ If you prefer direct access, configure your DynDNS client to use: **Authentication:** Basic Auth with your configured username/password +**Note:** If your router reaches Home Assistant over the local network, the request source is a private LAN address, which HomeDDNS rejects with `911`. In that case the router must send the public address via `myip` (or `myipv6`). + ### Router Configuration Examples #### UniFi Dream Machine @@ -134,7 +136,8 @@ If you prefer direct access, configure your DynDNS client to use: - Hostname: `home.example.com` - Username: `dyndns` - Password: Your configured password - - Server: `homeassistant.local:8053/%h` + - Server: `homeassistant.local:8053/nic/update?hostname=%h&myip=%i` + - Use this only if `%i` is a public address. Behind CGNAT it is a `100.64.0.0/10` address and is rejected; see the IPv6 / CGNAT section of the main README. #### Fritz!Box diff --git a/homeassistant/config.yaml b/homeassistant/config.yaml index d6f0050..7ed712b 100644 --- a/homeassistant/config.yaml +++ b/homeassistant/config.yaml @@ -46,8 +46,8 @@ options: schema: auth_username: str auth_password: password - dns_provider: list(netcup_ccp|route53) - domain: str + dns_provider: list(netcup_ccp|aws_route53|route53) + domain: str? dns_ttl: int(30,86400) port: int(1024,65535) log_level: list(debug|info|warn|error)? diff --git a/homeassistant/translations/en.yaml b/homeassistant/translations/en.yaml index 214347f..7d5de6e 100644 --- a/homeassistant/translations/en.yaml +++ b/homeassistant/translations/en.yaml @@ -2,15 +2,15 @@ configuration: auth_username: name: "Username" description: "Username for Basic Authentication (used by DynDNS clients)" - auth_password_hash: - name: "Password Hash" - description: "Bcrypt hash of your password. Generate with: docker run --rm -i ghcr.io/markussiebert/homeddns:latest hash-password" + auth_password: + name: "Password" + description: "Password for Basic Authentication (stored and compared as plain text)" dns_provider: name: "DNS Provider" - description: "Choose your DNS hosting provider" + description: "Choose your DNS hosting provider (netcup_ccp or aws_route53; route53 is accepted as an alias)" domain: name: "Domain" - description: "Your domain name (e.g., example.com)" + description: "Optional. Zone used for hostnames within it (e.g., example.com or example.co.uk); otherwise the last two labels are used (Netcup) or the zone is discovered automatically (Route53)" dns_ttl: name: "DNS TTL" description: "DNS record Time-To-Live in seconds (lower = faster updates, higher = less DNS queries)" diff --git a/mise.toml b/mise.toml index 3deff18..a006416 100644 --- a/mise.toml +++ b/mise.toml @@ -92,7 +92,7 @@ description = "Tidy go modules" run = "go mod tidy" [tasks.run] -description = "Run homeddns server (set env vars: AUTH_USERNAME, AUTH_PASSWORD_HASH, etc.)" +description = "Run homeddns server (set env vars: AUTH_USERNAME, AUTH_PASSWORD, etc.)" run = "go run main.go server" [tasks."run:dev"] From f4db296d6baffe1de8b02b876a012ac3b36b518d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:28:09 +0200 Subject: [PATCH 08/13] docs(k8s): add example Kubernetes manifests The README referenced k8s/deployment.yaml, k8s/deployment-route53.yaml and k8s/service.yaml, which did not exist. Add them together with a secret example. The deployments use /health as readiness and liveness probe, run as non-root with a read-only root filesystem, and the Route53 variant includes an IRSA ServiceAccount. Co-authored-by: Claude --- k8s/deployment-route53.yaml | 100 ++++++++++++++++++++++++++++++++++++ k8s/deployment.yaml | 89 ++++++++++++++++++++++++++++++++ k8s/secret.example.yaml | 18 +++++++ k8s/service.yaml | 17 ++++++ 4 files changed, 224 insertions(+) create mode 100644 k8s/deployment-route53.yaml create mode 100644 k8s/deployment.yaml create mode 100644 k8s/secret.example.yaml create mode 100644 k8s/service.yaml diff --git a/k8s/deployment-route53.yaml b/k8s/deployment-route53.yaml new file mode 100644 index 0000000..303a9b0 --- /dev/null +++ b/k8s/deployment-route53.yaml @@ -0,0 +1,100 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: homeddns + labels: + app: homeddns +spec: + replicas: 1 + selector: + matchLabels: + app: homeddns + template: + metadata: + labels: + app: homeddns + spec: + # IRSA (EKS): the ServiceAccount below carries the IAM role. + # Remove this line when using static credentials. + serviceAccountName: homeddns-route53 + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + containers: + - name: homeddns + # Upstream image. Build your own from this repository with + # `mise run build:ko-push` (set KO_DOCKER_REPO) to get local changes. + image: ghcr.io/markussiebert/homeddns:latest + args: ["server"] + ports: + - name: http + containerPort: 8053 + env: + - name: AUTH_USERNAME + valueFrom: + secretKeyRef: + name: homeddns-secret + key: auth-username + - name: AUTH_PASSWORD + valueFrom: + secretKeyRef: + name: homeddns-secret + key: auth-password + - name: DNS_TTL + value: "60" + # Restrict which hostnames clients may update + - name: ALLOWED_HOSTNAMES + value: home.example.com + # The Ingress controller is the direct peer; trust its + # X-Forwarded-For header. Adjust to your pod network CIDRs. + - name: TRUSTED_PROXIES + value: 10.0.0.0/8,fd00::/8 + - name: DNS_PROVIDER + value: aws_route53 + - name: AWS_REGION + value: us-east-1 + # Optional: skip zone discovery (allows the minimal IAM policy) + # - name: ROUTE53_ZONE_ID + # value: Z0123456789ABCDEFGHIJ + # Static credentials instead of IRSA: + # - name: AWS_ACCESS_KEY_ID + # valueFrom: + # secretKeyRef: + # name: homeddns-secret + # key: aws-access-key-id + # - name: AWS_SECRET_ACCESS_KEY + # valueFrom: + # secretKeyRef: + # name: homeddns-secret + # key: aws-secret-access-key + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + periodSeconds: 30 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +--- +# IRSA (EKS): annotate with an IAM role that has the Route53 policy from +# the README. Delete this ServiceAccount when using static credentials. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: homeddns-route53 + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT_ID:role/homeddns-route53 diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..acf4fc7 --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: homeddns + labels: + app: homeddns +spec: + replicas: 1 + selector: + matchLabels: + app: homeddns + template: + metadata: + labels: + app: homeddns + spec: + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + containers: + - name: homeddns + # Upstream image. Build your own from this repository with + # `mise run build:ko-push` (set KO_DOCKER_REPO) to get local changes. + image: ghcr.io/markussiebert/homeddns:latest + args: ["server"] + ports: + - name: http + containerPort: 8053 + env: + - name: AUTH_USERNAME + valueFrom: + secretKeyRef: + name: homeddns-secret + key: auth-username + - name: AUTH_PASSWORD + valueFrom: + secretKeyRef: + name: homeddns-secret + key: auth-password + - name: DNS_TTL + value: "60" + # Restrict which hostnames clients may update + - name: ALLOWED_HOSTNAMES + value: home.example.com + # The Ingress controller is the direct peer; trust its + # X-Forwarded-For header. Adjust to your pod network CIDRs. + - name: TRUSTED_PROXIES + value: 10.0.0.0/8,fd00::/8 + - name: DNS_PROVIDER + value: netcup_ccp + - name: DOMAIN + value: example.com + - name: NETCUP_CUSTOMER_NUMBER + valueFrom: + secretKeyRef: + name: homeddns-secret + key: netcup-customer-number + - name: NETCUP_API_KEY + valueFrom: + secretKeyRef: + name: homeddns-secret + key: netcup-api-key + - name: NETCUP_API_PASSWORD + valueFrom: + secretKeyRef: + name: homeddns-secret + key: netcup-api-password + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + periodSeconds: 30 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] diff --git a/k8s/secret.example.yaml b/k8s/secret.example.yaml new file mode 100644 index 0000000..588089a --- /dev/null +++ b/k8s/secret.example.yaml @@ -0,0 +1,18 @@ +# Example secret for homeddns. Copy, fill in and apply, or create it with +# `kubectl create secret generic homeddns-secret --from-literal=...`. +# Do not commit real credentials. +apiVersion: v1 +kind: Secret +metadata: + name: homeddns-secret +type: Opaque +stringData: + auth-username: dyndns + auth-password: your-secure-password + # Netcup provider only + netcup-customer-number: "123456" + netcup-api-key: your-api-key + netcup-api-password: your-api-password + # Route53 provider with static credentials only (not needed with IRSA) + # aws-access-key-id: AKIA... + # aws-secret-access-key: ... diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..724b407 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: homeddns + labels: + app: homeddns +spec: + type: ClusterIP + # Set to RequireDualStack/PreferDualStack on dual-stack clusters so the + # service is reachable via IPv6 (needed for IPv6-only DynDNS clients). + ipFamilyPolicy: SingleStack + selector: + app: homeddns + ports: + - name: http + port: 80 + targetPort: http From 86f2810bf4d600834ebf506f3cd78d5703234739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:29:50 +0200 Subject: [PATCH 09/13] docs: align README with the code and document IPv6 / CGNAT - Route53 is registered as aws_route53 and must be selected explicitly (default is netcup_ccp); DOMAIN is optional except for the update command; zone discovery, ROUTE53_ZONE_ID and two IAM policy variants - Complete environment variable table, response codes incl. nohost, 401 and 429; note that PORT is read but not applied - Passwords are plain text, not bcrypt hashes - New sections on IPv6/dual stack, rejected addresses and IPv6/CGNAT - UniFi: recommend /nic/update?hostname=%h without myip; the inadyn.conf edit is only needed for the /%h path format and may not persist - Reference the new k8s/ manifests, TLS and security notes Co-authored-by: Claude --- README.md | 212 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 160 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index d0d7eed..b7a1b99 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,13 @@ A lightweight, self-hosted DynDNS (Dynamic DNS) service with pluggable DNS provi - **AWS Route53**: Updates DNS records via AWS Route53 API - **Session Management**: Automatic session handling (Netcup: 15-minute timeout with proactive refresh) - **Multiple Formats**: Supports both standard DynDNS format and UniFi custom provider format +- **IPv4 and IPv6**: Sets A and/or AAAA records (`myip`, `myipv6`), rejects private, CGNAT, link-local and other non-public addresses - **Wildcard DNS**: Full support for wildcard DNS records (e.g., `*.example.com`) -- **Basic Authentication**: Simple and secure HTTP Basic Auth +- **Basic Authentication**: HTTP Basic Auth with constant-time comparison and a limit on failed logins +- **Hostname Allowlist**: Restrict which hostnames clients may update (`ALLOWED_HOSTNAMES`) - **Flexible Deployment**: Run with Docker Compose, Kubernetes, or Home Assistant addon - **Health Checks**: Built-in health endpoint for Kubernetes probes -- **Minimal Attack Surface**: Uses distroless base image and runs as non-root +- **Minimal Attack Surface**: Uses a distroless base image; the example Kubernetes manifests run it as non-root - **IAM Role Support**: AWS Route53 provider supports IRSA (IAM Roles for Service Accounts) ## Why Self-Host Dynamic DNS? @@ -48,17 +50,16 @@ Mentions of **Netcup**, **AWS**, and **Route53** are strictly descriptive: they │ └───────┬───────────────┘ │ │ ▼ │ │ ┌───────────────────────┐ │ -│ │ Netcup CCP API Client │ │ -│ │ - Session Management │ │ -│ │ - DNS Record Updates │ │ +│ │ DNS Provider Client │ │ +│ │ (Netcup CCP / Route53)│ │ │ └───────┬───────────────┘ │ └──────────┼──────────────────┘ │ HTTPS ▼ - ┌───────────────┐ - │ Netcup CCP │ - │ API Endpoint │ - └───────────────┘ + ┌───────────────────┐ + │ Netcup CCP API or │ + │ AWS Route53 API │ + └───────────────────┘ ``` ## Prerequisites @@ -66,7 +67,7 @@ Mentions of **Netcup**, **AWS**, and **Route53** are strictly descriptive: they - **Deployment Platform** (choose one): - Docker / Docker Compose - Kubernetes cluster - - Home Assistant (addon coming soon) + - Home Assistant (add-on definition in [`homeassistant/`](homeassistant/)) - **DNS Provider** (choose one): - **Netcup**: Account with API credentials, domain managed by Netcup - **Route53**: AWS account with Route53 hosted zone @@ -90,7 +91,8 @@ services: - AUTH_USERNAME=dyndns - AUTH_PASSWORD=your-secure-password - DNS_PROVIDER=netcup_ccp - - DOMAIN=example.com + - DOMAIN=example.com # optional, see Configuration + - ALLOWED_HOSTNAMES=home.example.com - DNS_TTL=60 # Netcup credentials - NETCUP_CUSTOMER_NUMBER=123456 @@ -110,6 +112,17 @@ docker-compose up -d ### Option 2: Kubernetes +Example manifests are in [`k8s/`](k8s/): + +| File | Purpose | +| ---- | ------- | +| `k8s/secret.example.yaml` | Credentials (copy and fill in) | +| `k8s/deployment.yaml` | Deployment for the Netcup provider | +| `k8s/deployment-route53.yaml` | Deployment for Route53, including an IRSA ServiceAccount | +| `k8s/service.yaml` | ClusterIP service on port 80 | + +The deployments use `/health` for readiness and liveness probes and run as UID 65532 with a read-only root filesystem. + ### 1. Create Kubernetes Secret Create a secret with your credentials: @@ -132,16 +145,19 @@ kubectl create secret generic homeddns-secret \ ### 2. Deploy to Kubernetes +Adjust `ALLOWED_HOSTNAMES`, `DOMAIN` and `TRUSTED_PROXIES` in the deployment, then: + ```bash -# Apply manifests -kubectl apply -f k8s/deployment.yaml +kubectl apply -f k8s/deployment.yaml # or k8s/deployment-route53.yaml kubectl apply -f k8s/service.yaml ``` -### 4. Expose the Service +### 3. Expose the Service **Option A: Ingress (Recommended)** +Behind an Ingress, the controller is the direct peer. Set `TRUSTED_PROXIES` to the controller's pod network so the client address is taken from `X-Forwarded-For`; otherwise automatic IP detection sees the controller's private address and rejects the update. + ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress @@ -188,11 +204,14 @@ The Netcup provider uses the CCP (Customer Control Panel) API to update DNS reco - `NETCUP_CUSTOMER_NUMBER` - Your Netcup customer number (required) - `NETCUP_API_KEY` - API key from CCP (required) - `NETCUP_API_PASSWORD` - API password from CCP (required) +- `DOMAIN` - Optional. The Netcup zone is taken from `DOMAIN` if the hostname is within it, otherwise from the last two labels of the hostname. Set it for multi-label TLDs such as `example.co.uk`. + +Instead of the environment variables, the credentials can be stored in `~/.homeddns/netcup_credentials`. **Example Deployment:** ```bash -ko apply -f k8s/deployment.yaml +kubectl apply -f k8s/deployment.yaml ``` **Features:** @@ -207,33 +226,61 @@ The Route53 provider uses the AWS SDK to update DNS records in Route53 hosted zo **Environment Variables:** -- `DNS_PROVIDER=route53` +- `DNS_PROVIDER=aws_route53` (**required**: the default provider is `netcup_ccp`; `route53` is accepted as an alias) +- `ROUTE53_ZONE_ID` - Optional. Use this hosted zone for all updates and skip zone discovery +- `AWS_REGION` - Region for the AWS SDK (Route53 itself is global, e.g. `us-east-1`) - AWS credentials via one of: - **IRSA (Recommended for EKS)**: IAM Role for Service Accounts - **IAM Instance Profile**: Attached to EC2 instances - **Environment Variables**: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - **Shared Credentials**: `~/.aws/credentials` +`DOMAIN` is not needed for Route53. Without `ROUTE53_ZONE_ID`, the hosted zone is discovered per hostname: the public hosted zone with the longest name that is a suffix of the hostname wins (so `home.dyn.example.com` uses a delegated `dyn.example.com` zone if it exists, and `home.example.co.uk` works). Private hosted zones are ignored. + **Example Deployment:** ```bash -ko apply -f k8s/deployment-route53.yaml +kubectl apply -f k8s/deployment-route53.yaml ``` **Required IAM Permissions:** +With zone discovery: + ```json { "Version": "2012-10-17", "Statement": [ + { + "Effect": "Allow", + "Action": "route53:ListHostedZones", + "Resource": "*" + }, { "Effect": "Allow", "Action": [ - "route53:ListHostedZones", "route53:ListResourceRecordSets", "route53:ChangeResourceRecordSets" ], - "Resource": "*" + "Resource": "arn:aws:route53:::hostedzone/*" + } + ] +} +``` + +Minimal, with a fixed `ROUTE53_ZONE_ID` (no `ListHostedZones` needed). Optionally restrict the record names with the `route53:ChangeResourceRecordSetsNormalizedRecordNames` condition key: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "route53:ListResourceRecordSets", + "route53:ChangeResourceRecordSets" + ], + "Resource": "arn:aws:route53:::hostedzone/Z0123456789ABCDEFGHIJ" } ] } @@ -247,15 +294,15 @@ ko apply -f k8s/deployment-route53.yaml apiVersion: v1 kind: ServiceAccount metadata: - name: dyndns-route53 + name: homeddns-route53 annotations: - eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT_ID:role/dyndns-route53 + eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT_ID:role/homeddns-route53 ``` 3. Deploy using `k8s/deployment-route53.yaml` **Features:** -- Automatic hosted zone discovery +- Automatic hosted zone discovery (longest suffix match, public zones only) or a fixed zone via `ROUTE53_ZONE_ID` - Hosted zone caching for performance - Support for all DNS record types - Wildcard DNS support @@ -266,7 +313,7 @@ ko apply -f k8s/deployment-route53.yaml The plugin system makes it easy to add new DNS providers: 1. Implement the `provider.Provider` interface in `internal/provider/` -2. Add provider initialization in `cmd/server/main.go` +2. Register a factory in the file's `init()` function with `RegisterFactory("name", factory)`; the provider loads its own credentials 3. Update documentation **Provider Interface:** @@ -274,8 +321,12 @@ The plugin system makes it easy to add new DNS providers: ```go type Provider interface { Name() string + // GetRecord returns ErrRecordNotFound if the record does not exist. GetRecord(ctx context.Context, domain, hostname, recordType string) (*DNSRecord, error) - UpdateRecord(ctx context.Context, domain string, record *DNSRecord) error + // UpdateRecord reports changed=false if the record already had the value. + UpdateRecord(ctx context.Context, domain string, record *DNSRecord) (changed bool, err error) + // DeleteRecord deletes the record only if it still has the given value. + DeleteRecord(ctx context.Context, domain string, record *DNSRecord) error Close(ctx context.Context) error } ``` @@ -291,6 +342,21 @@ curl -u "dyndns:your-password" \ Response: `good 1.2.3.4` or `nochg 1.2.3.4` +### IPv6 and Dual Stack + +`myip` may be an IPv4 or IPv6 address. `myipv6` sets the AAAA record. If both are given, both records are set: + +```bash +curl -u "dyndns:your-password" \ + "https://dyndns.example.com/nic/update?hostname=home.example.com&myip=1.2.3.4&myipv6=2001:db8::1" +``` + +Response: `good 1.2.3.4,2001:db8::1` (`nochg` if neither record changed) + +IPv4-mapped addresses (`::ffff:1.2.3.4`) are treated as IPv4 and set as A record. Addresses with a zone ID (`fe80::1%eth0`) are rejected. + +(`2001:db8::/32` is a documentation prefix and is rejected by the server; use your real address.) + ### UniFi Custom Provider Format ```bash @@ -302,13 +368,19 @@ Response: `good` or `nochg` ### Automatic IP Detection -Omit the `myip` parameter to automatically use the request source IP: +Omit `myip`/`myipv6` to use the client address of the request: ```bash curl -u "dyndns:your-password" \ "https://dyndns.example.com/nic/update?hostname=home.example.com" ``` +The client address is the TCP peer, unless the peer is listed in `TRUSTED_PROXIES`; then the `X-Forwarded-For` header is read from right to left and the first address that is not a trusted proxy is used. A client connecting over IPv6 sets the AAAA record, over IPv4 the A record. + +### Rejected Addresses + +Unless `ALLOW_PRIVATE_IPS=true`, the server answers `911` for addresses that cannot be a public DynDNS target: loopback, unspecified, multicast, link-local, RFC 1918 private, CGNAT (`100.64.0.0/10`), IPv6 ULA (`fc00::/7`) and documentation ranges (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`, `2001:db8::/32`, `3fff::/20`). The reason is logged as a warning. + ### Wildcard DNS ```bash @@ -316,28 +388,56 @@ curl -u "dyndns:your-password" \ "https://dyndns.example.com/nic/update?hostname=*.example.com&myip=1.2.3.4" ``` +## IPv6 / CGNAT + +Behind carrier-grade NAT the gateway has no public IPv4 address, but usually a public IPv6 address. HomeDDNS can then be updated over IPv6: + +- **Leave out `myip`** in the router configuration. UniFi replaces `%i` with the WAN IPv4 address, which behind CGNAT is a `100.64.0.0/10` address and is rejected. +- Without `myip`, the server uses the source address of the connection. If the gateway connects over IPv6, that is the **WAN IPv6 address of the gateway**, not of a LAN host, and the AAAA record is set. +- The server must be **reachable via IPv6** (AAAA record for the server name), otherwise the gateway connects over IPv4. The server listens on `:`, which in Go accepts IPv4 and IPv6 connections (dual stack). Behind a reverse proxy or Kubernetes Ingress, the proxy must be reachable via IPv6 and `TRUSTED_PROXIES` must be set. +- An old A record of the same name may still point to a wrong address. Set `CLEANUP_STALE_RECORD=true` to delete the A record when an AAAA update succeeds (and vice versa). Deletion only happens after a lookup of the existing record and only when a single address family is updated. +- For a LAN host (e.g. a server behind the gateway), the gateway's update does not help: the host has its own IPv6 address. Update a separate record for it with its own client, sending `myipv6`. + ## Configuration ### Environment Variables -| Variable | Required | Default | Description | -| ------------------------ | -------- | ------- | ------------------------- | -| `PORT` | No | `8053` | HTTP server port | -| `AUTH_USERNAME` | Yes | - | Basic auth username | -| `AUTH_PASSWORD` | Yes | - | Basic auth password | -| `NETCUP_CUSTOMER_NUMBER` | Yes | - | Netcup customer number | -| `NETCUP_API_KEY` | Yes | - | Netcup API key | -| `NETCUP_API_PASSWORD` | Yes | - | Netcup API password | -| `DNS_TTL` | No | `60` | DNS record TTL in seconds | +| Variable | Required | Default | Description | +| ------------------------ | ----------------- | ------------ | ----------- | +| `AUTH_USERNAME` | Yes | - | Basic auth username | +| `AUTH_PASSWORD` | Yes | - | Basic auth password (plain text, not a hash) | +| `DNS_PROVIDER` | No | `netcup_ccp` | `netcup_ccp` or `aws_route53` (alias `route53`) | +| `DOMAIN` | `update` command | - | Zone used for hostnames within it. Required for the `update` command, optional for the server | +| `ALLOWED_HOSTNAMES` | No (recommended) | all | Comma-separated hostnames that may be updated; `*.suffix` matches any name below `suffix`. Other hostnames get `nohost` (HTTP 403) | +| `DNS_TTL` | No | `60` | DNS record TTL in seconds | +| `TRUSTED_PROXIES` | No | none | Comma-separated CIDRs or IPs whose `X-Forwarded-For` header is trusted | +| `ALLOW_PRIVATE_IPS` | No | `false` | Accept private, CGNAT, link-local and other non-public addresses | +| `CLEANUP_STALE_RECORD` | No | `false` | Delete the A record after an AAAA update and vice versa | +| `SSL` | No | `false` | Serve HTTPS (`true` or `1`) | +| `CERTFILE` | With `SSL` | `/ssl/fullchain.pem` | Certificate path; relative paths are resolved below `/ssl/` | +| `KEYFILE` | With `SSL` | `/ssl/privkey.pem` | Key path; relative paths are resolved below `/ssl/` | +| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn` or `error` | +| `PORT` | No | `8053` | Read and validated, but currently **not applied**: the listen port comes from the `--port` flag of the `server` command (default `8053`) | +| `ADDON_OPTIONS_PATH` | No | - | Home Assistant `options.json`; its keys are exported as upper-case environment variables | +| `NETCUP_CUSTOMER_NUMBER` | Netcup | - | Netcup customer number | +| `NETCUP_API_KEY` | Netcup | - | Netcup API key | +| `NETCUP_API_PASSWORD` | Netcup | - | Netcup API password | +| `ROUTE53_ZONE_ID` | No | - | Route53: fixed hosted zone, skips zone discovery | +| `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Route53 | - | Standard AWS SDK configuration (or IRSA, instance profile, `~/.aws/credentials`) | ### Response Codes -| Code | Description | -| --------- | -------------------------------------- | -| `good` | DNS record updated successfully | -| `nochg` | IP address unchanged, no update needed | -| `notfqdn` | Invalid hostname format | -| `911` | Server error or invalid IP address | +| Response | Description | +| --------------------- | ----------- | +| `good [,]` | DNS record(s) updated successfully | +| `nochg [,]` | IP address unchanged, no update needed | +| `notfqdn` | Invalid hostname format | +| `nohost` (HTTP 403) | Hostname not in `ALLOWED_HOSTNAMES` | +| `911` | Provider error, or invalid / non-public IP address | +| HTTP 401 | Missing or wrong credentials | +| HTTP 429 | Too many failed logins from this address (10 within 15 minutes) | + +The UniFi path format (`/`) returns only the status word, without addresses. ## Router Configuration @@ -351,9 +451,11 @@ curl -u "dyndns:your-password" \ - **Hostname**: `home.example.com` (or `*.example.com` for wildcard) - **Username**: `dyndns` (or your configured username) - **Password**: Your DynDNS password - - **Server**: `dyndns.example.com/%h` (replace with your domain) + - **Server**: `dyndns.example.com/nic/update?hostname=%h` (recommended, replace with your server name) -**Note**: UniFi uses inadyn internally. You need to edit `/etc/inadyn.conf` to add `ddns-path = "/"`: +Without `myip`, the server uses the address the gateway connects from; this is the variant to use behind CGNAT (see [IPv6 / CGNAT](#ipv6--cgnat)). With a public WAN IPv4, `dyndns.example.com/nic/update?hostname=%h&myip=%i` also works. + +**Path format `/%h`:** If you use the server value `dyndns.example.com/%h` instead, inadyn on the gateway may need `ddns-path = "/"` in `/etc/inadyn.conf`. This edit is made on the gateway itself and does not have to survive a firmware update, reprovisioning or a change in the UniFi UI: ```bash # SSH into UniFi device @@ -374,7 +476,8 @@ killall inadyn 1. Navigate to: **Internet → Freigaben → DynDNS** 2. Configure: - **DynDNS-Anbieter**: `Benutzerdefiniert` - - **Update-URL**: `https://dyndns.example.com/nic/update?hostname=&myip=` + - **Update-URL**: `https://dyndns.example.com/nic/update?hostname=&myip=&myipv6=` + (drop `&myipv6=` for IPv4 only) - **Domainname**: `home.example.com` - **Benutzername**: `dyndns` - **Kennwort**: Your DynDNS password @@ -443,7 +546,7 @@ mise run run:dev # Or export environment variables manually export AUTH_USERNAME=dyndns -export AUTH_PASSWORD='$2a$10$...' +export AUTH_PASSWORD='your-password' export DNS_PROVIDER=netcup_ccp export DOMAIN=example.com export NETCUP_CUSTOMER_NUMBER=123456 @@ -507,10 +610,11 @@ Result: **14MB static binary** that runs anywhere with no dependencies! ## Security Considerations -- HTTP Basic Authentication for DynDNS endpoint -- Container runs as non-root user (UID 65532) -- Read-only root filesystem -- No privilege escalation +- **TLS**: Basic Auth sends the password in every request. Use plain HTTP only behind a TLS-terminating reverse proxy or Ingress, or enable `SSL=true`. +- HTTP Basic Authentication for the DynDNS endpoint, compared in constant time; 10 failed logins from one address within 15 minutes lead to HTTP 429 +- Set `ALLOWED_HOSTNAMES` so the credentials can only update the intended records +- `X-Forwarded-For` is ignored unless the peer is in `TRUSTED_PROXIES` +- The example Kubernetes manifests run the container as non-root (UID 65532) with a read-only root filesystem and without privilege escalation (the image itself defaults to root for Home Assistant compatibility) - Minimal container image (distroless) - All secrets stored in Kubernetes Secrets or environment variables @@ -533,17 +637,21 @@ kubectl logs -l app=homeddns --tail=100 -f **401 Unauthorized** - Check that Basic Auth credentials are correct -- Verify password hash was generated correctly +- `AUTH_PASSWORD` is compared as plain text; do not configure a hash **"notfqdn" Response** - Hostname must be a valid FQDN -- Ensure domain is managed by Netcup + +**"nohost" Response (HTTP 403)** + +- The hostname is not listed in `ALLOWED_HOSTNAMES` **"911" Response** -- Check Netcup API credentials -- Verify domain exists in Netcup account +- The IP address is not public (private, CGNAT, link-local, ...); the log shows the rejected address. Send the public address via `myip`/`myipv6`, set `TRUSTED_PROXIES` when running behind a proxy, or set `ALLOW_PRIVATE_IPS=true` for internal use +- Check the provider credentials (Netcup API credentials, AWS credentials and IAM policy) +- Verify the domain / hosted zone exists in your provider account - Check service logs for detailed error messages **Session Timeout** From 7a470212f2bbadfb80e2f7591dd4e121b7cd478d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:30:06 +0200 Subject: [PATCH 10/13] docs(agents): correct test and reference documentation notes AGENTS.md claimed no tests exist, although both providers have tests, and pointed to a repomix/ folder that is gitignored and not part of the repository. Also document test:race, the extended provider interface and the commit trailer for Claude Code sessions. Co-authored-by: Claude --- AGENTS.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 60409f3..2c218a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ - **Build Multi-platform**: `mise run build:ko-multiplatform` - **Run Server**: `mise run run` - **Test All**: `mise run test` +- **Test with Race Detector**: `mise run test:race` (part of `mise run ci`, needs cgo) - **Test Single Package**: `go test ./internal/provider` - **Format Code**: `mise run fmt` - **Clean Build Artifacts**: `mise run clean` @@ -47,10 +48,11 @@ All builds use aggressive optimization flags for minimal binary size: - **Separation of Concerns**: `cmd/` for CLI/config, `internal/` for business logic, providers self-contained ## Architecture Notes -- **Provider Interface**: All DNS providers implement `Provider` interface with `Name()`, `GetRecord()`, `UpdateRecord()`, `Close()` +- **Provider Interface**: All DNS providers implement `Provider` interface with `Name()`, `GetRecord()` (returns `ErrRecordNotFound`), `UpdateRecord()` (reports whether a change was sent), `DeleteRecord()`, `Close()` +- **Client IPs**: `internal/clientip` parses and validates update addresses (`net/netip`) and resolves the client address (`TRUSTED_PROXIES`) - **Credential Loading**: Providers load their own credentials (e.g., `LoadNetcupConfig()` checks env vars then `~/.homeddns/netcup_credentials`) - **Factory Pattern**: Providers register via `RegisterFactory()` in `init()` functions -- **No Tests Present**: Add tests when modifying critical logic (especially providers and handlers) +- **Tests**: Tests exist for both providers (`internal/provider`), the handler, auth, client IP handling and config loading. Add a test for every fix and when modifying critical logic ## Release Workflow The project uses a **Ko-first** strategy with clean separation of concerns: @@ -85,9 +87,10 @@ The project uses a **Ko-first** strategy with clean separation of concerns: - **Tags**: `latest`, `{version}` for containers ## Reference Documentation -If you are unsure about build tools or configurations, check the `repomix/` folder: -- **goreleaser.xml**: Complete documentation for GoReleaser (multi-platform release builds) -- **ko.xml**: Complete documentation for Ko (containerless Kubernetes image builder) +A `repomix/` folder with packed GoReleaser and Ko documentation (`goreleaser.xml`, `ko.xml`) is not part of the repository; it is listed in `.gitignore` as a local, temporary folder. If it is missing, use the official documentation: +- **GoReleaser**: https://goreleaser.com/ +- **Ko**: https://ko.build/ ## Git Practices -- **Conventional commits**: Always format commit messages using the Conventional Commits specification so release tooling can infer version bumps and changelog entries. \ No newline at end of file +- **Conventional commits**: Always format commit messages using the Conventional Commits specification so release tooling can infer version bumps and changelog entries. +- **AI attribution**: Commits created in Claude Code sessions end with the trailer `Co-authored-by: Claude `. \ No newline at end of file From 7fae746855e308ea771b01ea6bf9d5602e6ceb6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:30:14 +0200 Subject: [PATCH 11/13] docs: disclose AI-assisted development Co-authored-by: Claude --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index b7a1b99..440af89 100644 --- a/README.md +++ b/README.md @@ -659,6 +659,11 @@ kubectl logs -l app=homeddns --tail=100 -f - Sessions are automatically refreshed every 10 minutes - If you see frequent re-logins, check network connectivity to Netcup API +## AI-assisted development + +- **This fork** ([cvk77/homeddns](https://github.com/cvk77/homeddns)): Changes were created with Claude Code (an AI coding assistant) and reviewed by @cvk77. Commits from these sessions carry the trailer `Co-authored-by: Claude `. +- **Upstream** ([markussiebert/homeddns](https://github.com/markussiebert/homeddns)): The repository contains an `AGENTS.md` file with instructions for coding agents. Whether and to what extent AI tools were used upstream is not documented. + ## License This project inherits the license from the parent repository. From ced7a2f07943ce03b2a6bfef18dc5c022211aca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:38:58 +0200 Subject: [PATCH 12/13] fix(provider): exclude Route53 from netcup_ccp-only builds The Route53 build constraint negated a non-existent "netcup" tag instead of "netcup_ccp", so -tags netcup_ccp still compiled the Route53 provider. Use netcup_ccp, drop the legacy // +build lines and give the provider test files matching constraints so single-provider builds compile their tests. The new test:tags task (part of ci) verifies each variant only contains its own provider. Co-authored-by: Claude --- internal/provider/provider_aws_route53.go | 3 +-- .../provider/provider_aws_route53_test.go | 2 ++ internal/provider/provider_netcup_ccp.go | 1 - internal/provider/provider_netcup_ccp_test.go | 2 ++ mise.toml | 20 ++++++++++++++++++- 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/internal/provider/provider_aws_route53.go b/internal/provider/provider_aws_route53.go index 5d1d562..b5145ef 100644 --- a/internal/provider/provider_aws_route53.go +++ b/internal/provider/provider_aws_route53.go @@ -1,5 +1,4 @@ -//go:build aws_route53 || (!netcup && !aws_route53) -// +build aws_route53 !netcup,!aws_route53 +//go:build aws_route53 || (!netcup_ccp && !aws_route53) package provider diff --git a/internal/provider/provider_aws_route53_test.go b/internal/provider/provider_aws_route53_test.go index 7512b54..fe1fda8 100644 --- a/internal/provider/provider_aws_route53_test.go +++ b/internal/provider/provider_aws_route53_test.go @@ -1,3 +1,5 @@ +//go:build aws_route53 || (!netcup_ccp && !aws_route53) + package provider import ( diff --git a/internal/provider/provider_netcup_ccp.go b/internal/provider/provider_netcup_ccp.go index e0aaf57..d9e6185 100644 --- a/internal/provider/provider_netcup_ccp.go +++ b/internal/provider/provider_netcup_ccp.go @@ -1,5 +1,4 @@ //go:build netcup_ccp || (!netcup_ccp && !aws_route53) -// +build netcup_ccp !netcup_ccp,!aws_route53 package provider diff --git a/internal/provider/provider_netcup_ccp_test.go b/internal/provider/provider_netcup_ccp_test.go index b94d71b..96bdb98 100644 --- a/internal/provider/provider_netcup_ccp_test.go +++ b/internal/provider/provider_netcup_ccp_test.go @@ -1,3 +1,5 @@ +//go:build netcup_ccp || (!netcup_ccp && !aws_route53) + package provider import ( diff --git a/mise.toml b/mise.toml index a006416..b68e897 100644 --- a/mise.toml +++ b/mise.toml @@ -75,6 +75,24 @@ description = "Run all tests with the race detector (requires cgo)" run = "go test -race ./..." env = { CGO_ENABLED = "1" } +[tasks."test:tags"] +description = "Build and test single-provider variants (netcup_ccp, aws_route53 build tags)" +run = """ +check_excluded() { + if go list -tags "$1" -f '{% raw %}{{.GoFiles}}{% endraw %}' ./internal/provider | grep -q "$2"; then + echo "Error: -tags $1 must not compile $2" + exit 1 + fi +} +check_excluded netcup_ccp provider_aws_route53.go +check_excluded aws_route53 provider_netcup_ccp.go +for tags in netcup_ccp aws_route53; do + echo "Testing with -tags ${tags}" + go vet -tags "${tags}" ./... + go test -tags "${tags}" ./... +done +""" + [tasks."test:verbose"] description = "Run all tests with verbose output" run = "go test -v ./..." @@ -115,7 +133,7 @@ run = "rm -rf homeddns homeddns-linux-* cdk-dynamic-dns dyndns *.tar dist/" # CI/CD tasks [tasks.ci] description = "Run full CI pipeline (verify, test, build, validate)" -depends = ["tidy", "test", "test:race", "build", "build:ko", "release:validate"] +depends = ["tidy", "test", "test:race", "test:tags", "build", "build:ko", "release:validate"] run = """ echo "✅ CI pipeline completed successfully" echo "" From 49df4e86bdfe33d588a12e009e9b9df02cb29723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20von=20Kru=CC=88chten?= Date: Sun, 18 Oct 2026 11:39:30 +0200 Subject: [PATCH 13/13] fix: apply the PORT environment variable The server always used the --port flag, whose default of 8053 shadowed PORT, so PORT (and the Home Assistant port option, which is exported as PORT) had no effect. The flag no longer has a default; it overrides PORT only when given. PORT is also range-checked now. Co-authored-by: Claude --- README.md | 2 +- cmd/config.go | 3 +++ cmd/config_test.go | 30 ++++++++++++++++++++++++++++++ main.go | 13 +++++++++++-- main_test.go | 12 ++++++++++++ 5 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 main_test.go diff --git a/README.md b/README.md index 440af89..328d6df 100644 --- a/README.md +++ b/README.md @@ -417,7 +417,7 @@ Behind carrier-grade NAT the gateway has no public IPv4 address, but usually a p | `CERTFILE` | With `SSL` | `/ssl/fullchain.pem` | Certificate path; relative paths are resolved below `/ssl/` | | `KEYFILE` | With `SSL` | `/ssl/privkey.pem` | Key path; relative paths are resolved below `/ssl/` | | `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn` or `error` | -| `PORT` | No | `8053` | Read and validated, but currently **not applied**: the listen port comes from the `--port` flag of the `server` command (default `8053`) | +| `PORT` | No | `8053` | Listen port. The `--port` flag of the `server` command takes precedence | | `ADDON_OPTIONS_PATH` | No | - | Home Assistant `options.json`; its keys are exported as upper-case environment variables | | `NETCUP_CUSTOMER_NUMBER` | Netcup | - | Netcup customer number | | `NETCUP_API_KEY` | Netcup | - | Netcup API key | diff --git a/cmd/config.go b/cmd/config.go index 9ea4413..c7e0b42 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -142,6 +142,9 @@ func LoadConfig() (*Config, error) { if err != nil { return nil, logger.Errorf("invalid PORT: %w", err) } + if p < 1 || p > 65535 { + return nil, logger.Errorf("invalid PORT: %d is out of range", p) + } config.Port = p logger.Debug("Set port to: %d", p) } diff --git a/cmd/config_test.go b/cmd/config_test.go index 7ee3d1a..9d67bf3 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -15,6 +15,7 @@ func setBaseEnv(t *testing.T) { t.Setenv("ALLOW_PRIVATE_IPS", "") t.Setenv("CLEANUP_STALE_RECORD", "") t.Setenv("SSL", "") + t.Setenv("PORT", "") } func TestLoadConfig_DomainOptional(t *testing.T) { @@ -73,3 +74,32 @@ func TestLoadConfig_IPOptions(t *testing.T) { t.Fatalf("expected error for invalid TRUSTED_PROXIES") } } + +func TestLoadConfig_Port(t *testing.T) { + setBaseEnv(t) + + t.Setenv("PORT", "") + config, err := LoadConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if config.Port != 8053 { + t.Fatalf("expected default port 8053, got %d", config.Port) + } + + t.Setenv("PORT", "9000") + config, err = LoadConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if config.Port != 9000 { + t.Fatalf("expected port 9000, got %d", config.Port) + } + + for _, invalid := range []string{"abc", "0", "70000"} { + t.Setenv("PORT", invalid) + if _, err := LoadConfig(); err == nil { + t.Fatalf("expected error for PORT=%s", invalid) + } + } +} diff --git a/main.go b/main.go index 58ede91..ab43b7c 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,7 @@ import ( // CLI holds the command-line interface structure. type CLI struct { Server struct { - Port int `help:"Port to listen on." default:"8053"` + Port int `help:"Port to listen on (overrides the PORT environment variable, default 8053)."` } `cmd:"" help:"Run as a web server."` Update struct { @@ -40,6 +40,15 @@ func versionString() string { return fallbackVersion } +// serverPort returns the --port flag if given, otherwise the configured +// port (PORT environment variable or its default). +func serverPort(flagPort, configPort int) int { + if flagPort != 0 { + return flagPort + } + return configPort +} + func ensureServerDefaultForContainer() { if len(os.Args) > 1 { return @@ -96,7 +105,7 @@ func main() { switch ctx.Command() { case "server": - err = cmd.RunServer(cli.Server.Port, config) + err = cmd.RunServer(serverPort(cli.Server.Port, config.Port), config) case "update ": err = cmd.RunUpdate(cli.Update.Hostname, cli.Update.Type, config) default: diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..7f10965 --- /dev/null +++ b/main_test.go @@ -0,0 +1,12 @@ +package main + +import "testing" + +func TestServerPort(t *testing.T) { + if got := serverPort(0, 9000); got != 9000 { + t.Fatalf("expected configured port 9000, got %d", got) + } + if got := serverPort(8080, 9000); got != 8080 { + t.Fatalf("expected flag port 8080, got %d", got) + } +}