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/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 diff --git a/README.md b/README.md index d0d7eed..328d6df 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` | 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 | +| `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) + +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. -**Note**: UniFi uses inadyn internally. You need to edit `/etc/inadyn.conf` to add `ddns-path = "/"`: +**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** @@ -551,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. diff --git a/cmd/config.go b/cmd/config.go index 02d7d32..c7e0b42 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -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 { @@ -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) } @@ -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) @@ -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 diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..9d67bf3 --- /dev/null +++ b/cmd/config_test.go @@ -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) + } + } +} diff --git a/cmd/server.go b/cmd/server.go index c9a926c..324a11f 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" @@ -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() diff --git a/cmd/update.go b/cmd/update.go index 1099d95..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) @@ -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 } 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/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) + } +} 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 f153c5e..49fc97d 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,18 @@ 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. + 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 @@ -62,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) @@ -70,29 +90,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 + } - if err := h.updateDNS(ctx, domain, subdomain, ipAddress); err != nil { - logger.Error("Error updating DNS for %s: %v", hostname, err) - h.respond(w, "911", ipAddress, isStandardFormat) + if h.config.CleanupStaleRecords && len(addresses) == 1 { + h.cleanupStaleRecord(ctx, domain, h.buildHostname(subdomain, domain), recordType(addresses[0])) + } + + 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 @@ -123,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 "", "" @@ -146,61 +214,113 @@ 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) 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, } // 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 +} + +// 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 @@ -213,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 new file mode 100644 index 0000000..f4f7448 --- /dev/null +++ b/internal/handler/dyndns_test.go @@ -0,0 +1,280 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "sync" + "testing" + + "github.com/markussiebert/homeddns/internal/clientip" + "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) 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 { + 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)) + } +} + +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) + } +} + +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) + } + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index cb8986f..836c205 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,14 @@ 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) + // 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 cdaeaca..b5145ef 100644 --- a/internal/provider/provider_aws_route53.go +++ b/internal/provider/provider_aws_route53.go @@ -1,12 +1,13 @@ -//go:build aws_route53 || (!netcup && !aws_route53) -// +build aws_route53 !netcup,!aws_route53 +//go:build aws_route53 || (!netcup_ccp && !aws_route53) package provider import ( "context" "fmt" + "os" "strings" + "sync" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -25,7 +26,9 @@ type Route53API interface { // AwsRoute53Client represents an AWS Route53 client type AwsRoute53Client struct { client Route53API - zoneCache map[string]string // domain -> hostedZoneId cache + zoneID string // fixed hosted zone ID (ROUTE53_ZONE_ID); skips zone discovery when set + zoneMu sync.RWMutex + zoneCache map[string]string // hostname -> hostedZoneId cache } // NewAwsRoute53Client creates a new Route53 client @@ -55,6 +58,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{ @@ -68,20 +77,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), @@ -94,38 +125,31 @@ 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, 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") - } - - // Extract value - var value string - if len(recordSet.ResourceRecords) > 0 { - value = aws.ToString(recordSet.ResourceRecords[0].Value) + if !strings.EqualFold(normalizeRecordName(aws.ToString(recordSet.Name)), normalizeRecordName(fqdn)) || string(recordSet.Type) != recordType { + return nil, ErrRecordNotFound } + return &recordSet, nil +} - 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 -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) + zoneID, err := c.getHostedZoneID(ctx, record.Name) 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 @@ -136,7 +160,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 @@ -167,10 +191,53 @@ 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 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 } @@ -179,18 +246,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 - if zoneID, exists := c.zoneCache[domain]; exists { - logger.Debug("AWS Route53: Using cached zone ID for domain %s", domain) + c.zoneMu.RLock() + zoneID, exists := c.zoneCache[hostname] + c.zoneMu.RUnlock() + if exists { + 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 @@ -205,12 +282,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.zoneCache[domain] = zoneID - 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/") } } @@ -220,7 +301,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 @@ -237,6 +326,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 f9098c8..fe1fda8 100644 --- a/internal/provider/provider_aws_route53_test.go +++ b/internal/provider/provider_aws_route53_test.go @@ -1,8 +1,12 @@ +//go:build aws_route53 || (!netcup_ccp && !aws_route53) + package provider import ( "context" + "errors" "fmt" + "sync" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -151,12 +155,210 @@ 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) } } + +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() +} + +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") + } +} + +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") + } +} + +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 fd19e5f..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 @@ -512,11 +511,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 +524,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 +542,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 +560,36 @@ 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 +} + +// 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 diff --git a/internal/provider/provider_netcup_ccp_test.go b/internal/provider/provider_netcup_ccp_test.go index 0540b91..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 ( @@ -6,6 +8,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "slices" "sync" "testing" "time" @@ -75,7 +78,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 +105,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() @@ -129,8 +154,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 +164,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 +178,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 +196,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() @@ -243,3 +270,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) +} 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 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) + } +} diff --git a/mise.toml b/mise.toml index ed1cb7a..b68e897 100644 --- a/mise.toml +++ b/mise.toml @@ -70,6 +70,29 @@ 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: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 ./..." @@ -87,7 +110,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"] @@ -110,13 +133,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", "test:tags", "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"