Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
15 changes: 9 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
- **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 <noreply@anthropic.com>`.
217 changes: 165 additions & 52 deletions README.md

Large diffs are not rendered by default.

73 changes: 59 additions & 14 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,50 @@ 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
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))
return value == "true" || value == "1"
}

func LoadHomeAssistantConfig() error {
Expand Down Expand Up @@ -117,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)
}
Expand All @@ -138,18 +166,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)
Expand Down Expand Up @@ -197,6 +229,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
Expand Down
105 changes: 105 additions & 0 deletions cmd/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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", "")
t.Setenv("PORT", "")
}

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")
}
}

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)
}
}
}
16 changes: 14 additions & 2 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -28,15 +29,26 @@ 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,
Provider: p,
DefaultTTL: config.DefaultTTL,
Domain: config.Domain,
AllowedHostnames: config.AllowedHostnames,
ClientIP: clientIPResolver,
AllowPrivateIPs: config.AllowPrivateIPs,
CleanupStaleRecords: config.CleanupStaleRecords,
})

authMiddleware := auth.Middleware(auth.Config{
Username: config.Username,
Password: config.Password,
ClientIP: clientIPResolver,
})

mux := http.NewServeMux()
Expand Down
11 changes: 10 additions & 1 deletion cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -39,10 +43,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
}
Expand Down
10 changes: 6 additions & 4 deletions homeassistant/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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=<domain>&myip=<ipaddr>`
Expand Down Expand Up @@ -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
Expand Down
Loading