diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b7ce764 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 1.23 + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Install dependencies + run: go mod download + + - name: Run fmt check + run: go fmt ./... + + - name: Run vet + run: go vet ./... + + - name: Run tests + run: go test ./... -v + + - name: Build artifacts + run: | + GOOS=linux GOARCH=amd64 go build -o bin/updns-linux-amd64 ./cmd/updns + GOOS=darwin GOARCH=amd64 go build -o bin/updns-darwin-amd64 ./cmd/updns diff --git a/cmd/updns/main.go b/cmd/updns/main.go new file mode 100644 index 0000000..09849fe --- /dev/null +++ b/cmd/updns/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "git.cloonar.com/cloonar/updns/internal/config" + "git.cloonar.com/cloonar/updns/internal/server" +) + +func run(cfgPath string) error { + if cfgPath == "" { + return fmt.Errorf("missing --config flag") + } + cfg, err := config.LoadConfig(cfgPath) + if err != nil { + return fmt.Errorf("config load error: %w", err) + } + if err := server.StartServer(cfg); err != nil { + return fmt.Errorf("server error: %w", err) + } + return nil +} + +func main() { + cfgPath := flag.String("config", "", "path to config file") + flag.Parse() + if err := run(*cfgPath); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/updns/main_test.go b/cmd/updns/main_test.go new file mode 100644 index 0000000..282fd71 --- /dev/null +++ b/cmd/updns/main_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestRunMissingFlag(t *testing.T) { + err := run("") + if err == nil || err.Error() != "missing --config flag" { + t.Fatalf("expected missing flag error, got %v", err) + } +} + +func TestRunNonexistentConfig(t *testing.T) { + fake := filepath.Join(os.TempDir(), "no-such-file.yaml") + err := run(fake) + if err == nil || !contains(err.Error(), "config load error:") { + t.Fatalf("expected config load error, got %v", err) + } +} + +func TestRunUnsupportedProvider(t *testing.T) { + content := ` +server: + bind_address: ":0" + tls: + enabled: false +upstream: + provider: unknown +clients: {} +` + tmp := filepath.Join(os.TempDir(), "updns_test.yaml") + if err := os.WriteFile(tmp, []byte(content), 0644); err != nil { + t.Fatalf("failed to write temp config: %v", err) + } + defer os.Remove(tmp) + + err := run(tmp) + if err == nil || !contains(err.Error(), "server error: unsupported provider") { + t.Fatalf("expected unsupported provider error, got %v", err) + } +} + +func contains(s, substr string) bool { + return fmt.Sprintf("%v", s) != "" && (substr == s || len(s) > len(substr) && fmt.Sprint(s) != "" && (func() bool { + return len(s) >= len(substr) && s[:len(substr)] == substr + })()) +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..19605fc --- /dev/null +++ b/config.yaml @@ -0,0 +1,17 @@ +server: + bind_address: ":9090" + tls: + enabled: false + cert_file: "cert.pem" + key_file: "key.pem" +upstream: + provider: hetzner + hetzner: + api_token: "YOUR_HETZNER_API_TOKEN" +clients: + client1: + secret: "s3cr3t123" + exact: + - "home.example.com" + wildcard: + - "example.net" \ No newline at end of file diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..0a4480a --- /dev/null +++ b/coverage.out @@ -0,0 +1,47 @@ +mode: set +git.cloonar.com/cloonar/updns/internal/provider/hetzner/hetzner.go:18.45,23.2 1 1 +git.cloonar.com/cloonar/updns/internal/provider/hetzner/hetzner.go:27.79,31.2 2 1 +git.cloonar.com/cloonar/updns/internal/config/config.go:42.47,46.41 3 1 +git.cloonar.com/cloonar/updns/internal/config/config.go:46.41,48.3 1 0 +git.cloonar.com/cloonar/updns/internal/config/config.go:50.2,51.42 2 1 +git.cloonar.com/cloonar/updns/internal/config/config.go:51.42,53.3 1 0 +git.cloonar.com/cloonar/updns/internal/config/config.go:55.2,55.40 1 1 +git.cloonar.com/cloonar/updns/internal/config/config.go:55.40,56.58 1 1 +git.cloonar.com/cloonar/updns/internal/config/config.go:56.58,58.4 1 1 +git.cloonar.com/cloonar/updns/internal/config/config.go:61.2,61.18 1 1 +git.cloonar.com/cloonar/updns/cmd/updns/main.go:12.32,13.19 1 1 +git.cloonar.com/cloonar/updns/cmd/updns/main.go:13.19,15.3 1 1 +git.cloonar.com/cloonar/updns/cmd/updns/main.go:16.2,17.16 2 1 +git.cloonar.com/cloonar/updns/cmd/updns/main.go:17.16,19.3 1 1 +git.cloonar.com/cloonar/updns/cmd/updns/main.go:20.2,20.48 1 1 +git.cloonar.com/cloonar/updns/cmd/updns/main.go:20.48,22.3 1 1 +git.cloonar.com/cloonar/updns/cmd/updns/main.go:23.2,23.12 1 0 +git.cloonar.com/cloonar/updns/cmd/updns/main.go:26.13,29.38 3 0 +git.cloonar.com/cloonar/updns/cmd/updns/main.go:29.38,32.3 2 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:49.44,56.31 4 1 +git.cloonar.com/cloonar/updns/internal/server/server.go:57.17,58.60 1 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:59.10,60.71 1 1 +git.cloonar.com/cloonar/updns/internal/server/server.go:64.2,67.29 3 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:67.29,76.3 3 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:79.2,81.41 2 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:81.41,84.48 3 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:84.48,89.4 4 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:91.3,92.15 2 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:92.15,94.4 1 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:96.3,97.44 2 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:97.44,101.4 3 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:103.3,104.37 2 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:104.37,105.21 1 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:105.21,108.10 3 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:111.3,111.18 1 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:111.18,112.44 1 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:112.44,113.66 1 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:113.66,116.11 3 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:120.3,120.18 1 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:120.18,124.4 3 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:126.3,126.78 1 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:126.78,131.4 4 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:132.3,133.76 2 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:137.2,137.28 1 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:137.28,139.3 1 0 +git.cloonar.com/cloonar/updns/internal/server/server.go:140.2,140.38 1 0 diff --git a/development-plan.md b/development-plan.md new file mode 100644 index 0000000..1c77e8d --- /dev/null +++ b/development-plan.md @@ -0,0 +1,146 @@ +# Development Plan for UpDNS + +A step-by-step roadmap to build, test, and evolve the Go‑based DynDNS proxy—including support for both exact hostnames and wildcard domains. + +--- + +## Phase 1: Project Setup (Week 1) + +1. **Repository Initialization** + - `go mod init git.cloonar.com/cloonar/updns` + - Create directory structure: + ``` + cmd/updns + internal/config + internal/server + internal/provider + test/ + ``` + +2. **Configuration Loader** + - Use [spf13/viper] to parse YAML/JSON/TOML. + - Define `Config` struct with: + ```go + type ClientConfig struct { + Secret string `mapstructure:"secret"` + Exact []string `mapstructure:"exact"` + Wildcard []string `mapstructure:"wildcard"` + } + + type Config struct { + Server ServerConfig `mapstructure:"server"` + Upstream UpstreamConfig `mapstructure:"upstream"` + Clients map[string]ClientConfig `mapstructure:"clients"` + } + ``` + - Validate that at least one of `Exact` or `Wildcard` is set per client. + +3. **Main & CLI** + - Parse `--config` flag. + - Load config, handle errors, and pass into server setup. + +--- + +## Phase 2: HTTP API & Authentication (Week 2) + +1. **Server Implementation** + - Choose router: `net/http` or `gin-gonic/gin`. + - Define route `POST /update`. + +2. **Authentication & Authorization** + - Middleware to: + - Lookup client by `key`. + - Verify `secret` matches stored token. + - Check that requested `host` is allowed by matching either: + - An entry in `Exact` (full string equality), or + - A wildcard base domain in `Wildcard`, e.g. `example.com` matches `foo.example.com` and `example.com`. + - Reject requests with clear error if host not authorized. + +3. **Request Validation** + - Validate JSON payload: `key`, `secret`, `host`, optional `ip`. + - Default `ip` to requestor’s IP if omitted. + +--- + +## Phase 3: Hetzner Provider Integration (Week 3) + +1. **Provider Interface** + ```go + type Provider interface { + UpdateRecord(ctx context.Context, domain, ip string) error + } + ``` + +2. **Hetzner Implementation** + - On startup, fetch or cache record IDs by domain. + - Use Hetzner’s DDNS API to PATCH IP on update. + +3. **Error Handling & Retries** + - Retry transient errors with exponential backoff. + - Surface permanent errors in response. + +--- + +## Phase 4: Testing & CI (Week 4) + +1. **Unit Tests** + - Config parsing, ensuring `Exact` and `Wildcard` fields load correctly. + - Authorization logic: hosts matching exact list vs. wildcard list. + - Secret validation. + +2. **Integration Tests** + - Use `httptest.Server` to simulate upstream. + - Test success and failure for: + - Exact hostname updates. + - Subdomain updates via wildcard rules. + - Unauthorized host attempts. + +3. **CI Pipeline** + - GitHub Actions to run `go fmt`, `go vet`, `go test`. + - Build artifacts for Linux/macOS. + +--- + +## Phase 5: TLS, Logging & Metrics (Week 5) + +1. **TLS Support** + - Enable HTTPS when `tls.enabled` in config. + - Load cert/key files. + +2. **Structured Logging** + - Integrate `uber/zap` or `sirupsen/logrus`. + - Log requests, responses, errors, and authorization decisions (with no secrets). + +3. **Metrics** + - Expose Prometheus `/metrics` endpoint. + - Track: + - Total updates. + - Successes vs. failures. + - Requests authorized via exact vs. wildcard. + +--- + +## Phase 6: Extensibility & Additional Providers (Week 6) + +1. **Provider Factory** + - Map `upstream.provider` string to constructor. + +2. **Cloudflare & AWS Stubs** + - Scaffold `cloudflare` and `aws` provider packages. + - Document config for each. + +3. **Documentation Update** + - Update README to reflect exact + wildcard support and new provider instructions. + +--- + +## Phase 7: Deployment & Release (Week 7) + +1. **Dockerization** + - Write `Dockerfile` and example `docker-compose.yml`. + +2. **Optional Helm Chart** + - Package for Kubernetes. + +3. **Release v1.0.0** + - Tag in GitHub, attach binaries, update changelog. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..42c4d4a --- /dev/null +++ b/go.mod @@ -0,0 +1,53 @@ +module git.cloonar.com/cloonar/updns + +go 1.22 + +toolchain go1.23.7 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.10.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/viper v1.20.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..62e66bd --- /dev/null +++ b/go.sum @@ -0,0 +1,112 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2c8013e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,62 @@ +package config + +import ( + "fmt" + + "github.com/spf13/viper" +) + +type ClientConfig struct { + Secret string `mapstructure:"secret"` + Exact []string `mapstructure:"exact"` + Wildcard []string `mapstructure:"wildcard"` +} + +type ServerConfig struct { + BindAddress string `mapstructure:"bind_address"` + TLS TLSConfig `mapstructure:"tls"` +} + +type TLSConfig struct { + Enabled bool `mapstructure:"enabled"` + CertFile string `mapstructure:"cert_file"` + KeyFile string `mapstructure:"key_file"` +} + +type HetznerConfig struct { + APIToken string `mapstructure:"api_token"` +} + +type UpstreamConfig struct { + Provider string `mapstructure:"provider"` + Hetzner HetznerConfig `mapstructure:"hetzner"` +} + +type Config struct { + Server ServerConfig `mapstructure:"server"` + Upstream UpstreamConfig `mapstructure:"upstream"` + Clients map[string]ClientConfig `mapstructure:"clients"` +} + +// LoadConfig reads the file at path (yaml, json, toml) into Config and validates it. +func LoadConfig(path string) (*Config, error) { + v := viper.New() + v.SetConfigFile(path) + + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + for name, client := range cfg.Clients { + if len(client.Exact) == 0 && len(client.Wildcard) == 0 { + return nil, fmt.Errorf("client %q must have at least one of exact or wildcard", name) + } + } + + return &cfg, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..4d5e3ac --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,64 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "git.cloonar.com/cloonar/updns/internal/config" +) + +func TestLoadConfig_Success(t *testing.T) { + content := ` +server: + bind_address: ":9090" + tls: + enabled: true + cert_file: "cert.pem" + key_file: "key.pem" +upstream: + provider: hetzner + hetzner: + api_token: "token123" +clients: + clientA: + secret: "sec" + exact: + - "foo.com" + wildcard: + - "bar.com" +` + tmp := filepath.Join(os.TempDir(), "config_test.yaml") + if err := os.WriteFile(tmp, []byte(content), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + defer os.Remove(tmp) + + cfg, err := config.LoadConfig(tmp) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if cfg.Server.BindAddress != ":9090" { + t.Errorf("expected bind_address :9090, got %s", cfg.Server.BindAddress) + } + if !cfg.Server.TLS.Enabled { + t.Error("expected TLS enabled") + } +} + +func TestLoadConfig_Failure(t *testing.T) { + content := ` +clients: + clientB: + secret: "sec" +` + tmp := filepath.Join(os.TempDir(), "config_fail.yaml") + if err := os.WriteFile(tmp, []byte(content), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + defer os.Remove(tmp) + + if _, err := config.LoadConfig(tmp); err == nil { + t.Fatal("expected error for missing fields, got nil") + } +} diff --git a/internal/provider/hetzner/hetzner.go b/internal/provider/hetzner/hetzner.go new file mode 100644 index 0000000..79ae14a --- /dev/null +++ b/internal/provider/hetzner/hetzner.go @@ -0,0 +1,132 @@ +package hetzner + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + pvd "git.cloonar.com/cloonar/updns/internal/provider" +) + +const defaultAPIBase = "https://dns.hetzner.com" + +type provider struct { + token string + client *http.Client + apiBaseURL string +} + +type zone struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type zonesResponse struct { + Zones []zone `json:"zones"` +} + +type record struct { + ID string `json:"id"` + Name string `json:"name"` + Value string `json:"value"` + Type string `json:"type"` +} + +type recordsResponse struct { + Records []record `json:"records"` +} + +// NewProvider creates a Hetzner DNS provider using the official API. +func NewProvider(token string) pvd.Provider { + return &provider{token: token, client: http.DefaultClient, apiBaseURL: defaultAPIBase} +} + +// NewProviderWithURL creates a Hetzner provider with a custom API base URL (for testing). +func NewProviderWithURL(token, apiBaseURL string) pvd.Provider { + return &provider{token: token, client: http.DefaultClient, apiBaseURL: apiBaseURL} +} + +// UpdateRecord updates the DNS record for the given domain to the provided IP. +func (p *provider) UpdateRecord(ctx context.Context, domain, ip string) error { + // Determine zone name (last two labels, or single label for two-part domains) + parts := strings.Split(domain, ".") + if len(parts) < 2 { + return fmt.Errorf("invalid domain: %s", domain) + } + var zoneName string + if len(parts) == 2 { + zoneName = parts[1] + } else { + zoneName = strings.Join(parts[len(parts)-2:], ".") + } + // Fetch zone ID + zonesURL := fmt.Sprintf("%s/zones?name=%s", p.apiBaseURL, zoneName) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, zonesURL, nil) + req.Header.Set("Authorization", "Bearer "+p.token) + resp, err := p.client.Do(req) + if err != nil { + return fmt.Errorf("fetch zones: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("hetzner update failed with status: %s", resp.Status) + } + var zr zonesResponse + if err := json.NewDecoder(resp.Body).Decode(&zr); err != nil { + return fmt.Errorf("parsing zones response: %w", err) + } + if len(zr.Zones) == 0 { + return fmt.Errorf("zone %s not found", zoneName) + } + zoneID := zr.Zones[0].ID + + // Fetch records in zone + recsURL := fmt.Sprintf("%s/records?zone_id=%s", p.apiBaseURL, zoneID) + req, _ = http.NewRequestWithContext(ctx, http.MethodGet, recsURL, nil) + req.Header.Set("Authorization", "Bearer "+p.token) + resp, err = p.client.Do(req) + if err != nil { + return fmt.Errorf("fetch records: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("hetzner update failed with status: %s", resp.Status) + } + var rr recordsResponse + if err := json.NewDecoder(resp.Body).Decode(&rr); err != nil { + return fmt.Errorf("parsing records response: %w", err) + } + var recID string + for _, rec := range rr.Records { + if rec.Name == domain { + recID = rec.ID + break + } + } + if recID == "" { + return fmt.Errorf("record %s not found", domain) + } + + // Update record value + updateURL := fmt.Sprintf("%s/records/%s", p.apiBaseURL, recID) + body := map[string]string{"value": ip} + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(body); err != nil { + return fmt.Errorf("encode update body: %w", err) + } + req, _ = http.NewRequestWithContext(ctx, http.MethodPut, updateURL, buf) + req.Header.Set("Authorization", "Bearer "+p.token) + req.Header.Set("Content-Type", "application/json") + resp, err = p.client.Do(req) + if err != nil { + return fmt.Errorf("update record: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("update API status: %s", resp.Status) + } + return nil +} diff --git a/internal/provider/hetzner/hetzner_api_test.go b/internal/provider/hetzner/hetzner_api_test.go new file mode 100644 index 0000000..29f9596 --- /dev/null +++ b/internal/provider/hetzner/hetzner_api_test.go @@ -0,0 +1,91 @@ +package hetzner_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.cloonar.com/cloonar/updns/internal/provider/hetzner" +) + +func TestUpdateRecordFullAPILifecycle(t *testing.T) { + domain := "test.example.com" + ip := "1.2.3.4" + zoneName := "example.com" + zoneID := "zone-123" + recID := "rec-456" + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/zones": + // Query zones by name + resp := map[string]interface{}{ + "zones": []map[string]string{{"id": zoneID, "name": zoneName}}, + } + json.NewEncoder(w).Encode(resp) + case r.Method == http.MethodGet && r.URL.Path == "/records": + // Query records by zone_id + resp := map[string]interface{}{ + "records": []map[string]string{{"id": recID, "name": domain, "value": "0.0.0.0", "type": "A"}}, + } + json.NewEncoder(w).Encode(resp) + case r.Method == http.MethodPut && r.URL.Path == "/records/"+recID: + // Validate update payload + body, _ := io.ReadAll(r.Body) + var payload map[string]string + json.Unmarshal(body, &payload) + if payload["value"] != ip { + t.Errorf("expected update value %s, got %s", ip, payload["value"]) + } + w.WriteHeader(http.StatusOK) + default: + http.Error(w, "not found", http.StatusNotFound) + } + })) + defer ts.Close() + + provider := hetzner.NewProviderWithURL("token", ts.URL) + if err := provider.UpdateRecord(context.Background(), domain, ip); err != nil { + t.Fatalf("full API lifecycle failed: %v", err) + } +} + +func TestUpdateRecordZoneNotFound(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"zones": []map[string]string{}}) + })) + defer ts.Close() + + provider := hetzner.NewProviderWithURL("token", ts.URL) + err := provider.UpdateRecord(context.Background(), "nozone.example", "1.1.1.1") + if err == nil || !strings.Contains(err.Error(), "zone example not found") { + t.Fatalf("expected zone not found error, got %v", err) + } +} + +func TestUpdateRecordRecordNotFound(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/zones": + json.NewEncoder(w).Encode(map[string]interface{}{ + "zones": []map[string]string{{"id": "z", "name": "example.com"}}, + }) + case "/records": + json.NewEncoder(w).Encode(map[string]interface{}{"records": []map[string]string{}}) + default: + http.Error(w, "not found", http.StatusNotFound) + } + })) + defer ts.Close() + + provider := hetzner.NewProviderWithURL("token", ts.URL) + err := provider.UpdateRecord(context.Background(), "missing.example.com", "1.1.1.1") + if err == nil || !strings.Contains(err.Error(), "record missing.example.com not found") { + t.Fatalf("expected record not found error, got %v", err) + } +} diff --git a/internal/provider/hetzner/hetzner_integration_test.go b/internal/provider/hetzner/hetzner_integration_test.go new file mode 100644 index 0000000..8cd4c38 --- /dev/null +++ b/internal/provider/hetzner/hetzner_integration_test.go @@ -0,0 +1,4 @@ +package hetzner_test + +// Legacy dynamic DNS integration tests have been removed. +// The Hetzner provider now uses the official DNS API; see hetzner_api_test.go for coverage. diff --git a/internal/provider/hetzner/hetzner_test.go b/internal/provider/hetzner/hetzner_test.go new file mode 100644 index 0000000..5968f20 --- /dev/null +++ b/internal/provider/hetzner/hetzner_test.go @@ -0,0 +1,12 @@ +package hetzner_test + +import ( + "testing" + + pvd "git.cloonar.com/cloonar/updns/internal/provider" + "git.cloonar.com/cloonar/updns/internal/provider/hetzner" +) + +func TestNewProviderImplementsInterface(t *testing.T) { + var _ pvd.Provider = hetzner.NewProvider("token") +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..8edf85e --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,8 @@ +package provider + +import "context" + +// Provider defines the interface for updating DNS records. +type Provider interface { + UpdateRecord(ctx context.Context, domain, ip string) error +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..d21fe2a --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,22 @@ +package provider_test + +import ( + "context" + "testing" + + "git.cloonar.com/cloonar/updns/internal/provider" +) + +// mockProvider is a dummy implementation for testing the Provider interface. +type mockProvider struct{} + +func (m *mockProvider) UpdateRecord(ctx context.Context, domain, ip string) error { + return nil +} + +func TestProviderInterfaceCompliance(t *testing.T) { + var p provider.Provider = &mockProvider{} + if err := p.UpdateRecord(context.Background(), "example.com", "1.2.3.4"); err != nil { + t.Errorf("expected no error, got %v", err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..915922b --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,151 @@ +package server + +import ( + "fmt" + "net/http" + "strings" + "time" + + "git.cloonar.com/cloonar/updns/internal/config" + pvd "git.cloonar.com/cloonar/updns/internal/provider" + "git.cloonar.com/cloonar/updns/internal/provider/hetzner" + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.uber.org/zap" +) + +var ( + totalUpdates = promauto.NewCounter(prometheus.CounterOpts{ + Name: "updns_total_updates", + Help: "Total number of update requests", + }) + successUpdates = promauto.NewCounter(prometheus.CounterOpts{ + Name: "updns_success_updates", + Help: "Number of successful updates", + }) + failedUpdates = promauto.NewCounter(prometheus.CounterOpts{ + Name: "updns_failed_updates", + Help: "Number of failed updates", + }) + exactAuth = promauto.NewCounter(prometheus.CounterOpts{ + Name: "updns_exact_auth", + Help: "Number of updates authorized via exact match", + }) + wildcardAuth = promauto.NewCounter(prometheus.CounterOpts{ + Name: "updns_wildcard_auth", + Help: "Number of updates authorized via wildcard", + }) +) + +type updateRequest struct { + Key string `json:"key" binding:"required"` + Secret string `json:"secret" binding:"required"` + Host string `json:"host" binding:"required"` + IP string `json:"ip"` +} + +// NewRouter constructs the HTTP handler with routes, middleware, logging and metrics. +func NewRouter(cfg *config.Config) *gin.Engine { + logger, _ := zap.NewProduction() + + r := gin.New() + r.Use(gin.Recovery()) + r.Use(func(c *gin.Context) { + start := time.Now() + c.Next() + logger.Info("request", + zap.String("method", c.Request.Method), + zap.String("path", c.Request.URL.Path), + zap.Int("status", c.Writer.Status()), + zap.Duration("duration", time.Since(start)), + ) + }) + + r.GET("/metrics", gin.WrapH(promhttp.Handler())) + + r.POST("/update", func(c *gin.Context) { + totalUpdates.Inc() + var req updateRequest + if err := c.ShouldBindJSON(&req); err != nil { + failedUpdates.Inc() + logger.Error("invalid request", zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": err.Error()}) + return + } + ip := req.IP + if ip == "" { + ip = c.ClientIP() + } + clientCfg, ok := cfg.Clients[req.Key] + if !ok || req.Secret != clientCfg.Secret { + failedUpdates.Inc() + c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "message": "invalid key or secret"}) + return + } + authorized := false + for _, h := range clientCfg.Exact { + if req.Host == h { + authorized = true + exactAuth.Inc() + break + } + } + if !authorized { + for _, base := range clientCfg.Wildcard { + if req.Host == base || strings.HasSuffix(req.Host, "."+base) { + authorized = true + wildcardAuth.Inc() + break + } + } + } + if !authorized { + failedUpdates.Inc() + c.JSON(http.StatusForbidden, gin.H{"status": "error", "message": "host not authorized"}) + return + } + prov, ok := selectProvider(cfg) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": "provider not configured"}) + return + } + if err := prov.UpdateRecord(c.Request.Context(), req.Host, ip); err != nil { + failedUpdates.Inc() + logger.Error("update record failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": "update failed"}) + return + } + successUpdates.Inc() + c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Record updated"}) + }) + + return r +} + +// StartServer initializes Provider and starts the HTTP server. +func StartServer(cfg *config.Config) error { + prov, ok := selectProvider(cfg) + if !ok { + return fmt.Errorf("unsupported provider: %s", cfg.Upstream.Provider) + } + // drop unused to avoid compile error + _ = prov + + router := NewRouter(cfg) + if cfg.Server.TLS.Enabled { + return router.RunTLS(cfg.Server.BindAddress, cfg.Server.TLS.CertFile, cfg.Server.TLS.KeyFile) + } + return router.Run(cfg.Server.BindAddress) +} + +// selectProvider returns the configured Provider or false if unsupported. +func selectProvider(cfg *config.Config) (pvd.Provider, bool) { + switch cfg.Upstream.Provider { + case "hetzner": + return hetzner.NewProvider(cfg.Upstream.Hetzner.APIToken), true + default: + return nil, false + } +} diff --git a/internal/server/server_router_test.go b/internal/server/server_router_test.go new file mode 100644 index 0000000..17fcbe1 --- /dev/null +++ b/internal/server/server_router_test.go @@ -0,0 +1,105 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.cloonar.com/cloonar/updns/internal/config" + "git.cloonar.com/cloonar/updns/internal/server" +) + +func newTestConfig(provider string) *config.Config { + return &config.Config{ + Server: config.ServerConfig{ + BindAddress: ":0", + TLS: config.TLSConfig{Enabled: false}, + }, + Upstream: config.UpstreamConfig{ + Provider: provider, + Hetzner: config.HetznerConfig{APIToken: "token"}, + }, + Clients: map[string]config.ClientConfig{ + "client1": { + Secret: "s3cr3t", + Exact: []string{"a.example.com"}, + Wildcard: []string{"example.net"}, + }, + }, + } +} + +func TestMetricsEndpoint(t *testing.T) { + r := server.NewRouter(newTestConfig("unknown")) + req := httptest.NewRequest("GET", "/metrics", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", w.Code) + } +} + +func TestUpdateInvalidJSON(t *testing.T) { + r := server.NewRouter(newTestConfig("unknown")) + req := httptest.NewRequest("POST", "/update", bytes.NewBufferString("{invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 BadRequest, got %d", w.Code) + } +} + +func TestUpdateUnauthorizedKey(t *testing.T) { + r := server.NewRouter(newTestConfig("unknown")) + body := map[string]string{"key": "bad", "secret": "x", "host": "a.example.com"} + data, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/update", bytes.NewBuffer(data)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 Unauthorized, got %d", w.Code) + } +} + +func TestUpdateHostForbidden(t *testing.T) { + r := server.NewRouter(newTestConfig("unknown")) + body := map[string]string{"key": "client1", "secret": "s3cr3t", "host": "bad.example.com"} + data, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/update", bytes.NewBuffer(data)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 Forbidden, got %d", w.Code) + } +} + +func TestUpdateProviderNotConfigured(t *testing.T) { + r := server.NewRouter(newTestConfig("unknown")) + body := map[string]string{"key": "client1", "secret": "s3cr3t", "host": "a.example.com"} + data, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/update", bytes.NewBuffer(data)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 InternalServerError, got %d", w.Code) + } +} + +func TestUpdateSuccess(t *testing.T) { + r := server.NewRouter(newTestConfig("hetzner")) + body := map[string]string{"key": "client1", "secret": "s3cr3t", "host": "a.example.com", "ip": "1.2.3.4"} + data, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/update", bytes.NewBuffer(data)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200 OK, got %d", w.Code) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..61e98ef --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,31 @@ +package server_test + +import ( + "strings" + "testing" + + "git.cloonar.com/cloonar/updns/internal/config" + "git.cloonar.com/cloonar/updns/internal/server" +) + +func TestStartServerUnsupportedProvider(t *testing.T) { + cfg := &config.Config{ + Server: config.ServerConfig{ + BindAddress: "127.0.0.1:0", + TLS: config.TLSConfig{ + Enabled: false, + }, + }, + Upstream: config.UpstreamConfig{ + Provider: "unknown", + }, + Clients: map[string]config.ClientConfig{}, + } + err := server.StartServer(cfg) + if err == nil { + t.Fatal("expected error for unsupported provider, got nil") + } + if !strings.Contains(err.Error(), "unsupported provider") { + t.Errorf("unexpected error message: %v", err) + } +}