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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user