feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled

This commit is contained in:
admin committed 2026-08-28 14:02:07 +02:00
1 parent a000152423
commit f02ec9977c
41 files changed
+3277 -151

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
package appupdate
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
type AgentClient struct {
socket string
}
func NewAgentClient(socket string) *AgentClient {
return &AgentClient{socket: socket}
}
func (c *AgentClient) Start(ctx context.Context, request updateprotocol.Request) error {
encoded, err := json.Marshal(request)
if err != nil {
return err
}
response, err := c.do(ctx, http.MethodPost, "/v1/update", bytes.NewReader(encoded))
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusAccepted {
return nil
}
if response.StatusCode == http.StatusConflict {
return errors.New("ein Software-Update läuft bereits")
}
return fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode)
}
func (c *AgentClient) Status(ctx context.Context) (updateprotocol.Status, error) {
response, err := c.do(ctx, http.MethodGet, "/v1/status", nil)
if err != nil {
return updateprotocol.Status{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return updateprotocol.Status{}, fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode)
}
var status updateprotocol.Status
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&status); err != nil {
return updateprotocol.Status{}, fmt.Errorf("Status des Update-Agents lesen: %w", err)
}
return status, nil
}
func (c *AgentClient) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
if c == nil || c.socket == "" {
return nil, errors.New("Update-Agent ist nicht konfiguriert")
}
transport := &http.Transport{
DisableKeepAlives: true,
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
dialer := net.Dialer{Timeout: time.Second}
return dialer.DialContext(ctx, "unix", c.socket)
},
}
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
request, err := http.NewRequestWithContext(ctx, method, "http://update-agent"+path, body)
if err != nil {
return nil, err
}
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := client.Do(request)
if err != nil {
return nil, errors.New("Update-Agent ist nicht erreichbar")
}
return response, nil
}
+302
View File
@@ -0,0 +1,302 @@
package appupdate
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
"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"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
var ErrCredentialMissing = errors.New("Update-Zugang ist noch nicht eingerichtet")
type State struct {
CurrentVersion string
LatestVersion string
ReleaseURL string
DesiredVersion string
LastCheckedAt time.Time
LastError string
CredentialConfigured bool
UpdateAvailable bool
AgentAvailable bool
AgentStatus updateprotocol.Status
}
type Manager struct {
store *store.Store
cipher *secure.Cipher
logger *slog.Logger
apiURL string
interval time.Duration
agent *AgentClient
httpClient *http.Client
checkMu sync.Mutex
stateMu sync.RWMutex
state State
checkSignal chan struct{}
}
type releaseResponse struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket string, interval time.Duration, logger *slog.Logger) (*Manager, error) {
settings, err := database.GetSoftwareUpdateSettings(context.Background())
if err != nil {
return nil, err
}
manager := &Manager{
store: database,
cipher: cipher,
logger: logger,
apiURL: apiURL,
interval: interval,
agent: NewAgentClient(agentSocket),
checkSignal: make(chan struct{}, 1),
httpClient: &http.Client{
Timeout: 12 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
manager.setStoredState(settings)
return manager, nil
}
func (m *Manager) Start(ctx context.Context) {
go func() {
m.refreshAgentStatus(ctx)
initial := time.NewTimer(15 * time.Second)
defer initial.Stop()
var ticker *time.Ticker
var tick <-chan time.Time
for {
select {
case <-ctx.Done():
if ticker != nil {
ticker.Stop()
}
return
case <-initial.C:
if ticker == nil {
ticker = time.NewTicker(m.interval)
tick = ticker.C
}
m.backgroundCheck(ctx)
case <-tick:
m.backgroundCheck(ctx)
case <-m.checkSignal:
m.backgroundCheck(ctx)
}
}
}()
}
func (m *Manager) backgroundCheck(ctx context.Context) {
checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
if err := m.CheckNow(checkCtx); err != nil && !errors.Is(err, ErrCredentialMissing) && !errors.Is(err, context.Canceled) {
m.logger.Warn("software update check failed", "error", err)
}
m.refreshAgentStatus(ctx)
}
func (m *Manager) TriggerCheck() {
select {
case m.checkSignal <- struct{}{}:
default:
}
}
func (m *Manager) State() State {
m.stateMu.RLock()
defer m.stateMu.RUnlock()
return m.state
}
func (m *Manager) SaveCredential(ctx context.Context, username, token string) error {
username, token = strings.TrimSpace(username), strings.TrimSpace(token)
if username == "" || len(username) > 128 {
return errors.New("Gitea-Benutzername fehlt oder ist zu lang")
}
if len(token) < 8 || len(token) > 1024 {
return errors.New("Gitea-Token fehlt oder hat eine ungültige Länge")
}
ciphertext, nonce, err := m.cipher.Encrypt(token)
if err != nil {
return fmt.Errorf("Update-Zugang verschlüsseln: %w", err)
}
if err := m.store.SaveSoftwareUpdateCredential(ctx, username, ciphertext, nonce); err != nil {
return err
}
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
if err == nil {
m.setStoredState(settings)
}
return err
}
func (m *Manager) CheckNow(ctx context.Context) error {
m.checkMu.Lock()
defer m.checkMu.Unlock()
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
if err != nil {
return err
}
if settings.RegistryUsername == "" || len(settings.TokenCiphertext) == 0 || len(settings.TokenNonce) == 0 {
m.setStoredState(settings)
return ErrCredentialMissing
}
token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
if err != nil {
return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden"))
}
release, err := m.fetchLatest(ctx, token)
if err != nil {
return m.recordFailure(ctx, err)
}
checkedAt := time.Now().UTC()
if err := m.store.RecordSoftwareUpdateSuccess(ctx, release.TagName, release.HTMLURL, checkedAt); err != nil {
return err
}
settings, err = m.store.GetSoftwareUpdateSettings(ctx)
if err == nil {
m.setStoredState(settings)
}
return err
}
func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
version = strings.TrimSpace(version)
state := m.State()
if !validReleaseVersion(version) || version != state.LatestVersion {
return errors.New("die ausgewählte Version ist nicht das zuletzt geprüfte Release")
}
if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 {
return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version")
}
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
if err != nil {
return err
}
token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
if err != nil || settings.RegistryUsername == "" {
return ErrCredentialMissing
}
if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil {
return err
}
request := updateprotocol.Request{Version: version, RegistryUsername: settings.RegistryUsername, RegistryToken: token}
if err := m.agent.Start(ctx, request); err != nil {
return err
}
settings, err = m.store.GetSoftwareUpdateSettings(ctx)
if err == nil {
m.setStoredState(settings)
}
return err
}
func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, error) {
status, err := m.agent.Status(ctx)
m.stateMu.Lock()
m.state.AgentAvailable = err == nil
if err == nil {
m.state.AgentStatus = status
}
m.stateMu.Unlock()
return status, err
}
func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseResponse, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
if err != nil {
return releaseResponse{}, errors.New("Update-Endpunkt ist ungültig")
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "token "+token)
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
response, err := m.httpClient.Do(request)
if err != nil {
return releaseResponse{}, errors.New("Git-Server ist momentan nicht erreichbar")
}
defer response.Body.Close()
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
return releaseResponse{}, errors.New("Gitea-Zugang wurde abgelehnt")
}
if response.StatusCode != http.StatusOK {
return releaseResponse{}, fmt.Errorf("Git-Server antwortet mit HTTP %d", response.StatusCode)
}
var release releaseResponse
if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&release); err != nil {
return releaseResponse{}, errors.New("Release-Antwort des Git-Servers ist ungültig")
}
if release.Draft || release.Prerelease || !validReleaseVersion(release.TagName) {
return releaseResponse{}, errors.New("Git-Server lieferte kein gültiges stabiles Release")
}
release.HTMLURL = m.safeReleaseURL(release.HTMLURL)
return release, nil
}
func (m *Manager) safeReleaseURL(raw string) string {
apiURL, apiErr := url.Parse(m.apiURL)
releaseURL, releaseErr := url.Parse(raw)
if apiErr != nil || releaseErr != nil || releaseURL.Scheme != "https" || !strings.EqualFold(apiURL.Hostname(), releaseURL.Hostname()) {
return ""
}
return releaseURL.String()
}
func (m *Manager) recordFailure(ctx context.Context, checkErr error) error {
checkedAt := time.Now().UTC()
if err := m.store.RecordSoftwareUpdateFailure(ctx, checkErr.Error(), checkedAt); err != nil {
return err
}
if settings, err := m.store.GetSoftwareUpdateSettings(ctx); err == nil {
m.setStoredState(settings)
}
return checkErr
}
func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) {
state := State{
CurrentVersion: buildinfo.Version,
LatestVersion: settings.LatestVersion,
ReleaseURL: settings.ReleaseURL,
DesiredVersion: settings.DesiredVersion,
LastCheckedAt: settings.LastCheckedAt,
LastError: settings.LastError,
CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0,
}
if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil {
state.UpdateAvailable = comparison < 0
}
m.stateMu.Lock()
state.AgentAvailable = m.state.AgentAvailable
state.AgentStatus = m.state.AgentStatus
m.state = state
m.stateMu.Unlock()
}
func (m *Manager) refreshAgentStatus(ctx context.Context) {
statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_, _ = m.UpdateStatus(statusCtx)
}
+86
View File
@@ -0,0 +1,86 @@
package appupdate
import (
"context"
"encoding/base64"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Parallel()
const token = "test-token-that-must-stay-secret"
var unavailable atomic.Bool
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "token "+token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if unavailable.Load() {
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"tag_name":"v1.3.1","html_url":"https://example.invalid/releases/v1.3.1","draft":false,"prerelease":false}`))
}))
defer api.Close()
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() })
keyFile := filepath.Join(t.TempDir(), "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)
}
manager, err := New(database, cipher, api.URL, filepath.Join(t.TempDir(), "missing-agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil)))
if err != nil {
t.Fatal(err)
}
if err := manager.SaveCredential(ctx, "registry-user", token); err != nil {
t.Fatal(err)
}
if err := manager.CheckNow(ctx); err != nil {
t.Fatal(err)
}
state := manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError != "" {
t.Fatalf("new release was not detected: %#v", state)
}
settings, err := database.GetSoftwareUpdateSettings(ctx)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(settings.TokenCiphertext), token) {
t.Fatal("registry token was stored as plaintext")
}
unavailable.Store(true)
if err := manager.CheckNow(ctx); err == nil {
t.Fatal("unavailable Gitea endpoint was reported as successful")
}
state = manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError == "" {
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
}
}
+66
View File
@@ -0,0 +1,66 @@
package appupdate
import (
"fmt"
"strconv"
"strings"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
type semanticVersion struct {
major int
minor int
patch int
}
func parseVersion(raw string) (semanticVersion, error) {
if !strings.HasPrefix(raw, "v") || strings.ContainsAny(raw, "+-") {
return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
}
parts := strings.Split(strings.TrimPrefix(raw, "v"), ".")
if len(parts) != 3 {
return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
}
values := make([]int, 3)
for index, part := range parts {
if part == "" || (len(part) > 1 && part[0] == '0') {
return semanticVersion{}, fmt.Errorf("version contains an invalid number")
}
value, err := strconv.Atoi(part)
if err != nil || value < 0 || value > 1_000_000 {
return semanticVersion{}, fmt.Errorf("version contains an invalid number")
}
values[index] = value
}
return semanticVersion{major: values[0], minor: values[1], patch: values[2]}, nil
}
func compareVersions(left, right string) (int, error) {
a, err := parseVersion(left)
if err != nil {
return 0, err
}
b, err := parseVersion(right)
if err != nil {
return 0, err
}
av, bv := []int{a.major, a.minor, a.patch}, []int{b.major, b.minor, b.patch}
for index := range av {
if av[index] < bv[index] {
return -1, nil
}
if av[index] > bv[index] {
return 1, nil
}
}
return 0, nil
}
func validReleaseVersion(version string) bool {
if !updateprotocol.ValidVersion(version) {
return false
}
_, err := parseVersion(version)
return err == nil
}
+27
View File
@@ -0,0 +1,27 @@
package appupdate
import "testing"
func TestCompareVersionsUsesStrictStableSemanticVersions(t *testing.T) {
t.Parallel()
tests := []struct {
left string
right string
want int
}{
{left: "v1.2.2", right: "v1.2.3", want: -1},
{left: "v2.0.0", right: "v1.99.99", want: 1},
{left: "v1.2.2", right: "v1.2.2", want: 0},
}
for _, test := range tests {
got, err := compareVersions(test.left, test.right)
if err != nil || got != test.want {
t.Errorf("compareVersions(%q, %q) = %d, %v; want %d", test.left, test.right, got, err, test.want)
}
}
for _, invalid := range []string{"1.2.3", "v01.2.3", "v1.2", "v1.2.3-rc.1", "v1.2.3+build"} {
if validReleaseVersion(invalid) {
t.Errorf("invalid release version accepted: %q", invalid)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
package buildinfo
// Version is replaced through -ldflags for release images. The source fallback
// keeps local builds and tests honest about the currently checked-out release.
var Version = "v1.3.0"
+32
View File
@@ -1,8 +1,10 @@
package config
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -27,6 +29,9 @@ type Config struct {
HCloudBaseURL string
Mode string
ShutdownTimeout time.Duration
UpdateAPIURL string
UpdateAgentSock string
UpdateInterval time.Duration
}
func Load() (Config, error) {
@@ -51,6 +56,14 @@ func Load() (Config, error) {
}
keyFile := value("APP_MASTER_KEY_FILE", "/secrets/master_key")
updateInterval, err := duration("APP_SOFTWARE_UPDATE_CHECK_INTERVAL", 6*time.Hour)
if err != nil || updateInterval < 15*time.Minute || updateInterval > 7*24*time.Hour {
return Config{}, fmt.Errorf("APP_SOFTWARE_UPDATE_CHECK_INTERVAL must be between 15m and 168h")
}
updateAPIURL := value("APP_SOFTWARE_UPDATE_API_URL", "https://git.j-mahler.de/api/v1/repos/admin/hetzner-api-dyndns-records-webinterface/releases/latest")
if err := validateUpdateURL(updateAPIURL); err != nil {
return Config{}, fmt.Errorf("APP_SOFTWARE_UPDATE_API_URL: %w", err)
}
cookieSecure := mode == "https"
if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" {
@@ -71,9 +84,28 @@ func Load() (Config, error) {
HCloudBaseURL: strings.TrimRight(value("APP_HCLOUD_BASE_URL", "https://api.hetzner.cloud/v1"), "/"),
Mode: mode,
ShutdownTimeout: shutdownTimeout,
UpdateAPIURL: updateAPIURL,
UpdateAgentSock: value("APP_SOFTWARE_UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock"),
UpdateInterval: updateInterval,
}, nil
}
func validateUpdateURL(raw string) error {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") {
return errors.New("must be an absolute HTTPS URL without embedded credentials")
}
if parsed.Scheme == "https" {
return nil
}
host := parsed.Hostname()
address := net.ParseIP(host)
if host == "localhost" || (address != nil && address.IsLoopback()) {
return nil
}
return errors.New("HTTP is allowed only for loopback test endpoints")
}
func value(name, fallback string) string {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
return v
+15
View File
@@ -90,6 +90,21 @@ type PiHoleCredential struct {
Nonce []byte
}
// SoftwareUpdateSettings contains the locally persisted update state. The
// encrypted registry token is returned only to trusted backend components and
// is never passed to templates.
type SoftwareUpdateSettings struct {
RegistryUsername string
TokenCiphertext []byte
TokenNonce []byte
LatestVersion string
ReleaseURL string
DesiredVersion string
LastCheckedAt time.Time
LastError string
UpdatedAt time.Time
}
// PiHoleRecordLink tracks whether a local DNS entry is managed by this UI or
// merely retained at Pi-hole after being removed from the active WebUI list.
type PiHoleRecordLink struct {
+99 -20
View File
@@ -21,24 +21,42 @@ var (
ErrDisabled = errors.New("the updater is disabled")
)
const defaultRetryInitial = 5 * time.Second
type ipResolver interface {
ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) ipresolve.Result
ValidateEndpoint(ctx context.Context, raw string) error
}
type Scheduler struct {
store *store.Store
provider *hetzner.Client
resolver *ipresolve.Resolver
resolver ipResolver
cipher *secure.Cipher
logger *slog.Logger
running atomic.Bool
nextRun atomic.Int64
wake chan struct{}
retryMin time.Duration
retryMax time.Duration
}
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)}
return &Scheduler{
store: database,
provider: provider,
resolver: resolver,
cipher: cipher,
logger: logger,
wake: make(chan struct{}, 1),
retryMin: defaultRetryInitial,
}
}
func (s *Scheduler) Start(ctx context.Context) {
go func() {
defer s.nextRun.Store(0)
consecutiveFailures := 0
for {
settings, err := s.store.GetSettings(ctx)
if err != nil {
@@ -60,11 +78,22 @@ func (s *Scheduler) Start(ctx context.Context) {
}
continue
}
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
if wait < time.Minute {
wait = time.Minute
}
retryAfter := time.Duration(0)
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)
_, runErr := s.Run(ctx)
if runErr == nil {
consecutiveFailures = 0
} else if !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
consecutiveFailures++
retryAfter = s.retryDelay(consecutiveFailures, wait)
s.logger.Warn("scheduled update run failed", "error", runErr, "retry_in", retryAfter)
}
} else {
consecutiveFailures = 0
s.nextRun.Store(0)
select {
case <-ctx.Done():
@@ -73,9 +102,8 @@ func (s *Scheduler) Start(ctx context.Context) {
}
continue
}
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
if wait < time.Minute {
wait = time.Minute
if retryAfter > 0 {
wait = retryAfter
}
s.nextRun.Store(time.Now().UTC().Add(wait).Unix())
timer := time.NewTimer(wait)
@@ -84,6 +112,7 @@ func (s *Scheduler) Start(ctx context.Context) {
timer.Stop()
return
case <-s.wake:
consecutiveFailures = 0
if !timer.Stop() {
select {
case <-timer.C:
@@ -96,6 +125,30 @@ func (s *Scheduler) Start(ctx context.Context) {
}()
}
func (s *Scheduler) retryDelay(failures int, scheduledWait time.Duration) time.Duration {
minimum, maximum := s.retryMin, scheduledWait
if minimum <= 0 {
minimum = defaultRetryInitial
}
if s.retryMax > 0 && s.retryMax < maximum {
maximum = s.retryMax
}
if maximum < minimum {
maximum = minimum
}
wait := minimum
for attempt := 1; attempt < failures && wait < maximum; attempt++ {
if wait > maximum/2 {
return maximum
}
wait *= 2
}
if wait > maximum {
return maximum
}
return wait
}
func (s *Scheduler) Trigger() {
s.nextRun.Store(0)
select {
@@ -131,22 +184,43 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
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)
return s.finishRun(ctx, runID, "error", "", "", "Aktive Ziele konnten nicht geladen werden", err)
}
needsIPv6 := false
for _, target := range targets {
if target.Target.UpdateIPv6 {
needsIPv6 = true
break
}
}
ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled && needsIPv6)
var resolutionErr error
if ipResult.IPv4Err != nil {
resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv4-Auflösung fehlgeschlagen: %w", ipResult.IPv4Err))
}
if settings.IPv6Enabled && needsIPv6 && ipResult.IPv6Err != nil {
resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv6-Auflösung fehlgeschlagen: %w", ipResult.IPv6Err))
}
if ipResult.IPv4 == "" && ipResult.IPv6 == "" {
message := "Keine öffentliche IP ermittelt"
if resolutionErr == nil {
resolutionErr = errors.New(message)
} else {
message += ": " + resolutionErr.Error()
}
return s.finishRun(ctx, runID, "error", "", "", message, resolutionErr)
}
if len(targets) == 0 {
summary := "Keine aktiven DynDNS-Ziele"
return s.finishRun(ctx, runID, "ok", ipResult.IPv4, ipResult.IPv6, summary, nil)
status := "ok"
if resolutionErr != nil {
status = "partial"
summary += "; eine IP-Familie war nicht verfügbar"
}
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, resolutionErr)
}
grouped := make(map[int64][]store.ManagedTarget)
@@ -172,13 +246,18 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
status = "partial"
}
summary := fmt.Sprintf("%d Ziel(e) aktualisiert, %d Fehler", updated, failed)
if ipResult.IPv4Err != nil || (settings.IPv6Enabled && ipResult.IPv6Err != nil) {
if resolutionErr != 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)
var runErr error
if failed > 0 {
runErr = fmt.Errorf("%d DynDNS-Ziel(e) konnten nicht synchronisiert werden", failed)
}
runErr = errors.Join(runErr, resolutionErr)
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, runErr)
}
func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) {
+95
View File
@@ -5,11 +5,13 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
@@ -20,6 +22,24 @@ import (
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
type retryResolver struct {
calls atomic.Int32
recovered chan struct{}
}
func (r *retryResolver) ResolveWithIPv6(_ context.Context, _, _ []string, _ bool) ipresolve.Result {
call := r.calls.Add(1)
if call == 1 {
return ipresolve.Result{IPv4Err: errors.New("network is not ready")}
}
if call == 2 {
close(r.recovered)
}
return ipresolve.Result{IPv4: "198.51.100.42"}
}
func (r *retryResolver) ValidateEndpoint(_ context.Context, _ string) error { return nil }
func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -164,6 +184,81 @@ func TestDisabledUpdaterRejectsRunsAndTriggerResetsCountdown(t *testing.T) {
}
}
func TestSchedulerRetriesFailedStartupResolutionWithoutWaitingForFullInterval(t *testing.T) {
t.Parallel()
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
settings, err := database.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if err := database.UpdateSettings(context.Background(), settings.UpdateIntervalSeconds, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
t.Fatal(err)
}
resolver := &retryResolver{recovered: make(chan struct{})}
schedule := New(database, nil, ipresolve.New(), nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
schedule.resolver = resolver
schedule.retryMin = 5 * time.Millisecond
schedule.retryMax = 20 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
schedule.Start(ctx)
select {
case <-resolver.recovered:
case <-time.After(2 * time.Second):
t.Fatal("scheduler did not retry the failed startup resolution")
}
deadline := time.Now().Add(time.Second)
for {
settings, err = database.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if settings.LastIPv4 == "198.51.100.42" {
break
}
if time.Now().After(deadline) {
t.Fatalf("successful retry did not persist IPv4: %#v", settings)
}
time.Sleep(5 * time.Millisecond)
}
runs, err := database.RecentRuns(context.Background(), 10)
if err != nil {
t.Fatal(err)
}
if len(runs) != 2 || runs[0].Status != "ok" || runs[1].Status != "error" {
t.Fatalf("unexpected startup retry history: %#v", runs)
}
}
func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) {
t.Parallel()
schedule := &Scheduler{retryMin: 5 * time.Second}
tests := []struct {
failures int
want time.Duration
}{
{failures: 1, want: 5 * time.Second},
{failures: 2, want: 10 * time.Second},
{failures: 3, want: 20 * time.Second},
{failures: 4, want: 40 * time.Second},
{failures: 5, want: time.Minute},
{failures: 20, want: time.Minute},
}
for _, test := range tests {
if got := schedule.retryDelay(test.failures, time.Minute); got != test.want {
t.Errorf("retry delay after %d failures = %s, want %s", test.failures, got, test.want)
}
}
}
func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) {
t.Parallel()
schedule := &Scheduler{}
+44 -4
View File
@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
"unicode/utf8"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/chacha20poly1305"
@@ -21,6 +22,14 @@ const (
argonTime = 3
argonThreads = 2
argonKeyLen = 32
PasswordMinLength = 15
PasswordMaxLength = 128
// PasswordTimingHash is a non-secret, precomputed Argon2id hash used only
// to equalize failed-login work when a username does not exist. Keeping it
// static avoids allocating the full Argon2 workspace during every startup.
PasswordTimingHash = "argon2id$v=19$m=65536,t=3,p=2$qrFe78U1tJk+AQzTg0+BHw$n4NjN4mcmJmVrJa7xB3M2AYMDX3PBs+5Y7iYXb/dR5Q"
)
type Cipher struct {
@@ -129,8 +138,30 @@ func HashToken(token string) string {
}
func HashPassword(password string) (string, error) {
if len(password) < 12 {
return "", errors.New("password must contain at least 12 characters")
return hashPassword(password, PasswordMinLength)
}
// HashProvisionedPassword hashes the initial password chosen by an
// administrator when creating another account. Product policy deliberately
// leaves its length to that administrator while still rejecting empty,
// overlong, or invalid UTF-8 input.
func HashProvisionedPassword(password string) (string, error) {
return hashPassword(password, 1)
}
func hashPassword(password string, minimumLength int) (string, error) {
if !utf8.ValidString(password) {
return "", errors.New("Passwort muss gültiger UTF-8-Text sein")
}
length := utf8.RuneCountInString(password)
if length < minimumLength {
if minimumLength == 1 {
return "", errors.New("Passwort darf nicht leer sein")
}
return "", fmt.Errorf("Passwort muss mindestens %d Zeichen enthalten", minimumLength)
}
if length > PasswordMaxLength {
return "", fmt.Errorf("Passwort darf höchstens %d Zeichen enthalten", PasswordMaxLength)
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
@@ -142,6 +173,9 @@ func HashPassword(password string) (string, error) {
}
func VerifyPassword(encoded, password string) bool {
if !utf8.ValidString(password) || utf8.RuneCountInString(password) > PasswordMaxLength {
return false
}
parts := strings.Split(encoded, "$")
if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" {
return false
@@ -152,12 +186,18 @@ func VerifyPassword(encoded, password string) bool {
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
return false
}
if parts[2] != fmt.Sprintf("m=%d,t=%d,p=%d", memory, iterations, threads) || memory != argonMemory || iterations != argonTime || threads != argonThreads {
return false
}
if len(parts[3]) != base64.RawStdEncoding.EncodedLen(16) || len(parts[4]) != base64.RawStdEncoding.EncodedLen(argonKeyLen) {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
if err != nil || len(salt) != 16 {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
if err != nil || len(want) != argonKeyLen {
return false
}
got := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(want)))
+29 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -81,7 +82,34 @@ func TestPasswordHash(t *testing.T) {
if VerifyPassword(hash, "wrong password") {
t.Fatal("wrong password verified")
}
if _, err := HashPassword("short"); err == nil {
if VerifyPassword(strings.Replace(hash, "m=65536", "m=4294967295", 1), "a secure password") {
t.Fatal("password hash with unsafe Argon2 parameters was accepted")
}
if _, err := HashPassword(strings.Repeat("a", PasswordMinLength-1)); err == nil {
t.Fatal("short password was accepted")
}
if _, err := HashPassword(strings.Repeat("a", PasswordMaxLength+1)); err == nil {
t.Fatal("overlong password was accepted")
}
}
func TestPasswordTimingHashMatchesCurrentArgonParameters(t *testing.T) {
t.Parallel()
if !VerifyPassword(PasswordTimingHash, "login timing normalization only") {
t.Fatal("precomputed login timing hash no longer matches the configured Argon2 parameters")
}
}
func TestProvisionedPasswordHasNoMinimumLengthBeyondNonEmpty(t *testing.T) {
t.Parallel()
hash, err := HashProvisionedPassword("x")
if err != nil || !VerifyPassword(hash, "x") {
t.Fatalf("short provisioned password was not hashed correctly: %v", err)
}
if _, err := HashProvisionedPassword(""); err == nil {
t.Fatal("empty provisioned password was accepted")
}
if _, err := HashProvisionedPassword(strings.Repeat("x", PasswordMaxLength+1)); err == nil {
t.Fatal("overlong provisioned password was accepted")
}
}
+181 -9
View File
@@ -21,7 +21,12 @@ type Store struct {
db *sql.DB
}
var ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
var (
ErrAdminAlreadyConfigured = errors.New("an administrator is already configured")
ErrAdminAccessImmutable = errors.New("administrator access is inherent and cannot be edited")
ErrLastActiveAdmin = errors.New("the last active administrator cannot be disabled or demoted")
ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
)
type ManagedTarget struct {
Target model.Target
@@ -116,6 +121,9 @@ func (s *Store) migrate(ctx context.Context) error {
if err := s.applyMigrationV10(ctx, tx); err != nil {
return err
}
if err := s.applyMigrationV11(ctx, tx); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO settings(id, update_interval_seconds, ipv4_endpoints, ipv6_endpoints, updated_at) VALUES(1, ?, ?, ?, ?)`,
300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil {
return err
@@ -242,6 +250,23 @@ func (s *Store) applyMigrationV10(ctx context.Context, tx *sql.Tx) error {
return nil
}
func (s *Store) applyMigrationV11(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 11`).Scan(&count); err != nil {
return err
}
if count != 0 {
return nil
}
if _, err := tx.ExecContext(ctx, schemaV11); err != nil {
return fmt.Errorf("apply schema v11: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(11, ?)`, now()); err != nil {
return err
}
return nil
}
func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil {
@@ -453,6 +478,22 @@ const schemaV10 = `
ALTER TABLE pihole_record_links ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
`
const schemaV11 = `
CREATE TABLE software_update_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
registry_username TEXT NOT NULL DEFAULT '',
token_ciphertext BLOB NOT NULL DEFAULT X'',
token_nonce BLOB NOT NULL DEFAULT X'',
latest_version TEXT NOT NULL DEFAULT '',
release_url TEXT NOT NULL DEFAULT '',
desired_version TEXT NOT NULL DEFAULT '',
last_checked_at INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
INSERT INTO software_update_settings(id, updated_at) VALUES(1, strftime('%s','now'));
`
func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count)
@@ -460,8 +501,20 @@ func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
}
func (s *Store) CreateAdmin(ctx context.Context, username, displayName, passwordHash string) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at) VALUES(?, ?, ?, 'admin', 1, ?, ?)`, username, displayName, passwordHash, now(), now())
return err
result, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at)
SELECT ?, ?, ?, 'admin', 1, ?, ?
WHERE NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND active = 1)`, username, displayName, passwordHash, now(), now())
if err != nil {
return err
}
created, err := result.RowsAffected()
if err != nil {
return err
}
if created != 1 {
return ErrAdminAlreadyConfigured
}
return nil
}
func (s *Store) PasswordHash(ctx context.Context, userID int64) (string, error) {
@@ -481,6 +534,25 @@ func (s *Store) UpdatePassword(ctx context.Context, userID int64, passwordHash s
return nil
}
func (s *Store) UpdatePasswordKeepingSession(ctx context.Context, userID int64, passwordHash string, keepSessionID int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id != ?`, userID, keepSessionID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) GetUser(ctx context.Context, id int64) (model.User, error) {
return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, display_name, role, active, created_at, updated_at, last_login_at FROM users WHERE id = ?`, id))
}
@@ -531,18 +603,66 @@ func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordH
}
func (s *Store) UpdateUserState(ctx context.Context, userID int64, displayName, role string, active bool) error {
result, err := s.db.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var currentRole string
var currentActive int
if err := tx.QueryRowContext(ctx, `SELECT role, active FROM users WHERE id = ?`, userID).Scan(&currentRole, &currentActive); err != nil {
return err
}
if currentRole == "admin" && intBool(currentActive) && (role != "admin" || !active) {
var activeAdmins int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&activeAdmins); err != nil {
return err
}
if activeAdmins <= 1 {
return ErrLastActiveAdmin
}
}
result, err := tx.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count != 1 {
return sql.ErrNoRows
}
return nil
// Administrators receive full access from their role. Removing stale member
// grants prevents hidden permissions from resurfacing after a later demotion.
if role == "admin" {
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
return err
}
}
if currentRole != role || intBool(currentActive) != active {
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) SetUserPassword(ctx context.Context, userID int64, passwordHash string) error {
return s.UpdatePassword(ctx, userID, passwordHash)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) MarkUserLoggedIn(ctx context.Context, userID int64) error {
@@ -587,6 +707,13 @@ func (s *Store) ReplaceAccessGrants(ctx context.Context, userID int64, selection
return err
}
defer func() { _ = tx.Rollback() }()
var role string
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = ?`, userID).Scan(&role); err != nil {
return err
}
if role == "admin" {
return ErrAdminAccessImmutable
}
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
return err
}
@@ -702,8 +829,43 @@ func (s *Store) UpdateSettings(ctx context.Context, interval int, updaterEnabled
return err
}
func (s *Store) GetSoftwareUpdateSettings(ctx context.Context) (model.SoftwareUpdateSettings, error) {
var settings model.SoftwareUpdateSettings
var checkedAt, updatedAt int64
err := s.db.QueryRowContext(ctx, `SELECT registry_username, token_ciphertext, token_nonce, latest_version, release_url, desired_version, last_checked_at, last_error, updated_at FROM software_update_settings WHERE id = 1`).Scan(
&settings.RegistryUsername, &settings.TokenCiphertext, &settings.TokenNonce, &settings.LatestVersion, &settings.ReleaseURL, &settings.DesiredVersion, &checkedAt, &settings.LastError, &updatedAt,
)
settings.LastCheckedAt, settings.UpdatedAt = fromUnix(checkedAt), fromUnix(updatedAt)
return settings, err
}
func (s *Store) SaveSoftwareUpdateCredential(ctx context.Context, username string, ciphertext, nonce []byte) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET registry_username = ?, token_ciphertext = ?, token_nonce = ?, last_error = '', updated_at = ? WHERE id = 1`, username, ciphertext, nonce, now())
return err
}
func (s *Store) RecordSoftwareUpdateSuccess(ctx context.Context, latestVersion, releaseURL string, checkedAt time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET latest_version = ?, release_url = ?, last_checked_at = ?, last_error = '', updated_at = ? WHERE id = 1`, latestVersion, releaseURL, unix(checkedAt), now())
return err
}
// RecordSoftwareUpdateFailure intentionally retains the last known release so
// a temporary Gitea outage never removes an already visible update reminder.
func (s *Store) RecordSoftwareUpdateFailure(ctx context.Context, message string, checkedAt time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET last_checked_at = ?, last_error = ?, updated_at = ? WHERE id = 1`, unix(checkedAt), message, now())
return err
}
func (s *Store) SetDesiredSoftwareVersion(ctx context.Context, version string) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = ?, updated_at = ? WHERE id = 1`, version, now())
return err
}
func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error {
_, err := s.db.ExecContext(ctx, `UPDATE settings SET last_ipv4 = ?, last_ipv6 = ?, last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status)
_, err := s.db.ExecContext(ctx, `UPDATE settings SET
last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4),
last_ipv6 = COALESCE(NULLIF(?, ''), last_ipv6),
last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status)
return err
}
@@ -1095,8 +1257,18 @@ func (s *Store) ListTargetsWithIPv6(ctx context.Context) ([]model.Target, error)
}
func (s *Store) DisableIPv6ForTargets(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`)
return err
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_ipv6 = '' WHERE id = 1`); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) ArchiveTarget(ctx context.Context, id int64) error {
+128
View File
@@ -38,6 +38,26 @@ func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
}
}
func TestCreateAdminOnlyAllowsTheAtomicFirstSetup(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() })
if err := database.CreateAdmin(ctx, "admin", "Administrator", "password-hash"); err != nil {
t.Fatal(err)
}
if err := database.CreateAdmin(ctx, "second", "Second admin", "other-password-hash"); !errors.Is(err, ErrAdminAlreadyConfigured) {
t.Fatalf("second first-run administrator was not rejected: %v", err)
}
if _, _, err := database.GetUserByUsername(ctx, "second"); err == nil {
t.Fatal("rejected second administrator was persisted")
}
}
func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -168,6 +188,100 @@ func TestInactiveUserSessionIsRejected(t *testing.T) {
}
}
func TestAdministratorAccessIsRoleBasedAndImmutable(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 administrator password")
if err != nil {
t.Fatal(err)
}
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
t.Fatal(err)
}
admin, _, err := database.GetUserByUsername(ctx, "admin")
if err != nil {
t.Fatal(err)
}
if err := database.UpdateUserState(ctx, admin.ID, admin.DisplayName, "member", false); !errors.Is(err, ErrLastActiveAdmin) {
t.Fatalf("last active administrator could be removed: %v", err)
}
if err := database.ReplaceAccessGrants(ctx, admin.ID, AccessSelection{}); !errors.Is(err, ErrAdminAccessImmutable) {
t.Fatalf("administrator accepted editable individual grants: %v", 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"), "admin-access-test")
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "role-test", "role.example")
if err != nil {
t.Fatal(err)
}
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); err != nil {
t.Fatal(err)
}
memberSession, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: "member-role-session", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)})
if err != nil {
t.Fatal(err)
}
if err := database.UpdateUserState(ctx, member.ID, member.DisplayName, "admin", true); err != nil {
t.Fatal(err)
}
if session, err := database.GetSession(ctx, memberSession.TokenHash); err != nil || session != nil {
t.Fatalf("role change did not invalidate existing sessions: %#v, %v", session, err)
}
grants, err := database.ListAccessGrants(ctx, member.ID)
if err != nil {
t.Fatal(err)
}
if len(grants) != 0 {
t.Fatalf("promoted administrator retained hidden member grants: %#v", grants)
}
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); !errors.Is(err, ErrAdminAccessImmutable) {
t.Fatalf("promoted administrator accepted individual grants: %v", err)
}
}
func TestSoftwareUpdateStateRetainsKnownReleaseOnCheckFailure(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() })
if err := database.SaveSoftwareUpdateCredential(ctx, "registry-user", []byte("encrypted-token"), []byte("nonce")); err != nil {
t.Fatal(err)
}
checkedAt := time.Now().UTC().Truncate(time.Second)
if err := database.RecordSoftwareUpdateSuccess(ctx, "v1.2.3", "https://git.example/releases/v1.2.3", checkedAt); err != nil {
t.Fatal(err)
}
if err := database.SetDesiredSoftwareVersion(ctx, "v1.2.3"); err != nil {
t.Fatal(err)
}
if err := database.RecordSoftwareUpdateFailure(ctx, "temporarily unavailable", checkedAt.Add(time.Minute)); err != nil {
t.Fatal(err)
}
settings, err := database.GetSoftwareUpdateSettings(ctx)
if err != nil {
t.Fatal(err)
}
if settings.RegistryUsername != "registry-user" || string(settings.TokenCiphertext) != "encrypted-token" || settings.LatestVersion != "v1.2.3" || settings.DesiredVersion != "v1.2.3" || settings.LastError != "temporarily unavailable" {
t.Fatalf("software update state was not persisted safely: %#v", settings)
}
}
func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -268,6 +382,9 @@ func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
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.UpdateRunState(ctx, "198.51.100.10", "2001:db8::20", "ok"); err != nil {
t.Fatal(err)
}
if err := database.DisableIPv6ForTargets(ctx); err != nil {
t.Fatal(err)
}
@@ -275,6 +392,10 @@ func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
if err != nil || target.UpdateIPv6 || target.StaticIPv6 != "" {
t.Fatalf("IPv6 target was not disabled cleanly: %#v err=%v", target, err)
}
settings, err := database.GetSettings(ctx)
if err != nil || settings.LastIPv6 != "" {
t.Fatalf("disabled IPv6 kept a stale detected address: %#v err=%v", settings, err)
}
if err := database.DeleteTarget(ctx, target.ID); err != nil {
t.Fatal(err)
}
@@ -335,6 +456,13 @@ func TestUpdaterSettingsAndErrorsCanBeReset(t *testing.T) {
if err := database.UpdateRunState(ctx, "198.51.100.1", "", "partial"); err != nil {
t.Fatal(err)
}
if err := database.UpdateRunState(ctx, "", "", "error"); err != nil {
t.Fatal(err)
}
settings, err = database.GetSettings(ctx)
if err != nil || settings.LastIPv4 != "198.51.100.1" {
t.Fatalf("transient resolver failure discarded the last known IPv4: %#v err=%v", settings, err)
}
if count, err := database.CountErrors(ctx); err != nil || count != 3 {
t.Fatalf("unexpected error count: count=%d err=%v", count, err)
}
+133
View File
@@ -0,0 +1,133 @@
package updateagent
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
type Runner interface {
Update(context.Context, updateprotocol.Request, func(phase, message string)) error
}
type Controller struct {
runner Runner
logger *slog.Logger
mu sync.RWMutex
status updateprotocol.Status
}
func NewController(runner Runner, logger *slog.Logger) *Controller {
return &Controller{runner: runner, logger: logger, status: updateprotocol.Status{Phase: updateprotocol.PhaseIdle, UpdatedAt: time.Now().UTC()}}
}
func (c *Controller) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/status", c.statusHandler)
mux.HandleFunc("POST /v1/update", c.updateHandler)
return mux
}
func (c *Controller) statusHandler(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(c.Status())
}
func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
defer r.Body.Close()
var request updateprotocol.Request
decoder := json.NewDecoder(io.LimitReader(r.Body, 32<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid update request", http.StatusBadRequest)
return
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
http.Error(w, "invalid update request", http.StatusBadRequest)
return
}
request.Version = strings.TrimSpace(request.Version)
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
request.RegistryToken = strings.TrimSpace(request.RegistryToken)
if !updateprotocol.ValidVersion(request.Version) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
http.Error(w, "invalid update request", http.StatusBadRequest)
return
}
if !c.begin(request.Version) {
http.Error(w, "update already running", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte("{\"accepted\":true}\n"))
go c.run(request)
}
func (c *Controller) begin(version string) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.status.Busy() {
return false
}
now := time.Now().UTC()
c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now}
return true
}
func (c *Controller) run(request updateprotocol.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
err := c.runner.Update(ctx, request, c.setPhase)
registryToken := request.RegistryToken
request.RegistryToken = ""
if err != nil {
message := publicAgentError(err)
c.setPhase(updateprotocol.PhaseFailed, message)
c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryToken))
return
}
c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert")
c.logger.Info("software update completed", "version", request.Version)
}
func redactSecret(err error, secret string) error {
if err == nil || secret == "" || !strings.Contains(err.Error(), secret) {
return err
}
return errors.New(strings.ReplaceAll(err.Error(), secret, "[redacted]"))
}
func (c *Controller) setPhase(phase, message string) {
c.mu.Lock()
c.status.Phase = phase
c.status.Message = message
c.status.UpdatedAt = time.Now().UTC()
c.mu.Unlock()
}
func (c *Controller) Status() updateprotocol.Status {
c.mu.RLock()
defer c.mu.RUnlock()
return c.status
}
func publicAgentError(err error) string {
if err == nil {
return "Update fehlgeschlagen"
}
var public interface{ PublicMessage() string }
if errors.As(err, &public) {
return public.PublicMessage()
}
return "Update fehlgeschlagen; der bisherige Container wurde wiederhergestellt"
}
+95
View File
@@ -0,0 +1,95 @@
package updateagent
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
func TestRedactSecretRemovesCredentialFromErrors(t *testing.T) {
t.Parallel()
const secret = "sensitive-registry-token"
redacted := redactSecret(errors.New("registry rejected "+secret), secret)
if strings.Contains(redacted.Error(), secret) || !strings.Contains(redacted.Error(), "[redacted]") {
t.Fatalf("credential was not redacted: %q", redacted)
}
}
type blockingRunner struct {
started chan updateprotocol.Request
release chan struct{}
}
func (r *blockingRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) error {
progress(updateprotocol.PhasePulling, "image is being pulled")
r.started <- request
select {
case <-ctx.Done():
return ctx.Err()
case <-r.release:
return nil
}
}
func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
t.Parallel()
runner := &blockingRunner{started: make(chan updateprotocol.Request, 1), release: make(chan struct{})}
controller := NewController(runner, slog.New(slog.NewTextHandler(io.Discard, nil)))
handler := controller.Handler()
invalid := httptest.NewRecorder()
handler.ServeHTTP(invalid, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"stable","registry_username":"user","registry_token":"secret-token"}`)))
if invalid.Code != http.StatusBadRequest {
t.Fatalf("invalid version accepted: status=%d", invalid.Code)
}
trailing := httptest.NewRecorder()
handler.ServeHTTP(trailing, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v1.2.3","registry_username":"user","registry_token":"secret-token"} {}`)))
if trailing.Code != http.StatusBadRequest {
t.Fatalf("request with trailing JSON accepted: status=%d", trailing.Code)
}
const secret = "registry-token-must-not-leak"
body := []byte(`{"version":"v1.2.3","registry_username":"user","registry_token":"` + secret + `"}`)
accepted := httptest.NewRecorder()
handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
if accepted.Code != http.StatusAccepted {
t.Fatalf("valid update rejected: status=%d body=%s", accepted.Code, accepted.Body.String())
}
select {
case request := <-runner.started:
if request.Version != "v1.2.3" || request.RegistryToken != secret {
t.Fatalf("runner received an altered request: %#v", request)
}
case <-time.After(time.Second):
t.Fatal("update runner did not start")
}
busy := httptest.NewRecorder()
handler.ServeHTTP(busy, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
if busy.Code != http.StatusConflict {
t.Fatalf("parallel update was not rejected: status=%d", busy.Code)
}
status := httptest.NewRecorder()
handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/v1/status", nil))
if status.Code != http.StatusOK || strings.Contains(status.Body.String(), secret) || !strings.Contains(status.Body.String(), `"phase":"pulling"`) {
t.Fatalf("unsafe or invalid public status: status=%d body=%s", status.Code, status.Body.String())
}
close(runner.release)
deadline := time.Now().Add(time.Second)
for controller.Status().Phase != updateprotocol.PhaseComplete && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if controller.Status().Phase != updateprotocol.PhaseComplete {
t.Fatalf("update did not complete: %#v", controller.Status())
}
}
+452
View File
@@ -0,0 +1,452 @@
package updateagent
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
const defaultImageRepository = "git.j-mahler.de/admin/domain-control-webinterface-prod"
type DockerRunner struct {
docker *dockerClient
imageRepository string
targetLabel string
logger *slog.Logger
}
type updateError struct {
message string
err error
}
func (e *updateError) Error() string { return e.err.Error() }
func (e *updateError) Unwrap() error { return e.err }
func (e *updateError) PublicMessage() string { return e.message }
func NewDockerRunner(socket, imageRepository, targetLabel string, logger *slog.Logger) *DockerRunner {
if imageRepository == "" {
imageRepository = defaultImageRepository
}
return &DockerRunner{docker: newDockerClient(socket), imageRepository: imageRepository, targetLabel: targetLabel, logger: logger}
}
func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) (resultErr error) {
if !updateprotocol.ValidVersion(request.Version) {
return errors.New("invalid target version")
}
if err := r.docker.negotiate(ctx); err != nil {
return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err}
}
progress(updateprotocol.PhasePulling, "Container-Image wird geladen")
if err := r.docker.pullImage(ctx, r.imageRepository, request.Version, request.RegistryUsername, request.RegistryToken); err != nil {
return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err}
}
target, err := r.docker.findTarget(ctx, r.targetLabel)
if err != nil {
return &updateError{message: "Laufender Anwendungscontainer wurde nicht eindeutig gefunden", err: err}
}
inspected, err := r.docker.inspectContainer(ctx, target.ID)
if err != nil {
return err
}
originalName := strings.TrimPrefix(inspected.Name, "/")
if originalName == "" {
return errors.New("target container has no name")
}
backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().Unix())
var oldStopped, oldRenamed bool
var newID string
defer func() {
if resultErr == nil || (!oldStopped && !oldRenamed && newID == "") {
return
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, oldStopped, oldRenamed, newID); rollbackErr != nil {
r.logger.Error("software update rollback failed", "error", rollbackErr)
resultErr = &updateError{message: "Update und automatische Wiederherstellung sind fehlgeschlagen; Containerstatus am Host prüfen", err: fmt.Errorf("%w; rollback: %v", resultErr, rollbackErr)}
}
}()
progress(updateprotocol.PhaseRestarting, "Anwendung wird kontrolliert neu gestartet")
if err := r.docker.stopContainer(ctx, target.ID, 20); err != nil {
return err
}
oldStopped = true
if err := r.docker.renameContainer(ctx, target.ID, backupName); err != nil {
return err
}
oldRenamed = true
newID, err = r.docker.createReplacement(ctx, inspected, originalName, r.imageRepository+":"+request.Version)
if err != nil {
return err
}
if err := r.docker.startContainer(ctx, newID); err != nil {
return err
}
progress(updateprotocol.PhaseVerifying, "Neuer Container wird geprüft")
if err := r.docker.waitHealthy(ctx, newID, 100*time.Second); err != nil {
return err
}
if err := r.docker.removeContainer(ctx, target.ID, false); err != nil {
r.logger.Warn("old update rollback container could not be removed", "container", target.ID, "error", err)
}
oldStopped, oldRenamed, newID = false, false, ""
return nil
}
func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName string, oldStopped, oldRenamed bool, newID string) error {
var failures []string
if newID != "" {
if err := r.docker.stopContainer(ctx, newID, 5); err != nil && !isDockerNotModified(err) {
failures = append(failures, "stop replacement: "+err.Error())
}
if err := r.docker.removeContainer(ctx, newID, true); err != nil {
failures = append(failures, "remove replacement: "+err.Error())
}
}
if oldRenamed {
if err := r.docker.renameContainer(ctx, oldID, originalName); err != nil {
failures = append(failures, "restore name: "+err.Error())
}
}
if oldStopped {
if err := r.docker.startContainer(ctx, oldID); err != nil && !isDockerNotModified(err) {
failures = append(failures, "restart previous container: "+err.Error())
}
}
if len(failures) > 0 {
return errors.New(strings.Join(failures, "; "))
}
return nil
}
type dockerClient struct {
socket string
client *http.Client
apiPrefix string
}
type containerSummary struct {
ID string `json:"Id"`
Names []string `json:"Names"`
State string `json:"State"`
}
type containerInspect struct {
ID string `json:"Id"`
Name string `json:"Name"`
Config json.RawMessage `json:"Config"`
HostConfig json.RawMessage `json:"HostConfig"`
NetworkSettings struct {
Networks map[string]endpointSettings `json:"Networks"`
} `json:"NetworkSettings"`
State containerState `json:"State"`
}
type endpointSettings struct {
IPAMConfig json.RawMessage `json:"IPAMConfig,omitempty"`
Links []string `json:"Links,omitempty"`
Aliases []string `json:"Aliases,omitempty"`
MacAddress string `json:"MacAddress,omitempty"`
DriverOpts map[string]string `json:"DriverOpts,omitempty"`
GwPriority int `json:"GwPriority,omitempty"`
}
type containerState struct {
Running bool `json:"Running"`
ExitCode int `json:"ExitCode"`
Health *struct {
Status string `json:"Status"`
} `json:"Health"`
}
func newDockerClient(socket string) *dockerClient {
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, "unix", socket)
},
MaxIdleConns: 2,
MaxIdleConnsPerHost: 2,
IdleConnTimeout: 30 * time.Second,
}
return &dockerClient{socket: socket, client: &http.Client{Transport: transport}}
}
func (c *dockerClient) negotiate(ctx context.Context) error {
response, err := c.request(ctx, http.MethodGet, "/version", nil, nil, false)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return dockerStatusError(response)
}
var version struct {
APIVersion string `json:"ApiVersion"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&version); err != nil {
return fmt.Errorf("decode Docker API version: %w", err)
}
for _, character := range version.APIVersion {
if (character < '0' || character > '9') && character != '.' {
return errors.New("Docker returned an invalid API version")
}
}
if version.APIVersion == "" {
return errors.New("Docker returned an empty API version")
}
c.apiPrefix = "/v" + version.APIVersion
return nil
}
func (c *dockerClient) findTarget(ctx context.Context, label string) (containerSummary, error) {
filters, _ := json.Marshal(map[string][]string{"label": {label}})
query := url.Values{"all": {"1"}, "filters": {string(filters)}}
response, err := c.request(ctx, http.MethodGet, "/containers/json?"+query.Encode(), nil, nil, true)
if err != nil {
return containerSummary{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return containerSummary{}, dockerStatusError(response)
}
var containers []containerSummary
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&containers); err != nil {
return containerSummary{}, err
}
var running []containerSummary
for _, container := range containers {
if container.State == "running" {
running = append(running, container)
}
}
if len(running) != 1 {
return containerSummary{}, fmt.Errorf("expected exactly one running target container, found %d", len(running))
}
return running[0], nil
}
func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, token string) error {
authJSON, _ := json.Marshal(map[string]string{"username": username, "password": token, "serveraddress": strings.Split(repository, "/")[0]})
headers := http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}}
query := url.Values{"fromImage": {repository}, "tag": {tag}}
response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return dockerStatusError(response)
}
scanner := bufio.NewScanner(response.Body)
scanner.Buffer(make([]byte, 64<<10), 1<<20)
for scanner.Scan() {
var event struct {
Error string `json:"error"`
}
if err := json.Unmarshal(scanner.Bytes(), &event); err == nil && event.Error != "" {
return errors.New(event.Error)
}
}
return scanner.Err()
}
func (c *dockerClient) inspectContainer(ctx context.Context, id string) (containerInspect, error) {
response, err := c.request(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, nil, true)
if err != nil {
return containerInspect{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return containerInspect{}, dockerStatusError(response)
}
var inspected containerInspect
if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&inspected); err != nil {
return containerInspect{}, err
}
return inspected, nil
}
func (c *dockerClient) stopContainer(ctx context.Context, id string, seconds int) error {
query := url.Values{"t": {fmt.Sprint(seconds)}}
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/stop?"+query.Encode(), nil, nil, true)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified {
return nil
}
return dockerStatusError(response)
}
func (c *dockerClient) renameContainer(ctx context.Context, id, name string) error {
query := url.Values{"name": {name}}
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/rename?"+query.Encode(), nil, nil, true)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusNoContent {
return nil
}
return dockerStatusError(response)
}
func (c *dockerClient) createReplacement(ctx context.Context, inspected containerInspect, name, image string) (string, error) {
var body map[string]json.RawMessage
if err := json.Unmarshal(inspected.Config, &body); err != nil {
return "", fmt.Errorf("decode container config: %w", err)
}
imageJSON, _ := json.Marshal(image)
body["Image"] = imageJSON
body["HostConfig"] = inspected.HostConfig
endpoints := make(map[string]endpointSettings, len(inspected.NetworkSettings.Networks))
oldIDPrefix := inspected.ID
if len(oldIDPrefix) > 12 {
oldIDPrefix = oldIDPrefix[:12]
}
for network, endpoint := range inspected.NetworkSettings.Networks {
aliases := endpoint.Aliases[:0]
for _, alias := range endpoint.Aliases {
if alias != inspected.ID && alias != oldIDPrefix {
aliases = append(aliases, alias)
}
}
endpoint.Aliases = aliases
endpoints[network] = endpoint
}
networkingJSON, _ := json.Marshal(map[string]any{"EndpointsConfig": endpoints})
body["NetworkingConfig"] = networkingJSON
encoded, err := json.Marshal(body)
if err != nil {
return "", err
}
query := url.Values{"name": {name}}
response, err := c.request(ctx, http.MethodPost, "/containers/create?"+query.Encode(), bytes.NewReader(encoded), http.Header{"Content-Type": {"application/json"}}, true)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
return "", dockerStatusError(response)
}
var created struct {
ID string `json:"Id"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&created); err != nil {
return "", err
}
if created.ID == "" {
return "", errors.New("Docker created a replacement without an ID")
}
return created.ID, nil
}
func (c *dockerClient) startContainer(ctx context.Context, id string) error {
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/start", nil, nil, true)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified {
return nil
}
return dockerStatusError(response)
}
func (c *dockerClient) removeContainer(ctx context.Context, id string, force bool) error {
query := url.Values{"force": {fmt.Sprint(force)}, "v": {"0"}}
response, err := c.request(ctx, http.MethodDelete, "/containers/"+url.PathEscape(id)+"?"+query.Encode(), nil, nil, true)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotFound {
return nil
}
return dockerStatusError(response)
}
func (c *dockerClient) waitHealthy(ctx context.Context, id string, timeout time.Duration) error {
deadline := time.NewTimer(timeout)
ticker := time.NewTicker(time.Second)
defer deadline.Stop()
defer ticker.Stop()
consecutiveHealthy := 0
startedAt := time.Now()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return errors.New("replacement container did not become healthy in time")
case <-ticker.C:
inspected, err := c.inspectContainer(ctx, id)
if err != nil {
return err
}
if !inspected.State.Running {
return fmt.Errorf("replacement container stopped with exit code %d", inspected.State.ExitCode)
}
if inspected.State.Health == nil {
if time.Since(startedAt) >= 8*time.Second {
return nil
}
continue
}
if inspected.State.Health.Status == "healthy" {
consecutiveHealthy++
if consecutiveHealthy >= 3 {
return nil
}
} else {
consecutiveHealthy = 0
}
}
}
}
func (c *dockerClient) request(ctx context.Context, method, path string, body io.Reader, headers http.Header, versioned bool) (*http.Response, error) {
if versioned {
path = c.apiPrefix + path
}
request, err := http.NewRequestWithContext(ctx, method, "http://docker"+path, body)
if err != nil {
return nil, err
}
for name, values := range headers {
for _, value := range values {
request.Header.Add(name, value)
}
}
return c.client.Do(request)
}
func dockerStatusError(response *http.Response) error {
message, _ := io.ReadAll(io.LimitReader(response.Body, 8<<10))
trimmed := strings.TrimSpace(string(message))
if trimmed == "" {
trimmed = http.StatusText(response.StatusCode)
}
return fmt.Errorf("Docker API HTTP %d: %s", response.StatusCode, trimmed)
}
func isDockerNotModified(err error) bool {
return err != nil && strings.Contains(err.Error(), "HTTP 304")
}
+40
View File
@@ -0,0 +1,40 @@
package updateprotocol
import (
"regexp"
"time"
)
var releaseVersionPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`)
const (
PhaseIdle = "idle"
PhaseQueued = "queued"
PhasePulling = "pulling"
PhaseRestarting = "restarting"
PhaseVerifying = "verifying"
PhaseComplete = "complete"
PhaseFailed = "failed"
)
type Request struct {
Version string `json:"version"`
RegistryUsername string `json:"registry_username"`
RegistryToken string `json:"registry_token"`
}
type Status struct {
Phase string `json:"phase"`
TargetVersion string `json:"target_version,omitempty"`
Message string `json:"message,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
func (s Status) Busy() bool {
return s.Phase == PhaseQueued || s.Phase == PhasePulling || s.Phase == PhaseRestarting || s.Phase == PhaseVerifying
}
func ValidVersion(version string) bool {
return len(version) <= 64 && releaseVersionPattern.MatchString(version)
}
+1 -2
View File
@@ -15,7 +15,6 @@ import (
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
@@ -213,7 +212,7 @@ func (s *Server) piHolePassword(w http.ResponseWriter, r *http.Request, session
return
}
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) {
s.redirectNotice(w, r, "/pihole#pihole-connection", "Admin-Passwort nicht korrekt; Anwendungspasswort bleibt verborgen.", "error")
return
}
+243 -41
View File
@@ -21,6 +21,7 @@ import (
"golang.org/x/net/idna"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
@@ -36,9 +37,12 @@ type Server struct {
store *store.Store
cipher *secure.Cipher
scheduler *scheduler.Scheduler
updates *appupdate.Manager
logger *slog.Logger
templates map[string]*template.Template
limiter *loginLimiter
dummyHash string
authSlots chan struct{}
}
type viewData struct {
@@ -74,6 +78,7 @@ type viewData struct {
ErrorPiHoles []model.PiHoleIntegration
ErrorCount int
PiHoleView *piHoleView
SoftwareUpdate appupdate.State
}
type flashMessage struct {
@@ -91,12 +96,12 @@ type accessZone struct {
ManagedTargetIDs map[int64]bool
}
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, logger *slog.Logger) (*Server, error) {
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger) (*Server, error) {
templates, err := loadTemplates()
if err != nil {
return nil, err
}
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, logger: logger, templates: templates, limiter: newLoginLimiter()}, nil
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2)}, nil
}
func (s *Server) Handler() http.Handler {
@@ -144,11 +149,18 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /settings", s.adminOnly(s.settingsPage))
mux.Handle("POST /settings", s.adminOnly(s.settingsSave))
mux.Handle("POST /run", s.adminOnly(s.runNow))
mux.Handle("GET /system", s.adminOnly(s.softwareUpdatePage))
mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart))
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
mux.Handle("GET /security", s.authenticated(s.securityPage))
mux.Handle("GET /account", s.authenticated(s.accountPage))
mux.Handle("POST /account/password", s.authenticated(s.passwordChange))
mux.Handle("GET /security", s.authenticated(s.securityRedirect))
mux.Handle("POST /security/password", s.authenticated(s.passwordChange))
mux.Handle("GET /users", s.adminOnly(s.usersPage))
mux.Handle("GET /access", s.adminOnly(s.usersPage))
mux.Handle("GET /users", s.adminOnly(s.usersRedirect))
mux.Handle("GET /users/new", s.adminOnly(s.userNewPage))
mux.Handle("POST /users", s.adminOnly(s.userCreate))
mux.Handle("GET /users/{id}", s.adminOnly(s.userDetail))
@@ -157,6 +169,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /users/{id}/password", s.adminOnly(s.userPasswordReset))
mux.Handle("GET /api/v1/status", s.adminOnly(s.statusAPI))
mux.Handle("GET /api/v1/scheduler", s.authenticated(s.schedulerAPI))
mux.Handle("GET /api/v1/software-update/status", s.adminOnly(s.softwareUpdateStatusAPI))
return s.securityHeaders(mux)
}
@@ -226,6 +239,10 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.store.CreateAdmin(r.Context(), username, form["display_name"], hash); err != nil {
if errors.Is(err, store.ErrAdminAlreadyConfigured) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "constraint") {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
@@ -278,10 +295,15 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
}
username, usernameErr := normalizeUsername(r.FormValue("username"))
user, hash, err := s.store.GetUserByUsername(r.Context(), username)
if usernameErr != nil || err != nil || !user.Active || !secure.VerifyPassword(hash, r.FormValue("password")) {
lookupOK := usernameErr == nil && err == nil
if !lookupOK {
hash = s.dummyHash
}
passwordOK := s.verifyPassword(r.Context(), hash, r.FormValue("password"))
if !lookupOK || !user.Active || !passwordOK {
s.limiter.Fail(clientIP)
_ = s.store.AddAudit(r.Context(), "auth.failed", "Fehlgeschlagene Anmeldung")
s.renderError(w, "login", "Passwort nicht korrekt.", s.publicCSRF(w, r), map[string]string{})
s.renderError(w, "login", "Anmeldung nicht möglich. Prüfe deine Zugangsdaten.", s.publicCSRF(w, r), map[string]string{})
return
}
s.limiter.Reset(clientIP)
@@ -294,6 +316,23 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
s.redirectNotice(w, r, "/", "Angemeldet.")
}
func (s *Server) verifyPassword(ctx context.Context, hash, password string) bool {
if s.authSlots == nil {
return secure.VerifyPassword(hash, password)
}
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case s.authSlots <- struct{}{}:
defer func() { <-s.authSlots }()
return secure.VerifyPassword(hash, password)
case <-ctx.Done():
return false
case <-timer.C:
return false
}
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request, session *model.Session) {
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
@@ -641,7 +680,7 @@ func (s *Server) domainToken(w http.ResponseWriter, r *http.Request, session *mo
return
}
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) {
s.redirectNotice(w, r, domainURL(zoneID), "Passwort nicht korrekt; Token bleibt verborgen.", "error")
return
}
@@ -1240,6 +1279,126 @@ func (s *Server) runNow(w http.ResponseWriter, r *http.Request, session *model.S
s.redirectNotice(w, r, "/", "Aktualisierung gestartet.")
}
func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, session *model.Session) {
if s.updates == nil {
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
return
}
statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond)
_, _ = s.updates.UpdateStatus(statusCtx)
cancel()
s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State()})
}
func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
if s.updates == nil {
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
return
}
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) {
s.redirectNotice(w, r, "/system", "Admin-Passwort ist nicht korrekt.", "error")
return
}
if err := s.updates.SaveCredential(r.Context(), r.FormValue("registry_username"), r.FormValue("registry_token")); err != nil {
s.redirectNotice(w, r, "/system", "Update-Zugang konnte nicht gespeichert werden: "+safeMessage(err), "error")
return
}
_ = s.store.AddAudit(r.Context(), "software_update.credential_changed", "Update-Zugang geändert durch "+session.Username)
s.updates.TriggerCheck()
s.redirectNotice(w, r, "/system", "Update-Zugang verschlüsselt gespeichert; Versionsprüfung gestartet.")
}
func (s *Server) softwareUpdateCheck(w http.ResponseWriter, r *http.Request, session *model.Session) {
if s.updates == nil {
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
return
}
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
checkCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
err := s.updates.CheckNow(checkCtx)
cancel()
if err != nil {
if errors.Is(err, appupdate.ErrCredentialMissing) {
s.redirectNotice(w, r, "/system#update-access", "Richte zuerst den Update-Zugang ein.", "info")
return
}
s.redirectNotice(w, r, "/system", "Versionsprüfung nicht möglich: "+safeMessage(err)+". Der laufende Dienst bleibt unverändert.", "error")
return
}
s.redirectNotice(w, r, "/system", "Versionsprüfung abgeschlossen.")
}
func (s *Server) softwareUpdateStart(w http.ResponseWriter, r *http.Request, session *model.Session) {
if s.updates == nil {
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
return
}
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
version := strings.TrimSpace(r.FormValue("version"))
startCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
err := s.updates.RequestUpdate(startCtx, version)
cancel()
if err != nil {
if strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": false, "message": safeMessage(err)})
return
}
s.redirectNotice(w, r, "/system", "Update konnte nicht gestartet werden: "+safeMessage(err), "error")
return
}
_ = s.store.AddAudit(r.Context(), "software_update.started", "Software-Update auf "+version+" gestartet durch "+session.Username)
if strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": true, "version": version})
return
}
s.redirectNotice(w, r, "/system", "Update auf "+version+" wurde gestartet.", "info")
}
func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
if s.updates == nil {
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
return
}
statusCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
status, statusErr := s.updates.UpdateStatus(statusCtx)
cancel()
state := s.updates.State()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"agent_available": statusErr == nil,
"phase": status.Phase,
"message": status.Message,
"target_version": status.TargetVersion,
"current_version": state.CurrentVersion,
"latest_version": state.LatestVersion,
})
}
func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
targets, err := s.store.ListTargetsWithErrors(r.Context())
if err != nil {
@@ -1277,8 +1436,12 @@ func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *mo
s.redirectNotice(w, r, "/errors", "Fehlerliste geleert.")
}
func (s *Server) securityPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
s.render(w, "security", viewData{Title: "Sicherheit", Page: "security", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
s.render(w, "security", viewData{Title: "Mein Konto", Page: "account", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
}
func (s *Server) securityRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) {
http.Redirect(w, r, "/account", http.StatusPermanentRedirect)
}
func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session *model.Session) {
@@ -1290,26 +1453,25 @@ func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session
return
}
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
if err != nil || !secure.VerifyPassword(hash, r.FormValue("current_password")) {
s.redirectNotice(w, r, "/security", "Aktuelles Passwort ist nicht korrekt.", "error")
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) {
s.redirectNotice(w, r, "/account", "Aktuelles Passwort ist nicht korrekt.", "error")
return
}
if r.FormValue("new_password") != r.FormValue("confirm_password") {
s.redirectNotice(w, r, "/security", "Die neuen Passwörter stimmen nicht überein.", "error")
s.redirectNotice(w, r, "/account", "Die neuen Passwörter stimmen nicht überein.", "error")
return
}
newHash, err := secure.HashPassword(r.FormValue("new_password"))
if err != nil {
s.redirectNotice(w, r, "/security", err.Error(), "error")
s.redirectNotice(w, r, "/account", err.Error(), "error")
return
}
if err := s.store.UpdatePassword(r.Context(), session.UserID, newHash); err != nil {
if err := s.store.UpdatePasswordKeepingSession(r.Context(), session.UserID, newHash, session.ID); err != nil {
s.serverError(w, err)
return
}
_ = s.store.DeleteOtherSessions(r.Context(), session.UserID, session.ID)
_ = s.store.AddAudit(r.Context(), "security.password_changed", "Passwort geändert: "+session.Username)
s.redirectNotice(w, r, "/security", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.")
s.redirectNotice(w, r, "/account", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.")
}
func (s *Server) statusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
@@ -1338,6 +1500,7 @@ func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, _ *model.S
"next_run_unix": unixSeconds(s.scheduler.NextRunAt()),
"running": s.scheduler.IsRunning(),
"enabled": settings.UpdaterEnabled,
"last_ipv4": settings.LastIPv4,
})
}
@@ -1347,11 +1510,15 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *mode
s.serverError(w, err)
return
}
s.render(w, "users", viewData{Title: "Benutzer", Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
s.render(w, "users", viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
}
func (s *Server) usersRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) {
http.Redirect(w, r, "/access", http.StatusPermanentRedirect)
}
func (s *Server) userNewPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
}
func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
@@ -1380,7 +1547,7 @@ func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *mod
s.renderUserNewError(w, session, "Die Passwörter stimmen nicht überein.", form)
return
}
hash, err := secure.HashPassword(r.FormValue("password"))
hash, err := secure.HashProvisionedPassword(r.FormValue("password"))
if err != nil {
s.renderUserNewError(w, session, err.Error(), form)
return
@@ -1438,7 +1605,7 @@ func (s *Server) userDetail(w http.ResponseWriter, r *http.Request, session *mod
}
accessZones = append(accessZones, accessZone{Zone: zone, Targets: targets, DomainGranted: domainGrants[zone.ID], DomainManaged: domainManaged[zone.ID], CanCreateTargets: createTargetGrants[zone.ID], GrantedTargetIDs: targetGrants, ManagedTargetIDs: managedTargetGrants})
}
s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()})
s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()})
}
func (s *Server) userAccessSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
@@ -1524,24 +1691,14 @@ func (s *Server) userStateSave(w http.ResponseWriter, r *http.Request, session *
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerdaten sind ungültig.", "error")
return
}
if managedUser.IsAdmin() && (role != "admin" || !active) {
count, countErr := s.store.CountActiveAdmins(r.Context())
if countErr != nil {
s.serverError(w, countErr)
return
}
if count <= 1 {
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
if errors.Is(err, store.ErrLastActiveAdmin) {
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Der letzte aktive Admin kann nicht deaktiviert oder herabgestuft werden.", "error")
return
}
}
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
s.serverError(w, err)
return
}
if role != managedUser.Role || active != managedUser.Active {
_ = s.store.DeleteUserSessions(r.Context(), userID)
}
_ = s.store.AddAudit(r.Context(), "user.state_updated", "Benutzer geändert: "+managedUser.Username+" durch "+session.Username)
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerstatus gespeichert.")
}
@@ -1576,7 +1733,6 @@ func (s *Server) userPasswordReset(w http.ResponseWriter, r *http.Request, sessi
s.serverError(w, err)
return
}
_ = s.store.DeleteUserSessions(r.Context(), userID)
_ = s.store.AddAudit(r.Context(), "user.password_reset", "Passwort zurückgesetzt: "+managedUser.Username+" durch "+session.Username)
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.")
}
@@ -1719,6 +1875,9 @@ func (s *Server) render(w http.ResponseWriter, name string, data viewData) {
if settings, err := s.store.GetSettings(context.Background()); err == nil {
data.UpdaterEnabled = settings.UpdaterEnabled
}
if data.IsAdmin && s.updates != nil {
data.SoftwareUpdate = s.updates.State()
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
@@ -1732,7 +1891,7 @@ func (s *Server) renderError(w http.ResponseWriter, name, message, csrf string,
}
func (s *Server) renderUserNewError(w http.ResponseWriter, session *model.Session, message string, form map[string]string) {
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
}
func (s *Server) serverError(w http.ResponseWriter, err error) {
@@ -1774,7 +1933,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
}
func loadTemplates() (map[string]*template.Template, error) {
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "token", "users", "user_new", "user_detail"}
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "system", "token", "users", "user_new", "user_detail"}
functions := template.FuncMap{
"formatTime": func(value time.Time) string {
if value.IsZero() {
@@ -2043,15 +2202,30 @@ func remoteIP(r *http.Request) string {
}
type loginLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
mu sync.Mutex
attempts map[string][]time.Time
lastCleanup time.Time
}
const (
loginAttemptLimit = 5
loginAttemptWindow = 15 * time.Minute
loginLimiterCleanupEvery = time.Minute
loginLimiterMaxTrackedIPs = 4096
)
func newLoginLimiter() *loginLimiter {
return &loginLimiter{attempts: make(map[string][]time.Time), lastCleanup: time.Now()}
}
func newLoginLimiter() *loginLimiter { return &loginLimiter{attempts: make(map[string][]time.Time)} }
func (l *loginLimiter) Allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
cutoff := time.Now().Add(-15 * time.Minute)
now := time.Now()
if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) >= loginLimiterCleanupEvery {
l.cleanupLocked(now)
}
cutoff := now.Add(-loginAttemptWindow)
values := l.attempts[ip]
kept := values[:0]
for _, value := range values {
@@ -2059,12 +2233,22 @@ func (l *loginLimiter) Allow(ip string) bool {
kept = append(kept, value)
}
}
l.attempts[ip] = kept
return len(kept) < 5
if len(kept) == 0 {
delete(l.attempts, ip)
} else {
l.attempts[ip] = kept
}
if len(kept) == 0 && len(l.attempts) >= loginLimiterMaxTrackedIPs {
return false
}
return len(kept) < loginAttemptLimit
}
func (l *loginLimiter) Fail(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
if _, exists := l.attempts[ip]; !exists && len(l.attempts) >= loginLimiterMaxTrackedIPs {
return
}
l.attempts[ip] = append(l.attempts[ip], time.Now())
}
func (l *loginLimiter) Reset(ip string) {
@@ -2072,3 +2256,21 @@ func (l *loginLimiter) Reset(ip string) {
defer l.mu.Unlock()
delete(l.attempts, ip)
}
func (l *loginLimiter) cleanupLocked(now time.Time) {
cutoff := now.Add(-loginAttemptWindow)
for ip, values := range l.attempts {
kept := values[:0]
for _, value := range values {
if value.After(cutoff) {
kept = append(kept, value)
}
}
if len(kept) == 0 {
delete(l.attempts, ip)
} else {
l.attempts[ip] = kept
}
}
l.lastCleanup = now
}
+265 -2
View File
@@ -9,10 +9,12 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
@@ -223,6 +225,267 @@ func TestLoginErrorRendersAsToast(t *testing.T) {
if strings.Contains(body, `class="flash"`) {
t.Error("legacy in-flow flash message is still rendered")
}
for _, removed := range []string{"Geschützter Bereich", "Geschützte Anmeldung", "Rate-Limit · CSRF-Schutz", "Sitzungen laufen automatisch ab"} {
if strings.Contains(body, removed) {
t.Errorf("login still renders removed security marketing copy %q", removed)
}
}
}
func TestLoginLimiterPrunesExpiredClientsAndBoundsMemory(t *testing.T) {
t.Parallel()
limiter := newLoginLimiter()
limiter.attempts["expired"] = []time.Time{time.Now().Add(-loginAttemptWindow - time.Minute)}
limiter.lastCleanup = time.Now().Add(-loginLimiterCleanupEvery - time.Second)
if !limiter.Allow("fresh") {
t.Fatal("fresh client was rejected after expired entries should have been pruned")
}
if _, exists := limiter.attempts["expired"]; exists {
t.Fatal("expired login limiter entry was not pruned")
}
now := time.Now()
for index := 0; index < loginLimiterMaxTrackedIPs; index++ {
limiter.attempts["client-"+strconv.Itoa(index)] = []time.Time{now}
}
limiter.lastCleanup = now
if limiter.Allow("overflow") {
t.Fatal("untracked client was accepted after the bounded limiter reached capacity")
}
}
func TestMembersCannotSeeOrUseAccessAdministration(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() })
hash, err := secure.HashPassword("a sufficiently secure test password")
if err != nil {
t.Fatal(err)
}
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
t.Fatal(err)
}
admin, _, err := database.GetUserByUsername(ctx, "admin")
if err != nil {
t.Fatal(err)
}
member, err := database.CreateUser(ctx, "member", "Mitglied", hash, "member")
if err != nil {
t.Fatal(err)
}
const rawToken = "member-session-token"
if _, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: secure.HashToken(rawToken), CSRFToken: "member-csrf", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatal(err)
}
server := &Server{config: config.Config{}, store: database}
handler := server.Handler()
requests := []struct {
method string
path string
body string
}{
{method: http.MethodGet, path: "/access"},
{method: http.MethodGet, path: "/users"},
{method: http.MethodGet, path: "/users/new"},
{method: http.MethodGet, path: "/system"},
{method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"},
}
for _, test := range requests {
request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
request.AddCookie(&http.Cookie{Name: "dyndns_session", Value: rawToken})
if test.body != "" {
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Errorf("member request %s %s returned %d, want 403", test.method, test.path, response.Code)
}
}
templates, err := loadTemplates()
if err != nil {
t.Fatal(err)
}
var rendered bytes.Buffer
data := viewData{Title: "Mein Konto", Page: "account", CSRF: "member-csrf", User: member, IsAdmin: false}
if err := templates["security"].ExecuteTemplate(&rendered, "security", data); err != nil {
t.Fatal(err)
}
body := rendered.String()
if !strings.Contains(body, `href="/account"`) {
t.Error("member navigation does not expose the personal account")
}
for _, forbidden := range []string{`href="/access"`, `href="/users`, `href="/system"`, `Zugriff verwalten`} {
if strings.Contains(body, forbidden) {
t.Errorf("member UI exposes administrator control %q", forbidden)
}
}
}
func TestAdministratorRightsRenderAsImmutableFullAccess(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatal(err)
}
admin := model.User{ID: 7, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
var rendered bytes.Buffer
data := viewData{Title: admin.DisplayName, Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, ManagedUser: admin, AccessZones: []accessZone{{Zone: model.Zone{ID: 9, Name: "example.de"}}}}
if err := templates["user_detail"].ExecuteTemplate(&rendered, "user_detail", data); err != nil {
t.Fatal(err)
}
body := rendered.String()
for _, expected := range []string{"Vollzugriff ist fest mit der Rolle verbunden", "keine bearbeitbaren Domain- oder Host-Einzelrechte", "Alle Rechte"} {
if !strings.Contains(body, expected) {
t.Errorf("immutable administrator access UI is missing %q", expected)
}
}
for _, forbidden := range []string{`name="zone_`, `name="target_`, `name="manage_zone_`, `name="manage_target_`, `name="create_zone_`} {
if strings.Contains(body, forbidden) {
t.Errorf("administrator detail renders editable permission input %q", forbidden)
}
}
}
func TestAccessNavigationCombinesUsersAndSecurity(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatal(err)
}
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
var rendered bytes.Buffer
data := viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Users: []model.User{admin}}
if err := templates["users"].ExecuteTemplate(&rendered, "users", data); err != nil {
t.Fatal(err)
}
body := rendered.String()
navStart, navEnd := strings.Index(body, "<nav "), strings.Index(body, "</nav>")
if navStart < 0 || navEnd <= navStart {
t.Fatal("administrator navigation was not rendered")
}
navigation := body[navStart:navEnd]
if !strings.Contains(navigation, `href="/access"`) || !strings.Contains(body, "Zugriff &amp; Sicherheit") {
t.Fatal("combined access administration entry is missing")
}
if strings.Contains(body, "Zugangsdaten") || strings.Contains(body, "API-Secrets bleiben verschlüsselt gespeichert") {
t.Fatal("removed credential policy card is still rendered")
}
for _, legacy := range []string{`href="/security"`, `href="/users"`} {
if strings.Contains(navigation, legacy) {
t.Errorf("legacy separate navigation entry remains: %q", legacy)
}
}
}
func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatal(err)
}
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
updateState := appupdate.State{CurrentVersion: "v1.2.2", LatestVersion: "v1.2.3", UpdateAvailable: true, AgentAvailable: true, CredentialConfigured: true}
var notification bytes.Buffer
accountData := viewData{Title: "Mein Konto", Page: "account", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
if err := templates["security"].ExecuteTemplate(&notification, "security", accountData); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Neue Version verfügbar", "v1.2.3 ist bereit", `data-software-update-form`, `data-software-update-overlay`} {
if !strings.Contains(notification.String(), expected) {
t.Errorf("global update notification is missing %q", expected)
}
}
var page bytes.Buffer
systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Update-Zugang ersetzen"} {
if !strings.Contains(page.String(), expected) {
t.Errorf("software update page is missing %q", expected)
}
}
var freshPage bytes.Buffer
freshData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.2.2"}}
if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Nach Update suchen", "Installiert", "Verfügbar", "Zugang fehlt", "Update-Zugang einrichten"} {
if !strings.Contains(freshPage.String(), expected) {
t.Errorf("fresh software update page is missing %q", expected)
}
}
if strings.Contains(freshPage.String(), "Update-Kanal") {
t.Error("confusing legacy update channel box is still rendered")
}
}
func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatal(err)
}
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
var rendered bytes.Buffer
data := viewData{Title: "Benutzer anlegen", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Form: map[string]string{"role": "member"}}
if err := templates["user_new"].ExecuteTemplate(&rendered, "user_new", data); err != nil {
t.Fatal(err)
}
body := rendered.String()
for _, expected := range []string{"user-create-panel", "user-create-identity-grid", "field-spacer", `name="password"`, `name="confirm_password"`} {
if !strings.Contains(body, expected) {
t.Errorf("user creation form is missing %q", expected)
}
}
for _, field := range []string{`name="password"`, `name="confirm_password"`} {
start := strings.Index(body, field)
if start < 0 {
continue
}
end := strings.Index(body[start:], ">")
if end < 0 {
t.Fatalf("password input %q has no closing bracket", field)
}
if strings.Contains(body[start:start+end], "minlength=") {
t.Errorf("password input %q still has a minimum length", field)
}
}
}
func TestSetupRendersFirstRunOnboardingAndPasswordGuidance(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatalf("load templates: %v", err)
}
var rendered bytes.Buffer
data := viewData{
Title: "Ersteinrichtung",
Page: "setup",
Mode: "lan",
CSRF: "csrf",
Form: map[string]string{"username": "admin", "display_name": "Administrator"},
}
if err := templates["setup"].ExecuteTemplate(&rendered, "setup", data); err != nil {
t.Fatalf("render setup: %v", err)
}
body := rendered.String()
for _, expected := range []string{`class="onboarding-shell"`, `class="onboarding-form-wrap"`, `class="onboarding-progress"`, `aria-current="step"`, `Erster Start`, `Administrator anlegen`, `Aktuell`, `Schritt 1 von 3`, `Instanz bereit`, `data-setup-form`, `data-setup-password`, `minlength="15"`, `maxlength="128"`, `LAN-Modus aktiv`} {
if !strings.Contains(body, expected) {
t.Errorf("first-run onboarding is missing %q", expected)
}
}
if strings.Contains(body, "Kein E-Mail-Reset") {
t.Error("first-run onboarding still renders the removed password reset footnote")
}
}
func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testing.T) {
@@ -238,7 +501,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
CSRF: "csrf",
IsAdmin: true,
Zone: model.Zone{ID: 1, Name: "example.de"},
Settings: model.Settings{IPv6Enabled: true},
Settings: model.Settings{IPv6Enabled: true, LastIPv4: "198.51.100.42"},
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}},
}
@@ -251,7 +514,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
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"`, `data-scroll-page-end`, `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.`} {
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-current-ipv4-placeholder`, `placeholder="198.51.100.42"`, `data-target-requirement`, `data-ipv6-enabled="true"`, `data-scroll-page-end`, `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)
}
+143 -8
View File
@@ -91,6 +91,32 @@
syncIPv6Settings();
}
const setupForm = document.querySelector('[data-setup-form]');
if (setupForm) {
const password = setupForm.querySelector('[data-setup-password]');
const confirmation = setupForm.querySelector('[data-setup-confirm]');
const lengthHint = setupForm.querySelector('[data-password-length]');
const matchHint = setupForm.querySelector('[data-password-match]');
const syncPasswordGuidance = () => {
const length = password ? [...password.value].length : 0;
const longEnough = length >= 15 && length <= 128;
const matches = Boolean(confirmation?.value) && confirmation.value === password?.value;
lengthHint?.classList.toggle('is-valid', longEnough);
matchHint?.classList.toggle('is-valid', matches);
lengthHint?.setAttribute('data-state', longEnough ? 'valid' : 'pending');
matchHint?.setAttribute('data-state', matches ? 'valid' : 'pending');
password?.classList.toggle('is-valid', longEnough);
confirmation?.classList.toggle('is-valid', matches);
setupForm.classList.toggle('is-password-ready', longEnough && matches);
confirmation?.setCustomValidity(confirmation.value && !matches ? 'Die Passphrasen stimmen nicht überein.' : '');
};
password?.addEventListener('input', syncPasswordGuidance);
confirmation?.addEventListener('input', syncPasswordGuidance);
syncPasswordGuidance();
}
for (const form of document.querySelectorAll('[data-target-form]')) {
const active = form.querySelector('[data-target-active]');
const staticFields = form.querySelector('[data-static-ip-fields]');
@@ -226,12 +252,107 @@
});
}
const countdowns = [...document.querySelectorAll('[data-countdown]')];
if (countdowns.length === 0) return;
const softwareUpdateForms = [...document.querySelectorAll('[data-software-update-form]')];
if (softwareUpdateForms.length > 0) {
const overlay = document.querySelector('[data-software-update-overlay]');
const message = overlay?.querySelector('[data-software-update-message]');
const close = overlay?.querySelector('[data-software-update-close]');
const phaseNodes = [...(overlay?.querySelectorAll('[data-update-phase]') || [])];
const phaseOrder = ['pulling', 'restarting', 'verifying'];
let polling = false;
let targetVersion = '';
let startedAt = 0;
let nextRun = Number(countdowns[0].dataset.countdown || 0);
let running = countdowns[0].dataset.running === 'true';
let enabled = countdowns[0].dataset.enabled !== 'false';
const setUpdatePhase = (phase, text) => {
if (!overlay) return;
overlay.dataset.updateState = phase;
if (message && text) message.textContent = text;
const currentIndex = phaseOrder.indexOf(phase);
for (const node of phaseNodes) {
const nodeIndex = phaseOrder.indexOf(node.dataset.updatePhase);
node.classList.toggle('is-active', nodeIndex === currentIndex);
node.classList.toggle('is-complete', currentIndex > nodeIndex || phase === 'complete');
}
};
const pollSoftwareUpdate = async () => {
if (!polling) return;
if (Date.now() - startedAt > 12 * 60 * 1000) {
polling = false;
setUpdatePhase('failed', 'Das Update dauert ungewöhnlich lange. Der DNS-Dienst läuft unabhängig davon weiter; prüfe den Status erneut.');
if (close) close.hidden = false;
return;
}
try {
const response = await fetch('/api/v1/software-update/status', {headers: {'Accept': 'application/json'}, cache: 'no-store'});
if (!response.ok) throw new Error('status unavailable');
const state = await response.json();
const phase = state.phase || 'queued';
const messages = {
queued: 'Update ist eingereiht.',
pulling: 'Das neue Container-Image wird geladen. Der laufende Dienst bleibt dabei online.',
restarting: 'Der Dienst wechselt kontrolliert auf die neue Version.',
verifying: 'Die neue Instanz durchläuft den Healthcheck.',
complete: `Version ${state.target_version || targetVersion} ist bereit.`,
failed: state.message || 'Update fehlgeschlagen. Die bisherige Version bleibt aktiv.',
};
setUpdatePhase(phase, messages[phase] || state.message || 'Update läuft.');
if (phase === 'complete') {
polling = false;
window.setTimeout(() => window.location.assign('/system'), 1400);
return;
}
if (phase === 'failed') {
polling = false;
if (close) close.hidden = false;
return;
}
} catch {
setUpdatePhase('reconnecting', 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.');
}
window.setTimeout(pollSoftwareUpdate, 1500);
};
for (const form of softwareUpdateForms) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (polling || !overlay) return;
const formData = new FormData(form);
targetVersion = String(formData.get('version') || '');
overlay.hidden = false;
document.body.classList.add('software-update-open');
setUpdatePhase('queued', `Update auf ${targetVersion} wird vorbereitet.`);
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.setAttribute('disabled', '');
try {
const response = await fetch(form.action, {method: 'POST', body: formData, headers: {'Accept': 'application/json'}});
const result = await response.json().catch(() => ({}));
if (!response.ok || !result.accepted) throw new Error(result.message || 'Update konnte nicht gestartet werden.');
polling = true;
startedAt = Date.now();
window.setTimeout(pollSoftwareUpdate, 400);
} catch (error) {
setUpdatePhase('failed', error instanceof Error ? error.message : 'Update konnte nicht gestartet werden.');
if (close) close.hidden = false;
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.removeAttribute('disabled');
}
});
}
close?.addEventListener('click', () => {
overlay.hidden = true;
document.body.classList.remove('software-update-open');
close.hidden = true;
for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled');
});
}
const countdowns = [...document.querySelectorAll('[data-countdown]')];
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
if (countdowns.length === 0 && currentIPv4Fields.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) => {
@@ -243,6 +364,7 @@
};
const refreshSchedule = async () => {
if (document.hidden) return;
const now = Date.now();
if (now - lastRefresh < 2500) return;
lastRefresh = now;
@@ -253,12 +375,17 @@
nextRun = Number(state.next_run_unix || 0);
running = Boolean(state.running);
enabled = state.enabled !== false;
if (typeof state.last_ipv4 === 'string' && state.last_ipv4.trim()) {
for (const field of currentIPv4Fields) field.placeholder = state.last_ipv4.trim();
}
} catch {
// The visible countdown keeps its last known state when the status request fails.
// Visible scheduler data keeps its last known state when the request fails.
}
};
const tick = () => {
if (document.hidden) return;
if (countdowns.length === 0) return;
if (!enabled) {
for (const node of countdowns) {
node.textContent = 'Inaktiv';
@@ -276,8 +403,16 @@
if (running || nextRun === 0 || remaining === 0) refreshSchedule();
};
tick();
if (countdowns.length > 0) {
tick();
window.setInterval(tick, 1000);
}
refreshSchedule();
window.setInterval(tick, 1000);
window.setInterval(refreshSchedule, 30000);
document.addEventListener('visibilitychange', () => {
if (document.hidden) return;
lastRefresh = 0;
tick();
refreshSchedule();
});
})();
+270 -4
View File
@@ -27,6 +27,7 @@
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
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; }
@@ -59,12 +60,15 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
.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; }
.nav-update-dot { width: 7px; height: 7px; margin-left: auto; border-radius: 50%; background: #60a5fa; box-shadow: 0 0 0 3px rgba(96,165,250,.14), 0 0 12px rgba(96,165,250,.42); }
.sidebar-account { display: grid; grid-template-columns: minmax(0, 1fr) 28px; gap: 7px; align-items: center; margin-top: auto; padding: 12px 5px 2px; border-top: 1px solid #26344a; }
.sidebar-account-link { display: grid; min-width: 0; grid-template-columns: 32px minmax(0,1fr); gap: 9px; align-items: center; padding: 3px; border-radius: 7px; }
.sidebar-account-link:hover { background: #1b2739; color: #fff; }
.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 form { display: block; }
.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; }
@@ -72,10 +76,21 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
.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; }
.software-update-banner { display: grid; width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); grid-template-columns: 38px minmax(0,1fr) auto; gap: 12px; align-items: center; margin: 14px auto -8px; padding: 11px 12px; border: 1px solid #274975; border-radius: 12px; background: radial-gradient(circle at 12% 0, rgba(59,130,246,.24), transparent 38%), linear-gradient(110deg, #122038, #172b48); color: #edf5ff; box-shadow: 0 14px 30px rgba(15,23,42,.12); animation: software-banner-in .45s cubic-bezier(.2,.8,.3,1) both; }
.software-update-banner-icon { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid rgba(147,197,253,.25); border-radius: 10px; background: rgba(37,99,235,.18); color: #93c5fd; font-size: 18px; }
.software-update-banner div > span, .software-update-banner div > strong { display: block; }
.software-update-banner div > span { color: #8fa6c2; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
.software-update-banner div > strong { margin-top: 2px; font-size: 12px; }
.software-update-banner-actions { display: flex; gap: 7px; align-items: center; }
.software-update-banner-actions form { display: contents; }
.software-update-banner .button-quiet { border-color: rgba(191,219,254,.2); background: rgba(255,255,255,.055); color: #dbeafe; }
.software-update-banner .button-quiet:hover { background: rgba(255,255,255,.1); color: #fff; }
.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%); }
.auth-shell { display: grid; min-height: 100vh; min-height: 100svh; place-items: center; padding: clamp(18px, 4vw, 48px); background: radial-gradient(circle at 12% 18%, rgba(37,99,235,.1), transparent 28%), linear-gradient(145deg, #edf2f7 0%, #f8fafc 55%, #eef2f6 100%); }
.page-setup { overflow: hidden; }
.page-setup .auth-shell { width: 100%; height: 100vh; height: 100svh; min-height: 0; overflow: hidden; padding: 0; }
.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; }
@@ -315,6 +330,91 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.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; }
.login-card { position: relative; overflow: hidden; width: min(460px, 100%); padding: 34px; border-radius: 18px; }
.login-card::before { position: absolute; top: 0; right: 0; left: 0; height: 3px; background: linear-gradient(90deg, var(--accent), #2dd4bf); content: ""; }
.login-card-top { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.login-mode { padding: 4px 7px; border: 1px solid #efd18a; border-radius: 99px; background: var(--warning-soft); color: var(--warning); font-family: var(--mono); font-size: 8px; font-weight: 800; }
.login-mode.is-secure { border-color: #b4ddd4; background: #effaf7; color: var(--success); }
.login-card h1 { margin-top: 36px; }
.login-card .lede { margin-bottom: 24px; }
.onboarding-shell { display: grid; width: 100%; height: 100%; min-height: 0; grid-template-columns: minmax(400px, .9fr) minmax(560px, 1.1fr); overflow: hidden; background: #f8fafc; }
.onboarding-intro { position: relative; display: flex; min-width: 0; overflow: hidden; flex-direction: column; padding: clamp(34px, 4vw, 64px); background: radial-gradient(circle at 12% -5%, rgba(59,130,246,.27), transparent 34%), radial-gradient(circle at 105% 88%, rgba(45,212,191,.12), transparent 36%), linear-gradient(150deg, #0e1726 0%, #152238 100%); color: #fff; }
.onboarding-intro::before { position: absolute; inset: 0; background-image: linear-gradient(rgba(148,163,184,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(148,163,184,.035) 1px, transparent 1px); background-size: 38px 38px; content: ""; pointer-events: none; }
.onboarding-intro::after { position: absolute; top: 0; right: 0; width: 1px; height: 100%; background: rgba(255,255,255,.12); content: ""; pointer-events: none; }
.onboarding-brand-row { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.onboarding-edition { color: #718096; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.auth-brand-inverse { color: #f8fafc; }
.auth-brand-inverse span { border: 1px solid #3a4b65; background: rgba(28,41,64,.88); color: #b9d0ff; box-shadow: 0 8px 24px rgba(0,0,0,.16); }
.onboarding-copy { position: relative; z-index: 1; margin-top: clamp(38px, 6vh, 68px); }
.onboarding-eyebrow { display: inline-flex; margin-bottom: 15px; padding: 5px 8px; border: 1px solid rgba(147,164,186,.2); border-radius: 99px; background: rgba(15,23,42,.24); color: #9fb0c7; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .13em; text-transform: uppercase; }
.onboarding-copy h1 { max-width: 620px; margin-bottom: 16px; color: #f8fafc; font-size: clamp(40px, 4.3vw, 64px); line-height: 1; letter-spacing: -.06em; }
.onboarding-copy p { max-width: 500px; margin: 0; color: #9aa8ba; font-size: clamp(11px, 1vw, 14px); line-height: 1.6; }
.onboarding-progress { position: relative; z-index: 1; max-width: 520px; margin-top: clamp(28px, 4vh, 42px); }
.onboarding-progress > div { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: #77869a; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.onboarding-progress strong { color: #aec8f6; font-size: 8px; }
.onboarding-progress-track { display: block; height: 3px; margin-top: 10px; overflow: hidden; border-radius: 99px; background: rgba(148,163,184,.14); }
.onboarding-progress-track i { display: block; width: 33.333%; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #3b82f6, #75a7ff); box-shadow: 0 0 16px rgba(59,130,246,.55); }
.onboarding-steps { position: relative; z-index: 1; display: grid; max-width: 520px; gap: 7px; margin: 14px 0 0; padding: 0; list-style: none; }
.onboarding-steps::before { position: absolute; top: 35px; bottom: 35px; left: 29px; z-index: -1; width: 1px; background: rgba(148,163,184,.14); content: ""; }
.onboarding-steps li { position: relative; display: grid; min-height: 62px; grid-template-columns: 36px minmax(0,1fr); gap: 13px; align-items: center; padding: 10px 12px; border: 1px solid transparent; border-radius: 12px; color: #718096; }
.onboarding-steps li > span { position: relative; z-index: 1; display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid rgba(148,163,184,.16); border-radius: 10px; background: #152238; color: #718096; font-family: var(--mono); font-size: 9px; font-weight: 750; }
.onboarding-steps strong, .onboarding-steps small { display: block; }
.onboarding-steps strong { color: #bac5d4; font-size: 12px; }
.onboarding-steps small { margin-top: 2px; color: #718096; font-size: 9px; line-height: 1.45; }
.onboarding-step-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.onboarding-step-title em { padding: 3px 6px; border-radius: 99px; background: rgba(59,130,246,.18); color: #a8c7ff; font-family: var(--mono); font-size: 7px; font-style: normal; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.onboarding-steps .is-current { border-color: rgba(96,165,250,.26); background: linear-gradient(90deg, rgba(37,99,235,.18), rgba(37,99,235,.055)); box-shadow: inset 3px 0 #4f8ff7, 0 10px 28px rgba(2,8,23,.12); }
.onboarding-steps .is-current > span { border-color: #4f8ff7; background: #2563eb; color: #fff; box-shadow: 0 6px 18px rgba(37,99,235,.28); }
.onboarding-steps .is-current strong { color: #fff; }
.onboarding-card { display: grid; min-width: 0; place-items: center; padding: clamp(32px, 4vw, 64px) clamp(36px, 6vw, 96px) clamp(48px, 7vw, 96px); background: #f8fafc; }
.onboarding-form-wrap { width: min(660px, 100%); }
.onboarding-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: clamp(20px, 3vh, 28px); color: #7b8797; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.setup-ready { display: inline-flex; align-items: center; gap: 8px; color: #4f6074; }
.setup-ready i { width: 7px; height: 7px; border-radius: 50%; background: #22a06b; box-shadow: 0 0 0 3px rgba(34,160,107,.1); }
.onboarding-form-heading h2 { margin: 0 0 10px; font-size: clamp(30px, 3.2vw, 44px); line-height: 1.08; letter-spacing: -.05em; }
.onboarding-lede { max-width: 590px; margin-bottom: clamp(18px, 2.6vh, 24px); color: var(--muted); font-size: 12px; line-height: 1.6; }
.setup-form { gap: 14px; }
.setup-field-row { gap: 16px; }
.setup-form label { gap: 6px; color: #334155; font-size: 10px; }
.setup-form input { min-height: 46px; border-radius: 8px; background: #fff; }
.setup-form input.is-valid:not(:focus) { border-color: #91cfc3; box-shadow: 0 0 0 3px rgba(15,118,110,.055); }
.password-guidance { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; }
.password-guidance span { display: flex; min-width: 0; min-height: 38px; align-items: center; gap: 8px; padding: 7px 10px; border: 1px solid #dbe2e9; border-radius: 9px; background: #fff; color: #738093; font-size: 9px; font-weight: 700; line-height: 1.25; transition: border-color .24s ease, background .24s ease, color .24s ease, box-shadow .24s ease, transform .24s ease; }
.password-guidance i { display: grid; width: 20px; height: 20px; flex: none; place-items: center; border-radius: 7px; background: #edf1f5; color: transparent; font-family: var(--mono); font-size: 10px; font-style: normal; font-weight: 900; transform: scale(.82); transition: background .24s ease, color .18s ease, transform .3s cubic-bezier(.2,1.5,.4,1); }
.password-guidance span.is-valid { border-color: #a8dacf; background: linear-gradient(120deg, #f4fbf9, #edf9f6); color: #0d6f67; box-shadow: 0 5px 16px rgba(15,118,110,.08); transform: translateY(-1px); animation: guidance-pop .38s cubic-bezier(.2,.9,.3,1.35) both; }
.password-guidance span.is-valid i { background: #0f8a7f; color: #fff; box-shadow: 0 4px 10px rgba(15,118,110,.2); transform: scale(1); animation: guidance-check .42s cubic-bezier(.2,1.55,.4,1) both; }
.setup-submit { position: relative; width: 100%; min-height: 48px; overflow: hidden; margin-top: 2px; border-radius: 8px; transition: border-color .2s, background .2s, box-shadow .24s, transform .2s; }
.setup-submit > span { position: relative; z-index: 1; transition: transform .22s ease; }
.setup-submit:hover > span { transform: translateX(3px); }
.setup-form.is-password-ready .setup-submit { box-shadow: 0 10px 24px rgba(37,99,235,.18); }
.setup-form.is-password-ready .setup-submit::after { position: absolute; top: -80%; bottom: -80%; left: -35%; width: 24%; background: linear-gradient(90deg, transparent, rgba(255,255,255,.48), transparent); content: ""; pointer-events: none; transform: rotate(16deg); animation: setup-button-sheen .75s ease-out both; }
.onboarding-footer { margin-top: clamp(16px, 2.5vh, 22px); padding-top: 16px; border-top: 1px solid var(--line); }
.onboarding-mode { display: grid; grid-template-columns: 20px minmax(0,1fr); gap: 8px; align-items: center; color: #7a5a16; }
.onboarding-mode > span { display: grid; width: 18px; height: 18px; place-items: center; border: 1px solid #d7bd80; border-radius: 50%; font-family: var(--mono); font-size: 8px; font-weight: 800; }
.onboarding-mode p { margin: 0; }
.onboarding-mode strong, .onboarding-mode small { display: block; }
.onboarding-mode strong { font-size: 9px; }
.onboarding-mode small { margin-top: 1px; color: #887347; font-size: 8px; font-weight: 500; line-height: 1.4; }
.onboarding-mode.is-secure { color: var(--success); }
.onboarding-mode.is-secure > span { border-color: #8bc5ba; }
.onboarding-mode.is-secure small { color: #59877f; }
@keyframes setup-reveal { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
@keyframes setup-panel-reveal { from { opacity: 0; transform: translateY(18px) scale(.988); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes setup-progress-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes setup-ready-pulse { 0% { box-shadow: 0 0 0 0 rgba(34,160,107,.32); } 65% { box-shadow: 0 0 0 7px rgba(34,160,107,0); } 100% { box-shadow: 0 0 0 3px rgba(34,160,107,.1); } }
@keyframes guidance-pop { 0% { transform: translateY(0) scale(.97); } 70% { transform: translateY(-2px) scale(1.015); } 100% { transform: translateY(-1px) scale(1); } }
@keyframes guidance-check { 0% { opacity: 0; transform: scale(.35) rotate(-18deg); } 70% { opacity: 1; transform: scale(1.12) rotate(3deg); } 100% { opacity: 1; transform: scale(1) rotate(0); } }
@keyframes setup-button-sheen { from { left: -35%; } to { left: 118%; } }
.onboarding-brand-row { animation: setup-reveal .5s cubic-bezier(.2,.8,.3,1) both; }
.onboarding-copy { animation: setup-reveal .55s .06s cubic-bezier(.2,.8,.3,1) both; }
.onboarding-progress { animation: setup-reveal .55s .12s cubic-bezier(.2,.8,.3,1) both; }
.onboarding-progress-track i { transform-origin: left; animation: setup-progress-fill .75s .38s cubic-bezier(.2,.8,.3,1) both; }
.onboarding-steps li { animation: setup-reveal .5s cubic-bezier(.2,.8,.3,1) both; }
.onboarding-steps li:nth-child(1) { animation-delay: .18s; }
.onboarding-steps li:nth-child(2) { animation-delay: .24s; }
.onboarding-steps li:nth-child(3) { animation-delay: .3s; }
.onboarding-form-wrap { animation: setup-panel-reveal .58s .08s cubic-bezier(.2,.8,.3,1) both; }
.setup-ready i { animation: setup-ready-pulse .8s .58s ease-out both; }
.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; }
@@ -336,6 +436,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.role-chip.admin { background: var(--accent-soft); color: #315eaa; }
.row-arrow { color: var(--accent); }
.access-panel { margin-top: 12px; }
.access-policy-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
.access-policy-card { display: grid; min-height: 76px; grid-template-columns: 36px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.access-policy-card.is-admin { border-color: #b9cdef; background: linear-gradient(120deg,#fff 62%,#f0f5ff); }
.access-policy-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 9px; background: #edf1f5; color: #526174; font-family: var(--mono); font-size: 10px; font-weight: 900; }
.access-policy-card.is-admin .access-policy-icon { background: var(--accent-soft); color: #315eaa; }
.access-policy-card strong, .access-policy-card small { display: block; }
.access-policy-card strong { font-size: 11px; }
.access-policy-card small { margin-top: 2px; color: var(--muted); font-size: 8px; line-height: 1.4; }
.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; }
@@ -349,6 +457,77 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.access-target strong, .access-target small { display: block; }
.access-target small { color: var(--muted); font-size: 8px; }
.access-manage-switch { white-space: nowrap; }
.admin-full-access-card { display: grid; grid-template-columns: 46px minmax(0,1fr) auto; gap: 14px; align-items: center; padding: 17px; border: 1px solid #acd8ce; border-radius: var(--radius); background: linear-gradient(110deg,#fff 65%,#eefaf7); box-shadow: var(--shadow); }
.admin-full-access-icon { display: grid; width: 44px; height: 44px; place-items: center; border-radius: 12px; background: #dff5ef; color: var(--success); font-family: var(--mono); font-size: 13px; font-weight: 900; }
.admin-full-access-card h2 { margin-bottom: 3px; }
.admin-full-access-card p { margin: 0; color: var(--muted); font-size: 10px; }
.account-password-panel { width: min(760px,100%); }
.user-create-panel { width: min(930px,100%); }
.user-create-panel .wide-form { max-width: none; }
.user-create-grid { gap: 14px; align-items: stretch; }
.user-create-grid > label { min-width: 0; align-content: start; }
.user-create-grid input { height: 42px; }
.user-create-identity-grid > label small { min-height: 27px; }
.field-spacer { visibility: hidden; }
.software-version-card { position: relative; display: grid; min-height: 164px; overflow: hidden; grid-template-columns: 62px minmax(0,1fr) auto; gap: 20px; align-items: center; margin-bottom: 12px; padding: 24px; border: 1px solid var(--line); border-radius: 14px; background: #fff; box-shadow: var(--shadow); }
.software-version-card::after { position: absolute; top: -90px; right: -70px; width: 260px; height: 260px; border-radius: 50%; background: radial-gradient(circle,rgba(37,99,235,.08),transparent 68%); content: ""; pointer-events: none; }
.software-version-card.has-update { border-color: #b4c9ec; background: linear-gradient(120deg,#fff 55%,#f1f6ff); box-shadow: 0 16px 38px rgba(37,99,235,.09); }
.software-version-visual { position: relative; display: grid; width: 58px; height: 58px; place-items: center; border: 1px solid #cbd8e8; border-radius: 16px; background: #f4f7fb; color: #315eaa; font-family: var(--mono); font-size: 11px; font-weight: 900; }
.software-version-visual i { position: absolute; right: -3px; bottom: -3px; width: 14px; height: 14px; border: 3px solid #fff; border-radius: 50%; background: #22a06b; }
.software-version-copy { min-width: 0; }
.software-version-values { display: flex; flex-wrap: wrap; gap: 0; align-items: stretch; }
.software-version-cell { display: grid; min-width: 150px; gap: 3px; align-content: center; padding-right: 24px; }
.software-version-cell + .software-version-cell { padding-left: 24px; border-left: 1px solid var(--line); }
.software-version-cell span { color: var(--muted); font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; }
.software-version-cell strong { color: var(--text); font-family: var(--mono); font-size: clamp(22px,2.6vw,32px); line-height: 1.1; letter-spacing: -.04em; }
.software-version-cell.is-latest strong { color: var(--accent); }
.software-version-state { margin: 9px 0 0; color: var(--muted); font-size: 10px; }
.software-version-meta { display: flex; flex-wrap: wrap; gap: 6px 15px; margin-top: 10px; color: var(--muted); font-size: 8px; }
.software-version-meta b { color: #4b5c70; font-weight: 750; }
.software-version-action { position: relative; z-index: 1; }
.software-check-button { gap: 7px; }
.software-check-button i { font-style: normal; font-size: 15px; line-height: 1; transition: transform .2s ease; }
.software-check-button:hover i { transform: rotate(45deg); }
.software-update-button { min-height: 44px; border-radius: 9px; box-shadow: 0 10px 22px rgba(37,99,235,.16); }
.software-update-button i { font-style: normal; transition: transform .2s ease; }
.software-update-button:hover i { transform: translate(2px,-2px); }
.update-credential-panel { width: min(930px,100%); }
.update-inline-warning { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; align-items: center; margin-top: 12px; padding: 9px; border: 1px solid #ead18e; border-radius: 8px; background: var(--warning-soft); }
.update-inline-warning > span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: rgba(154,103,0,.1); color: var(--warning); font-weight: 900; }
.update-inline-warning p { margin: 0; }
.update-inline-warning strong, .update-inline-warning small { display: block; }
.update-inline-warning strong { color: #74540b; font-size: 9px; }
.update-inline-warning small { margin-top: 2px; color: #887347; font-size: 8px; overflow-wrap: anywhere; }
.software-update-overlay { position: fixed; z-index: 2200; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(8,15,28,.72); backdrop-filter: blur(10px); animation: update-overlay-in .25s ease both; }
.software-update-dialog { width: min(520px,100%); padding: 30px; border: 1px solid rgba(255,255,255,.14); border-radius: 18px; background: #111c2e; color: #f8fafc; box-shadow: 0 30px 80px rgba(0,0,0,.32); text-align: center; }
.software-update-dialog .section-label { color: #7f94af; }
.software-update-dialog h2 { margin: 8px 0 9px; color: #fff; font-size: 24px; }
.software-update-dialog p { min-height: 38px; margin: 0 auto 20px; color: #9babc0; font-size: 11px; line-height: 1.55; }
.software-update-orbit { position: relative; display: grid; width: 62px; height: 62px; place-items: center; margin: 0 auto 20px; border: 1px solid #344862; border-radius: 18px; background: #18283f; color: #b7d1ff; font-family: var(--mono); font-size: 11px; font-weight: 900; }
.software-update-orbit i { position: absolute; inset: -8px; border: 1px solid transparent; border-top-color: #60a5fa; border-right-color: rgba(96,165,250,.25); border-radius: 22px; animation: update-orbit 1.15s linear infinite; }
.software-update-progress { height: 5px; overflow: hidden; border-radius: 99px; background: #24344b; }
.software-update-progress i { display: block; width: 8%; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#3b82f6,#60a5fa,#2dd4bf); box-shadow: 0 0 14px rgba(96,165,250,.45); transition: width .55s cubic-bezier(.2,.8,.3,1), background .2s; }
.software-update-overlay[data-update-state="pulling"] .software-update-progress i { width: 38%; }
.software-update-overlay[data-update-state="restarting"] .software-update-progress i { width: 65%; }
.software-update-overlay[data-update-state="reconnecting"] .software-update-progress i { width: 73%; }
.software-update-overlay[data-update-state="verifying"] .software-update-progress i { width: 88%; }
.software-update-overlay[data-update-state="complete"] .software-update-progress i { width: 100%; }
.software-update-overlay[data-update-state="failed"] .software-update-progress i { width: 100%; background: #e45c70; }
.software-update-overlay[data-update-state="complete"] .software-update-orbit i { border-color: #34d399; animation: none; }
.software-update-overlay[data-update-state="failed"] .software-update-orbit i { border-color: #fb7185; animation: none; }
.software-update-phases { display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; margin: 18px 0 0; padding: 0; list-style: none; }
.software-update-phases li { display: grid; gap: 4px; justify-items: center; color: #61738b; font-size: 8px; font-weight: 700; }
.software-update-phases li span { display: grid; width: 24px; height: 24px; place-items: center; border: 1px solid #2c3e57; border-radius: 8px; font-family: var(--mono); font-size: 7px; }
.software-update-phases li.is-active { color: #b9d4ff; }
.software-update-phases li.is-active span { border-color: #4b83d5; background: rgba(59,130,246,.16); color: #9ec5ff; }
.software-update-phases li.is-complete { color: #6fc5b6; }
.software-update-phases li.is-complete span { border-color: #327e73; background: rgba(15,118,110,.16); color: #75d4c4; }
.software-update-dialog [data-software-update-close] { margin: 20px auto 0; border-color: #40516a; background: #1a2940; color: #dbe7f6; }
body.software-update-open { overflow: hidden; }
@keyframes software-banner-in { from { opacity: 0; transform: translateY(-9px); } to { opacity: 1; transform: translateY(0); } }
@keyframes update-overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes update-orbit { to { transform: rotate(360deg); } }
.error-summary-grid { display: grid; grid-template-columns: repeat(4,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; }
@@ -381,6 +560,23 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.pihole-action-grid { grid-template-columns: 1fr; }
}
@media (max-width: 900px) {
.onboarding-shell { grid-template-columns: minmax(300px, .72fr) minmax(500px, 1.28fr); }
.onboarding-intro { padding: 34px; }
.onboarding-copy { margin-top: 38px; }
.onboarding-copy h1 { font-size: 43px; }
.onboarding-progress { margin-top: 27px; }
.onboarding-steps small { display: none; }
.onboarding-card { padding: 34px 42px; }
}
@media (max-width: 820px), (max-height: 600px) {
.onboarding-shell { grid-template-columns: 1fr; }
.onboarding-intro { display: none; }
.onboarding-card { padding: clamp(20px, 5vw, 38px); }
.onboarding-form-wrap { width: min(660px, 100%); }
}
@media (max-width: 720px) {
.admin-layout { display: block; }
.sidebar { position: static; height: auto; padding: 10px 12px; }
@@ -413,6 +609,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.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-policy-grid { grid-template-columns: 1fr; }
.access-zone-controls { width: 100%; }
.access-target { grid-template-columns: 1fr; }
.error-summary-grid { grid-template-columns: 1fr; }
@@ -420,9 +617,78 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.error-item > a { grid-column: 2; }
.pihole-action-grid { grid-template-columns: 1fr; }
.pihole-connect-stage { min-height: 0; }
.software-update-banner { width: calc(100% - 24px); grid-template-columns: 34px minmax(0,1fr); margin-top: 12px; }
.software-update-banner-actions { grid-column: 1 / -1; }
.software-update-banner-actions .button, .software-update-banner-actions form { width: 100%; }
.software-version-card { grid-template-columns: 48px minmax(0,1fr); gap: 13px; padding: 18px; }
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
.software-version-action { grid-column: 1 / -1; }
.software-version-action form, .software-version-action .button { width: 100%; }
.admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); }
.admin-full-access-card > .status-pill { grid-column: 2; }
.auth-shell { align-items: start; padding: 12px; }
.page-setup .auth-shell { align-items: stretch; padding: 0; }
.setup-field-row { grid-template-columns: repeat(2, minmax(0,1fr)); }
.onboarding-card { padding: 24px; }
.onboarding-card-meta { margin-bottom: 24px; }
.onboarding-form-heading h2 { font-size: 32px; }
.onboarding-lede { margin-bottom: 20px; }
.setup-form { gap: 12px; }
.setup-form input { min-height: 43px; }
.onboarding-footer { margin-top: 17px; padding-top: 15px; }
}
@media (max-width: 520px) {
.access-policy-card { grid-template-columns: 34px minmax(0,1fr); }
.access-policy-card > .status-pill { grid-column: 2; justify-self: start; }
.software-version-values { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); }
.software-version-cell { min-width: 0; padding-right: 12px; }
.software-version-cell + .software-version-cell { padding-left: 12px; }
.software-version-cell strong { font-size: 21px; }
.software-update-dialog { padding: 24px 18px; }
.onboarding-card { padding: 18px; }
.onboarding-card-meta { margin-bottom: 18px; }
.onboarding-form-heading h2 { font-size: 28px; }
.onboarding-lede { margin-bottom: 15px; font-size: 10px; }
.setup-field-row { grid-template-columns: 1fr; gap: 9px; }
.setup-form { gap: 9px; }
.setup-form label { gap: 4px; }
.setup-form input { min-height: 39px; }
.setup-submit { min-height: 42px; }
.onboarding-footer { margin-top: 12px; padding-top: 11px; }
}
@media (max-height: 700px) {
.onboarding-intro { padding-block: 30px; }
.onboarding-copy { margin-top: 28px; }
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
.onboarding-copy p { font-size: 11px; }
.onboarding-progress { margin-top: 20px; }
.onboarding-progress-track { margin-top: 7px; }
.onboarding-steps { gap: 4px; margin-top: 9px; }
.onboarding-steps li { min-height: 50px; padding-block: 7px; }
.onboarding-steps small { display: none; }
.onboarding-card { padding-block: 24px; }
.onboarding-card-meta { margin-bottom: 18px; }
.onboarding-form-heading h2 { font-size: 31px; }
.onboarding-lede { margin-bottom: 17px; }
.setup-form { gap: 10px; }
.setup-form input { min-height: 41px; }
.onboarding-footer { margin-top: 13px; padding-top: 13px; }
}
@media (max-height: 560px) {
.onboarding-card-meta { margin-bottom: 12px; }
.onboarding-lede { display: none; }
.onboarding-form-heading h2 { margin-bottom: 14px; }
.setup-form input { min-height: 38px; }
.password-guidance span { min-height: 32px; padding: 5px 7px; }
.password-guidance i { width: 18px; height: 18px; }
.setup-submit { min-height: 40px; }
.onboarding-footer { padding-top: 10px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
.toast, .toast-progress { animation: none !important; }
}
+2 -2
View File
@@ -32,7 +32,7 @@
<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>
<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 data-current-ipv4-placeholder value="{{.StaticIPv4}}" inputmode="decimal" placeholder="{{if $.Settings.LastIPv4}}{{$.Settings.LastIPv4}}{{else}}203.0.113.10{{end}}" {{if .Active}}disabled{{else if .UpdateIPv4}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist; grau steht die zuletzt ermittelte aktuelle IPv4.</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>
@@ -106,7 +106,7 @@
{{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>
<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 data-current-ipv4-placeholder inputmode="decimal" placeholder="{{if .Settings.LastIPv4}}{{.Settings.LastIPv4}}{{else}}203.0.113.10{{end}}" disabled><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist; grau steht die zuletzt ermittelte aktuelle IPv4.</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>
+27 -6
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>{{.Title}} · DNS Control</title>
<link rel="stylesheet" href="/static/styles.css?v=features-15">
<script src="/static/app.js?v=features-15" defer></script>
<link rel="stylesheet" href="/static/styles.css?v=features-21">
<script src="/static/app.js?v=features-21" defer></script>
</head>
<body class="page-{{.Page}}">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
@@ -26,13 +26,12 @@
<a href="/pihole" {{if eq .Page "pihole"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Pi-hole</a>
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Updater</a>
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Fehler</a>
<a href="/users" {{if eq .Page "users"}}aria-current="page"{{end}}><span class="nav-icon">06</span>Benutzer</a>
<a href="/access" {{if eq .Page "access"}}aria-current="page"{{end}}><span class="nav-icon">06</span>Zugriff</a>
<a href="/system" {{if eq .Page "system"}}aria-current="page"{{end}}><span class="nav-icon">07</span>Update{{if .SoftwareUpdate.UpdateAvailable}}<i class="nav-update-dot" aria-label="Update verfügbar"></i>{{end}}</a>
{{end}}
<a href="/security" {{if eq .Page "security"}}aria-current="page"{{end}}><span class="nav-icon">{{if .IsAdmin}}07{{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>
<a class="sidebar-account-link" href="/account" aria-label="Eigenes Konto öffnen"><span class="account-avatar">{{initial .User.DisplayName}}</span><span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}} · {{if .IsAdmin}}Admin{{else}}Mitglied{{end}}</small></span></a>
<form action="/logout" method="post">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" title="Abmelden" aria-label="Abmelden">↪</button>
@@ -44,6 +43,16 @@
<div><span>DNS Control</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>
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
<aside class="software-update-banner" aria-label="Software-Update verfügbar">
<span class="software-update-banner-icon" aria-hidden="true">↗</span>
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
<div class="software-update-banner-actions">
<a class="button button-quiet" href="/system">Details</a>
{{if .SoftwareUpdate.AgentAvailable}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
</div>
</aside>
{{end}}
<main id="main" class="app-shell">
{{template "content" .}}
</main>
@@ -67,6 +76,18 @@
</div>
</div>
{{end}}
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title-global">
<div class="software-update-dialog">
<div class="software-update-orbit" aria-hidden="true"><span>DC</span><i></i></div>
<span class="section-label">Systemupdate</span><h2 id="software-update-title-global">Update wird vorbereitet</h2>
<p data-software-update-message>Das neue Container-Image wird geladen.</p>
<div class="software-update-progress"><i data-software-update-progress></i></div>
<ol class="software-update-phases" aria-label="Update-Fortschritt"><li data-update-phase="pulling"><span>01</span>Image laden</li><li data-update-phase="restarting"><span>02</span>Dienst wechseln</li><li data-update-phase="verifying"><span>03</span>Healthcheck</li></ol>
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
</div>
</div>
{{end}}
</body>
</html>
{{end}}
+6 -6
View File
@@ -1,18 +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>
<section class="auth-card login-card">
<div class="login-card-top"><div class="auth-brand"><span>DD</span> DNS Control</div><span class="login-mode {{if eq .Mode "https"}}is-secure{{end}}">{{if eq .Mode "https"}}HTTPS{{else}}LAN{{end}}</span></div>
<h1>Willkommen zurück</h1>
<p class="lede">Melde dich mit deinem persönlichen Zugang an.</p>
<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>
<input name="username" type="text" autocomplete="username" autocapitalize="none" spellcheck="false" maxlength="40" required autofocus>
</label>
<label>Passwort
<input name="password" type="password" autocomplete="current-password" required>
<input name="password" type="password" autocomplete="current-password" maxlength="128" 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}}
+2 -5
View File
@@ -1,8 +1,5 @@
{{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>
<header class="page-heading"><div><span class="page-kicker">Persönlich</span><h1>Mein Konto</h1><p>@{{.User.Username}} · {{if .IsAdmin}}Administrator{{else}}Mitglied{{end}}</p></div>{{if .IsAdmin}}<a class="button button-quiet" href="/access">Zugriff verwalten</a>{{end}}</header>
<section class="form-panel account-password-panel"><div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Eigenes Passwort ändern</h2></div></div><form action="/account/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" maxlength="128" required></label><div class="form-grid"><label>Neues Passwort <input type="password" name="new_password" autocomplete="new-password" minlength="15" maxlength="128" required></label><label>Passwort wiederholen <input type="password" name="confirm_password" autocomplete="new-password" minlength="15" maxlength="128" required></label></div><button class="button button-primary" type="submit">Passwort aktualisieren</button></form></section>
{{end}}
+60 -22
View File
@@ -1,26 +1,64 @@
{{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 class="onboarding-shell">
<aside class="onboarding-intro">
<div class="onboarding-brand-row">
<div class="auth-brand auth-brand-inverse"><span>DD</span> DNS Control</div>
<span class="onboarding-edition">Self-hosted</span>
</div>
<div class="onboarding-copy">
<span class="onboarding-eyebrow">Erster Start</span>
<h1>DNS-Verwaltung,<br>die einfach läuft.</h1>
<p>Einrichten, verbinden, automatisch aktuell halten.</p>
</div>
<div class="onboarding-progress" aria-label="Einrichtung: Schritt 1 von 3">
<div><span>Einrichtung</span><strong>1 von 3</strong></div>
<span class="onboarding-progress-track" aria-hidden="true"><i></i></span>
</div>
<ol class="onboarding-steps" aria-label="Einrichtung">
<li class="is-current" aria-current="step"><span>01</span><div><div class="onboarding-step-title"><strong>Administrator anlegen</strong><em>Aktuell</em></div><small>Sicheren Zugang für diese Instanz erstellen.</small></div></li>
<li><span>02</span><div><strong>Dienste verbinden</strong><small>Hetzner und optional Pi-hole hinzufügen.</small></div></li>
<li><span>03</span><div><strong>Automatisieren</strong><small>DNS-Einträge zuverlässig aktuell halten.</small></div></li>
</ol>
</aside>
<section class="onboarding-card" aria-labelledby="setup-title">
<div class="onboarding-form-wrap">
<div class="onboarding-card-meta">
<span class="setup-ready"><i aria-hidden="true"></i> Instanz bereit</span>
<span>Schritt 1 von 3</span>
</div>
<header class="onboarding-form-heading">
<h2 id="setup-title">Administrator anlegen</h2>
<p class="onboarding-lede">Lege den ersten Administrator für diese Instanz an.</p>
</header>
<form action="/setup" method="post" class="stacked-form setup-form" data-setup-form>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="form-grid setup-field-row"><label>Benutzername
<input name="username" type="text" value="{{index .Form "username"}}" autocomplete="username" autocapitalize="none" spellcheck="false" minlength="3" maxlength="40" required autofocus>
</label>
<label>Anzeigename
<input name="display_name" type="text" value="{{index .Form "display_name"}}" autocomplete="name" maxlength="80" required>
</label></div>
<div class="form-grid setup-field-row"><label>Admin-Passphrase
<input name="password" type="password" autocomplete="new-password" minlength="15" maxlength="128" aria-describedby="password-guidance" data-setup-password required>
</label>
<label>Passphrase wiederholen
<input name="confirm_password" type="password" autocomplete="new-password" minlength="15" maxlength="128" data-setup-confirm required>
</label></div>
<div class="password-guidance" id="password-guidance" aria-live="polite">
<span data-password-length><i aria-hidden="true">✓</i> Mindestens 15 Zeichen</span>
<span data-password-match><i aria-hidden="true">✓</i> Eingaben stimmen überein</span>
</div>
<button class="button button-primary setup-submit" type="submit">Administrator erstellen <span aria-hidden="true">→</span></button>
</form>
<div class="onboarding-footer onboarding-mode {{if eq .Mode "https"}}is-secure{{end}}">
<span aria-hidden="true">{{if eq .Mode "https"}}●{{else}}!{{end}}</span>
<p><strong>{{if eq .Mode "https"}}HTTPS-Modus aktiv{{else}}LAN-Modus aktiv{{end}}</strong><small>{{if eq .Mode "https"}}Verschlüsselte Verbindung{{else}}Nur im lokalen Netz verwenden{{end}}</small></p>
</div>
</div>
</section>
</section>
{{end}}
+58
View File
@@ -0,0 +1,58 @@
{{define "system"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading system-heading">
<div><span class="page-kicker">Wartung</span><h1>Software-Update</h1><p>Version prüfen und die Instanz kontrolliert aktualisieren.</p></div>
<form action="/system/check" method="post"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button button-quiet software-check-button" type="submit"><i aria-hidden="true">↻</i> Nach Update suchen</button></form>
</header>
<section class="software-version-card {{if .SoftwareUpdate.UpdateAvailable}}has-update{{end}}">
<div class="software-version-visual" aria-hidden="true"><span>DC</span><i></i></div>
<div class="software-version-copy">
<div class="software-version-values">
<div class="software-version-cell"><span>Installiert</span><strong>{{.SoftwareUpdate.CurrentVersion}}</strong></div>
<div class="software-version-cell is-latest"><span>Verfügbar</span><strong>{{if .SoftwareUpdate.LatestVersion}}{{.SoftwareUpdate.LatestVersion}}{{else}}–{{end}}</strong></div>
</div>
<p class="software-version-state">{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if .SoftwareUpdate.LastCheckedAt}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p>
<div class="software-version-meta"><span>Letzte Prüfung <b>{{formatTime .SoftwareUpdate.LastCheckedAt}}</b></span><span>Update-Agent <b>{{if .SoftwareUpdate.AgentAvailable}}bereit{{else}}nicht erreichbar{{end}}</b></span></div>
{{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</small></p></div>{{end}}
</div>
<div class="software-version-action">
{{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentAvailable}}
<form action="/system/update" method="post" data-software-update-form>
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}">
<button class="button button-primary software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
</form>
{{else if .SoftwareUpdate.UpdateAvailable}}
<span class="status-pill status-warn">Update-Agent nicht erreichbar</span>
{{else}}
<span class="status-pill {{if or .SoftwareUpdate.LastError (not .SoftwareUpdate.CredentialConfigured)}}status-warn{{else}}status-good{{end}}">{{if not .SoftwareUpdate.CredentialConfigured}}Zugang fehlt{{else if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}Bereit{{end}}</span>
{{end}}
</div>
</section>
<section class="panel update-credential-panel" id="update-access">
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>{{if .SoftwareUpdate.CredentialConfigured}}Update-Zugang ersetzen{{else}}Update-Zugang einrichten{{end}}</h2></div>{{if .SoftwareUpdate.CredentialConfigured}}<span class="status-pill status-good">verschlüsselt</span>{{end}}</div>
<form action="/system/update-credential" method="post" class="stacked-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="form-grid"><label>Gitea-Benutzername <input name="registry_username" autocomplete="username" maxlength="128" required></label><label>Token <input name="registry_token" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></label></div>
<label>Admin-Passwort bestätigen <input name="current_password" type="password" autocomplete="current-password" maxlength="128" required></label>
<div class="form-actions"><button class="button button-primary" type="submit">Zugang sicher speichern</button></div>
</form>
</section>
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title">
<div class="software-update-dialog">
<div class="software-update-orbit" aria-hidden="true"><span>DC</span><i></i></div>
<span class="section-label">Systemupdate</span>
<h2 id="software-update-title">Update wird vorbereitet</h2>
<p data-software-update-message>Das neue Container-Image wird geladen. DNS-Daten und Secrets bleiben erhalten.</p>
<div class="software-update-progress"><i data-software-update-progress></i></div>
<ol class="software-update-phases" aria-label="Update-Fortschritt">
<li data-update-phase="pulling"><span>01</span>Image laden</li>
<li data-update-phase="restarting"><span>02</span>Dienst wechseln</li>
<li data-update-phase="verifying"><span>03</span>Healthcheck</li>
</ol>
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
</div>
</div>
{{end}}
+4 -4
View File
@@ -2,7 +2,7 @@
{{define "content"}}
<header class="page-heading">
<div>
<a class="back-link" href="/users">← Benutzer</a>
<a class="back-link" href="/access">← Zugriff & Sicherheit</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>
@@ -25,8 +25,8 @@
<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>
<label>Neues Passwort <input name="password" type="password" minlength="15" maxlength="128" autocomplete="new-password" required></label>
<label>Passwort wiederholen <input name="confirm_password" type="password" minlength="15" maxlength="128" 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>
@@ -68,6 +68,6 @@
</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>
<section class="admin-full-access-card access-panel"><span class="admin-full-access-icon" aria-hidden="true">A</span><div><span class="section-label">Administratorrolle</span><h2>Vollzugriff ist fest mit der Rolle verbunden</h2><p>Für Administratoren gibt es keine bearbeitbaren Domain- oder Host-Einzelrechte.</p></div><span class="status-pill status-good">Alle Rechte</span></section>
{{end}}
{{end}}
+5 -5
View File
@@ -1,13 +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">
<header class="page-heading"><div><a class="back-link" href="/access">← Zugriff & Sicherheit</a><span class="page-kicker">Neues Konto</span><h1>Benutzer anlegen</h1><p>Mitgliedskonten starten ohne Freigaben.</p></div></header>
<section class="form-panel compact-panel user-create-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>
<div class="form-grid user-create-grid user-create-identity-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><small class="field-spacer" aria-hidden="true">&nbsp;</small></label></div>
<div class="form-grid user-create-grid"><label>Startpasswort <input name="password" type="password" autocomplete="new-password" maxlength="128" required></label><label>Passwort wiederholen <input name="confirm_password" type="password" autocomplete="new-password" maxlength="128" 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>
<div class="form-actions"><button class="button button-primary" type="submit">Benutzer anlegen</button><a class="button button-quiet" href="/access">Abbrechen</a></div>
</form>
</section>
{{end}}
+5 -1
View File
@@ -1,5 +1,9 @@
{{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>
<header class="page-heading"><div><span class="page-kicker">Administration</span><h1>Zugriff & Sicherheit</h1><p>Konten, Rollen und Freigaben an einem Ort.</p></div><div class="heading-actions"><a class="button button-quiet" href="/account">Mein Konto</a><a class="button button-primary" href="/users/new">Benutzer anlegen</a></div></header>
<section class="access-policy-grid" aria-label="Rollenmodell">
<article class="access-policy-card is-admin"><span class="access-policy-icon" aria-hidden="true">A</span><div><strong>Administratoren</strong><small>Vollzugriff ohne bearbeitbare Einzelrechte</small></div><span class="status-pill status-good">Vollzugriff</span></article>
<article class="access-policy-card"><span class="access-policy-icon" aria-hidden="true">M</span><div><strong>Mitglieder</strong><small>Nur ausdrücklich zugewiesene Domains und Hosts</small></div><span class="status-pill">Freigaben</span></article>
</section>
<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}}