feat: production-ready domain control dashboard
Container-Image veröffentlichen / container (push) Has been cancelled

This commit is contained in:
admin committed 2026-07-12 20:56:45 +02:00
1 parent 9c60eaf3fe
commit df7516e7ab
45 files changed
+7932 -1

No files matched your search

+92
View File
@@ -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
}
+221
View File
@@ -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}
}
+148
View File
@@ -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"))
}
}
+190
View File
@@ -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()
}
+34
View File
@@ -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)
}
}
+137
View File
@@ -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
}
+830
View File
@@ -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
}
+538
View File
@@ -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)
}
}
}
+122
View File
@@ -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
}
+59
View File
@@ -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
+294
View File
@@ -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
+216
View File
@@ -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.&notice_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&notice_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")
}
}
+252
View File
@@ -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);
})();
+396
View File
@@ -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; }
}
+58
View File
@@ -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}}
+128
View File
@@ -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}}
+13
View File
@@ -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}}
+22
View File
@@ -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}}
+53
View File
@@ -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}}
+71
View File
@@ -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}}
+18
View File
@@ -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}}
+8
View File
@@ -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}}
+43
View File
@@ -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}}
+26
View File
@@ -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}}
+4
View File
@@ -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}}
+73
View File
@@ -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}}
+13
View File
@@ -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}}
+5
View File
@@ -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}}