feat: production-ready domain control dashboard
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
9c60eaf3fe
commit
df7516e7ab
45 files changed
+7932
-1
No files matched your search
@@ -0,0 +1,92 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultIPv4Primary = "https://api.ipify.org"
|
||||
DefaultIPv4Fallback = "https://ifconfig.co/ip"
|
||||
DefaultIPv6Primary = "https://api6.ipify.org"
|
||||
DefaultIPv6Fallback = "https://ifconfig.co/ip"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Address string
|
||||
DataDir string
|
||||
DatabasePath string
|
||||
MasterKeyFile string
|
||||
CookieSecure bool
|
||||
SessionTTL time.Duration
|
||||
HCloudBaseURL string
|
||||
Mode string
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
dataDir := value("APP_DATA_DIR", "/data")
|
||||
address := value("APP_ADDRESS", ":8080")
|
||||
if _, _, err := net.SplitHostPort(address); err != nil {
|
||||
return Config{}, fmt.Errorf("APP_ADDRESS must be host:port: %w", err)
|
||||
}
|
||||
|
||||
mode := strings.ToLower(value("APP_MODE", "lan"))
|
||||
if mode != "lan" && mode != "https" {
|
||||
return Config{}, fmt.Errorf("APP_MODE must be either lan or https")
|
||||
}
|
||||
|
||||
ttl, err := duration("APP_SESSION_TTL", 8*time.Hour)
|
||||
if err != nil || ttl < time.Hour || ttl > 7*24*time.Hour {
|
||||
return Config{}, fmt.Errorf("APP_SESSION_TTL must be between 1h and 168h")
|
||||
}
|
||||
shutdownTimeout, err := duration("APP_SHUTDOWN_TIMEOUT", 15*time.Second)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("APP_SHUTDOWN_TIMEOUT: %w", err)
|
||||
}
|
||||
|
||||
keyFile := os.Getenv("APP_MASTER_KEY_FILE")
|
||||
if keyFile == "" {
|
||||
return Config{}, fmt.Errorf("APP_MASTER_KEY_FILE is required (mount a Docker secret there)")
|
||||
}
|
||||
|
||||
cookieSecure := mode == "https"
|
||||
if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" {
|
||||
parsed, parseErr := strconv.ParseBool(raw)
|
||||
if parseErr != nil {
|
||||
return Config{}, fmt.Errorf("APP_COOKIE_SECURE: %w", parseErr)
|
||||
}
|
||||
cookieSecure = parsed
|
||||
}
|
||||
|
||||
return Config{
|
||||
Address: address,
|
||||
DataDir: dataDir,
|
||||
DatabasePath: value("APP_DATABASE_PATH", filepath.Join(dataDir, "dyndns.db")),
|
||||
MasterKeyFile: keyFile,
|
||||
CookieSecure: cookieSecure,
|
||||
SessionTTL: ttl,
|
||||
HCloudBaseURL: strings.TrimRight(value("APP_HCLOUD_BASE_URL", "https://api.hetzner.cloud/v1"), "/"),
|
||||
Mode: mode,
|
||||
ShutdownTimeout: shutdownTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func value(name, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func duration(name string, fallback time.Duration) (time.Duration, error) {
|
||||
if raw := strings.TrimSpace(os.Getenv(name)); raw != "" {
|
||||
return time.ParseDuration(raw)
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package hetzner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type Zone struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
Value string `json:"value"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
type RRSet struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL *int `json:"ttl"`
|
||||
Records []Record `json:"records"`
|
||||
Protection struct {
|
||||
Change bool `json:"change"`
|
||||
} `json:"protection"`
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Code != "" {
|
||||
return fmt.Sprintf("Hetzner API %d (%s): %s", e.Status, e.Code, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("Hetzner API %d: %s", e.Status, e.Message)
|
||||
}
|
||||
|
||||
func New(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
http: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) ListZones(ctx context.Context, token string) ([]Zone, error) {
|
||||
var result []Zone
|
||||
for page := 1; ; page++ {
|
||||
var response struct {
|
||||
Zones []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"zones"`
|
||||
Meta struct {
|
||||
Pagination struct {
|
||||
NextPage *int `json:"next_page"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
query := url.Values{"page": {strconv.Itoa(page)}, "per_page": {"50"}}
|
||||
if err := c.doJSON(ctx, token, http.MethodGet, "/zones?"+query.Encode(), nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, zone := range response.Zones {
|
||||
result = append(result, Zone{ID: strconv.FormatInt(zone.ID, 10), Name: zone.Name})
|
||||
}
|
||||
if response.Meta.Pagination.NextPage == nil {
|
||||
break
|
||||
}
|
||||
page = *response.Meta.Pagination.NextPage - 1
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListRRSets(ctx context.Context, token, zoneID string) ([]RRSet, error) {
|
||||
var result []RRSet
|
||||
for page := 1; ; page++ {
|
||||
var response struct {
|
||||
RRSets []RRSet `json:"rrsets"`
|
||||
Meta struct {
|
||||
Pagination struct {
|
||||
NextPage *int `json:"next_page"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
query := url.Values{"page": {strconv.Itoa(page)}, "per_page": {"100"}}
|
||||
if err := c.doJSON(ctx, token, http.MethodGet, "/zones/"+url.PathEscape(zoneID)+"/rrsets?"+query.Encode(), nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, response.RRSets...)
|
||||
if response.Meta.Pagination.NextPage == nil {
|
||||
break
|
||||
}
|
||||
page = *response.Meta.Pagination.NextPage - 1
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateRRSet(ctx context.Context, token, zoneID, name, recordType string, ttl int, records []Record) error {
|
||||
body := struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL int `json:"ttl"`
|
||||
Records []Record `json:"records"`
|
||||
}{Name: name, Type: recordType, TTL: ttl, Records: records}
|
||||
return c.doJSON(ctx, token, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/rrsets", body, nil)
|
||||
}
|
||||
|
||||
func (c *Client) SetRRSetRecords(ctx context.Context, token, zoneID, name, recordType string, records []Record) error {
|
||||
body := struct {
|
||||
Records []Record `json:"records"`
|
||||
}{Records: records}
|
||||
return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/set_records", body, nil)
|
||||
}
|
||||
|
||||
func (c *Client) ChangeRRSetTTL(ctx context.Context, token, zoneID, name, recordType string, ttl int) error {
|
||||
body := struct {
|
||||
TTL int `json:"ttl"`
|
||||
}{TTL: ttl}
|
||||
return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/change_ttl", body, nil)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteRRSet(ctx context.Context, token, zoneID, name, recordType string) error {
|
||||
return c.doJSON(ctx, token, http.MethodDelete, rrsetPath(zoneID, name, recordType), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateRecordComments(ctx context.Context, token, zoneID, name, recordType string, records []Record) error {
|
||||
body := struct {
|
||||
Records []struct {
|
||||
Value string `json:"value"`
|
||||
Comment string `json:"comment"`
|
||||
} `json:"records"`
|
||||
}{Records: make([]struct {
|
||||
Value string `json:"value"`
|
||||
Comment string `json:"comment"`
|
||||
}, 0, len(records))}
|
||||
for _, record := range records {
|
||||
body.Records = append(body.Records, struct {
|
||||
Value string `json:"value"`
|
||||
Comment string `json:"comment"`
|
||||
}{Value: record.Value, Comment: record.Comment})
|
||||
}
|
||||
return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/update_records", body, nil)
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, token, method, path string, requestBody any, responseBody any) error {
|
||||
var body io.Reader
|
||||
if requestBody != nil {
|
||||
encoded, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(encoded)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if requestBody != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request Hetzner API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
content, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Hetzner API response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return decodeAPIError(resp.StatusCode, content)
|
||||
}
|
||||
if responseBody != nil && len(content) > 0 {
|
||||
if err := json.Unmarshal(content, responseBody); err != nil {
|
||||
return fmt.Errorf("decode Hetzner API response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rrsetPath(zoneID, name, recordType string) string {
|
||||
return "/zones/" + url.PathEscape(zoneID) + "/rrsets/" + url.PathEscape(name) + "/" + url.PathEscape(recordType)
|
||||
}
|
||||
|
||||
func decodeAPIError(status int, content []byte) error {
|
||||
var response struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(content, &response)
|
||||
message := strings.TrimSpace(response.Error.Message)
|
||||
if message == "" {
|
||||
message = http.StatusText(status)
|
||||
}
|
||||
return &APIError{Status: status, Code: response.Error.Code, Message: message}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package hetzner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
type capturedRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Authorization string
|
||||
Records []Record
|
||||
}
|
||||
captured := make(chan capturedRequest, 1)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Records []Record `json:"records"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
captured <- capturedRequest{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Authorization: r.Header.Get("Authorization"),
|
||||
Records: body.Records,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"action":{"status":"running"}}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client := New(api.URL)
|
||||
err := client.SetRRSetRecords(
|
||||
context.Background(),
|
||||
"test-token",
|
||||
"123",
|
||||
"home",
|
||||
"A",
|
||||
[]Record{{Value: "198.51.100.42", Comment: "DynDNS"}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("set RRSet records: %v", err)
|
||||
}
|
||||
request := <-captured
|
||||
if request.Method != http.MethodPost {
|
||||
t.Errorf("unexpected method: got %s, want POST", request.Method)
|
||||
}
|
||||
if request.Path != "/zones/123/rrsets/home/A/actions/set_records" {
|
||||
t.Errorf("unexpected path: %s", request.Path)
|
||||
}
|
||||
if request.Authorization != "Bearer test-token" {
|
||||
t.Errorf("unexpected authorization header: %q", request.Authorization)
|
||||
}
|
||||
if len(request.Records) != 1 || request.Records[0].Value != "198.51.100.42" || request.Records[0].Comment != "DynDNS" {
|
||||
t.Errorf("unexpected records payload: %#v", request.Records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRecordCommentsAlwaysSendsComment(t *testing.T) {
|
||||
t.Parallel()
|
||||
captured := make(chan map[string]any, 1)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body["method"] = r.Method
|
||||
body["path"] = r.URL.Path
|
||||
captured <- body
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client := New(api.URL)
|
||||
if err := client.UpdateRecordComments(context.Background(), "test-token", "123", "home", "A", []Record{{Value: "198.51.100.42"}}); err != nil {
|
||||
t.Fatalf("update record comment: %v", err)
|
||||
}
|
||||
request := <-captured
|
||||
if request["method"] != http.MethodPost || request["path"] != "/zones/123/rrsets/home/A/actions/update_records" {
|
||||
t.Fatalf("unexpected request: %#v", request)
|
||||
}
|
||||
records, ok := request["records"].([]any)
|
||||
if !ok || len(records) != 1 {
|
||||
t.Fatalf("unexpected records payload: %#v", request["records"])
|
||||
}
|
||||
record, ok := records[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected record payload: %#v", records[0])
|
||||
}
|
||||
comment, exists := record["comment"]
|
||||
if !exists || comment != "" {
|
||||
t.Errorf("empty comment must be sent explicitly, got %#v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeRRSetTTLUsesDedicatedAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
captured := make(chan map[string]any, 1)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body["method"] = r.Method
|
||||
body["path"] = r.URL.Path
|
||||
captured <- body
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client := New(api.URL)
|
||||
if err := client.ChangeRRSetTTL(context.Background(), "test-token", "123", "home", "A", 300); err != nil {
|
||||
t.Fatalf("change RRSet TTL: %v", err)
|
||||
}
|
||||
request := <-captured
|
||||
if request["method"] != http.MethodPost || request["path"] != "/zones/123/rrsets/home/A/actions/change_ttl" || request["ttl"] != float64(300) {
|
||||
t.Fatalf("unexpected request: %#v", request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRRSetUsesRRSetEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
captured := make(chan *http.Request, 1)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
captured <- r
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client := New(api.URL)
|
||||
if err := client.DeleteRRSet(context.Background(), "test-token", "123", "www", "CNAME"); err != nil {
|
||||
t.Fatalf("delete RRSet: %v", err)
|
||||
}
|
||||
request := <-captured
|
||||
if request.Method != http.MethodDelete || request.URL.Path != "/zones/123/rrsets/www/CNAME" || request.Header.Get("Authorization") != "Bearer test-token" {
|
||||
t.Fatalf("unexpected request: %s %s %q", request.Method, request.URL.Path, request.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package ipresolve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
IPv4 string
|
||||
IPv6 string
|
||||
IPv4Err error
|
||||
IPv6Err error
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
resolver *net.Resolver
|
||||
}
|
||||
|
||||
func New() *Resolver { return &Resolver{resolver: net.DefaultResolver} }
|
||||
|
||||
func (r *Resolver) Resolve(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string) Result {
|
||||
return r.ResolveWithIPv6(ctx, ipv4Endpoints, ipv6Endpoints, true)
|
||||
}
|
||||
|
||||
func (r *Resolver) ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) Result {
|
||||
result := Result{}
|
||||
result.IPv4, result.IPv4Err = r.resolveFamily(ctx, ipv4Endpoints, true)
|
||||
if ipv6Enabled {
|
||||
result.IPv6, result.IPv6Err = r.resolveFamily(ctx, ipv6Endpoints, false)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *Resolver) ValidateEndpoint(ctx context.Context, raw string) error {
|
||||
_, _, err := r.parseAndResolve(ctx, raw, true)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
_, _, err6 := r.parseAndResolve(ctx, raw, false)
|
||||
if err6 == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("endpoint is not a reachable public HTTPS host: %w", err)
|
||||
}
|
||||
|
||||
func (r *Resolver) resolveFamily(ctx context.Context, endpoints []string, ipv4 bool) (string, error) {
|
||||
if len(endpoints) == 0 {
|
||||
return "", errors.New("no endpoints configured")
|
||||
}
|
||||
var failures []string
|
||||
for _, endpoint := range endpoints {
|
||||
value, err := r.resolveOne(ctx, endpoint, ipv4)
|
||||
if err == nil {
|
||||
return value, nil
|
||||
}
|
||||
failures = append(failures, safeEndpointName(endpoint)+": "+err.Error())
|
||||
}
|
||||
family := "IPv6"
|
||||
if ipv4 {
|
||||
family = "IPv4"
|
||||
}
|
||||
return "", fmt.Errorf("all %s endpoints failed: %s", family, strings.Join(failures, "; "))
|
||||
}
|
||||
|
||||
func (r *Resolver) resolveOne(ctx context.Context, raw string, ipv4 bool) (string, error) {
|
||||
u, ips, err := r.parseAndResolve(ctx, raw, ipv4)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
port = "443"
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
IdleConnTimeout: 10 * time.Second,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: u.Hostname()},
|
||||
DialContext: func(dialCtx context.Context, _, _ string) (net.Conn, error) {
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
conn, dialErr := dialer.DialContext(dialCtx, "tcp", net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
return nil, lastErr
|
||||
},
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "text/plain")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1025))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(body) > 1024 {
|
||||
return "", errors.New("response is larger than 1 KiB")
|
||||
}
|
||||
address, err := netip.ParseAddr(strings.TrimSpace(string(body)))
|
||||
if err != nil {
|
||||
return "", errors.New("response is not a plain IP address")
|
||||
}
|
||||
if ipv4 != address.Is4() {
|
||||
return "", errors.New("response has the wrong IP family")
|
||||
}
|
||||
if !publicAddress(address) {
|
||||
return "", errors.New("response is not a globally routable address")
|
||||
}
|
||||
return address.String(), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) parseAndResolve(ctx context.Context, raw string, ipv4 bool) (*url.URL, []netip.Addr, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u == nil {
|
||||
return nil, nil, errors.New("invalid URL")
|
||||
}
|
||||
if u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
|
||||
return nil, nil, errors.New("only credential-free HTTPS URLs are allowed")
|
||||
}
|
||||
if port := u.Port(); port != "" && port != "443" {
|
||||
return nil, nil, errors.New("only HTTPS port 443 is allowed")
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return nil, nil, errors.New("query parameters are not allowed")
|
||||
}
|
||||
host := u.Hostname()
|
||||
if literal, err := netip.ParseAddr(host); err == nil {
|
||||
if literal.Is4() != ipv4 || !publicAddress(literal) {
|
||||
return nil, nil, errors.New("host must resolve to a public address of the requested family")
|
||||
}
|
||||
return u, []netip.Addr{literal}, nil
|
||||
}
|
||||
addresses, err := r.resolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve host: %w", err)
|
||||
}
|
||||
filtered := make([]netip.Addr, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
if address.Is4() == ipv4 && publicAddress(address) {
|
||||
filtered = append(filtered, address)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, nil, errors.New("host has no public address of the requested family")
|
||||
}
|
||||
return u, filtered, nil
|
||||
}
|
||||
|
||||
func publicAddress(address netip.Addr) bool {
|
||||
return address.IsValid() && address.IsGlobalUnicast() && !address.IsPrivate() && !address.IsLoopback() && !address.IsLinkLocalUnicast() && !address.IsLinkLocalMulticast() && !address.IsUnspecified() && !address.IsMulticast()
|
||||
}
|
||||
|
||||
func safeEndpointName(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return "configured endpoint"
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package ipresolve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPublicAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, raw := range []string{"1.1.1.1", "2001:4860:4860::8888"} {
|
||||
address := netip.MustParseAddr(raw)
|
||||
if !publicAddress(address) {
|
||||
t.Fatalf("expected %s to be public", raw)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"127.0.0.1", "192.168.1.1", "169.254.1.1", "::1", "fe80::1"} {
|
||||
address := netip.MustParseAddr(raw)
|
||||
if publicAddress(address) {
|
||||
t.Fatalf("expected %s to be rejected", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledIPv6IsNotResolvedOrReportedAsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := New().ResolveWithIPv6(context.Background(), nil, nil, false)
|
||||
if result.IPv4Err == nil {
|
||||
t.Fatal("missing IPv4 endpoints should still be reported")
|
||||
}
|
||||
if result.IPv6 != "" || result.IPv6Err != nil {
|
||||
t.Fatalf("disabled IPv6 must be skipped cleanly: %#v", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Settings are global to the single-admin installation.
|
||||
type Settings struct {
|
||||
UpdateIntervalSeconds int
|
||||
UpdaterEnabled bool
|
||||
IPv6Enabled bool
|
||||
IPv4Endpoints []string
|
||||
IPv6Endpoints []string
|
||||
LastIPv4 string
|
||||
LastIPv6 string
|
||||
LastRunAt time.Time
|
||||
LastRunStatus string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Credential is a reusable, encrypted Hetzner project token.
|
||||
type Credential struct {
|
||||
ID int64
|
||||
Label string
|
||||
Ciphertext []byte
|
||||
Nonce []byte
|
||||
Fingerprint string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Zone is one Hetzner DNS zone selected by the administrator.
|
||||
type Zone struct {
|
||||
ID int64
|
||||
CredentialID int64
|
||||
ProviderID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
LastSyncAt time.Time
|
||||
LastError string
|
||||
}
|
||||
|
||||
// Target is the desired state for one host name and its dynamic address families.
|
||||
type Target struct {
|
||||
ID int64
|
||||
ZoneID int64
|
||||
RecordName string
|
||||
FQDN string
|
||||
UpdateIPv4 bool
|
||||
UpdateIPv6 bool
|
||||
Active bool
|
||||
StaticIPv4 string
|
||||
StaticIPv6 string
|
||||
Description string
|
||||
TTL int
|
||||
LastUpdatedAt time.Time
|
||||
LastStatus string
|
||||
LastError string
|
||||
CreatedAt time.Time
|
||||
ArchivedAt time.Time
|
||||
}
|
||||
|
||||
// CNAMERecord is a static DNS alias managed directly in a zone. It is kept
|
||||
// separate from Target because the DynDNS worker must never overwrite it.
|
||||
type CNAMERecord struct {
|
||||
ID int64
|
||||
ZoneID int64
|
||||
Active bool
|
||||
RecordName string
|
||||
FQDN string
|
||||
Destination string
|
||||
Description string
|
||||
TTL int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
DisplayName string
|
||||
Role string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastLoginAt time.Time
|
||||
}
|
||||
|
||||
func (u User) IsAdmin() bool { return u.Role == "admin" }
|
||||
|
||||
type AccessGrant struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
ZoneID int64
|
||||
TargetID *int64
|
||||
CanManageTargets bool
|
||||
CanCreateTargets bool
|
||||
}
|
||||
|
||||
// Session deliberately contains only hashes/random CSRF material, never a password.
|
||||
type Session struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Username string
|
||||
DisplayName string
|
||||
Role string
|
||||
TokenHash string
|
||||
CSRFToken string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
ReauthUntil time.Time
|
||||
}
|
||||
|
||||
type AuditEvent struct {
|
||||
ID int64
|
||||
CreatedAt time.Time
|
||||
Kind string
|
||||
Message string
|
||||
}
|
||||
|
||||
type UpdateRun struct {
|
||||
ID int64
|
||||
StartedAt time.Time
|
||||
FinishedAt time.Time
|
||||
Status string
|
||||
IPv4 string
|
||||
IPv6 string
|
||||
Summary string
|
||||
}
|
||||
|
||||
type Dashboard struct {
|
||||
Settings Settings
|
||||
Zones []Zone
|
||||
ActiveTargets int
|
||||
RecentRuns []UpdateRun
|
||||
RecentEvents []AuditEvent
|
||||
NextRunAt time.Time
|
||||
SchedulerRunning bool
|
||||
ErrorCount int
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRunInProgress = errors.New("an update run is already in progress")
|
||||
ErrDisabled = errors.New("the updater is disabled")
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
store *store.Store
|
||||
provider *hetzner.Client
|
||||
resolver *ipresolve.Resolver
|
||||
cipher *secure.Cipher
|
||||
logger *slog.Logger
|
||||
running atomic.Bool
|
||||
nextRun atomic.Int64
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Resolver, cipher *secure.Cipher, logger *slog.Logger) *Scheduler {
|
||||
return &Scheduler{store: database, provider: provider, resolver: resolver, cipher: cipher, logger: logger, wake: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
go func() {
|
||||
defer s.nextRun.Store(0)
|
||||
for {
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("read scheduler settings", "error", err)
|
||||
s.nextRun.Store(0)
|
||||
timer := time.NewTimer(time.Minute)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-s.wake:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
continue
|
||||
}
|
||||
if settings.UpdaterEnabled {
|
||||
if _, runErr := s.Run(ctx); runErr != nil && !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
|
||||
s.logger.Warn("scheduled update run failed", "error", runErr)
|
||||
}
|
||||
} else {
|
||||
s.nextRun.Store(0)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-s.wake:
|
||||
}
|
||||
continue
|
||||
}
|
||||
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
|
||||
if wait < time.Minute {
|
||||
wait = time.Minute
|
||||
}
|
||||
s.nextRun.Store(time.Now().UTC().Add(wait).Unix())
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-s.wake:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Trigger() {
|
||||
s.nextRun.Store(0)
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) IsRunning() bool { return s.running.Load() }
|
||||
|
||||
func (s *Scheduler) NextRunAt() time.Time {
|
||||
value := s.nextRun.Load()
|
||||
if value == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(value, 0).UTC()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Run(ctx context.Context) (string, error) {
|
||||
if !s.running.CompareAndSwap(false, true) {
|
||||
return "", ErrRunInProgress
|
||||
}
|
||||
defer s.running.Store(false)
|
||||
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !settings.UpdaterEnabled {
|
||||
return "", ErrDisabled
|
||||
}
|
||||
runID, err := s.store.CreateUpdateRun(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled)
|
||||
if ipResult.IPv4 == "" && ipResult.IPv6 == "" {
|
||||
message := "Keine öffentliche IP ermittelt"
|
||||
if ipResult.IPv4Err != nil {
|
||||
message += ": " + ipResult.IPv4Err.Error()
|
||||
}
|
||||
return s.finishRun(ctx, runID, "error", "", "", message, errors.New(message))
|
||||
}
|
||||
|
||||
targets, err := s.store.ListManagedTargets(ctx)
|
||||
if err != nil {
|
||||
return s.finishRun(ctx, runID, "error", ipResult.IPv4, ipResult.IPv6, "Aktive Ziele konnten nicht geladen werden", err)
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
summary := "Keine aktiven DynDNS-Ziele"
|
||||
return s.finishRun(ctx, runID, "ok", ipResult.IPv4, ipResult.IPv6, summary, nil)
|
||||
}
|
||||
|
||||
grouped := make(map[int64][]store.ManagedTarget)
|
||||
for _, target := range targets {
|
||||
grouped[target.Zone.ID] = append(grouped[target.Zone.ID], target)
|
||||
}
|
||||
updated, failed := 0, 0
|
||||
for _, group := range grouped {
|
||||
if len(group) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := s.syncZoneTargets(ctx, group, ipResult.IPv4, ipResult.IPv6, settings.IPv6Enabled, &updated, &failed); err != nil {
|
||||
failed += len(group)
|
||||
s.logger.Warn("sync DNS zone", "zone", group[0].Zone.Name, "error", err)
|
||||
for _, target := range group {
|
||||
_ = s.store.UpdateTargetResult(ctx, target.Target.ID, "Fehler", safeError(err), false)
|
||||
}
|
||||
_ = s.store.MarkZoneSynced(ctx, group[0].Zone.ID, safeError(err))
|
||||
}
|
||||
}
|
||||
status := "ok"
|
||||
if failed > 0 {
|
||||
status = "partial"
|
||||
}
|
||||
summary := fmt.Sprintf("%d Ziel(e) aktualisiert, %d Fehler", updated, failed)
|
||||
if ipResult.IPv4Err != nil || (settings.IPv6Enabled && ipResult.IPv6Err != nil) {
|
||||
summary += "; eine IP-Familie war nicht verfügbar"
|
||||
if status == "ok" {
|
||||
status = "partial"
|
||||
}
|
||||
}
|
||||
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, nil)
|
||||
}
|
||||
|
||||
func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) {
|
||||
_ = s.store.FinishUpdateRun(ctx, runID, status, ipv4, ipv6, summary)
|
||||
_ = s.store.UpdateRunState(ctx, ipv4, ipv6, status)
|
||||
if status != "ok" {
|
||||
_ = s.store.AddAudit(ctx, "update."+status, summary)
|
||||
}
|
||||
return summary, runErr
|
||||
}
|
||||
|
||||
func (s *Scheduler) syncZoneTargets(ctx context.Context, targets []store.ManagedTarget, ipv4, ipv6 string, ipv6Enabled bool, updated, failed *int) error {
|
||||
zone, credential := targets[0].Zone, targets[0].Credential
|
||||
token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byKey := make(map[string]hetzner.RRSet, len(rrsets))
|
||||
for _, rrset := range rrsets {
|
||||
if rrset.Type == "A" || rrset.Type == "AAAA" {
|
||||
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
|
||||
}
|
||||
}
|
||||
for _, item := range targets {
|
||||
targetUpdated, status, targetErr := s.syncTarget(ctx, token, zone, item.Target, byKey, ipv4, ipv6, ipv6Enabled)
|
||||
if targetErr != nil {
|
||||
*failed = *failed + 1
|
||||
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, "Fehler", safeError(targetErr), false)
|
||||
continue
|
||||
}
|
||||
if targetUpdated {
|
||||
*updated = *updated + 1
|
||||
}
|
||||
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, status, "", targetUpdated)
|
||||
}
|
||||
return s.store.MarkZoneSynced(ctx, zone.ID, "")
|
||||
}
|
||||
|
||||
func (s *Scheduler) syncTarget(ctx context.Context, token string, zone model.Zone, target model.Target, rrsets map[string]hetzner.RRSet, ipv4, ipv6 string, ipv6Enabled bool) (bool, string, error) {
|
||||
var states []string
|
||||
updated := false
|
||||
if target.UpdateIPv4 {
|
||||
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "A")], "A", ipv4)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
updated = updated || changed
|
||||
states = append(states, state)
|
||||
}
|
||||
if target.UpdateIPv6 {
|
||||
if !ipv6Enabled {
|
||||
states = append(states, "AAAA global deaktiviert")
|
||||
} else {
|
||||
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "AAAA")], "AAAA", ipv6)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
updated = updated || changed
|
||||
states = append(states, state)
|
||||
}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return false, "Deaktivierte IP-Familien", nil
|
||||
}
|
||||
return updated, strings.Join(states, " · "), nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zone, target model.Target, rrset hetzner.RRSet, recordType, ip string) (bool, string, error) {
|
||||
if ip == "" {
|
||||
return false, recordType + " nicht verfügbar", nil
|
||||
}
|
||||
if rrset.Name == "" {
|
||||
ttl := target.TTL
|
||||
if ttl < 60 {
|
||||
ttl = 60
|
||||
}
|
||||
if err := s.provider.CreateRRSet(ctx, token, zone.ProviderID, target.RecordName, recordType, ttl, []hetzner.Record{{Value: ip, Comment: target.Description}}); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
return true, recordType + " angelegt", nil
|
||||
}
|
||||
if rrset.Protection.Change {
|
||||
return false, "", errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt")
|
||||
}
|
||||
if len(rrset.Records) != 1 {
|
||||
return false, "", fmt.Errorf("%s enthält %d Records und wird aus Sicherheitsgründen nicht geändert", rrset.Name, len(rrset.Records))
|
||||
}
|
||||
ttlChanged := false
|
||||
if rrset.TTL == nil || *rrset.TTL != targetTTL(target.TTL) {
|
||||
if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, target.RecordName, recordType, targetTTL(target.TTL)); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
ttlChanged = true
|
||||
}
|
||||
current := rrset.Records[0]
|
||||
if current.Value == ip {
|
||||
if current.Comment != target.Description {
|
||||
if err := s.provider.UpdateRecordComments(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: current.Value, Comment: target.Description}}); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
status := recordType + " Kommentar synchronisiert"
|
||||
if ttlChanged {
|
||||
status += " · TTL aktualisiert"
|
||||
}
|
||||
return true, status, nil
|
||||
}
|
||||
if ttlChanged {
|
||||
return true, recordType + " TTL aktualisiert", nil
|
||||
}
|
||||
return false, recordType + " unverändert", nil
|
||||
}
|
||||
if err := s.provider.SetRRSetRecords(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: ip, Comment: target.Description}}); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
status := recordType + " aktualisiert"
|
||||
if ttlChanged {
|
||||
status += " · TTL aktualisiert"
|
||||
}
|
||||
return true, status, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) SyncZone(ctx context.Context, zoneID int64) (int, error) {
|
||||
zone, credential, err := s.store.GetZoneWithCredential(ctx, zoneID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
_ = s.store.MarkZoneSynced(ctx, zone.ID, safeError(err))
|
||||
return 0, err
|
||||
}
|
||||
type imported struct {
|
||||
ipv4, ipv6 bool
|
||||
description string
|
||||
ttl int
|
||||
}
|
||||
grouped := map[string]*imported{}
|
||||
var cnames []model.CNAMERecord
|
||||
for _, rrset := range rrsets {
|
||||
if rrset.Type == "CNAME" {
|
||||
if len(rrset.Records) != 1 {
|
||||
continue
|
||||
}
|
||||
ttl := targetTTL(60)
|
||||
if rrset.TTL != nil {
|
||||
ttl = targetTTL(*rrset.TTL)
|
||||
}
|
||||
cnames = append(cnames, model.CNAMERecord{
|
||||
ZoneID: zone.ID,
|
||||
Active: true,
|
||||
RecordName: normalRecordName(rrset.Name),
|
||||
FQDN: fqdn(rrset.Name, zone.Name),
|
||||
Destination: rrset.Records[0].Value,
|
||||
Description: rrset.Records[0].Comment,
|
||||
TTL: ttl,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if rrset.Type != "A" && rrset.Type != "AAAA" {
|
||||
continue
|
||||
}
|
||||
entry := grouped[rrset.Name]
|
||||
if entry == nil {
|
||||
entry = &imported{ttl: 60}
|
||||
grouped[rrset.Name] = entry
|
||||
}
|
||||
if rrset.Type == "A" {
|
||||
entry.ipv4 = true
|
||||
} else {
|
||||
entry.ipv6 = true
|
||||
}
|
||||
if rrset.TTL != nil && *rrset.TTL >= 60 {
|
||||
entry.ttl = *rrset.TTL
|
||||
}
|
||||
if entry.description == "" && len(rrset.Records) > 0 {
|
||||
entry.description = rrset.Records[0].Comment
|
||||
}
|
||||
}
|
||||
added := 0
|
||||
for name, entry := range grouped {
|
||||
target := model.Target{ZoneID: zone.ID, RecordName: normalRecordName(name), FQDN: fqdn(name, zone.Name), UpdateIPv4: entry.ipv4, UpdateIPv6: entry.ipv6, Description: entry.description, TTL: entry.ttl}
|
||||
isNew, err := s.store.UpsertImportedTarget(ctx, target)
|
||||
if err != nil {
|
||||
return added, err
|
||||
}
|
||||
if isNew {
|
||||
added++
|
||||
continue
|
||||
}
|
||||
if err := s.store.UpdateImportedTargetTTL(ctx, zone.ID, target.RecordName, target.TTL); err != nil {
|
||||
return added, err
|
||||
}
|
||||
}
|
||||
if err := s.store.ReplaceCNAMERecords(ctx, zone.ID, cnames); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_ = s.store.MarkZoneSynced(ctx, zone.ID, "")
|
||||
_ = s.store.AddAudit(ctx, "zone.import", fmt.Sprintf("%s: %d neue A/AAAA-Ziele importiert", zone.Name, added))
|
||||
return added, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) SyncTargetComment(ctx context.Context, target model.Target) error {
|
||||
zone, credential, err := s.store.GetZoneWithCredential(ctx, target.ZoneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rrset := range rrsets {
|
||||
if rrset.Name != target.RecordName || (rrset.Type != "A" && rrset.Type != "AAAA") {
|
||||
continue
|
||||
}
|
||||
if (rrset.Type == "A" && !target.UpdateIPv4) || (rrset.Type == "AAAA" && !target.UpdateIPv6) || len(rrset.Records) != 1 {
|
||||
continue
|
||||
}
|
||||
if rrset.Records[0].Comment != target.Description {
|
||||
if err := s.provider.UpdateRecordComments(ctx, token, zone.ProviderID, target.RecordName, rrset.Type, []hetzner.Record{{Value: rrset.Records[0].Value, Comment: target.Description}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncTargetTTL applies a changed TTL immediately when the selected A/AAAA
|
||||
// RRsets already exist. Missing RRsets will receive the same TTL when the
|
||||
// DynDNS worker creates them on its next run.
|
||||
func (s *Scheduler) SyncTargetTTL(ctx context.Context, target model.Target) error {
|
||||
zone, token, err := s.zoneToken(ctx, target.ZoneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ttl := targetTTL(target.TTL)
|
||||
var recordTypes []string
|
||||
for _, rrset := range rrsets {
|
||||
if rrset.Name != target.RecordName {
|
||||
continue
|
||||
}
|
||||
if (rrset.Type == "A" && !target.UpdateIPv4) || (rrset.Type == "AAAA" && !target.UpdateIPv6) || (rrset.Type != "A" && rrset.Type != "AAAA") {
|
||||
continue
|
||||
}
|
||||
if rrset.TTL != nil && *rrset.TTL == ttl {
|
||||
continue
|
||||
}
|
||||
if rrset.Protection.Change {
|
||||
return errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt")
|
||||
}
|
||||
recordTypes = append(recordTypes, rrset.Type)
|
||||
}
|
||||
for _, recordType := range recordTypes {
|
||||
if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, target.RecordName, recordType, ttl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncStaticTarget writes manually entered addresses while DynDNS is disabled.
|
||||
// It deliberately reuses the guarded RRSet update path used by the updater:
|
||||
// protected or multi-record RRSets are left untouched.
|
||||
func (s *Scheduler) SyncStaticTarget(ctx context.Context, target model.Target) (bool, string, error) {
|
||||
zone, token, err := s.zoneToken(ctx, target.ZoneID)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
byKey := make(map[string]hetzner.RRSet, len(rrsets))
|
||||
for _, rrset := range rrsets {
|
||||
if rrset.Type == "A" || rrset.Type == "AAAA" {
|
||||
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
|
||||
}
|
||||
}
|
||||
updated := false
|
||||
var states []string
|
||||
if target.UpdateIPv4 && target.StaticIPv4 != "" {
|
||||
changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "A")], "A", target.StaticIPv4)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
updated = updated || changed
|
||||
states = append(states, state)
|
||||
}
|
||||
if target.UpdateIPv6 && target.StaticIPv6 != "" {
|
||||
changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "AAAA")], "AAAA", target.StaticIPv6)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
updated = updated || changed
|
||||
states = append(states, state)
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return false, "Keine feste IP eingetragen", nil
|
||||
}
|
||||
return updated, strings.Join(states, " · "), nil
|
||||
}
|
||||
|
||||
// DeleteTargetFamilies removes selected A/AAAA RRSets while keeping the local
|
||||
// target itself. This lets users temporarily disable every family without
|
||||
// losing its configuration.
|
||||
func (s *Scheduler) DeleteTargetFamilies(ctx context.Context, target model.Target, deleteIPv4, deleteIPv6 bool) (int, error) {
|
||||
if !deleteIPv4 && !deleteIPv6 {
|
||||
return 0, nil
|
||||
}
|
||||
zone, token, err := s.zoneToken(ctx, target.ZoneID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var recordTypes []string
|
||||
for _, rrset := range rrsets {
|
||||
if rrset.Name != target.RecordName || (rrset.Type != "A" && rrset.Type != "AAAA") {
|
||||
continue
|
||||
}
|
||||
if (rrset.Type == "A" && !deleteIPv4) || (rrset.Type == "AAAA" && !deleteIPv6) {
|
||||
continue
|
||||
}
|
||||
if rrset.Protection.Change {
|
||||
return 0, errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt")
|
||||
}
|
||||
recordTypes = append(recordTypes, rrset.Type)
|
||||
}
|
||||
for _, recordType := range recordTypes {
|
||||
if err := s.provider.DeleteRRSet(ctx, token, zone.ProviderID, target.RecordName, recordType); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(recordTypes), nil
|
||||
}
|
||||
|
||||
// DeleteZoneRecords removes every A, AAAA and CNAME RRSet represented by the
|
||||
// supplied local records. Other provider records in the zone remain untouched.
|
||||
// Protected RRsets are rejected before the first remote deletion.
|
||||
func (s *Scheduler) DeleteZoneRecords(ctx context.Context, zoneID int64, targets []model.Target, cnames []model.CNAMERecord) (int, error) {
|
||||
zone, token, err := s.zoneToken(ctx, zoneID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
managed := make(map[string]struct{}, len(targets)*2+len(cnames))
|
||||
for _, target := range targets {
|
||||
managed[rrKey(target.RecordName, "A")] = struct{}{}
|
||||
managed[rrKey(target.RecordName, "AAAA")] = struct{}{}
|
||||
}
|
||||
for _, record := range cnames {
|
||||
managed[rrKey(record.RecordName, "CNAME")] = struct{}{}
|
||||
}
|
||||
deletions := make([]hetzner.RRSet, 0, len(managed))
|
||||
for _, rrset := range rrsets {
|
||||
if _, found := managed[rrKey(rrset.Name, rrset.Type)]; !found {
|
||||
continue
|
||||
}
|
||||
if rrset.Protection.Change {
|
||||
return 0, fmt.Errorf("das RRset %s (%s) ist bei Hetzner gegen Änderungen geschützt", fqdn(rrset.Name, zone.Name), rrset.Type)
|
||||
}
|
||||
deletions = append(deletions, rrset)
|
||||
}
|
||||
deleted := 0
|
||||
for _, rrset := range deletions {
|
||||
if err := s.provider.DeleteRRSet(ctx, token, zone.ProviderID, rrset.Name, rrset.Type); err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// DeleteIPv6Targets removes all managed AAAA RRSets before global IPv6 is
|
||||
// disabled. All protected RRSets are detected before the first deletion.
|
||||
func (s *Scheduler) DeleteIPv6Targets(ctx context.Context, targets []model.Target) (int, error) {
|
||||
type zoneState struct {
|
||||
zone model.Zone
|
||||
token string
|
||||
rrsets map[string]hetzner.RRSet
|
||||
}
|
||||
type deletion struct {
|
||||
zone model.Zone
|
||||
token string
|
||||
name string
|
||||
}
|
||||
states := make(map[int64]zoneState)
|
||||
deletions := make([]deletion, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
state, found := states[target.ZoneID]
|
||||
if !found {
|
||||
zone, token, err := s.zoneToken(ctx, target.ZoneID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
state = zoneState{zone: zone, token: token, rrsets: make(map[string]hetzner.RRSet, len(rrsets))}
|
||||
for _, rrset := range rrsets {
|
||||
state.rrsets[rrKey(rrset.Name, rrset.Type)] = rrset
|
||||
}
|
||||
states[target.ZoneID] = state
|
||||
}
|
||||
rrset, exists := state.rrsets[rrKey(target.RecordName, "AAAA")]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if rrset.Protection.Change {
|
||||
return 0, errors.New("mindestens ein AAAA-Record ist bei Hetzner gegen Änderungen geschützt")
|
||||
}
|
||||
deletions = append(deletions, deletion{zone: state.zone, token: state.token, name: target.RecordName})
|
||||
}
|
||||
for _, item := range deletions {
|
||||
if err := s.provider.DeleteRRSet(ctx, item.token, item.zone.ProviderID, item.name, "AAAA"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(deletions), nil
|
||||
}
|
||||
|
||||
// DisableIPv6 removes managed AAAA RRSets and disables IPv6 on every linked
|
||||
// local target. It is safe to run when IPv6 is already disabled.
|
||||
func (s *Scheduler) DisableIPv6(ctx context.Context) (int, error) {
|
||||
targets, err := s.store.ListTargetsWithIPv6(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted, err := s.DeleteIPv6Targets(ctx, targets)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := s.store.DisableIPv6ForTargets(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// SyncZoneTTL updates the TTL of every RRSet this application manages in a
|
||||
// zone. It preflights protected or stale CNAME RRSets before changing any
|
||||
// remote value, while missing DynDNS RRSets keep the new TTL locally for their
|
||||
// eventual creation.
|
||||
func (s *Scheduler) SyncZoneTTL(ctx context.Context, zoneID int64, targets []model.Target, cnames []model.CNAMERecord, ttl int) (int, error) {
|
||||
zone, token, err := s.zoneToken(ctx, zoneID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
byKey := make(map[string]hetzner.RRSet, len(rrsets))
|
||||
for _, rrset := range rrsets {
|
||||
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
|
||||
}
|
||||
type change struct {
|
||||
name string
|
||||
recordType string
|
||||
}
|
||||
changes := make([]change, 0, len(targets)*2+len(cnames))
|
||||
seen := make(map[string]bool, cap(changes))
|
||||
queue := func(name, recordType string, required bool) error {
|
||||
key := rrKey(name, recordType)
|
||||
rrset, found := byKey[key]
|
||||
if !found {
|
||||
if required {
|
||||
return fmt.Errorf("der CNAME %s wurde bei Hetzner nicht gefunden; lies die Zone erneut ein", fqdn(name, zone.Name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if rrset.TTL != nil && *rrset.TTL == targetTTL(ttl) {
|
||||
return nil
|
||||
}
|
||||
if rrset.Protection.Change {
|
||||
return errors.New("mindestens ein RRset ist bei Hetzner gegen Änderungen geschützt")
|
||||
}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
changes = append(changes, change{name: name, recordType: recordType})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, target := range targets {
|
||||
if target.UpdateIPv4 {
|
||||
if err := queue(target.RecordName, "A", false); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if target.UpdateIPv6 {
|
||||
if err := queue(target.RecordName, "AAAA", false); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, record := range cnames {
|
||||
if !record.Active {
|
||||
continue
|
||||
}
|
||||
if err := queue(record.RecordName, "CNAME", true); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
for _, item := range changes {
|
||||
if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, item.name, item.recordType, targetTTL(ttl)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(changes), nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) CreateCNAME(ctx context.Context, record model.CNAMERecord) error {
|
||||
zone, token, err := s.zoneToken(ctx, record.ZoneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.provider.CreateRRSet(ctx, token, zone.ProviderID, record.RecordName, "CNAME", targetTTL(record.TTL), []hetzner.Record{{Value: record.Destination, Comment: record.Description}})
|
||||
}
|
||||
|
||||
func (s *Scheduler) UpdateCNAME(ctx context.Context, previous, record model.CNAMERecord) error {
|
||||
if previous.ZoneID != record.ZoneID || previous.RecordName != record.RecordName {
|
||||
return errors.New("der Name eines CNAME-Records kann nicht geändert werden")
|
||||
}
|
||||
if previous.Destination == record.Destination && previous.Description == record.Description && previous.TTL == record.TTL {
|
||||
return nil
|
||||
}
|
||||
zone, token, err := s.zoneToken(ctx, record.ZoneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var current *hetzner.RRSet
|
||||
for index := range rrsets {
|
||||
if rrsets[index].Name == record.RecordName && rrsets[index].Type == "CNAME" {
|
||||
current = &rrsets[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if current == nil {
|
||||
return errors.New("der CNAME-Record wurde bei Hetzner nicht gefunden; lies die Zone erneut ein")
|
||||
}
|
||||
if current.Protection.Change {
|
||||
return errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt")
|
||||
}
|
||||
if previous.TTL != record.TTL {
|
||||
if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, record.RecordName, "CNAME", targetTTL(record.TTL)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if previous.Destination != record.Destination || previous.Description != record.Description {
|
||||
if err := s.provider.SetRRSetRecords(ctx, token, zone.ProviderID, record.RecordName, "CNAME", []hetzner.Record{{Value: record.Destination, Comment: record.Description}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) DeleteCNAME(ctx context.Context, record model.CNAMERecord) error {
|
||||
zone, token, err := s.zoneToken(ctx, record.ZoneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.provider.DeleteRRSet(ctx, token, zone.ProviderID, record.RecordName, "CNAME")
|
||||
}
|
||||
|
||||
func (s *Scheduler) zoneToken(ctx context.Context, zoneID int64) (model.Zone, string, error) {
|
||||
zone, credential, err := s.store.GetZoneWithCredential(ctx, zoneID)
|
||||
if err != nil {
|
||||
return model.Zone{}, "", err
|
||||
}
|
||||
token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||
if err != nil {
|
||||
return model.Zone{}, "", err
|
||||
}
|
||||
return zone, token, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) ValidateEndpoints(ctx context.Context, endpoints []string) error {
|
||||
for _, endpoint := range endpoints {
|
||||
if err := s.resolver.ValidateEndpoint(ctx, endpoint); err != nil {
|
||||
return fmt.Errorf("%s: %w", endpoint, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) ValidateToken(ctx context.Context, token string) ([]hetzner.Zone, error) {
|
||||
return s.provider.ListZones(ctx, token)
|
||||
}
|
||||
|
||||
func (s *Scheduler) Provider() *hetzner.Client { return s.provider }
|
||||
|
||||
func rrKey(name, recordType string) string { return normalRecordName(name) + "|" + recordType }
|
||||
|
||||
func targetTTL(ttl int) int {
|
||||
if ttl < 60 {
|
||||
return 60
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
func normalRecordName(name string) string {
|
||||
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
|
||||
if name == "" || name == "@" {
|
||||
return "@"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func fqdn(name, zone string) string {
|
||||
if normalRecordName(name) == "@" {
|
||||
return zone
|
||||
}
|
||||
return normalRecordName(name) + "." + zone
|
||||
}
|
||||
|
||||
func safeError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
message := err.Error()
|
||||
if len(message) > 300 {
|
||||
return message[:300]
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/zones/123/rrsets" || r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"rrsets": [
|
||||
{"name":"@","type":"A","ttl":60,"records":[{"value":"198.51.100.10","comment":"Apex"}]},
|
||||
{"name":"home","type":"A","ttl":120,"records":[{"value":"198.51.100.11","comment":"Router"}]},
|
||||
{"name":"home","type":"AAAA","ttl":120,"records":[{"value":"2001:db8::10","comment":"Router"}]},
|
||||
{"name":"www","type":"CNAME","ttl":300,"records":[{"value":"example.de."}]}
|
||||
],
|
||||
"meta":{"pagination":{"next_page":null}}
|
||||
}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
keyFile := filepath.Join(dir, "master-key")
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipher, err := secure.LoadCipher(keyFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("test-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
added, err := schedule.SyncZone(ctx, zone.ID)
|
||||
if err != nil || added != 2 {
|
||||
t.Fatalf("expected apex and home to be imported, added=%d err=%v", added, err)
|
||||
}
|
||||
targets, err := database.ListTargets(ctx, zone.ID)
|
||||
if err != nil || len(targets) != 2 {
|
||||
t.Fatalf("expected two address targets, got %#v err=%v", targets, err)
|
||||
}
|
||||
var homeID int64
|
||||
for _, target := range targets {
|
||||
if target.RecordName == "home" {
|
||||
homeID = target.ID
|
||||
if !target.UpdateIPv4 || !target.UpdateIPv6 || target.Active || target.Description != "Router" || target.TTL != 120 {
|
||||
t.Fatalf("home target was not grouped safely: %#v", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
if homeID == 0 {
|
||||
t.Fatal("home target missing")
|
||||
}
|
||||
cnames, err := database.ListCNAMERecords(ctx, zone.ID)
|
||||
if err != nil || len(cnames) != 1 || !cnames[0].Active || cnames[0].RecordName != "www" || cnames[0].Destination != "example.de." || cnames[0].TTL != 300 {
|
||||
t.Fatalf("CNAME was not imported: %#v err=%v", cnames, err)
|
||||
}
|
||||
home, err := database.GetTarget(ctx, homeID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home.TTL = 60
|
||||
if err := database.UpdateTarget(ctx, home); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
added, err = schedule.SyncZone(ctx, zone.ID)
|
||||
if err != nil || added != 0 {
|
||||
t.Fatalf("TTL refresh import failed: added=%d err=%v", added, err)
|
||||
}
|
||||
home, err = database.GetTarget(ctx, homeID)
|
||||
if err != nil || home.TTL != 120 {
|
||||
t.Fatalf("provider TTL was not refreshed on existing target: %#v err=%v", home, err)
|
||||
}
|
||||
if err := database.ArchiveTarget(ctx, homeID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
added, err = schedule.SyncZone(ctx, zone.ID)
|
||||
if err != nil || added != 0 {
|
||||
t.Fatalf("re-import should not restore archived target, added=%d err=%v", added, err)
|
||||
}
|
||||
linked, _ := database.ListTargets(ctx, zone.ID)
|
||||
archived, _ := database.ListArchivedTargets(ctx, zone.ID)
|
||||
if len(linked) != 1 || len(archived) != 1 || archived[0].ID != homeID {
|
||||
t.Fatalf("archive state changed during provider import: linked=%#v archived=%#v", linked, archived)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledUpdaterRejectsRunsAndTriggerResetsCountdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
settings, err := database.GetSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateSettings(ctx, settings.UpdateIntervalSeconds, false, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
if _, err := schedule.Run(ctx); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("disabled updater accepted a run: %v", err)
|
||||
}
|
||||
runs, err := database.RecentRuns(ctx, 10)
|
||||
if err != nil || len(runs) != 0 {
|
||||
t.Fatalf("disabled run must not create history: %#v err=%v", runs, err)
|
||||
}
|
||||
|
||||
schedule.nextRun.Store(time.Now().Add(time.Hour).Unix())
|
||||
schedule.Trigger()
|
||||
if !schedule.NextRunAt().IsZero() {
|
||||
t.Fatalf("trigger did not reset the visible countdown: %v", schedule.NextRunAt())
|
||||
}
|
||||
select {
|
||||
case <-schedule.wake:
|
||||
default:
|
||||
t.Fatal("trigger did not wake the scheduler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) {
|
||||
t.Parallel()
|
||||
schedule := &Scheduler{}
|
||||
changed, status, err := schedule.syncTarget(
|
||||
context.Background(),
|
||||
"unused-token",
|
||||
model.Zone{ProviderID: "123"},
|
||||
model.Target{RecordName: "home", UpdateIPv6: true},
|
||||
nil,
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
)
|
||||
if err != nil || changed || status != "AAAA global deaktiviert" {
|
||||
t.Fatalf("disabled IPv6 was not skipped cleanly: changed=%v status=%q err=%v", changed, status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTargetTTLUpdatesExistingRRSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
changes := make(chan int, 1)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/zones/123/rrsets":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]}],"meta":{"pagination":{"next_page":null}}}`))
|
||||
case "/zones/123/rrsets/home/A/actions/change_ttl":
|
||||
var body struct {
|
||||
TTL int `json:"ttl"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
changes <- body.TTL
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
keyFile := filepath.Join(dir, "master-key")
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipher, err := secure.LoadCipher(keyFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("test-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
if err := schedule.SyncTargetTTL(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, TTL: 300}); err != nil {
|
||||
t.Fatalf("sync target TTL: %v", err)
|
||||
}
|
||||
select {
|
||||
case ttl := <-changes:
|
||||
if ttl != 300 {
|
||||
t.Fatalf("unexpected changed TTL: %d", ttl)
|
||||
}
|
||||
default:
|
||||
t.Fatal("TTL action was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncStaticTargetWritesManualAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
actions := make(chan string, 2)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/zones/123/rrsets":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11","comment":"Old"}]}],"meta":{"pagination":{"next_page":null}}}`))
|
||||
case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records":
|
||||
actions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
keyFile := filepath.Join(dir, "master-key")
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipher, err := secure.LoadCipher(keyFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("test-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
changed, status, err := schedule.SyncStaticTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, StaticIPv4: "203.0.113.25", Description: "Manual", TTL: 300})
|
||||
if err != nil || !changed || status != "A aktualisiert · TTL aktualisiert" {
|
||||
t.Fatalf("static target was not synchronized: changed=%v status=%q err=%v", changed, status, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for range 2 {
|
||||
seen[<-actions] = true
|
||||
}
|
||||
for _, expected := range []string{"/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records"} {
|
||||
if !seen[expected] {
|
||||
t.Errorf("missing static update action %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncZoneTTLUpdatesManagedAddressAndCNAMERecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
actions := make(chan string, 2)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/zones/123/rrsets":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},{"name":"www","type":"CNAME","ttl":300,"records":[{"value":"home.example.de."}]}],"meta":{"pagination":{"next_page":null}}}`))
|
||||
case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl":
|
||||
actions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
keyFile := filepath.Join(dir, "master-key")
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipher, err := secure.LoadCipher(keyFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("test-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
changed, err := schedule.SyncZoneTTL(ctx, zone.ID, []model.Target{{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true}}, []model.CNAMERecord{{ZoneID: zone.ID, Active: true, RecordName: "www"}}, 600)
|
||||
if err != nil || changed != 2 {
|
||||
t.Fatalf("zone TTL was not synchronized: changed=%d err=%v", changed, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for range 2 {
|
||||
seen[<-actions] = true
|
||||
}
|
||||
for _, expected := range []string{"/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl"} {
|
||||
if !seen[expected] {
|
||||
t.Errorf("missing bulk TTL action %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTargetFamiliesAndGlobalIPv6Cleanup(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
deletions := make(chan string, 2)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/zones/123/rrsets":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::11"}]}],"meta":{"pagination":{"next_page":null}}}`))
|
||||
case "/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA":
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "unexpected method", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
deletions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
keyFile := filepath.Join(dir, "master-key")
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipher, err := secure.LoadCipher(keyFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("test-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, UpdateIPv6: true}
|
||||
|
||||
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
deleted, err := schedule.DeleteTargetFamilies(ctx, target, true, false)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("A record was not deleted: deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
deleted, err = schedule.DeleteIPv6Targets(ctx, []model.Target{target})
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("global AAAA cleanup failed: deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for range 2 {
|
||||
seen[<-deletions] = true
|
||||
}
|
||||
for _, expected := range []string{"/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA"} {
|
||||
if !seen[expected] {
|
||||
t.Errorf("missing delete request %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteZoneRecordsRemovesOnlyLocallyRepresentedRRSets(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
deletions := make(chan string, 4)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/zones/123/rrsets":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"rrsets":[
|
||||
{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},
|
||||
{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::11"}]},
|
||||
{"name":"legacy","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::12"}]},
|
||||
{"name":"www","type":"CNAME","ttl":300,"records":[{"value":"home.example.de."}]},
|
||||
{"name":"mail","type":"CNAME","ttl":300,"records":[{"value":"external.example."}]},
|
||||
{"name":"@","type":"TXT","ttl":300,"records":[{"value":"unmanaged"}]}
|
||||
],"meta":{"pagination":{"next_page":null}}}`))
|
||||
case "/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA", "/zones/123/rrsets/legacy/AAAA", "/zones/123/rrsets/www/CNAME":
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "unexpected method", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
deletions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
keyFile := filepath.Join(dir, "master-key")
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipher, err := secure.LoadCipher(keyFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("test-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
deleted, err := schedule.DeleteZoneRecords(
|
||||
ctx,
|
||||
zone.ID,
|
||||
[]model.Target{{RecordName: "home"}, {RecordName: "legacy"}},
|
||||
[]model.CNAMERecord{{RecordName: "www"}},
|
||||
)
|
||||
if err != nil || deleted != 4 {
|
||||
t.Fatalf("managed zone records were not deleted: deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for range 4 {
|
||||
seen[<-deletions] = true
|
||||
}
|
||||
for _, expected := range []string{"/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA", "/zones/123/rrsets/legacy/AAAA", "/zones/123/rrsets/www/CNAME"} {
|
||||
if !seen[expected] {
|
||||
t.Errorf("missing delete request %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package secure
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
const (
|
||||
argonMemory = 64 * 1024
|
||||
argonTime = 3
|
||||
argonThreads = 2
|
||||
argonKeyLen = 32
|
||||
)
|
||||
|
||||
type Cipher struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
func LoadCipher(path string) (*Cipher, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read master key: %w", err)
|
||||
}
|
||||
raw = []byte(strings.TrimSpace(string(raw)))
|
||||
key, err := base64.RawStdEncoding.DecodeString(string(raw))
|
||||
if err != nil {
|
||||
key, err = base64.StdEncoding.DecodeString(string(raw))
|
||||
}
|
||||
if err != nil || len(key) != chacha20poly1305.KeySize {
|
||||
return nil, errors.New("master key must be a base64-encoded 32-byte value")
|
||||
}
|
||||
return &Cipher{key: key}, nil
|
||||
}
|
||||
|
||||
func (c *Cipher) Encrypt(plaintext string) (ciphertext, nonce []byte, err error) {
|
||||
aead, err := chacha20poly1305.NewX(c.key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
nonce = make([]byte, chacha20poly1305.NonceSizeX)
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return nil, nil, fmt.Errorf("create encryption nonce: %w", err)
|
||||
}
|
||||
return aead.Seal(nil, nonce, []byte(plaintext), nil), nonce, nil
|
||||
}
|
||||
|
||||
func (c *Cipher) Decrypt(ciphertext, nonce []byte) (string, error) {
|
||||
aead, err := chacha20poly1305.NewX(c.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", errors.New("could not decrypt stored credential")
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func (c *Cipher) Fingerprint(value string) string {
|
||||
mac := hmac.New(sha256.New, c.key)
|
||||
_, _ = mac.Write([]byte(value))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func RandomToken(bytes int) (string, error) {
|
||||
buf := make([]byte, bytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func HashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) < 12 {
|
||||
return "", errors.New("password must contain at least 12 characters")
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads,
|
||||
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(encoded, password string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" {
|
||||
return false
|
||||
}
|
||||
var memory uint32
|
||||
var iterations uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package secure
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCipherRoundTripAndFingerprint(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
path := filepath.Join(dir, "master-key")
|
||||
if err := os.WriteFile(path, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipher, err := LoadCipher(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("hetzner-token-value")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(ciphertext) == "hetzner-token-value" {
|
||||
t.Fatal("token was not encrypted")
|
||||
}
|
||||
plaintext, err := cipher.Decrypt(ciphertext, nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plaintext != "hetzner-token-value" {
|
||||
t.Fatalf("got %q", plaintext)
|
||||
}
|
||||
if cipher.Fingerprint("same") != cipher.Fingerprint("same") || cipher.Fingerprint("same") == cipher.Fingerprint("different") {
|
||||
t.Fatal("fingerprint must be deterministic and token-specific")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
hash, err := HashPassword("a secure password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !VerifyPassword(hash, "a secure password") {
|
||||
t.Fatal("expected password verification to succeed")
|
||||
}
|
||||
if VerifyPassword(hash, "wrong password") {
|
||||
t.Fatal("wrong password verified")
|
||||
}
|
||||
if _, err := HashPassword("short"); err == nil {
|
||||
t.Fatal("short password was accepted")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,294 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
)
|
||||
|
||||
func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
hash, err := secure.HashPassword("a secure password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
member, err := database.CreateUser(ctx, "alice", "Alice", hash, "member")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "fingerprint")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "10", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.de", UpdateIPv4: true, Active: true, StaticIPv4: "203.0.113.10", TTL: 60})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if home.StaticIPv4 != "203.0.113.10" {
|
||||
t.Fatalf("static IPv4 was not persisted: %#v", home)
|
||||
}
|
||||
other, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "other", FQDN: "other.example.de", UpdateIPv4: true, TTL: 60})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{TargetIDs: []int64{home.ID}, ManageTargetIDs: []int64{home.ID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowed, err := database.CanAccessTarget(ctx, member.ID, zone.ID, home.ID)
|
||||
if err != nil || !allowed {
|
||||
t.Fatalf("target-specific grant was not honored: allowed=%v err=%v", allowed, err)
|
||||
}
|
||||
allowed, err = database.CanAccessTarget(ctx, member.ID, zone.ID, other.ID)
|
||||
if err != nil || allowed {
|
||||
t.Fatalf("ungranted target was visible: allowed=%v err=%v", allowed, err)
|
||||
}
|
||||
managed, err := database.CanManageTarget(ctx, member.ID, zone.ID, home.ID)
|
||||
if err != nil || !managed {
|
||||
t.Fatalf("target management grant was not honored: managed=%v err=%v", managed, err)
|
||||
}
|
||||
targets, err := database.ListAccessibleTargets(ctx, member.ID, zone.ID)
|
||||
if err != nil || len(targets) != 1 || targets[0].ID != home.ID {
|
||||
t.Fatalf("expected one scoped target, got %#v (err=%v)", targets, err)
|
||||
}
|
||||
|
||||
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}, ManageZoneIDs: []int64{zone.ID}, CreateTargetZoneIDs: []int64{zone.ID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targets, err = database.ListAccessibleTargets(ctx, member.ID, zone.ID)
|
||||
if err != nil || len(targets) != 2 {
|
||||
t.Fatalf("domain grant did not include all targets: %d, %v", len(targets), err)
|
||||
}
|
||||
canCreate, err := database.CanCreateTarget(ctx, member.ID, zone.ID)
|
||||
if err != nil || !canCreate {
|
||||
t.Fatalf("domain create grant was not honored: canCreate=%v err=%v", canCreate, err)
|
||||
}
|
||||
|
||||
if err := database.ArchiveTarget(ctx, home.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linked, err := database.ListTargets(ctx, zone.ID)
|
||||
if err != nil || len(linked) != 1 || linked[0].ID != other.ID {
|
||||
t.Fatalf("archived target remained linked: %#v, %v", linked, err)
|
||||
}
|
||||
archived, err := database.ListArchivedTargets(ctx, zone.ID)
|
||||
if err != nil || len(archived) != 1 || archived[0].ID != home.ID || archived[0].ArchivedAt.IsZero() {
|
||||
t.Fatalf("archived target was not retained: %#v, %v", archived, err)
|
||||
}
|
||||
imported, err := database.UpsertImportedTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.de", UpdateIPv4: true, TTL: 60})
|
||||
if err != nil || imported {
|
||||
t.Fatalf("provider re-import unexpectedly restored an archived target: imported=%v err=%v", imported, err)
|
||||
}
|
||||
if err := database.RestoreTarget(ctx, home.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := database.GetTarget(ctx, home.ID)
|
||||
if err != nil || !restored.ArchivedAt.IsZero() || restored.Active {
|
||||
t.Fatalf("target was not safely restored as inactive: %#v, %v", restored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInactiveUserSessionIsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hash, _ := secure.HashPassword("a secure password")
|
||||
user, err := database.CreateUser(ctx, "member", "Member", hash, "member")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := database.CreateSession(ctx, model.Session{UserID: user.ID, TokenHash: "token", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := database.GetSession(ctx, session.TokenHash)
|
||||
if err != nil || loaded == nil || loaded.Username != "member" {
|
||||
t.Fatalf("session was not resolved with user: %#v, %v", loaded, err)
|
||||
}
|
||||
if err := database.UpdateUserState(ctx, user.ID, "Member", "member", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err = database.GetSession(ctx, session.TokenHash)
|
||||
if err != nil || loaded != nil {
|
||||
t.Fatalf("inactive user session was accepted: %#v, %v", loaded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "ipv6-fingerprint")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "40", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.de", UpdateIPv4: true, UpdateIPv6: true, StaticIPv6: "2001:db8::10", TTL: 60})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targets, err := database.ListTargetsWithIPv6(ctx)
|
||||
if err != nil || len(targets) != 1 || targets[0].ID != target.ID {
|
||||
t.Fatalf("IPv6 target was not listed: %#v err=%v", targets, err)
|
||||
}
|
||||
if err := database.DisableIPv6ForTargets(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err = database.GetTarget(ctx, target.ID)
|
||||
if err != nil || target.UpdateIPv6 || target.StaticIPv6 != "" {
|
||||
t.Fatalf("IPv6 target was not disabled cleanly: %#v err=%v", target, err)
|
||||
}
|
||||
if err := database.DeleteTarget(ctx, target.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.GetTarget(ctx, target.ID); err == nil {
|
||||
t.Fatal("deleted target was still found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdaterSettingsAndErrorsCanBeReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
settings, err := database.GetSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !settings.UpdaterEnabled || !settings.IPv6Enabled {
|
||||
t.Fatalf("new installations must enable updater and IPv6 by default: %#v", settings)
|
||||
}
|
||||
if err := database.UpdateSettings(ctx, 600, false, false, []string{"https://v4.example"}, []string{"https://v6.example"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err = database.GetSettings(ctx)
|
||||
if err != nil || settings.UpdaterEnabled || settings.IPv6Enabled || settings.UpdateIntervalSeconds != 600 {
|
||||
t.Fatalf("updater settings were not persisted: %#v err=%v", settings, err)
|
||||
}
|
||||
|
||||
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "error-fingerprint")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "20", "errors.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.errors.example", UpdateIPv4: true, Active: true, TTL: 60})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateTargetResult(ctx, target.ID, "Fehler", "provider rejected update", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.MarkZoneSynced(ctx, zone.ID, "zone unavailable"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runID, err := database.CreateUpdateRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.FinishUpdateRun(ctx, runID, "partial", "198.51.100.1", "", "one error"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateRunState(ctx, "198.51.100.1", "", "partial"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count, err := database.CountErrors(ctx); err != nil || count != 3 {
|
||||
t.Fatalf("unexpected error count: count=%d err=%v", count, err)
|
||||
}
|
||||
if err := database.ClearErrors(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count, err := database.CountErrors(ctx); err != nil || count != 0 {
|
||||
t.Fatalf("errors were not cleared: count=%d err=%v", count, err)
|
||||
}
|
||||
clearedTarget, err := database.GetTarget(ctx, target.ID)
|
||||
if err != nil || clearedTarget.LastError != "" || clearedTarget.LastStatus != "Zurückgesetzt" {
|
||||
t.Fatalf("target error was not reset: %#v err=%v", clearedTarget, err)
|
||||
}
|
||||
settings, err = database.GetSettings(ctx)
|
||||
if err != nil || settings.LastRunStatus != "" {
|
||||
t.Fatalf("last run error state was not reset: %#v err=%v", settings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "cname-fingerprint")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "30", "example.de")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := database.CreateCNAMERecord(ctx, model.CNAMERecord{ZoneID: zone.ID, Active: true, RecordName: "www", FQDN: "www.example.de", Destination: "app.example.net.", Description: "Website", TTL: 300})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created.Destination = "edge.example.net."
|
||||
created.TTL = 600
|
||||
if err := database.UpdateCNAMERecord(ctx, created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := database.GetCNAMERecord(ctx, created.ID)
|
||||
if err != nil || !updated.Active || updated.Destination != "edge.example.net." || updated.TTL != 600 {
|
||||
t.Fatalf("CNAME update not persisted: %#v, %v", updated, err)
|
||||
}
|
||||
|
||||
if err := database.ReplaceCNAMERecords(ctx, zone.ID, []model.CNAMERecord{{ZoneID: zone.ID, RecordName: "cdn", FQDN: "cdn.example.de", Destination: "assets.example.net.", TTL: 120}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err := database.ListCNAMERecords(ctx, zone.ID)
|
||||
if err != nil || len(records) != 2 {
|
||||
t.Fatalf("provider CNAME import was not mirrored: %#v, %v", records, err)
|
||||
}
|
||||
byName := make(map[string]model.CNAMERecord, len(records))
|
||||
for _, record := range records {
|
||||
byName[record.RecordName] = record
|
||||
}
|
||||
if byName["www"].Active || !byName["cdn"].Active || byName["cdn"].Destination != "assets.example.net." || byName["cdn"].TTL != 120 {
|
||||
t.Fatalf("inactive/local CNAME lifecycle was not preserved: %#v", byName)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,216 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
)
|
||||
|
||||
func TestInputNormalizers(t *testing.T) {
|
||||
t.Parallel()
|
||||
username, err := normalizeUsername("Alice_Admin")
|
||||
if err != nil || username != "alice_admin" {
|
||||
t.Fatalf("username normalization failed: %q, %v", username, err)
|
||||
}
|
||||
if _, err := normalizeUsername("not allowed!"); err == nil {
|
||||
t.Fatal("invalid username accepted")
|
||||
}
|
||||
zone, err := normalizeZone("BÜCHER.example.")
|
||||
if err != nil || zone != "xn--bcher-kva.example" {
|
||||
t.Fatalf("zone normalization failed: %q, %v", zone, err)
|
||||
}
|
||||
record, fqdn, err := normalizeRecord("home", "example.de")
|
||||
if err != nil || record != "home" || fqdn != "home.example.de" {
|
||||
t.Fatalf("record normalization failed: %q %q %v", record, fqdn, err)
|
||||
}
|
||||
destination, err := normalizeCNAMEDestination("BÜCHER.example.")
|
||||
if err != nil || destination != "xn--bcher-kva.example." {
|
||||
t.Fatalf("CNAME destination normalization failed: %q %v", destination, err)
|
||||
}
|
||||
if _, err := normalizeCNAMEDestination("198.51.100.20"); err == nil {
|
||||
t.Fatal("IP address accepted as CNAME destination")
|
||||
}
|
||||
if ttl, err := parseTTL("300"); err != nil || ttl != 300 {
|
||||
t.Fatalf("TTL parsing failed: %d %v", ttl, err)
|
||||
}
|
||||
if _, err := parseTTL("120"); err == nil {
|
||||
t.Fatal("unsupported TTL accepted")
|
||||
}
|
||||
if ttl, err := parseEditableTTL("120", 120); err != nil || ttl != 120 {
|
||||
t.Fatalf("existing unsupported TTL could not be preserved: %d %v", ttl, err)
|
||||
}
|
||||
staticIPv4, err := normalizeStaticIP("203.0.113.10", 4)
|
||||
if err != nil || staticIPv4 != "203.0.113.10" {
|
||||
t.Fatalf("static IPv4 normalization failed: %q %v", staticIPv4, err)
|
||||
}
|
||||
if _, err := normalizeStaticIP("203.0.113.10", 6); err == nil {
|
||||
t.Fatal("IPv4 accepted as static IPv6")
|
||||
}
|
||||
missingStaticIP := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("static_ipv4="))
|
||||
missingStaticIP.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if _, _, err := parseStaticTargetIPs(missingStaticIP, true, false); err == nil {
|
||||
t.Fatal("inactive DynDNS target accepted enabled IPv4 without a static address")
|
||||
}
|
||||
staticAddresses := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("static_ipv4=203.0.113.20&static_ipv6=2001%3Adb8%3A%3A20"))
|
||||
staticAddresses.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
ipv4, ipv6, err := parseStaticTargetIPs(staticAddresses, true, true)
|
||||
if err != nil || ipv4 != "203.0.113.20" || ipv6 != "2001:db8::20" {
|
||||
t.Fatalf("valid static addresses rejected: ipv4=%q ipv6=%q err=%v", ipv4, ipv6, err)
|
||||
}
|
||||
if targetDynDNSActive(true, false, false) {
|
||||
t.Fatal("DynDNS remained active without an address family")
|
||||
}
|
||||
if !targetDynDNSActive(true, true, false) {
|
||||
t.Fatal("IPv4-only DynDNS was rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnixSecondsUsesZeroForUnscheduledRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := unixSeconds(time.Time{}); got != 0 {
|
||||
t.Fatalf("zero time must be exposed as unix 0, got %d", got)
|
||||
}
|
||||
value := time.Date(2026, time.July, 11, 12, 30, 0, 0, time.UTC)
|
||||
if got := unixSeconds(value); got != value.Unix() {
|
||||
t.Fatalf("unexpected unix time: got %d, want %d", got, value.Unix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoticeUsesValidatedKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
request := httptest.NewRequest(http.MethodGet, "/login?notice=Passwort+nicht+korrekt.¬ice_kind=error", nil)
|
||||
got := notice(request)
|
||||
if got == nil || got.Message != "Passwort nicht korrekt." || got.Kind != "error" {
|
||||
t.Fatalf("unexpected notice: %#v", got)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/login?notice=Hinweis¬ice_kind=not-a-valid-kind", nil)
|
||||
got = notice(request)
|
||||
if got == nil || got.Kind != "info" {
|
||||
t.Fatalf("invalid kind must fall back to info: %#v", got)
|
||||
}
|
||||
|
||||
if got := notice(httptest.NewRequest(http.MethodGet, "/login", nil)); got != nil {
|
||||
t.Fatalf("empty notice must not render a toast: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectNoticePreservesQueryAndAddsKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/settings", nil)
|
||||
server := &Server{}
|
||||
server.redirectNotice(response, request, "/domains?view=active", "Gespeichert.")
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected status: got %d, want %d", response.Code, http.StatusSeeOther)
|
||||
}
|
||||
location, err := response.Result().Location()
|
||||
if err != nil {
|
||||
t.Fatalf("parse redirect location: %v", err)
|
||||
}
|
||||
if got := location.Query().Get("view"); got != "active" {
|
||||
t.Fatalf("existing query lost: %q", got)
|
||||
}
|
||||
if got := location.Query().Get("notice"); got != "Gespeichert." {
|
||||
t.Fatalf("unexpected notice: %q", got)
|
||||
}
|
||||
if got := location.Query().Get("notice_kind"); got != "success" {
|
||||
t.Fatalf("unexpected notice kind: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginErrorRendersAsToast(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("load templates: %v", err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{
|
||||
Title: "Eingabe prüfen",
|
||||
Page: "login",
|
||||
Flash: newFlash("Passwort nicht korrekt.", "error"),
|
||||
}
|
||||
if err := templates["login"].ExecuteTemplate(&rendered, "login", data); err != nil {
|
||||
t.Fatalf("render login: %v", err)
|
||||
}
|
||||
body := rendered.String()
|
||||
for _, expected := range []string{`class="toast toast-error"`, `data-toast`, `role="alert"`, `Passwort nicht korrekt.`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("rendered login is missing %q", expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `class="flash"`) {
|
||||
t.Error("legacy in-flow flash message is still rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("load templates: %v", err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{
|
||||
Title: "example.de",
|
||||
Page: "domains",
|
||||
CSRF: "csrf",
|
||||
IsAdmin: true,
|
||||
Zone: model.Zone{ID: 1, Name: "example.de"},
|
||||
Settings: model.Settings{IPv6Enabled: true},
|
||||
Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, Active: true, TTL: 300, LastStatus: "A unverändert", LastUpdatedAt: time.Date(2026, time.July, 11, 10, 26, 0, 0, time.UTC)}},
|
||||
CNAMERecords: []model.CNAMERecord{{ID: 3, Active: true, FQDN: "www.example.de", Destination: "home.example.de.", TTL: 600}},
|
||||
}
|
||||
if err := templates["domain_detail"].ExecuteTemplate(&rendered, "domain_detail", data); err != nil {
|
||||
t.Fatalf("render domain detail: %v", err)
|
||||
}
|
||||
body := rendered.String()
|
||||
statusAt := strings.Index(body, `A unverändert`)
|
||||
timeAt := strings.Index(body, `class="target-meta"><time>`)
|
||||
if timeAt < 0 || statusAt < timeAt {
|
||||
t.Fatalf("target time must be rendered before the status pill: %s", body)
|
||||
}
|
||||
for _, expected := range []string{`name="ttl"`, `value="300"`, `action="/cnames"`, `action="/domains/1/ttl"`, `action="/domains/1/delete"`, `action="/cnames/3/remove"`, `action="/cnames/3/delete"`, `data-static-ip-fields`, `data-static-ipv4`, `data-target-requirement`, `data-ipv6-enabled="true"`, `TTL für alle setzen`, `DynDNS aktivieren`, `Ziel vollständig löschen`, `Domain vollständig löschen`, `CNAME aktiv`, `Aus WebUI entfernen`, `cname-edit-grid`, `CNAME-Aliase`, `home.example.de.`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("domain detail is missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("load templates: %v", err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{
|
||||
Title: "example.de",
|
||||
Page: "domains",
|
||||
CSRF: "csrf",
|
||||
IsAdmin: true,
|
||||
Zone: model.Zone{ID: 1, Name: "example.de"},
|
||||
Settings: model.Settings{IPv6Enabled: false},
|
||||
Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, UpdateIPv6: true, TTL: 60}},
|
||||
}
|
||||
if err := templates["domain_detail"].ExecuteTemplate(&rendered, "domain_detail", data); err != nil {
|
||||
t.Fatalf("render domain detail: %v", err)
|
||||
}
|
||||
body := rendered.String()
|
||||
for _, expected := range []string{`data-ipv6-enabled="false"`, `IPv6 / AAAA pausiert`, `data-static-ipv6`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("globally-disabled IPv6 UI is missing %q", expected)
|
||||
}
|
||||
}
|
||||
inputAt := strings.Index(body, `name="update_ipv6"`)
|
||||
if inputAt < 0 || !strings.Contains(body[inputAt:inputAt+min(100, len(body)-inputAt)], "disabled") {
|
||||
t.Error("globally-disabled IPv6 checkbox remained enabled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
(() => {
|
||||
const toast = document.querySelector('[data-toast]');
|
||||
if (toast) {
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const region = toast.closest('.toast-region');
|
||||
const duration = toast.classList.contains('toast-error') ? 6500 : 4800;
|
||||
let remaining = duration;
|
||||
let startedAt = 0;
|
||||
let dismissTimer;
|
||||
let timerRunning = false;
|
||||
|
||||
const dismiss = () => {
|
||||
if (!toast.isConnected || toast.classList.contains('is-leaving')) return;
|
||||
window.clearTimeout(dismissTimer);
|
||||
timerRunning = false;
|
||||
toast.classList.add('is-leaving');
|
||||
if (reducedMotion) {
|
||||
region?.remove();
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => region?.remove(), 220);
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
if (timerRunning || toast.classList.contains('is-leaving')) return;
|
||||
toast.classList.remove('is-paused');
|
||||
startedAt = performance.now();
|
||||
timerRunning = true;
|
||||
dismissTimer = window.setTimeout(dismiss, remaining);
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
if (!timerRunning || toast.classList.contains('is-leaving')) return;
|
||||
remaining = Math.max(0, remaining - (performance.now() - startedAt));
|
||||
window.clearTimeout(dismissTimer);
|
||||
timerRunning = false;
|
||||
toast.classList.add('is-paused');
|
||||
};
|
||||
|
||||
toast.querySelector('[data-toast-close]')?.addEventListener('click', dismiss);
|
||||
toast.addEventListener('pointerenter', pause);
|
||||
toast.addEventListener('pointerleave', () => {
|
||||
if (!toast.contains(document.activeElement)) resume();
|
||||
});
|
||||
toast.addEventListener('focusin', pause);
|
||||
toast.addEventListener('focusout', () => {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!toast.matches(':hover') && !toast.contains(document.activeElement)) resume();
|
||||
});
|
||||
});
|
||||
resume();
|
||||
}
|
||||
|
||||
const currentURL = new URL(window.location.href);
|
||||
if (currentURL.searchParams.has('notice')) {
|
||||
currentURL.searchParams.delete('notice');
|
||||
currentURL.searchParams.delete('notice_kind');
|
||||
const cleanLocation = `${currentURL.pathname}${currentURL.search}${currentURL.hash}`;
|
||||
window.history.replaceState(window.history.state, '', cleanLocation);
|
||||
}
|
||||
|
||||
const settingsForm = document.querySelector('[data-settings-form]');
|
||||
if (settingsForm) {
|
||||
const updaterToggle = settingsForm.querySelector('[data-updater-toggle]');
|
||||
const ipv6Toggle = settingsForm.querySelector('[data-ipv6-toggle]');
|
||||
const ipv6Settings = settingsForm.querySelector('[data-ipv6-settings]');
|
||||
|
||||
const syncToggleCard = (input) => {
|
||||
if (!input) return;
|
||||
const card = input.closest('.setting-switch-card');
|
||||
card?.classList.toggle('is-enabled', input.checked);
|
||||
const label = input.closest('.setting-switch')?.querySelector('b');
|
||||
if (label) label.textContent = input.checked ? 'Aktiv' : 'Inaktiv';
|
||||
};
|
||||
|
||||
const syncIPv6Settings = () => {
|
||||
if (!ipv6Toggle || !ipv6Settings) return;
|
||||
ipv6Settings.classList.toggle('is-disabled', !ipv6Toggle.checked);
|
||||
ipv6Settings.setAttribute('aria-disabled', String(!ipv6Toggle.checked));
|
||||
const textarea = ipv6Settings.querySelector('textarea');
|
||||
if (textarea) textarea.required = ipv6Toggle.checked;
|
||||
};
|
||||
|
||||
updaterToggle?.addEventListener('change', () => syncToggleCard(updaterToggle));
|
||||
ipv6Toggle?.addEventListener('change', () => {
|
||||
syncToggleCard(ipv6Toggle);
|
||||
syncIPv6Settings();
|
||||
});
|
||||
syncToggleCard(updaterToggle);
|
||||
syncToggleCard(ipv6Toggle);
|
||||
syncIPv6Settings();
|
||||
}
|
||||
|
||||
for (const form of document.querySelectorAll('[data-target-form]')) {
|
||||
const active = form.querySelector('[data-target-active]');
|
||||
const staticFields = form.querySelector('[data-static-ip-fields]');
|
||||
const ipv4Toggle = form.querySelector('[name="update_ipv4"]');
|
||||
const ipv6Toggle = form.querySelector('[name="update_ipv6"]');
|
||||
const staticIPv4 = form.querySelector('[data-static-ipv4]');
|
||||
const staticIPv6 = form.querySelector('[data-static-ipv6]');
|
||||
const requirement = form.querySelector('[data-target-requirement]');
|
||||
const submit = form.querySelector('button[type="submit"]');
|
||||
const ipv6Enabled = form.dataset.ipv6Enabled !== 'false';
|
||||
const ipv4Selected = () => ipv4Toggle ? ipv4Toggle.checked : form.dataset.updateIpv4 === 'true';
|
||||
const ipv6Selected = () => ipv6Enabled && (ipv6Toggle ? ipv6Toggle.checked : form.dataset.updateIpv6 === 'true');
|
||||
|
||||
const syncStaticFields = () => {
|
||||
if (!active || !staticFields) return;
|
||||
const useIPv4 = ipv4Selected();
|
||||
const useIPv6 = ipv6Selected();
|
||||
const hasFamily = useIPv4 || useIPv6;
|
||||
if (active.checked && !hasFamily) active.checked = false;
|
||||
|
||||
active.disabled = !hasFamily;
|
||||
active.closest('.switch')?.classList.toggle('switch-disabled', !hasFamily);
|
||||
|
||||
const useStatic = !active.checked && hasFamily;
|
||||
staticFields.hidden = !useStatic;
|
||||
staticFields.setAttribute('aria-hidden', String(!useStatic));
|
||||
if (staticIPv4) {
|
||||
staticIPv4.disabled = !useStatic || !useIPv4;
|
||||
staticIPv4.required = useStatic && useIPv4;
|
||||
}
|
||||
if (staticIPv6) {
|
||||
staticIPv6.disabled = !useStatic || !useIPv6;
|
||||
staticIPv6.required = useStatic && useIPv6;
|
||||
}
|
||||
|
||||
const missingStaticIP = useStatic && (
|
||||
(useIPv4 && !staticIPv4?.value.trim()) ||
|
||||
(useIPv6 && !staticIPv6?.value.trim())
|
||||
);
|
||||
if (submit) submit.disabled = missingStaticIP;
|
||||
if (requirement) requirement.hidden = !missingStaticIP;
|
||||
};
|
||||
|
||||
active?.addEventListener('change', syncStaticFields);
|
||||
for (const familyToggle of [ipv4Toggle, ipv6Toggle]) {
|
||||
familyToggle?.addEventListener('change', () => {
|
||||
if (!familyToggle.checked && active) active.checked = false;
|
||||
syncStaticFields();
|
||||
});
|
||||
}
|
||||
staticIPv4?.addEventListener('input', syncStaticFields);
|
||||
staticIPv6?.addEventListener('input', syncStaticFields);
|
||||
syncStaticFields();
|
||||
}
|
||||
|
||||
for (const zone of document.querySelectorAll('[data-access-zone]')) {
|
||||
const access = zone.querySelector('[data-zone-access]');
|
||||
const manage = zone.querySelector('[data-zone-manage]');
|
||||
const create = zone.querySelector('[data-zone-create]');
|
||||
access?.addEventListener('change', () => {
|
||||
if (!access.checked) {
|
||||
if (manage) manage.checked = false;
|
||||
if (create) create.checked = false;
|
||||
}
|
||||
});
|
||||
manage?.addEventListener('change', () => {
|
||||
if (manage.checked && access) access.checked = true;
|
||||
if (!manage.checked && create) create.checked = false;
|
||||
});
|
||||
create?.addEventListener('change', () => {
|
||||
if (create.checked) {
|
||||
if (access) access.checked = true;
|
||||
if (manage) manage.checked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const target of document.querySelectorAll('[data-access-target]')) {
|
||||
const access = target.querySelector('[data-target-access]');
|
||||
const manage = target.querySelector('[data-target-manage]');
|
||||
access?.addEventListener('change', () => {
|
||||
if (!access.checked && manage) manage.checked = false;
|
||||
});
|
||||
manage?.addEventListener('change', () => {
|
||||
if (manage.checked && access) access.checked = true;
|
||||
});
|
||||
}
|
||||
|
||||
const copyButtons = document.querySelectorAll('[data-copy-target]');
|
||||
for (const button of copyButtons) {
|
||||
button.addEventListener('click', async () => {
|
||||
const target = document.getElementById(button.dataset.copyTarget);
|
||||
if (!target) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(target.textContent);
|
||||
const original = button.textContent;
|
||||
button.textContent = 'Kopiert';
|
||||
window.setTimeout(() => { button.textContent = original; }, 2000);
|
||||
} catch {
|
||||
button.textContent = 'Manuell kopieren';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
||||
if (countdowns.length === 0) return;
|
||||
|
||||
let nextRun = Number(countdowns[0].dataset.countdown || 0);
|
||||
let running = countdowns[0].dataset.running === 'true';
|
||||
let enabled = countdowns[0].dataset.enabled !== 'false';
|
||||
let lastRefresh = 0;
|
||||
|
||||
const formatRemaining = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const rest = seconds % 60;
|
||||
if (hours > 0) return `${hours}:${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const refreshSchedule = async () => {
|
||||
const now = Date.now();
|
||||
if (now - lastRefresh < 2500) return;
|
||||
lastRefresh = now;
|
||||
try {
|
||||
const response = await fetch('/api/v1/scheduler', {headers: {'Accept': 'application/json'}, cache: 'no-store'});
|
||||
if (!response.ok) return;
|
||||
const state = await response.json();
|
||||
nextRun = Number(state.next_run_unix || 0);
|
||||
running = Boolean(state.running);
|
||||
enabled = state.enabled !== false;
|
||||
} catch {
|
||||
// The visible countdown keeps its last known state when the status request fails.
|
||||
}
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (!enabled) {
|
||||
for (const node of countdowns) {
|
||||
node.textContent = 'Inaktiv';
|
||||
node.setAttribute('aria-label', 'Automatischer Updater ist deaktiviert');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const remaining = Math.max(0, nextRun - now);
|
||||
const label = running ? 'Update läuft' : (nextRun > 0 ? formatRemaining(remaining) : '--:--');
|
||||
for (const node of countdowns) {
|
||||
node.textContent = label;
|
||||
node.setAttribute('aria-label', running ? 'Update läuft' : `Nächste Aktualisierung in ${remaining} Sekunden`);
|
||||
}
|
||||
if (running || nextRun === 0 || remaining === 0) refreshSchedule();
|
||||
};
|
||||
|
||||
tick();
|
||||
refreshSchedule();
|
||||
window.setInterval(tick, 1000);
|
||||
window.setInterval(refreshSchedule, 30000);
|
||||
})();
|
||||
@@ -0,0 +1,396 @@
|
||||
:root {
|
||||
--bg: #f3f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-subtle: #f8fafc;
|
||||
--sidebar: #101827;
|
||||
--sidebar-hover: #1b2739;
|
||||
--text: #182230;
|
||||
--muted: #647386;
|
||||
--line: #dce2e8;
|
||||
--line-strong: #c8d1db;
|
||||
--accent: #2563eb;
|
||||
--accent-dark: #1d4ed8;
|
||||
--accent-soft: #eaf1ff;
|
||||
--success: #0f766e;
|
||||
--success-soft: #e5f7f3;
|
||||
--warning: #9a6700;
|
||||
--warning-soft: #fff6d8;
|
||||
--danger: #b4233c;
|
||||
--danger-soft: #fff0f2;
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
--shadow: 0 1px 2px rgba(16, 24, 39, .04), 0 8px 24px rgba(16, 24, 39, .035);
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; min-height: 100%; background: var(--bg); }
|
||||
body { min-height: 100vh; margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button, summary { cursor: pointer; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
a:hover { color: var(--accent); }
|
||||
h1, h2, h3, p, dl, dd { margin-top: 0; }
|
||||
h1 { margin-bottom: 6px; font-size: clamp(25px, 3vw, 31px); line-height: 1.15; letter-spacing: -.035em; }
|
||||
h2 { margin-bottom: 0; font-size: 16px; line-height: 1.3; letter-spacing: -.015em; }
|
||||
h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
||||
|
||||
.skip-link { position: fixed; top: -50px; left: 12px; z-index: 100; padding: 8px 12px; border-radius: 6px; background: #fff; color: var(--text); box-shadow: var(--shadow); }
|
||||
.skip-link:focus { top: 12px; }
|
||||
|
||||
.admin-layout { display: grid; min-height: 100vh; grid-template-columns: 232px minmax(0, 1fr); }
|
||||
.sidebar { position: sticky; top: 0; display: flex; height: 100vh; flex-direction: column; padding: 18px 14px 14px; background: var(--sidebar); color: #d7deea; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 3px 5px 17px; color: #fff; }
|
||||
.brand:hover { color: #fff; }
|
||||
.brand-mark { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid #41506a; border-radius: 8px; background: #192438; color: #8eb3ff; font-family: var(--mono); font-size: 11px; font-weight: 800; }
|
||||
.brand strong, .brand small { display: block; }
|
||||
.brand strong { font-size: 13px; line-height: 1.2; }
|
||||
.brand small { margin-top: 2px; color: #7f8ca1; font-family: var(--mono); font-size: 9px; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.instance-state { display: flex; align-items: center; gap: 7px; margin: 0 5px 17px; padding: 7px 9px; border: 1px solid #26344a; border-radius: 6px; color: #9eabc0; font-family: var(--mono); font-size: 10px; text-transform: uppercase; }
|
||||
.instance-state span { width: 7px; height: 7px; border-radius: 50%; background: #34d399; box-shadow: 0 0 0 3px rgba(52, 211, 153, .12); }
|
||||
.instance-state.is-inactive { border-color: #3a3540; color: #958b98; }
|
||||
.instance-state.is-inactive span { background: #7d8796; box-shadow: 0 0 0 3px rgba(125,135,150,.12); }
|
||||
.sidebar nav { display: grid; gap: 3px; }
|
||||
.sidebar nav a { display: flex; min-height: 38px; align-items: center; gap: 11px; padding: 0 10px; border-radius: 6px; color: #9eabc0; font-size: 12px; font-weight: 650; }
|
||||
.sidebar nav a:hover { background: var(--sidebar-hover); color: #fff; }
|
||||
.sidebar nav a[aria-current="page"] { background: #21304a; color: #fff; box-shadow: inset 2px 0 #6d9fff; }
|
||||
.nav-icon { color: #68768c; font-family: var(--mono); font-size: 9px; }
|
||||
.sidebar nav a[aria-current="page"] .nav-icon { color: #8eb3ff; }
|
||||
.sidebar-account { display: grid; grid-template-columns: 32px minmax(0, 1fr) 28px; gap: 9px; align-items: center; margin-top: auto; padding: 12px 5px 2px; border-top: 1px solid #26344a; }
|
||||
.account-avatar { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 7px; background: #28364b; color: #dce8ff; font-size: 12px; font-weight: 800; }
|
||||
.sidebar-account strong, .sidebar-account small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-account strong { color: #e8edf5; font-size: 11px; }
|
||||
.sidebar-account small { color: #748298; font-size: 9px; }
|
||||
.sidebar-account form { display: contents; }
|
||||
.sidebar-account button { display: grid; width: 28px; height: 28px; place-items: center; border: 0; border-radius: 5px; background: transparent; color: #7e8da3; }
|
||||
.sidebar-account button:hover { background: #26344a; color: #fff; }
|
||||
|
||||
.workspace { min-width: 0; }
|
||||
.workspace-bar { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 clamp(18px, 3vw, 36px); border-bottom: 1px solid var(--line); background: rgba(255,255,255,.9); backdrop-filter: blur(12px); }
|
||||
.workspace-bar div { display: flex; gap: 6px; color: var(--muted); font-family: var(--mono); font-size: 10px; text-transform: uppercase; }
|
||||
.workspace-bar strong { color: var(--text); font-weight: 700; }
|
||||
.environment-badge { padding: 4px 7px; border: 1px solid #efd18a; border-radius: 4px; background: var(--warning-soft); color: #7b5707; font-family: var(--mono); font-size: 9px; font-weight: 700; }
|
||||
.environment-badge.secure { border-color: #9fd5ca; background: var(--success-soft); color: var(--success); }
|
||||
.app-shell { width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); margin: 0 auto; padding: 30px 0 52px; }
|
||||
.auth-shell { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: linear-gradient(135deg, #eef2f6 0%, #f8fafc 52%, #edf2f7 100%); }
|
||||
|
||||
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 22px; }
|
||||
.page-heading p { max-width: 720px; margin-bottom: 0; color: var(--muted); font-size: 13px; }
|
||||
.page-kicker, .section-label, .metric-label { display: block; margin-bottom: 5px; color: var(--muted); font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.back-link { display: inline-block; margin-bottom: 12px; color: var(--muted); font-size: 11px; font-weight: 650; }
|
||||
.back-link:hover { color: var(--accent); }
|
||||
.toast-region { position: fixed; z-index: 1000; bottom: max(18px, env(safe-area-inset-bottom)); left: 50%; width: min(460px, calc(100% - 28px)); pointer-events: none; transform: translateX(-50%); }
|
||||
.toast { --toast-tone: #60a5fa; --toast-tone-soft: rgba(96, 165, 250, .14); position: relative; display: grid; grid-template-columns: 34px minmax(0, 1fr) 30px; gap: 11px; align-items: center; min-height: 62px; overflow: hidden; padding: 11px 10px 11px 12px; border: 1px solid rgba(255,255,255,.13); border-radius: 14px; background: rgba(16, 24, 39, .96); color: #f8fafc; box-shadow: 0 20px 45px rgba(15, 23, 42, .24), 0 4px 12px rgba(15, 23, 42, .14); backdrop-filter: blur(16px); pointer-events: auto; animation: toast-in .42s cubic-bezier(.21, 1, .36, 1) both; }
|
||||
.toast::before { position: absolute; inset: 0 0 auto; height: 1px; background: linear-gradient(90deg, transparent, rgba(255,255,255,.28), transparent); content: ""; }
|
||||
.toast-success { --toast-tone: #34d399; --toast-tone-soft: rgba(52, 211, 153, .14); }
|
||||
.toast-error { --toast-tone: #fb7185; --toast-tone-soft: rgba(251, 113, 133, .15); }
|
||||
.toast-icon { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid color-mix(in srgb, var(--toast-tone) 35%, transparent); border-radius: 10px; background: var(--toast-tone-soft); color: var(--toast-tone); font-family: var(--mono); font-size: 13px; font-weight: 900; }
|
||||
.toast-copy { min-width: 0; }
|
||||
.toast-eyebrow { display: block; margin-bottom: 2px; color: var(--toast-tone); font-family: var(--mono); font-size: 8px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.toast-copy p { margin: 0; color: #eef2f7; font-size: 12px; font-weight: 600; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.toast-close { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: #8e9caf; font-size: 19px; line-height: 1; transition: background .14s, color .14s; }
|
||||
.toast-close:hover { background: rgba(255,255,255,.08); color: #fff; }
|
||||
.toast-close:focus-visible { outline: 3px solid rgba(96, 165, 250, .32); outline-offset: 1px; }
|
||||
.toast-progress { position: absolute; right: 0; bottom: 0; left: 0; height: 2px; background: var(--toast-tone); transform-origin: left; animation: toast-progress 4.8s linear both; }
|
||||
.toast-error .toast-progress { animation-duration: 6.5s; }
|
||||
.toast.is-paused .toast-progress { animation-play-state: paused; }
|
||||
.toast.is-leaving { animation: toast-out .2s ease-in both; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(18px) scale(.97); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes toast-out { to { opacity: 0; transform: translateY(10px) scale(.98); } }
|
||||
@keyframes toast-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
|
||||
.button { display: inline-flex; min-height: 38px; align-items: center; justify-content: center; gap: 7px; padding: 0 13px; border: 1px solid transparent; border-radius: 6px; background: var(--surface-subtle); color: var(--text); font-size: 12px; font-weight: 700; line-height: 1; transition: border-color .14s, background .14s, box-shadow .14s; }
|
||||
.button:hover { color: var(--text); box-shadow: 0 2px 6px rgba(16,24,39,.08); }
|
||||
.button:disabled { border-color: var(--line); background: #e8edf2; color: #8995a3; box-shadow: none; cursor: not-allowed; }
|
||||
.button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid rgba(37, 99, 235, .22); outline-offset: 2px; }
|
||||
.button-primary { border-color: var(--accent); background: var(--accent); color: #fff; }
|
||||
.button-primary:hover { border-color: var(--accent-dark); background: var(--accent-dark); color: #fff; }
|
||||
.button-quiet { border-color: var(--line-strong); background: #fff; }
|
||||
.button-danger, .button-danger-soft { border-color: #efb8c2; background: var(--danger-soft); color: var(--danger); }
|
||||
.button-danger:hover, .button-danger-soft:hover { border-color: #dc8c9b; background: #ffe4e8; color: #961b31; }
|
||||
.text-danger { color: var(--danger); font-size: 12px; font-weight: 700; }
|
||||
|
||||
.metric-grid { display: grid; grid-template-columns: .72fr 1.15fr 1.15fr .9fr; gap: 10px; margin-bottom: 12px; }
|
||||
.metric-card { display: flex; min-height: 122px; flex-direction: column; padding: 15px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.metric-card strong { margin-top: auto; overflow: hidden; color: var(--text); font-size: 26px; line-height: 1.15; letter-spacing: -.04em; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.metric-card small { margin-top: 5px; color: var(--muted); font-size: 10px; }
|
||||
.metric-card .address-value { font-size: clamp(12px, 1.45vw, 18px); letter-spacing: -.035em; }
|
||||
.metric-card .address-value.compact { font-size: clamp(10px, 1.12vw, 14px); }
|
||||
.metric-card-disabled { border-color: #d7dde4; background: #f1f3f6; box-shadow: none; }
|
||||
.metric-card-disabled strong { color: #788596; }
|
||||
.countdown-card { border-top: 3px solid var(--accent); padding-top: 13px; }
|
||||
.countdown-card.metric-card-disabled { border-top-color: #9aa5b2; }
|
||||
.countdown { font-size: 28px !important; font-variant-numeric: tabular-nums; }
|
||||
.mono { font-family: var(--mono); }
|
||||
|
||||
.panel, .form-panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.panel { padding: 17px; }
|
||||
.form-panel { padding: 22px; }
|
||||
.compact-panel { max-width: 930px; }
|
||||
.panel-heading, .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
.panel-heading a { color: var(--accent); font-size: 11px; font-weight: 700; }
|
||||
.panel-links, .heading-actions { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
|
||||
.panel-links { justify-content: flex-end; }
|
||||
.panel-description { max-width: 760px; margin: -4px 0 15px; color: var(--muted); font-size: 12px; }
|
||||
.panel-divider { height: 1px; margin: 18px 0; background: var(--line); }
|
||||
.dashboard-grid, .split-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: 12px; margin-bottom: 12px; }
|
||||
.split-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.data-list { display: grid; }
|
||||
.data-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 9px; align-items: center; padding: 10px 0; border-top: 1px solid #edf0f3; }
|
||||
.data-row:first-child { border-top: 0; }
|
||||
.data-row strong, .data-row small { display: block; }
|
||||
.data-row strong { font-size: 12px; font-weight: 650; }
|
||||
.data-row small { margin-top: 2px; color: var(--muted); font-size: 10px; }
|
||||
.state-dot { width: 7px; height: 7px; border-radius: 50%; background: #a8b2bf; }
|
||||
.state-dot.status-good { background: var(--success); }
|
||||
.state-dot.status-warn { background: var(--warning); }
|
||||
.state-dot.status-bad { background: var(--danger); }
|
||||
.status-pill { display: inline-flex; width: fit-content; min-height: 22px; align-items: center; padding: 0 7px; border-radius: 99px; background: #eef1f4; color: #556476; font-size: 9px; font-weight: 800; white-space: nowrap; }
|
||||
.status-good { color: var(--success) !important; }
|
||||
.status-warn { color: var(--warning) !important; }
|
||||
.status-bad { color: var(--danger) !important; }
|
||||
.status-pill.status-good { background: var(--success-soft); }
|
||||
.status-pill.status-warn { background: var(--warning-soft); }
|
||||
.status-pill.status-bad { background: var(--danger-soft); }
|
||||
|
||||
.domain-list { display: grid; }
|
||||
.domain-list > a { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 0; border-top: 1px solid #edf0f3; }
|
||||
.domain-list > a:first-child { border-top: 0; }
|
||||
.domain-list strong, .domain-list small { display: block; }
|
||||
.domain-list strong { font-size: 12px; }
|
||||
.domain-list small { margin-top: 2px; color: var(--muted); font-size: 10px; }
|
||||
.zone-glyph, .empty-icon { display: grid; width: 34px; height: 28px; place-items: center; border: 1px solid #bfd0ea; border-radius: 5px; background: var(--accent-soft); color: #315eaa; font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||
.audit-panel { margin-top: 12px; }
|
||||
.audit-list { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.audit-list li { display: grid; grid-template-columns: 140px minmax(0,1fr) auto; gap: 12px; align-items: center; padding: 9px 0; border-top: 1px solid #edf0f3; }
|
||||
.audit-list li:first-child { border-top: 0; }
|
||||
.audit-list time, .audit-list code { color: var(--muted); font-family: var(--mono); font-size: 9px; }
|
||||
.audit-list strong { font-size: 11px; font-weight: 600; }
|
||||
|
||||
.domain-board { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.zone-card { display: flex; min-height: 220px; flex-direction: column; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.zone-card:hover { border-color: #b6c4d4; }
|
||||
.zone-card-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.zone-card h2 { margin: 26px 0 12px; overflow-wrap: anywhere; font-size: 17px; }
|
||||
.zone-card dl { display: grid; gap: 5px; margin-bottom: 14px; }
|
||||
.zone-card dl div { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.zone-card dt, .zone-card dd { color: var(--muted); font-size: 10px; }
|
||||
.zone-card dd { color: var(--text); text-align: right; }
|
||||
.inline-error { margin-bottom: 12px; color: var(--danger); font-size: 10px; }
|
||||
.card-link { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 10px; border-top: 1px solid var(--line); color: var(--accent); font-size: 11px; font-weight: 750; }
|
||||
.empty-board { grid-column: 1 / -1; padding: 54px 20px; border: 1px dashed var(--line-strong); border-radius: var(--radius); background: rgba(255,255,255,.55); text-align: center; }
|
||||
.empty-board .empty-icon { width: 46px; height: 38px; margin: 0 auto 13px; }
|
||||
.empty-board h2 { margin-bottom: 5px; }
|
||||
.empty-board p { max-width: 560px; margin: 0 auto 16px; color: var(--muted); font-size: 12px; }
|
||||
.empty-state { padding: 32px 12px; color: var(--muted); font-size: 11px; text-align: center; }
|
||||
.empty-state a { color: var(--accent); font-weight: 700; }
|
||||
|
||||
.zone-summary { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); margin-bottom: 22px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.zone-summary div { padding: 12px 14px; border-left: 1px solid var(--line); }
|
||||
.zone-summary div:first-child { border-left: 0; }
|
||||
.zone-summary span, .zone-summary strong { display: block; }
|
||||
.zone-summary span { color: var(--muted); font-family: var(--mono); font-size: 8px; text-transform: uppercase; }
|
||||
.zone-summary strong { margin-top: 4px; font-size: 11px; }
|
||||
.bulk-ttl-panel { margin: -10px 0 18px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-subtle); }
|
||||
.bulk-ttl-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(150px, 190px) auto; gap: 12px; align-items: end; }
|
||||
.bulk-ttl-form > div { display: grid; gap: 3px; }
|
||||
.bulk-ttl-form strong { font-size: 11px; }
|
||||
.bulk-ttl-form small { color: var(--muted); font-size: 9px; }
|
||||
.targets-section { margin-bottom: 18px; }
|
||||
.record-count { color: var(--muted); font-family: var(--mono); font-size: 10px; }
|
||||
.target-list { display: grid; gap: 7px; }
|
||||
.target-row { display: grid; grid-template-columns: minmax(0,1fr) auto; overflow: hidden; border: 1px solid var(--line); border-left: 3px solid #b9c3ce; border-radius: var(--radius-sm); background: var(--surface); }
|
||||
.target-row.is-active { border-left-color: var(--success); }
|
||||
.target-main { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 12px 14px; }
|
||||
.target-main > div { min-width: 0; }
|
||||
.target-status { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #aeb8c3; }
|
||||
.target-status.on { background: var(--success); box-shadow: 0 0 0 3px rgba(15,118,110,.1); }
|
||||
.target-main h3 { overflow-wrap: anywhere; }
|
||||
.target-main p { display: flex; gap: 7px; align-items: center; overflow: hidden; margin: 0; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.family-chip { display: inline-flex; flex: none; padding: 2px 5px; border-radius: 4px; background: #e9eef4; color: #45576b; font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||
.target-meta { display: flex; min-width: 210px; align-items: center; justify-content: flex-end; gap: 10px; padding: 12px 14px; color: var(--muted); font-family: var(--mono); font-size: 9px; }
|
||||
.target-edit { grid-column: 1 / -1; border-top: 1px solid var(--line); }
|
||||
.target-edit > summary { display: flex; align-items: center; justify-content: space-between; padding: 7px 14px; color: #52647a; font-size: 10px; font-weight: 700; list-style: none; }
|
||||
.target-edit > summary::-webkit-details-marker, details > summary::-webkit-details-marker { display: none; }
|
||||
.target-edit[open] > summary { background: #f8fafc; }
|
||||
.edit-inner { display: grid; grid-template-columns: minmax(0,1fr) 260px; gap: 14px; padding: 14px; border-top: 1px solid var(--line); background: var(--surface-subtle); }
|
||||
.target-form, .unlink-form { display: grid; gap: 11px; }
|
||||
.danger-actions { display: grid; align-content: start; gap: 11px; }
|
||||
.unlink-form { align-content: start; padding: 12px; border: 1px solid #efc6cd; border-radius: var(--radius-sm); background: #fffafb; }
|
||||
.unlink-form strong { color: var(--danger); font-size: 11px; }
|
||||
.unlink-form p { margin-bottom: 0; color: #865764; font-size: 10px; }
|
||||
.target-requirement { margin: 0; padding: 8px 10px; border-left: 3px solid var(--danger); background: #fff3f5; color: #8f2f42; font-size: 10px; }
|
||||
.target-requirement[hidden] { display: none; }
|
||||
.row-error { grid-column: 1 / -1; margin: 0; padding: 8px 14px; border-top: 1px solid #f3d5da; background: #fff8f9; color: var(--danger); font-size: 10px; }
|
||||
.archive-panel { margin-bottom: 18px; border-left: 3px solid #8a98a9; }
|
||||
.archive-list { display: grid; }
|
||||
.archive-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 0; border-top: 1px solid #edf0f3; }
|
||||
.archive-row:first-child { border-top: 0; }
|
||||
.archive-row strong, .archive-row small { display: block; }
|
||||
.archive-row strong { font-size: 11px; }
|
||||
.archive-row small { margin-top: 2px; color: var(--muted); font-family: var(--mono); font-size: 9px; }
|
||||
.admin-actions-grid { margin-top: 18px; }
|
||||
|
||||
.stacked-form { display: grid; gap: 13px; }
|
||||
.wide-form { width: 100%; max-width: 850px; }
|
||||
.settings-form { display: grid; gap: 12px; }
|
||||
.settings-mode-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; max-width: 930px; }
|
||||
.setting-switch-card { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 18px; align-items: center; min-height: 116px; padding: 17px; border: 1px solid var(--line); border-left: 3px solid #aab4c0; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); cursor: pointer; transition: border-color .18s, background .18s, transform .18s; }
|
||||
.setting-switch-card:hover { border-color: #bcc7d3; transform: translateY(-1px); }
|
||||
.setting-switch-card.is-enabled { border-left-color: var(--success); background: linear-gradient(110deg, #fff 65%, #f0faf7); }
|
||||
.setting-switch-copy, .setting-switch-copy strong, .setting-switch-copy small { display: block; }
|
||||
.setting-switch-copy strong { margin-bottom: 4px; color: var(--text); font-size: 15px; }
|
||||
.setting-switch-copy small { max-width: 430px; color: var(--muted); font-size: 10px; font-weight: 500; line-height: 1.45; }
|
||||
.setting-switch { flex-direction: column; gap: 5px; }
|
||||
.setting-switch b { min-width: 48px; color: var(--muted); font-family: var(--mono); font-size: 8px; text-align: center; text-transform: uppercase; }
|
||||
.setting-switch-card.is-enabled .setting-switch b { color: var(--success); }
|
||||
.settings-fields { max-width: 930px; }
|
||||
.endpoint-setting { transition: opacity .18s; }
|
||||
.endpoint-setting.is-disabled { opacity: .48; }
|
||||
.endpoint-setting.is-disabled textarea { background: #f0f2f5; }
|
||||
label { display: grid; gap: 5px; color: #37475a; font-size: 11px; font-weight: 700; }
|
||||
label small { color: var(--muted); font-size: 9px; font-weight: 500; line-height: 1.45; }
|
||||
input, textarea, select { width: 100%; min-height: 39px; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 6px; outline: none; background: #fff; color: var(--text); font-size: 12px; transition: border-color .14s, box-shadow .14s; }
|
||||
textarea { resize: vertical; font-family: var(--mono); font-size: 10px; line-height: 1.55; }
|
||||
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; }
|
||||
.cname-edit-grid { align-items: start; }
|
||||
.field-note { display: block; margin-top: -6px; color: var(--muted); font-size: 9px; line-height: 1.45; }
|
||||
.form-actions { display: flex; flex-wrap: wrap; gap: 8px; padding-top: 2px; }
|
||||
.toggle-row { display: flex; flex-wrap: wrap; gap: 11px 16px; align-items: center; }
|
||||
.switch { display: inline-flex; width: auto; align-items: center; gap: 7px; color: var(--text); font-size: 10px; font-weight: 700; }
|
||||
.switch input { position: absolute; width: 1px; min-height: 1px; opacity: 0; }
|
||||
.switch > span { position: relative; width: 28px; height: 16px; border-radius: 99px; background: #b9c3cf; transition: background .14s; }
|
||||
.switch > span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 10px; height: 10px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.2); transition: transform .14s; }
|
||||
.switch input:checked + span { background: var(--success); }
|
||||
.switch input:checked + span::after { transform: translateX(12px); }
|
||||
.switch input:focus-visible + span { outline: 3px solid rgba(37,99,235,.2); outline-offset: 2px; }
|
||||
.switch-disabled { color: var(--muted); opacity: .65; }
|
||||
.action-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 12px; }
|
||||
.info-box { display: grid; grid-template-columns: 150px 1fr; gap: 12px; padding: 11px 12px; border: 1px solid #bfd1e8; border-radius: 6px; background: #f2f7fd; font-size: 10px; }
|
||||
.info-box strong { color: #315e8b; }
|
||||
.info-box span { color: #526d86; }
|
||||
.technical-summary { display: grid; grid-template-columns: auto 1fr; gap: 5px 12px; align-content: center; padding: 11px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-subtle); }
|
||||
.technical-summary span { color: var(--muted); font-family: var(--mono); font-size: 9px; text-transform: uppercase; }
|
||||
.technical-summary strong { font-size: 10px; text-align: right; }
|
||||
.inset-form { margin-top: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-subtle); }
|
||||
|
||||
.auth-card, .token-card { width: min(500px, 100%); padding: 28px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: 0 18px 45px rgba(16,24,39,.1); }
|
||||
.auth-card h1, .token-card h1 { margin: 20px 0 7px; font-size: 25px; }
|
||||
.auth-card .lede, .token-card > p { margin-bottom: 20px; color: var(--muted); font-size: 12px; }
|
||||
.auth-brand { display: flex; align-items: center; gap: 9px; color: var(--text); font-size: 12px; font-weight: 800; }
|
||||
.auth-brand span { display: grid; width: 31px; height: 31px; place-items: center; border-radius: 6px; background: var(--sidebar); color: #9dbbff; font-family: var(--mono); font-size: 9px; }
|
||||
.security-strip, .login-meta { margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 9px; }
|
||||
.security-strip span { color: var(--success); }
|
||||
.login-meta { text-align: center; }
|
||||
.token-card { width: min(680px, 100%); }
|
||||
.token-value { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; align-items: center; margin: 17px 0; padding: 10px; border: 1px solid #b6cbe8; border-radius: 6px; background: #f2f7fd; }
|
||||
.token-value code { overflow: auto; font-family: var(--mono); font-size: 10px; white-space: nowrap; }
|
||||
.warning-box { margin-top: 14px; padding: 10px 11px; border: 1px solid #ead18e; border-radius: 6px; background: var(--warning-soft); color: #74540b; font-size: 10px; }
|
||||
|
||||
.security-facts ul { display: grid; gap: 9px; margin: 0; padding: 0; list-style: none; }
|
||||
.security-facts li { display: grid; gap: 2px; padding-left: 10px; border-left: 2px solid #9fcac4; }
|
||||
.security-facts strong { font-size: 11px; }
|
||||
.security-facts span { color: var(--muted); font-size: 10px; }
|
||||
.user-table { display: grid; }
|
||||
.user-table-head, .user-table-row { display: grid; grid-template-columns: minmax(160px,1.2fr) .55fr .55fr minmax(120px,.8fr) 20px; gap: 10px; align-items: center; }
|
||||
.user-table-head { padding: 4px 7px 8px; color: var(--muted); font-family: var(--mono); font-size: 8px; font-weight: 800; text-transform: uppercase; }
|
||||
.user-table-row { padding: 10px 7px; border-top: 1px solid #edf0f3; }
|
||||
.user-table-row:hover { background: var(--surface-subtle); color: var(--text); }
|
||||
.user-table-row strong, .user-table-row small { display: block; }
|
||||
.user-table-row strong { font-size: 11px; }
|
||||
.user-table-row small, .muted-cell { color: var(--muted); font-size: 9px; }
|
||||
.role-chip { display: inline-flex; width: fit-content; padding: 3px 6px; border-radius: 4px; background: #edf1f5; color: #536477; font-size: 9px; font-weight: 800; }
|
||||
.role-chip.admin { background: var(--accent-soft); color: #315eaa; }
|
||||
.row-arrow { color: var(--accent); }
|
||||
.access-panel { margin-top: 12px; }
|
||||
.access-intro { max-width: 780px; margin: -3px 0 14px; color: var(--muted); font-size: 10px; }
|
||||
.access-list { display: grid; gap: 9px; }
|
||||
.access-zone { overflow: hidden; border: 1px solid var(--line); border-radius: 7px; }
|
||||
.access-zone-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding: 12px; background: var(--surface-subtle); }
|
||||
.access-zone-head small { color: var(--muted); font-size: 9px; }
|
||||
.access-zone-controls { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 9px 14px; }
|
||||
.access-targets { display: grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap: 1px; border-top: 1px solid var(--line); background: var(--line); }
|
||||
.access-target { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 10px; align-items: center; min-height: 62px; padding: 8px 10px; background: #fff; color: var(--text); font-size: 10px; font-weight: 600; }
|
||||
.access-target-main { display: flex; width: auto; grid-template-columns: none; align-items: center; gap: 8px; }
|
||||
.access-target-main input { width: 15px; min-height: 15px; flex: none; accent-color: var(--accent); }
|
||||
.access-target strong, .access-target small { display: block; }
|
||||
.access-target small { color: var(--muted); font-size: 8px; }
|
||||
.access-manage-switch { white-space: nowrap; }
|
||||
|
||||
.error-summary-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
||||
.error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
|
||||
.error-summary-grid span { color: #865764; font-family: var(--mono); font-size: 9px; text-transform: uppercase; }
|
||||
.error-summary-grid strong { color: var(--danger); font-size: 20px; }
|
||||
.error-section { margin-bottom: 12px; }
|
||||
.error-list { display: grid; }
|
||||
.error-item { display: grid; grid-template-columns: 26px minmax(0,1fr) auto; gap: 10px; align-items: start; padding: 11px 0; border-top: 1px solid #edf0f3; }
|
||||
.error-item:first-child { border-top: 0; }
|
||||
.error-item > div { min-width: 0; }
|
||||
.error-item strong, .error-item small { display: block; }
|
||||
.error-item strong { font-size: 11px; }
|
||||
.error-item p { margin: 3px 0; color: var(--danger); font-size: 10px; overflow-wrap: anywhere; }
|
||||
.error-item small { color: var(--muted); font-family: var(--mono); font-size: 8px; }
|
||||
.error-item > a { color: var(--accent); font-size: 10px; font-weight: 700; }
|
||||
.error-mark { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 7px; background: var(--danger-soft); color: var(--danger); font-family: var(--mono); font-size: 10px; font-weight: 900; }
|
||||
.run-error-list .error-item { grid-template-columns: 10px minmax(0,1fr); align-items: center; }
|
||||
.error-empty { display: flex; max-width: 720px; align-items: center; gap: 14px; }
|
||||
.error-empty-icon { display: grid; width: 38px; height: 38px; flex: none; place-items: center; border-radius: 10px; background: var(--success-soft); color: var(--success); font-weight: 900; }
|
||||
.error-empty h2 { margin-bottom: 3px; }
|
||||
.error-empty p { margin: 0; color: var(--muted); font-size: 11px; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.admin-layout { grid-template-columns: 190px minmax(0,1fr); }
|
||||
.sidebar { padding-inline: 10px; }
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.dashboard-grid, .split-grid { grid-template-columns: 1fr; }
|
||||
.edit-inner { grid-template-columns: 1fr; }
|
||||
.settings-mode-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.admin-layout { display: block; }
|
||||
.sidebar { position: static; height: auto; padding: 10px 12px; }
|
||||
.brand { padding-bottom: 9px; }
|
||||
.instance-state { display: none; }
|
||||
.sidebar nav { display: flex; overflow-x: auto; padding-bottom: 2px; }
|
||||
.sidebar nav a { min-width: fit-content; }
|
||||
.sidebar-account { display: none; }
|
||||
.workspace-bar { min-height: 45px; padding-inline: 14px; }
|
||||
.app-shell { width: min(100% - 24px, 1280px); padding-top: 20px; }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; gap: 12px; }
|
||||
.page-heading > form, .page-heading > .button { width: 100%; }
|
||||
.page-heading .button { width: 100%; }
|
||||
.heading-actions { width: 100%; }
|
||||
.heading-actions form, .heading-actions .button { width: 100%; }
|
||||
.form-grid, .metric-grid, .zone-summary { grid-template-columns: 1fr; }
|
||||
.bulk-ttl-form { grid-template-columns: 1fr; }
|
||||
.bulk-ttl-form .button { width: 100%; }
|
||||
.zone-summary div { border-top: 1px solid var(--line); border-left: 0; }
|
||||
.zone-summary div:first-child { border-top: 0; }
|
||||
.target-row { grid-template-columns: 1fr; }
|
||||
.target-meta { min-width: 0; justify-content: space-between; padding-top: 0; }
|
||||
.target-edit { grid-column: 1; }
|
||||
.audit-list li { grid-template-columns: 1fr auto; gap: 3px 10px; }
|
||||
.audit-list strong { grid-column: 1 / -1; grid-row: 1; }
|
||||
.user-table-head { display: none; }
|
||||
.user-table-row { grid-template-columns: minmax(0,1fr) auto auto; gap: 7px; }
|
||||
.user-table-row > :nth-child(4) { display: none; }
|
||||
.archive-row { align-items: flex-start; flex-direction: column; }
|
||||
.archive-row .button { width: 100%; }
|
||||
.info-box { grid-template-columns: 1fr; }
|
||||
.access-zone-head, .access-zone-controls { align-items: flex-start; flex-direction: column; }
|
||||
.access-zone-controls { width: 100%; }
|
||||
.access-target { grid-template-columns: 1fr; }
|
||||
.error-summary-grid { grid-template-columns: 1fr; }
|
||||
.error-item { grid-template-columns: 26px minmax(0,1fr); }
|
||||
.error-item > a { grid-column: 2; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
.toast, .toast-progress { animation: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{{define "dashboard"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<span class="page-kicker">Systemstatus</span>
|
||||
<h1>Übersicht</h1>
|
||||
<p>Status der verbundenen DNS-Zonen und des automatischen Update-Dienstes.</p>
|
||||
</div>
|
||||
{{if .IsAdmin}}
|
||||
<div class="heading-actions">
|
||||
<form action="/run" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button button-primary" type="submit" {{if not .Dashboard.Settings.UpdaterEnabled}}disabled title="Updater ist deaktiviert"{{end}}>Update jetzt ausführen</button>
|
||||
</form>
|
||||
{{if not .Dashboard.Settings.UpdaterEnabled}}<a class="button button-quiet" href="/settings">Dienst aktivieren</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</header>
|
||||
|
||||
<section class="metric-grid" aria-label="DynDNS-Status">
|
||||
<article class="metric-card"><span class="metric-label">Aktive Ziele</span><strong>{{.Dashboard.ActiveTargets}}</strong><small>{{len .Dashboard.Zones}} sichtbare DNS-Zone(n)</small></article>
|
||||
<article class="metric-card"><span class="metric-label">Öffentliche IPv4</span><strong class="mono address-value">{{if .Dashboard.Settings.LastIPv4}}{{.Dashboard.Settings.LastIPv4}}{{else}}Nicht ermittelt{{end}}</strong><small>Quelle: externer IP-Resolver</small></article>
|
||||
<article class="metric-card {{if not .Dashboard.Settings.IPv6Enabled}}metric-card-disabled{{end}}"><span class="metric-label">Öffentliche IPv6</span><strong class="mono address-value compact">{{if not .Dashboard.Settings.IPv6Enabled}}Deaktiviert{{else if .Dashboard.Settings.LastIPv6}}{{.Dashboard.Settings.LastIPv6}}{{else}}Nicht ermittelt{{end}}</strong><small>{{if .Dashboard.Settings.IPv6Enabled}}Quelle: externer IP-Resolver{{else}}AAAA-Updates pausiert{{end}}</small></article>
|
||||
<article class="metric-card countdown-card {{if not .Dashboard.Settings.UpdaterEnabled}}metric-card-disabled{{end}}"><span class="metric-label">Nächste Aktualisierung</span><strong class="mono countdown" data-countdown="{{unixSeconds .Dashboard.NextRunAt}}" data-running="{{.Dashboard.SchedulerRunning}}" data-enabled="{{.Dashboard.Settings.UpdaterEnabled}}">{{if .Dashboard.Settings.UpdaterEnabled}}--:--{{else}}Inaktiv{{end}}</strong><small>{{if .Dashboard.Settings.UpdaterEnabled}}Intervall: {{.Dashboard.Settings.UpdateIntervalSeconds}} Sekunden{{else}}Dienst in den Einstellungen deaktiviert{{end}}</small></article>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
{{if .IsAdmin}}
|
||||
<article class="panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Scheduler</span><h2>Letzte Update-Läufe</h2></div><div class="panel-links"><a href="/errors" class="{{if .Dashboard.ErrorCount}}text-danger{{end}}">Alle Fehler{{if .Dashboard.ErrorCount}} · {{.Dashboard.ErrorCount}}{{end}}</a><a href="/settings">Konfiguration</a></div></div>
|
||||
{{if .Dashboard.RecentRuns}}
|
||||
<div class="data-list">
|
||||
{{range .Dashboard.RecentRuns}}
|
||||
<div class="data-row"><span class="state-dot {{statusClass .Status}}"></span><span><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}}</small></span><span class="status-pill {{statusClass .Status}}">{{.Status}}</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty-state">Noch kein Update-Lauf protokolliert.</div>{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
<article class="panel">
|
||||
<div class="panel-heading"><div><span class="section-label">DNS-Zonen</span><h2>Verbundene Domains</h2></div><a href="/domains">Verwalten</a></div>
|
||||
{{if .Dashboard.Zones}}
|
||||
<div class="domain-list">
|
||||
{{range .Dashboard.Zones}}
|
||||
<a href="/domains/{{.ID}}"><span class="zone-glyph">DNS</span><span><strong>{{.Name}}</strong><small>Synchronisiert: {{formatTime .LastSyncAt}}</small></span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Fehler{{else}}Bereit{{end}}</span></a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty-state">{{if .IsAdmin}}Keine Domain verbunden. <a href="/domains/new">Domain hinzufügen</a>{{else}}Keine Domain zugewiesen.{{end}}</div>{{end}}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{{if and .IsAdmin .Dashboard.RecentEvents}}
|
||||
<section class="panel audit-panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Audit</span><h2>Systemereignisse</h2></div></div>
|
||||
<ul class="audit-list">{{range .Dashboard.RecentEvents}}<li><time>{{formatTime .CreatedAt}}</time><strong>{{.Message}}</strong><code>{{.Kind}}</code></li>{{end}}</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,128 @@
|
||||
{{define "domain_detail"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div><a class="back-link" href="/domains">← Domains</a><span class="page-kicker">DNS-Zone</span><h1>{{.Zone.Name}}</h1><p>{{if .IsAdmin}}DynDNS-Ziele und statische CNAME-Aliase dieser Zone.{{else}}Dir zugewiesene DynDNS-Ziele dieser Zone.{{end}}</p></div>
|
||||
{{if .IsAdmin}}<form action="/domains/{{.Zone.ID}}/import" method="post"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button button-quiet" type="submit">Records neu einlesen</button></form>{{end}}
|
||||
</header>
|
||||
|
||||
<section class="zone-summary" aria-label="Zonenstatus">
|
||||
<div><span>Provider</span><strong>Hetzner Cloud DNS</strong></div>
|
||||
<div><span>Letzter Import</span><strong>{{formatTime .Zone.LastSyncAt}}</strong></div>
|
||||
<div><span>Verknüpfte Ziele</span><strong>{{len .Targets}}</strong></div>
|
||||
{{if .IsAdmin}}<div><span>Getrennte Ziele</span><strong>{{len .ArchivedTargets}}</strong></div>{{end}}
|
||||
</section>
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<section class="bulk-ttl-panel" aria-label="TTL für verwaltete Records setzen">
|
||||
<form action="/domains/{{.Zone.ID}}/ttl" method="post" class="bulk-ttl-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><div><span class="section-label">Sammelaktion</span><strong>TTL für alle verwalteten Records</strong><small>A-/AAAA-Ziele und CNAMEs dieser Oberfläche; andere Hetzner-Records bleiben unverändert.</small></div><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label><button class="button button-quiet" type="submit">TTL für alle setzen</button></form>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="targets-section">
|
||||
<div class="section-heading"><div><span class="section-label">Verknüpfte Records</span><h2>Subdomains und Root-Record</h2></div><span class="record-count">{{len .Targets}} Einträge</span></div>
|
||||
{{if .Targets}}
|
||||
<div class="target-list">
|
||||
{{range .Targets}}
|
||||
{{$target := .}}
|
||||
{{$canManage := or $.IsAdmin (index $.ManageTargetIDs .ID)}}
|
||||
<article class="target-row {{if .Active}}is-active{{end}}">
|
||||
<div class="target-main"><span class="target-status {{if .Active}}on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.FQDN}}</h3><p><span class="family-chip">{{families .}}</span><span>{{if .Description}}{{.Description}}{{else}}Keine Beschreibung{{end}}</span></p></div></div>
|
||||
<div class="target-meta"><time>{{formatTime .LastUpdatedAt}}</time><span class="status-pill {{statusClass .LastStatus}}">{{if .LastStatus}}{{.LastStatus}}{{else}}Nicht ausgeführt{{end}}</span></div>
|
||||
{{if $canManage}}<details class="target-edit"><summary>Einstellungen <span>⌄</span></summary><div class="edit-inner">
|
||||
<form action="/targets/{{.ID}}/save" method="post" class="target-form" data-target-form data-ipv6-enabled="{{$.Settings.IPv6Enabled}}" data-update-ipv4="{{.UpdateIPv4}}" data-update-ipv6="{{.UpdateIPv6}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active {{if .Active}}checked{{end}}><span></span> DynDNS aktiv</label>{{if $.IsAdmin}}<label class="switch"><input type="checkbox" name="update_ipv4" {{if .UpdateIPv4}}checked{{end}}><span></span> IPv4 / A</label><label class="switch {{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if and .UpdateIPv6 $.Settings.IPv6Enabled}}checked{{end}} {{if not $.Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6 / AAAA{{if not $.Settings.IPv6Enabled}} pausiert{{end}}</label>{{end}}</div>
|
||||
<div class="form-grid" data-static-ip-fields {{if .Active}}hidden{{end}} aria-hidden="{{if .Active}}true{{else}}false{{end}}"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 value="{{.StaticIPv4}}" inputmode="decimal" placeholder="203.0.113.10" {{if .Active}}disabled{{else if .UpdateIPv4}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 value="{{.StaticIPv6}}" inputmode="text" placeholder="2001:db8::10" {{if or .Active (not $.Settings.IPv6Enabled)}}disabled{{else if .UpdateIPv6}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div>
|
||||
<p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p>
|
||||
<div class="form-grid"><label>Beschreibung <input name="description" value="{{.Description}}" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{if not (supportedTTL $target.TTL)}}<option value="{{$target.TTL}}" selected>{{$target.TTL}} Sekunden (bestehend)</option>{{end}}{{range ttlOptions}}<option value="{{.}}" {{if eq . $target.TTL}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
{{if $.IsAdmin}}
|
||||
<div class="danger-actions"><form action="/targets/{{.ID}}/archive" method="post" class="unlink-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<strong>Verknüpfung trennen</strong>
|
||||
<p>Entfernt nur die lokale DynDNS-Verknüpfung. Die DNS-Records bleiben erhalten.</p>
|
||||
<button class="button button-danger-soft" type="submit">Verknüpfung trennen</button>
|
||||
</form><form action="/targets/{{.ID}}/delete" method="post" class="unlink-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<strong>Ziel vollständig löschen</strong>
|
||||
<p>Löscht das Ziel und alle zugehörigen A-/AAAA-Records bei Hetzner.</p>
|
||||
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
|
||||
</form></div>
|
||||
{{end}}
|
||||
</div></details>{{end}}
|
||||
{{if .LastError}}<p class="row-error">{{.LastError}}</p>{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty-state">Keine verknüpften A-/AAAA-Records vorhanden. {{if or .IsAdmin .CanCreateTargets}}Lege unten eine neue Subdomain an.{{end}}</div>{{end}}
|
||||
</section>
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<section class="targets-section">
|
||||
<div class="section-heading"><div><span class="section-label">Statische Records</span><h2>CNAME-Aliase</h2></div><span class="record-count">{{len .CNAMERecords}} Einträge</span></div>
|
||||
{{if .CNAMERecords}}
|
||||
<div class="target-list">
|
||||
{{range .CNAMERecords}}
|
||||
{{$cname := .}}
|
||||
<article class="target-row {{if .Active}}is-active{{end}}">
|
||||
<div class="target-main"><span class="target-status {{if .Active}}on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.FQDN}}</h3><p><span class="family-chip">CNAME</span><span>→ {{.Destination}}</span>{{if .Description}}<span>· {{.Description}}</span>{{end}}</p></div></div>
|
||||
<div class="target-meta"><span class="status-pill {{if .Active}}status-good{{end}}">{{if .Active}}Aktiv{{else}}Inaktiv{{end}}</span></div>
|
||||
<details class="target-edit"><summary>Bearbeiten <span>⌄</span></summary><div class="edit-inner">
|
||||
<form action="/cnames/{{.ID}}/save" method="post" class="target-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<div class="toggle-row"><label class="switch"><input type="checkbox" name="active" {{if .Active}}checked{{end}}><span></span> CNAME aktiv</label></div>
|
||||
<div class="form-grid cname-edit-grid"><label>CNAME-Ziel <input name="destination" value="{{.Destination}}" maxlength="253" required></label><label>TTL <select name="ttl" required>{{if not (supportedTTL $cname.TTL)}}<option value="{{$cname.TTL}}" selected>{{$cname.TTL}} Sekunden (bestehend)</option>{{end}}{{range ttlOptions}}<option value="{{.}}" {{if eq . $cname.TTL}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><small class="field-note">Vollständiger Domainname; der abschließende Punkt wird ergänzt.</small>
|
||||
<label>Beschreibung <input name="description" value="{{.Description}}" maxlength="512" placeholder="Interne Beschreibung"></label>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
<div class="danger-actions"><form action="/cnames/{{.ID}}/remove" method="post" class="unlink-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<strong>Aus WebUI entfernen</strong>
|
||||
<p>Entfernt nur die lokale Verknüpfung. Der CNAME-Record bei Hetzner bleibt unverändert.</p>
|
||||
<button class="button button-danger-soft" type="submit">Aus WebUI entfernen</button>
|
||||
</form><form action="/cnames/{{.ID}}/delete" method="post" class="unlink-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<strong>Ziel vollständig löschen</strong>
|
||||
<p>Löscht das Ziel aus der WebUI und den zugehörigen CNAME-Record bei Hetzner.</p>
|
||||
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
|
||||
</form></div>
|
||||
</div></details>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty-state">Keine CNAME-Aliase vorhanden. Lege unten einen neuen Alias an.</div>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if and .IsAdmin .ArchivedTargets}}
|
||||
<section class="panel archive-panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Archiv</span><h2>Getrennte Ziele</h2></div><span class="record-count">{{len .ArchivedTargets}} Einträge</span></div>
|
||||
<p class="panel-description">Diese Einträge sind nicht mehr mit dem Updater verknüpft. Die zugehörigen Hetzner-DNS-Records wurden beim Trennen nicht gelöscht.</p>
|
||||
<div class="archive-list">
|
||||
{{range .ArchivedTargets}}
|
||||
<div class="archive-row"><span><strong>{{.FQDN}}</strong><small>{{families .}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/targets/{{.ID}}/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if or .IsAdmin .CanCreateTargets}}
|
||||
<section class="action-grid admin-actions-grid">
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Record anlegen</span><h2>Neue Subdomain</h2></div></div>
|
||||
<form action="/targets" method="post" class="stacked-form" data-target-form data-ipv6-enabled="{{.Settings.IPv6Enabled}}" data-update-ipv4="true" data-update-ipv6="false"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><label>{{if .IsAdmin}}Name oder Root-Record{{else}}Subdomain{{end}} <input name="record_name" required placeholder="{{if .IsAdmin}}home oder @{{else}}home{{end}}"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active checked><span></span> DynDNS aktivieren</label><label class="switch"><input type="checkbox" name="update_ipv4" checked><span></span> IPv4</label><label class="switch {{if not .Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if not .Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6{{if not .Settings.IPv6Enabled}} pausiert{{end}}</label></div><div class="form-grid" data-static-ip-fields hidden aria-hidden="true"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 inputmode="decimal" placeholder="203.0.113.10" disabled><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not .Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 inputmode="text" placeholder="2001:db8::10" disabled><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div><p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><button class="button button-primary" type="submit">Record anlegen</button></form>
|
||||
</article>
|
||||
{{if .IsAdmin}}
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Statischer Record</span><h2>Neuen CNAME anlegen</h2></div></div>
|
||||
<form action="/cnames" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><div class="form-grid"><label>Alias <input name="record_name" required placeholder="www"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><label>CNAME-Ziel <input name="destination" required placeholder="app.example.net."><small>Vollständiger Domainname; ein abschließender Punkt ist optional.</small></label></div><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" checked><span></span> CNAME aktivieren</label></div><button class="button button-primary" type="submit">CNAME anlegen</button></form>
|
||||
</article>
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Zonenzugang</span><h2>Verbindung verwalten</h2></div></div>
|
||||
<p class="panel-description">Der API-Token bleibt verschlüsselt. Eine Anzeige erfordert erneut das Admin-Passwort.</p>
|
||||
<details><summary class="button button-quiet">API-Token anzeigen</summary><form action="/domains/{{.Zone.ID}}/token" method="post" class="stacked-form inset-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Admin-Passwort <input type="password" name="password" autocomplete="current-password" required></label><button class="button button-danger-soft" type="submit">Token entschlüsseln</button></form></details>
|
||||
<div class="panel-divider"></div>
|
||||
<details><summary class="text-danger">Domain-Verbindung entfernen</summary><div class="danger-actions inset-form"><form action="/domains/{{.Zone.ID}}/remove" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><strong>Nur aus der WebUI entfernen</strong><p>Löscht ausschließlich die lokale Konfiguration. Alle DNS-Records bei Hetzner bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Lokale Verbindung entfernen</button></form><form action="/domains/{{.Zone.ID}}/delete" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><strong>Domain vollständig löschen</strong><p>Löscht alle hier verwalteten A-, AAAA- und CNAME-Einträge aus der WebUI sowie die zugehörigen Records bei Hetzner. Andere Record-Typen bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Domain und Records löschen</button></form></div></details>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{define "domain_new"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><a class="back-link" href="/domains">← Domains</a><span class="page-kicker">Hetzner-Verbindung</span><h1>Domain hinzufügen</h1><p>Nach erfolgreicher Anmeldung werden alle vorhandenen A- und AAAA-Records der Zone automatisch importiert und als inaktive DynDNS-Ziele angezeigt.</p></div></header>
|
||||
<section class="form-panel compact-panel">
|
||||
<form action="/domains" method="post" class="stacked-form wide-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid"><label>Interne Bezeichnung <input name="label" type="text" value="{{index .Form "label"}}" placeholder="Privates DNS-Projekt"></label><label>DNS-Zone <input name="zone" type="text" value="{{index .Form "zone"}}" placeholder="example.de" required autocomplete="off"><small>Muss im Hetzner-Projekt des Tokens vorhanden sein.</small></label></div>
|
||||
<label>Hetzner API-Token <input name="token" type="password" autocomplete="off" spellcheck="false" required><small>Wird validiert und anschließend verschlüsselt gespeichert.</small></label>
|
||||
<div class="info-box"><strong>Automatischer Import</strong><span>Nach dem Verbinden liest die Anwendung alle A-/AAAA-RRsets ein. Kein importierter Record wird automatisch für DynDNS aktiviert.</span></div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Verbindung prüfen und importieren</button><a class="button button-quiet" href="/domains">Abbrechen</a></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{define "domains"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div><span class="page-kicker">DNS-Verwaltung</span><h1>Domains</h1><p>{{if .IsAdmin}}Hetzner-Zonen, Zugangszustand und DynDNS-Ziele verwalten.{{else}}Freigegebene Domains und Subdomains verwalten.{{end}}</p></div>
|
||||
{{if .IsAdmin}}<a class="button button-primary" href="/domains/new">Domain hinzufügen</a>{{end}}
|
||||
</header>
|
||||
<section class="domain-board">
|
||||
{{if .Zones}}
|
||||
{{range .Zones}}
|
||||
<article class="zone-card">
|
||||
<div class="zone-card-top"><span class="zone-glyph">DNS</span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Verbindungsfehler{{else}}Verbunden{{end}}</span></div>
|
||||
<h2>{{.Name}}</h2>
|
||||
<dl><div><dt>Letzter Import</dt><dd>{{formatTime .LastSyncAt}}</dd></div><div><dt>Provider</dt><dd>Hetzner Cloud DNS</dd></div></dl>
|
||||
{{if .LastError}}<p class="inline-error">{{.LastError}}</p>{{end}}
|
||||
<a href="/domains/{{.ID}}" class="card-link">Ziele öffnen <span>→</span></a>
|
||||
</article>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-board"><span class="empty-icon">DNS</span><h2>{{if .IsAdmin}}Keine Domain verbunden{{else}}Keine Berechtigung zugewiesen{{end}}</h2><p>{{if .IsAdmin}}Füge eine Hetzner-Zone hinzu. A- und AAAA-Records werden beim Verbinden automatisch importiert.{{else}}Ein Admin kann dir eine vollständige Domain oder einzelne Subdomains freigeben.{{end}}</p>{{if .IsAdmin}}<a class="button button-primary" href="/domains/new">Domain hinzufügen</a>{{end}}</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,53 @@
|
||||
{{define "errors"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div><a class="back-link" href="/">← Übersicht</a><span class="page-kicker">Diagnose</span><h1>Fehlerzentrale</h1><p>Aktuelle Ziel- und Zonenfehler sowie alle gespeicherten fehlgeschlagenen oder partiellen Update-Läufe.</p></div>
|
||||
<form action="/errors/clear" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
{{if eq .ErrorCount 0}}
|
||||
<section class="panel error-empty"><span class="error-empty-icon" aria-hidden="true">✓</span><div><h2>Keine Fehler gespeichert</h2><p>Aktuell sind weder Ziel- oder Zonenfehler noch problematische Update-Läufe vorhanden.</p></div></section>
|
||||
{{else}}
|
||||
<section class="error-summary-grid" aria-label="Fehlerübersicht">
|
||||
<article><span>Ziele</span><strong>{{len .ErrorTargets}}</strong></article>
|
||||
<article><span>Zonen</span><strong>{{len .ErrorZones}}</strong></article>
|
||||
<article><span>Update-Läufe</span><strong>{{len .ErrorRuns}}</strong></article>
|
||||
</section>
|
||||
|
||||
{{if .ErrorTargets}}
|
||||
<section class="panel error-section">
|
||||
<div class="panel-heading"><div><span class="section-label">Aktuell</span><h2>Zielprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorTargets}}</span></div>
|
||||
<div class="error-list">
|
||||
{{range .ErrorTargets}}
|
||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.FQDN}}</strong><p>{{.LastError}}</p><small>{{if .LastStatus}}{{.LastStatus}}{{else}}Fehler{{end}} · letzter erfolgreicher Abgleich {{formatTime .LastUpdatedAt}}</small></div><a href="/domains/{{.ZoneID}}">Öffnen →</a></article>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .ErrorZones}}
|
||||
<section class="panel error-section">
|
||||
<div class="panel-heading"><div><span class="section-label">Provider</span><h2>Zonenprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorZones}}</span></div>
|
||||
<div class="error-list">
|
||||
{{range .ErrorZones}}
|
||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>Letzter Synchronisationsversuch {{formatTime .LastSyncAt}}</small></div><a href="/domains/{{.ID}}">Öffnen →</a></article>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .ErrorRuns}}
|
||||
<section class="panel error-section">
|
||||
<div class="panel-heading"><div><span class="section-label">Historie</span><h2>Problematische Update-Läufe</h2></div><span class="status-pill status-bad">{{len .ErrorRuns}}</span></div>
|
||||
<div class="error-list run-error-list">
|
||||
{{range .ErrorRuns}}
|
||||
<article class="error-item"><span class="state-dot {{statusClass .Status}}"></span><div><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}} · {{.Status}}{{if .IPv4}} · IPv4 {{.IPv4}}{{end}}{{if .IPv6}} · IPv6 {{.IPv6}}{{end}}</small></div></article>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,71 @@
|
||||
{{define "layout"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>{{.Title}} · Hetzner DynDNS</title>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=features-5">
|
||||
<script src="/static/app.js?v=features-5" defer></script>
|
||||
</head>
|
||||
<body class="page-{{.Page}}">
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
{{if and (ne .Page "setup") (ne .Page "login")}}
|
||||
<div class="admin-layout">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/" aria-label="Hetzner DynDNS Startseite">
|
||||
<span class="brand-mark">DD</span>
|
||||
<span><strong>DynDNS Console</strong><small>Hetzner DNS</small></span>
|
||||
</a>
|
||||
<div class="instance-state {{if not .UpdaterEnabled}}is-inactive{{end}}"><span></span> Dienst {{if .UpdaterEnabled}}aktiv{{else}}inaktiv{{end}}</div>
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<a href="/" {{if eq .Page "dashboard"}}aria-current="page"{{end}}><span class="nav-icon">01</span>Übersicht</a>
|
||||
<a href="/domains" {{if eq .Page "domains"}}aria-current="page"{{end}}><span class="nav-icon">02</span>Domains</a>
|
||||
{{if .IsAdmin}}
|
||||
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Updater</a>
|
||||
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Fehler</a>
|
||||
<a href="/users" {{if eq .Page "users"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Benutzer</a>
|
||||
{{end}}
|
||||
<a href="/security" {{if eq .Page "security"}}aria-current="page"{{end}}><span class="nav-icon">{{if .IsAdmin}}06{{else}}03{{end}}</span>Sicherheit</a>
|
||||
</nav>
|
||||
<div class="sidebar-account">
|
||||
<span class="account-avatar">{{initial .User.DisplayName}}</span>
|
||||
<span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}} · {{if .IsAdmin}}Admin{{else}}Mitglied{{end}}</small></span>
|
||||
<form action="/logout" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button type="submit" title="Abmelden" aria-label="Abmelden">↪</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
<header class="workspace-bar">
|
||||
<div><span>DynDNS</span><strong>/ {{.Title}}</strong></div>
|
||||
{{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}}
|
||||
</header>
|
||||
<main id="main" class="app-shell">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<main id="main" class="auth-shell">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
{{end}}
|
||||
{{if .Flash}}
|
||||
<div class="toast-region">
|
||||
<div class="toast toast-{{.Flash.Kind}}" data-toast role="{{if eq .Flash.Kind "error"}}alert{{else}}status{{end}}" aria-atomic="true">
|
||||
<span class="toast-icon" aria-hidden="true">{{if eq .Flash.Kind "error"}}!{{else if eq .Flash.Kind "info"}}i{{else}}✓{{end}}</span>
|
||||
<div class="toast-copy">
|
||||
<span class="toast-eyebrow">{{if eq .Flash.Kind "error"}}Bitte prüfen{{else if eq .Flash.Kind "info"}}Hinweis{{else}}Erledigt{{end}}</span>
|
||||
<p>{{.Flash.Message}}</p>
|
||||
</div>
|
||||
<button class="toast-close" type="button" data-toast-close aria-label="Meldung schließen">×</button>
|
||||
<span class="toast-progress" aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,18 @@
|
||||
{{define "login"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
|
||||
<h1>Anmelden</h1>
|
||||
<form action="/login" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Benutzername
|
||||
<input name="username" type="text" autocomplete="username" required autofocus>
|
||||
</label>
|
||||
<label>Passwort
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button class="button button-primary" type="submit">Anmelden <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
<div class="login-meta">Sitzungen laufen nach acht Stunden automatisch ab.</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "security"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><span class="page-kicker">Konto und Laufzeit</span><h1>Sicherheit</h1><p>Eigenes Passwort ändern und aktive Sicherheitsmechanismen der Instanz prüfen.</p></div></header>
|
||||
<section class="split-grid">
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort ändern</h2></div></div><form action="/security/password" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort <input type="password" name="current_password" autocomplete="current-password" required></label><label>Neues Passwort <input type="password" name="new_password" autocomplete="new-password" minlength="12" required></label><label>Neues Passwort wiederholen <input type="password" name="confirm_password" autocomplete="new-password" minlength="12" required></label><button class="button button-primary" type="submit">Passwort aktualisieren</button></form></article>
|
||||
<article class="panel security-facts"><div class="panel-heading"><div><span class="section-label">Instanzschutz</span><h2>Aktive Maßnahmen</h2></div></div><ul><li><strong>Argon2id</strong><span>Passwörter werden ausschließlich als gehärtete Hashes gespeichert.</span></li><li><strong>XChaCha20-Poly1305</strong><span>Hetzner-Tokens sind authentifiziert verschlüsselt.</span></li><li><strong>Rollen und Grants</strong><span>Mitglieder sehen nur explizit freigegebene Domains oder Hosts.</span></li><li><strong>CSRF und Sessions</strong><span>Schreibzugriffe sind tokengebunden; Sitzungen laufen automatisch ab.</span></li></ul>{{if eq .Mode "lan"}}<div class="warning-box">HTTP-LAN-Modus aktiv. Nur in einem isolierten, vertrauenswürdigen Netzwerk verwenden.</div>{{end}}</article>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "settings"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div><span class="page-kicker">Scheduler</span><h1>Updater-Konfiguration</h1><p>Dienststatus, Intervall und IP-Familien zentral steuern.</p></div>
|
||||
</header>
|
||||
|
||||
<form action="/settings" method="post" class="settings-form" data-settings-form>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<section class="settings-mode-grid" aria-label="Updater-Schalter">
|
||||
<label class="setting-switch-card {{if .Settings.UpdaterEnabled}}is-enabled{{end}}">
|
||||
<span class="setting-switch-copy"><span class="section-label">Automatik</span><strong>DynDNS-Dienst</strong><small>Stoppt geplante und automatisch ausgelöste Aktualisierungen vollständig.</small></span>
|
||||
<span class="switch setting-switch"><input type="checkbox" name="updater_enabled" data-updater-toggle {{if .Settings.UpdaterEnabled}}checked{{end}}><span></span><b>{{if .Settings.UpdaterEnabled}}Aktiv{{else}}Inaktiv{{end}}</b></span>
|
||||
</label>
|
||||
<label class="setting-switch-card {{if .Settings.IPv6Enabled}}is-enabled{{end}}">
|
||||
<span class="setting-switch-copy"><span class="section-label">IP-Familie</span><strong>IPv6-Aktualisierung</strong><small>Deaktiviert IPv6-Ermittlung und AAAA-Updates, ohne Zielkonfigurationen zu löschen.</small></span>
|
||||
<span class="switch setting-switch"><input type="checkbox" name="ipv6_enabled" data-ipv6-toggle {{if .Settings.IPv6Enabled}}checked{{end}}><span></span><b>{{if .Settings.IPv6Enabled}}Aktiv{{else}}Inaktiv{{end}}</b></span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="form-panel compact-panel settings-fields">
|
||||
<div class="stacked-form wide-form">
|
||||
<div class="form-grid">
|
||||
<label>Abfrageintervall in Sekunden
|
||||
<input type="number" name="interval" min="60" max="86400" value="{{.Settings.UpdateIntervalSeconds}}" required>
|
||||
<small>Zulässig: 60 bis 86.400 Sekunden. Jeder manuelle oder automatisch ausgelöste Lauf startet dieses Intervall neu.</small>
|
||||
</label>
|
||||
<div class="technical-summary"><span>Letzter Lauf</span><strong>{{formatTime .Settings.LastRunAt}}</strong><span>Status</span><strong class="{{statusClass .Settings.LastRunStatus}}">{{if .Settings.LastRunStatus}}{{.Settings.LastRunStatus}}{{else}}Nicht ausgeführt{{end}}</strong></div>
|
||||
</div>
|
||||
<div class="form-grid endpoint-grid">
|
||||
<label>IPv4-Endpunkte
|
||||
<textarea name="ipv4_endpoints" rows="4" spellcheck="false" required>{{formatEndpoints .Settings.IPv4Endpoints}}</textarea>
|
||||
<small>Eine HTTPS-URL pro Zeile; Reihenfolge entspricht der Fallback-Reihenfolge.</small>
|
||||
</label>
|
||||
<label class="endpoint-setting {{if not .Settings.IPv6Enabled}}is-disabled{{end}}" data-ipv6-settings aria-disabled="{{if .Settings.IPv6Enabled}}false{{else}}true{{end}}">IPv6-Endpunkte
|
||||
<textarea name="ipv6_endpoints" rows="4" spellcheck="false" {{if .Settings.IPv6Enabled}}required{{end}}>{{formatEndpoints .Settings.IPv6Endpoints}}</textarea>
|
||||
<small>Werden nur verwendet, wenn IPv6 oben aktiviert ist.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Konfiguration speichern</button><a class="button button-quiet" href="/">Zur Übersicht</a></div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{define "setup"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
|
||||
<h1>System einrichten</h1>
|
||||
<p class="lede">Erstelle den ersten Administrator. Weitere Benutzer und Zugriffsrechte können anschließend in der Verwaltung angelegt werden.</p>
|
||||
<form action="/setup" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid"><label>Admin-Benutzername
|
||||
<input name="username" type="text" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required autofocus>
|
||||
</label>
|
||||
<label>Anzeigename
|
||||
<input name="display_name" type="text" value="{{index .Form "display_name"}}" maxlength="80" required>
|
||||
</label></div>
|
||||
<label>Admin-Passwort
|
||||
<input name="password" type="password" autocomplete="new-password" minlength="12" required>
|
||||
<small>Mindestens 12 Zeichen; dieses Passwort kann in V1 nicht über die Oberfläche zurückgesetzt werden.</small>
|
||||
</label>
|
||||
<label>Passwort wiederholen
|
||||
<input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required>
|
||||
</label>
|
||||
<button class="button button-primary" type="submit">Sichere Einrichtung abschließen <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
<div class="security-strip"><span>✓</span> Argon2id-Passworthash · verschlüsselte API-Tokens · lokale Datenhaltung</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{define "token"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="token-card"><span class="page-kicker">Sensible Zugangsdaten</span><h1>API-Token</h1><p>Zone: <strong>{{.Zone.Name}}</strong>. Der Wert wird nicht in URL, Audit-Log oder Browser-Cache gespeichert.</p><div class="token-value"><code id="api-token">{{.Token}}</code><button class="button button-primary" type="button" data-copy-target="api-token">Kopieren</button></div><p class="warning-box">Nach dem Kopieren diese Seite schließen. Eine erneute Anzeige erfordert wieder das Admin-Passwort.</p><a class="button button-quiet" href="/domains/{{.Zone.ID}}">Zur Domain zurückkehren</a></section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,73 @@
|
||||
{{define "user_detail"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<a class="back-link" href="/users">← Benutzer</a>
|
||||
<span class="page-kicker">Benutzerprofil</span>
|
||||
<h1>{{.ManagedUser.DisplayName}}</h1>
|
||||
<p>@{{.ManagedUser.Username}} · {{if eq .ManagedUser.Role "admin"}}Admin mit vollständigem Zugriff{{else}}Mitglied mit gezielten Freigaben{{end}}</p>
|
||||
</div>
|
||||
<span class="status-pill {{if .ManagedUser.Active}}status-good{{else}}status-bad{{end}}">{{if .ManagedUser.Active}}aktiv{{else}}deaktiviert{{end}}</span>
|
||||
</header>
|
||||
|
||||
<section class="split-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Konto</span><h2>Rolle und Status</h2></div></div>
|
||||
<form action="/users/{{.ManagedUser.ID}}/state" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Anzeigename <input name="display_name" value="{{.ManagedUser.DisplayName}}" maxlength="80" required></label>
|
||||
<label>Rolle <select name="role"><option value="member" {{if eq .ManagedUser.Role "member"}}selected{{end}}>Mitglied</option><option value="admin" {{if eq .ManagedUser.Role "admin"}}selected{{end}}>Admin</option></select></label>
|
||||
<label class="switch"><input type="checkbox" name="active" {{if .ManagedUser.Active}}checked{{end}}><span></span> Konto aktiv</label>
|
||||
<button class="button button-primary" type="submit">Kontostatus speichern</button>
|
||||
</form>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort zurücksetzen</h2></div></div>
|
||||
<form action="/users/{{.ManagedUser.ID}}/password" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Neues Passwort <input name="password" type="password" minlength="12" autocomplete="new-password" required></label>
|
||||
<label>Passwort wiederholen <input name="confirm_password" type="password" minlength="12" autocomplete="new-password" required></label>
|
||||
<button class="button button-danger" type="submit">Passwort zurücksetzen</button>
|
||||
<small>Alle Sitzungen dieses Benutzers werden dabei beendet.</small>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{{if eq .ManagedUser.Role "member"}}
|
||||
<section class="panel access-panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Berechtigungen</span><h2>Domain- und Subdomain-Zugriffe</h2></div></div>
|
||||
<p class="access-intro">Lege getrennt fest, was sichtbar ist, was bearbeitet werden darf und in welchen Domains das Mitglied selbst neue Subdomains anlegen kann.</p>
|
||||
<form action="/users/{{.ManagedUser.ID}}/access" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="access-list">
|
||||
{{range .AccessZones}}
|
||||
{{$accessZone := .}}
|
||||
<article class="access-zone" data-access-zone>
|
||||
<div class="access-zone-head">
|
||||
<div><h3>{{$accessZone.Zone.Name}}</h3><small>{{len $accessZone.Targets}} bekannte DynDNS-Ziele</small></div>
|
||||
<div class="access-zone-controls">
|
||||
<label class="switch"><input type="checkbox" name="zone_{{$accessZone.Zone.ID}}" data-zone-access {{if $accessZone.DomainGranted}}checked{{end}}><span></span> Ganze Domain sehen</label>
|
||||
<label class="switch"><input type="checkbox" name="manage_zone_{{$accessZone.Zone.ID}}" data-zone-manage {{if $accessZone.DomainManaged}}checked{{end}}><span></span> Alle Ziele bearbeiten</label>
|
||||
<label class="switch"><input type="checkbox" name="create_zone_{{$accessZone.Zone.ID}}" data-zone-create {{if $accessZone.CanCreateTargets}}checked{{end}}><span></span> Subdomains anlegen</label>
|
||||
</div>
|
||||
</div>
|
||||
{{if $accessZone.Targets}}
|
||||
<div class="access-targets">
|
||||
{{range $accessZone.Targets}}
|
||||
<div class="access-target" data-access-target>
|
||||
<label class="access-target-main"><input type="checkbox" name="target_{{.ID}}" data-target-access {{if index $accessZone.GrantedTargetIDs .ID}}checked{{end}}><span><strong>{{.FQDN}}</strong><small>{{families .}} · {{if .Active}}aktiv{{else}}inaktiv{{end}}</small></span></label>
|
||||
<label class="switch access-manage-switch"><input type="checkbox" name="manage_target_{{.ID}}" data-target-manage {{if index $accessZone.ManagedTargetIDs .ID}}checked{{end}}><span></span> Bearbeiten</label>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<p class="muted-cell">Keine A-/AAAA-Records importiert.</p>{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Zugriffsrechte speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="panel access-panel"><div class="warning-box">Dieser Benutzer ist Admin und hat deshalb automatisch vollen Zugriff auf alle Domains, Hosts, Tokens und Einstellungen.</div></section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{define "user_new"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><a class="back-link" href="/users">← Benutzer</a><span class="page-kicker">Neues Konto</span><h1>Benutzer anlegen</h1><p>Mitgliedskonten erhalten zunächst keine Domain- oder Host-Berechtigungen.</p></div></header>
|
||||
<section class="form-panel compact-panel">
|
||||
<form action="/users" method="post" class="stacked-form wide-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid"><label>Benutzername <input name="username" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required><small>Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich.</small></label><label>Anzeigename <input name="display_name" value="{{index .Form "display_name"}}" maxlength="80" required></label></div>
|
||||
<div class="form-grid"><label>Startpasswort <input name="password" type="password" autocomplete="new-password" minlength="12" required></label><label>Passwort wiederholen <input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required></label></div>
|
||||
<label>Rolle <select name="role"><option value="member" {{if eq (index .Form "role") "member"}}selected{{end}}>Mitglied – nur explizit freigegebene Domains/Hosts</option><option value="admin" {{if eq (index .Form "role") "admin"}}selected{{end}}>Admin – vollständige Verwaltung</option></select><small>Admins dürfen Tokens sehen, Einstellungen ändern und weitere Benutzer verwalten.</small></label>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Benutzer anlegen</button><a class="button button-quiet" href="/users">Abbrechen</a></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,5 @@
|
||||
{{define "users"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><span class="page-kicker">Zugriffssteuerung</span><h1>Benutzer</h1><p>Konten, Rollen sowie Domain- und Subdomain-Berechtigungen verwalten.</p></div><a class="button button-primary" href="/users/new">Benutzer anlegen</a></header>
|
||||
<section class="panel user-table-panel"><div class="panel-heading"><div><span class="section-label">Konten</span><h2>{{len .Users}} Benutzer</h2></div></div><div class="user-table" role="table" aria-label="Benutzer"><div class="user-table-head" role="row"><span>Benutzer</span><span>Rolle</span><span>Status</span><span>Letzte Anmeldung</span><span></span></div>{{range .Users}}<a href="/users/{{.ID}}" class="user-table-row" role="row"><span><strong>{{.DisplayName}}</strong><small>@{{.Username}}</small></span><span class="role-chip {{.Role}}">{{if eq .Role "admin"}}Admin{{else}}Mitglied{{end}}</span><span class="status-pill {{if .Active}}status-good{{else}}status-bad{{end}}">{{if .Active}}aktiv{{else}}deaktiviert{{end}}</span><span class="muted-cell">{{formatTime .LastLoginAt}}</span><span class="row-arrow">→</span></a>{{end}}</div></section>
|
||||
{{end}}
|
||||
Reference in new issue
Block a user