feat: Implement configuration management and DNS provider integration
- Added configuration management using Viper in internal/config/config.go - Implemented ClientConfig, ServerConfig, TLSConfig, HetznerConfig, UpstreamConfig, and main Config struct. - Created LoadConfig function to read and validate configuration files. - Developed Hetzner DNS provider in internal/provider/hetzner/hetzner.go with methods for updating DNS records. - Added comprehensive unit tests for configuration loading and Hetzner provider functionality. - Established HTTP server with metrics and update endpoint in internal/server/server.go. - Implemented request handling, authorization, and error management in the server. - Created integration tests for the Hetzner provider API interactions. - Removed legacy dynamic DNS integration tests in favor of the new API-based approach.
This commit is contained in:
47
.github/workflows/ci.yml
vendored
Normal file
47
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||||
33
cmd/updns/main.go
Normal file
33
cmd/updns/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
cmd/updns/main_test.go
Normal file
51
cmd/updns/main_test.go
Normal file
@@ -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
|
||||||
|
})())
|
||||||
|
}
|
||||||
17
config.yaml
Normal file
17
config.yaml
Normal file
@@ -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"
|
||||||
47
coverage.out
Normal file
47
coverage.out
Normal file
@@ -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
|
||||||
146
development-plan.md
Normal file
146
development-plan.md
Normal file
@@ -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.
|
||||||
53
go.mod
Normal file
53
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
112
go.sum
Normal file
112
go.sum
Normal file
@@ -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=
|
||||||
62
internal/config/config.go
Normal file
62
internal/config/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
64
internal/config/config_test.go
Normal file
64
internal/config/config_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
132
internal/provider/hetzner/hetzner.go
Normal file
132
internal/provider/hetzner/hetzner.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
91
internal/provider/hetzner/hetzner_api_test.go
Normal file
91
internal/provider/hetzner/hetzner_api_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
internal/provider/hetzner/hetzner_integration_test.go
Normal file
4
internal/provider/hetzner/hetzner_integration_test.go
Normal file
@@ -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.
|
||||||
12
internal/provider/hetzner/hetzner_test.go
Normal file
12
internal/provider/hetzner/hetzner_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
8
internal/provider/provider.go
Normal file
8
internal/provider/provider.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
22
internal/provider/provider_test.go
Normal file
22
internal/provider/provider_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
151
internal/server/server.go
Normal file
151
internal/server/server.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
105
internal/server/server_router_test.go
Normal file
105
internal/server/server_router_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
internal/server/server_test.go
Normal file
31
internal/server/server_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user