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:
2025-04-21 00:45:38 +02:00
parent ea62c6b396
commit adae58b7bc
19 changed files with 1188 additions and 0 deletions

151
internal/server/server.go Normal file
View 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
}
}

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

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