feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
a000152423
commit
f02ec9977c
41 files changed
+3277
-151
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
@@ -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(¤tRole, ¤tActive); 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 & 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(¬ification, "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
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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"> </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}}
|
||||
@@ -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}}
|
||||
Reference in new issue
Block a user