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

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

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

View 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.

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