fix: harden DynDNS recovery and publish v1.5.1
This commit is contained in:
1 parent
3383b91e49
commit
81def77e3c
37 files changed
+2415
-250
No files matched your search
@@ -0,0 +1,70 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
)
|
||||
|
||||
// Only this allowlisted report is downloadable, never raw Docker logs, stored
|
||||
// error messages, audit messages, configuration, credentials or session data.
|
||||
func (s *Server) downloadDiagnostics(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
settings, err := s.store.GetSettings(r.Context())
|
||||
if err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
runs, err := s.store.RecentRuns(r.Context(), 100)
|
||||
if err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
type run struct {
|
||||
ID int64 `json:"id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
report := struct {
|
||||
Format int `json:"format"`
|
||||
Version string `json:"version"`
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
Network *diagnostics.Network `json:"current_network"`
|
||||
UpdaterEnabled bool `json:"updater_enabled"`
|
||||
IPv6Enabled bool `json:"ipv6_enabled"`
|
||||
IntervalSeconds int `json:"interval_seconds"`
|
||||
LastIPv4 string `json:"last_ipv4,omitempty"`
|
||||
LastIPv6 string `json:"last_ipv6,omitempty"`
|
||||
Runs []run `json:"recent_runs"`
|
||||
Events []diagnostics.Event `json:"events"`
|
||||
}{
|
||||
Format: 1, Version: buildinfo.Version, ExportedAt: time.Now().UTC(), Network: diagnostics.CurrentNetwork(),
|
||||
UpdaterEnabled: settings.UpdaterEnabled, IPv6Enabled: settings.IPv6Enabled, IntervalSeconds: settings.UpdateIntervalSeconds,
|
||||
Events: diagnostics.Snapshot(),
|
||||
}
|
||||
if ip, err := netip.ParseAddr(settings.LastIPv4); err == nil && ip.Zone() == "" {
|
||||
report.LastIPv4 = ip.String()
|
||||
}
|
||||
if ip, err := netip.ParseAddr(settings.LastIPv6); err == nil && ip.Zone() == "" {
|
||||
report.LastIPv6 = ip.String()
|
||||
}
|
||||
for _, item := range runs {
|
||||
status := "unknown"
|
||||
switch item.Status {
|
||||
case "running", "ok", "error", "partial":
|
||||
status = item.Status
|
||||
}
|
||||
report.Runs = append(report.Runs, run{item.ID, item.StartedAt, status})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="domain-control-diagnostics.json"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
_ = encoder.Encode(report)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
func TestPiHolePageAndRemovalNeverContactUnavailablePiHole(t *testing.T) {
|
||||
t.Parallel()
|
||||
var requests atomic.Int32
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); http.Error(w, "unavailable", 503) }))
|
||||
defer api.Close()
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: api.URL + "/api"}, model.PiHoleCredential{Ciphertext: []byte("secret"), Nonce: []byte("secret")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = database.SyncPiHoleRecordLinks(ctx, integration.ID, []string{"192.168.1.10 nas.lan"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = database.MarkPiHoleSynced(ctx, integration.ID, "connection timed out"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{store: database, templates: templates, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
session := &model.Session{CSRFToken: "csrf", Username: "admin", Role: "admin"}
|
||||
response := httptest.NewRecorder()
|
||||
server.piHolePage(response, httptest.NewRequest(http.MethodGet, "/pihole", nil), session)
|
||||
if response.Code != 200 || !strings.Contains(response.Body.String(), "nas.lan") || !strings.Contains(response.Body.String(), "Lokale Verbindung entfernen") {
|
||||
t.Fatalf("offline page unavailable: %d", response.Code)
|
||||
}
|
||||
id := strconv.FormatInt(integration.ID, 10)
|
||||
request := httptest.NewRequest(http.MethodPost, "/pihole/"+id+"/remove", strings.NewReader(url.Values{"csrf": {"csrf"}}.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.SetPathValue("id", id)
|
||||
response = httptest.NewRecorder()
|
||||
server.piHoleRemove(response, request, session)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("offline removal failed: %d", response.Code)
|
||||
}
|
||||
integrations, err := database.ListPiHoleIntegrations(ctx)
|
||||
if err != nil || len(integrations) != 0 {
|
||||
t.Fatal("connection not removed")
|
||||
}
|
||||
if requests.Load() != 0 {
|
||||
t.Fatalf("offline page/removal contacted Pi-hole %d times", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticDownloadExcludesStoredSecretsAndFreeText(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)
|
||||
}
|
||||
defer database.Close()
|
||||
const secret = "git-hetzner-pihole-cookie-password-secret"
|
||||
if err = database.SaveSoftwareUpdateCredential(ctx, secret, []byte(secret), []byte(secret)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = database.AddAudit(ctx, "arbitrary", secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := database.CreateUpdateRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = database.FinishUpdateRun(ctx, id, "error", secret, secret, "https://user:"+secret+"@host/?token="+secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{store: database, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
response := httptest.NewRecorder()
|
||||
server.downloadDiagnostics(response, httptest.NewRequest(http.MethodGet, "/errors/diagnostics", nil), &model.Session{Role: "admin"})
|
||||
if response.Code != 200 || !strings.Contains(response.Header().Get("Content-Disposition"), "attachment") || response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("invalid download: %d", response.Code)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), secret) {
|
||||
t.Fatal("secret included in download")
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"status": "error"`) {
|
||||
t.Fatal("run status missing")
|
||||
}
|
||||
anonymous := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, "/errors/diagnostics", nil))
|
||||
if anonymous.Code == 200 {
|
||||
t.Fatal("anonymous diagnostic access accepted")
|
||||
}
|
||||
}
|
||||
+29
-25
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
|
||||
"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/store"
|
||||
@@ -44,18 +45,12 @@ func (s *Server) piHolePage(w http.ResponseWriter, r *http.Request, session *mod
|
||||
var view *piHoleView
|
||||
if len(integrations) > 0 {
|
||||
integration := integrations[0]
|
||||
current := piHoleView{Integration: integration}
|
||||
client, password, accessErr := s.piHoleAccess(r.Context(), integration.ID)
|
||||
var links []model.PiHoleRecordLink
|
||||
if accessErr == nil {
|
||||
var hosts []string
|
||||
hosts, accessErr = client.ListHosts(r.Context(), password)
|
||||
if accessErr == nil {
|
||||
links, accessErr = s.store.SyncPiHoleRecordLinks(r.Context(), integration.ID, hosts)
|
||||
}
|
||||
}
|
||||
if accessErr != nil {
|
||||
links, _ = s.store.ListPiHoleRecordLinks(r.Context(), integration.ID)
|
||||
current := piHoleView{Integration: integration, FetchError: integration.LastError}
|
||||
// Navigation and removing an obsolete connection must work offline.
|
||||
links, err := s.store.ListPiHoleRecordLinks(r.Context(), integration.ID)
|
||||
if err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
for _, link := range links {
|
||||
record := parsePiHoleRecord(link.Value)
|
||||
@@ -68,15 +63,6 @@ func (s *Server) piHolePage(w http.ResponseWriter, r *http.Request, session *mod
|
||||
current.ArchivedRecords = append(current.ArchivedRecords, record)
|
||||
}
|
||||
}
|
||||
if accessErr != nil {
|
||||
current.FetchError = safeMessage(accessErr)
|
||||
current.Integration.LastError = current.FetchError
|
||||
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, current.FetchError)
|
||||
} else {
|
||||
current.Integration.LastError = ""
|
||||
current.Integration.LastSyncAt = time.Now().UTC()
|
||||
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, "")
|
||||
}
|
||||
sortPiHoleRecords(current.Records)
|
||||
sortPiHoleRecords(current.ArchivedRecords)
|
||||
view = ¤t
|
||||
@@ -122,7 +108,8 @@ func (s *Server) piHoleConnect(w http.ResponseWriter, r *http.Request, session *
|
||||
s.redirectNotice(w, r, "/pihole", err.Error(), "error")
|
||||
return
|
||||
}
|
||||
if _, err := client.ListHosts(r.Context(), password); err != nil {
|
||||
hosts, err := client.ListHosts(r.Context(), password)
|
||||
if err != nil {
|
||||
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung fehlgeschlagen: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
@@ -140,6 +127,10 @@ func (s *Server) piHoleConnect(w http.ResponseWriter, r *http.Request, session *
|
||||
s.redirectNotice(w, r, "/pihole", message, "error")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.SyncPiHoleRecordLinks(r.Context(), integration.ID, hosts); err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, "")
|
||||
_ = s.store.AddAudit(r.Context(), "pihole.connected", "Pi-hole verbunden: "+integration.Name+" durch "+session.Username)
|
||||
s.redirectNotice(w, r, piHoleURL(integration.ID), "Pi-hole verbunden und lokale DNS-Einträge eingelesen.")
|
||||
@@ -180,7 +171,8 @@ func (s *Server) piHoleSave(w http.ResponseWriter, r *http.Request, session *mod
|
||||
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||
return
|
||||
}
|
||||
if _, err := client.ListHosts(r.Context(), password); err != nil {
|
||||
hosts, err := client.ListHosts(r.Context(), password)
|
||||
if err != nil {
|
||||
s.redirectNotice(w, r, piHoleURL(id), "Neue Pi-hole-Konfiguration nicht gespeichert: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
@@ -194,6 +186,10 @@ func (s *Server) piHoleSave(w http.ResponseWriter, r *http.Request, session *mod
|
||||
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung konnte nicht gespeichert werden: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.SyncPiHoleRecordLinks(r.Context(), id, hosts); err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||
_ = s.store.AddAudit(r.Context(), "pihole.updated", "Pi-hole-Verbindung geändert: "+integration.Name+" durch "+session.Username)
|
||||
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung geprüft und gespeichert.")
|
||||
@@ -254,16 +250,24 @@ func (s *Server) piHoleRefresh(w http.ResponseWriter, r *http.Request, session *
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
client, password, err := s.piHoleAccess(r.Context(), id)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
client, password, err := s.piHoleAccess(ctx, id)
|
||||
var hosts []string
|
||||
if err == nil {
|
||||
_, err = client.ListHosts(r.Context(), password)
|
||||
hosts, err = client.ListHosts(ctx, password)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = s.store.SyncPiHoleRecordLinks(r.Context(), id, hosts)
|
||||
}
|
||||
if err != nil {
|
||||
diagnostics.Record(diagnostics.Event{Code: "pihole_refresh_failed", Target: "pihole", Failure: diagnostics.Failure(err), HTTPStatus: diagnostics.HTTPStatus(err)})
|
||||
message := safeMessage(err)
|
||||
_ = s.store.MarkPiHoleSynced(r.Context(), id, message)
|
||||
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole konnte nicht neu eingelesen werden: "+message, "error")
|
||||
return
|
||||
}
|
||||
diagnostics.Record(diagnostics.Event{Code: "pihole_refresh_ok", Target: "pihole"})
|
||||
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||
s.redirectNotice(w, r, piHoleURL(id), "Lokale DNS-Einträge wurden neu eingelesen.")
|
||||
}
|
||||
|
||||
+51
-23
@@ -14,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -144,22 +145,22 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /", s.authenticated(s.dashboard))
|
||||
mux.Handle("GET /domains", s.authenticated(s.domains))
|
||||
mux.Handle("GET /domains/new", s.adminOnly(s.domainNew))
|
||||
mux.Handle("POST /domains", s.adminOnly(s.domainCreate))
|
||||
mux.Handle("POST /domains", s.adminOnly(s.dnsChange(s.domainCreate)))
|
||||
mux.Handle("GET /domains/{id}", s.authenticated(s.domainDetail))
|
||||
mux.Handle("POST /domains/{id}/import", s.adminOnly(s.domainImport))
|
||||
mux.Handle("POST /domains/{id}/ttl", s.adminOnly(s.domainTTLSave))
|
||||
mux.Handle("POST /domains/{id}/remove", s.adminOnly(s.domainRemove))
|
||||
mux.Handle("POST /domains/{id}/delete", s.adminOnly(s.domainDelete))
|
||||
mux.Handle("POST /domains/{id}/token", s.adminOnly(s.domainToken))
|
||||
mux.Handle("POST /targets", s.authenticated(s.targetCreate))
|
||||
mux.Handle("POST /targets/{id}/save", s.authenticated(s.targetSave))
|
||||
mux.Handle("POST /targets/{id}/delete", s.adminOnly(s.targetDelete))
|
||||
mux.Handle("POST /targets/{id}/archive", s.adminOnly(s.targetArchive))
|
||||
mux.Handle("POST /targets/{id}/restore", s.adminOnly(s.targetRestore))
|
||||
mux.Handle("POST /cnames", s.adminOnly(s.cnameCreate))
|
||||
mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.cnameSave))
|
||||
mux.Handle("POST /cnames/{id}/remove", s.adminOnly(s.cnameRemove))
|
||||
mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.cnameDelete))
|
||||
mux.Handle("POST /domains/{id}/import", s.adminOnly(s.dnsChange(s.domainImport)))
|
||||
mux.Handle("POST /domains/{id}/ttl", s.adminOnly(s.dnsChange(s.domainTTLSave)))
|
||||
mux.Handle("POST /domains/{id}/remove", s.adminOnly(s.dnsChange(s.domainRemove)))
|
||||
mux.Handle("POST /domains/{id}/delete", s.adminOnly(s.dnsChange(s.domainDelete)))
|
||||
mux.Handle("POST /domains/{id}/token", s.adminOnly(s.dnsChange(s.domainToken)))
|
||||
mux.Handle("POST /targets", s.authenticated(s.dnsChange(s.targetCreate)))
|
||||
mux.Handle("POST /targets/{id}/save", s.authenticated(s.dnsChange(s.targetSave)))
|
||||
mux.Handle("POST /targets/{id}/delete", s.adminOnly(s.dnsChange(s.targetDelete)))
|
||||
mux.Handle("POST /targets/{id}/archive", s.adminOnly(s.dnsChange(s.targetArchive)))
|
||||
mux.Handle("POST /targets/{id}/restore", s.adminOnly(s.dnsChange(s.targetRestore)))
|
||||
mux.Handle("POST /cnames", s.adminOnly(s.dnsChange(s.cnameCreate)))
|
||||
mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.dnsChange(s.cnameSave)))
|
||||
mux.Handle("POST /cnames/{id}/remove", s.adminOnly(s.dnsChange(s.cnameRemove)))
|
||||
mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.dnsChange(s.cnameDelete)))
|
||||
mux.Handle("GET /pihole", s.adminOnly(s.piHolePage))
|
||||
mux.Handle("POST /pihole/connect", s.adminOnly(s.piHoleConnect))
|
||||
mux.Handle("POST /pihole/{id}/save", s.adminOnly(s.piHoleSave))
|
||||
@@ -173,13 +174,14 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /pihole/{id}/records/archive", s.adminOnly(s.piHoleRecordArchive))
|
||||
mux.Handle("POST /pihole/{id}/records/restore", s.adminOnly(s.piHoleRecordRestore))
|
||||
mux.Handle("GET /settings", s.adminOnly(s.settingsPage))
|
||||
mux.Handle("POST /settings", s.adminOnly(s.settingsSave))
|
||||
mux.Handle("POST /settings", s.adminOnly(s.dnsChange(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("POST /system/restart", s.adminOnly(s.containerRestart))
|
||||
mux.Handle("GET /errors/diagnostics", s.adminOnly(s.downloadDiagnostics))
|
||||
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
|
||||
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
||||
mux.Handle("GET /account", s.authenticated(s.accountPage))
|
||||
@@ -818,7 +820,7 @@ func (s *Server) targetCreate(w http.ResponseWriter, r *http.Request, session *m
|
||||
if !target.Active && hasStaticIP(target) {
|
||||
updated, status, syncErr := s.scheduler.SyncStaticTarget(r.Context(), target)
|
||||
if syncErr != nil {
|
||||
_ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), false)
|
||||
_ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), updated)
|
||||
s.redirectNotice(w, r, domainURL(zoneID), "Ziel angelegt, aber die feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error")
|
||||
return
|
||||
}
|
||||
@@ -932,7 +934,7 @@ func (s *Server) targetSave(w http.ResponseWriter, r *http.Request, session *mod
|
||||
if !target.Active && hasStaticIP(target) {
|
||||
updated, status, syncErr := s.scheduler.SyncStaticTarget(r.Context(), target)
|
||||
if syncErr != nil {
|
||||
_ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), false)
|
||||
_ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), updated)
|
||||
s.redirectNotice(w, r, domainURL(target.ZoneID), "Feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error")
|
||||
return
|
||||
}
|
||||
@@ -1261,11 +1263,13 @@ func (s *Server) settingsSave(w http.ResponseWriter, r *http.Request, session *m
|
||||
s.redirectNotice(w, r, "/settings", "Für aktiviertes IPv6 ist mindestens ein HTTPS-Endpoint erforderlich.", "error")
|
||||
return
|
||||
}
|
||||
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv4); err != nil {
|
||||
s.redirectNotice(w, r, "/settings", "IPv4-Endpoint ungültig: "+safeMessage(err), "error")
|
||||
return
|
||||
if !slices.Equal(ipv4, current.IPv4Endpoints) {
|
||||
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv4); err != nil {
|
||||
s.redirectNotice(w, r, "/settings", "IPv4-Endpoint ungültig: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
}
|
||||
if ipv6Enabled {
|
||||
if ipv6Enabled && !slices.Equal(ipv6, current.IPv6Endpoints) {
|
||||
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv6); err != nil {
|
||||
s.redirectNotice(w, r, "/settings", "IPv6-Endpoint ungültig: "+safeMessage(err), "error")
|
||||
return
|
||||
@@ -1364,7 +1368,7 @@ func (s *Server) softwareUpdateCheck(w http.ResponseWriter, r *http.Request, ses
|
||||
cancel()
|
||||
if err != nil {
|
||||
if errors.Is(err, appupdate.ErrCredentialMissing) {
|
||||
s.redirectNotice(w, r, "/system#update-access", "Richte zuerst den Update-Zugang ein.", "info")
|
||||
s.redirectNotice(w, r, "/system#update-access", appupdate.ErrCredentialMissing.Error(), "info")
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Versionsprüfung nicht möglich: "+safeMessage(err)+". Der laufende Dienst bleibt unverändert.", "error")
|
||||
@@ -1847,6 +1851,30 @@ func (s *Server) userPasswordReset(w http.ResponseWriter, r *http.Request, sessi
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.")
|
||||
}
|
||||
|
||||
// The lock spans reading the old target, remote changes and saving the new
|
||||
// configuration. Locking individual HTTP calls still permits stale worker writes.
|
||||
func (s *Server) dnsChange(next func(http.ResponseWriter, *http.Request, *model.Session)) func(http.ResponseWriter, *http.Request, *model.Session) {
|
||||
return func(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültiges CSRF-Token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
unlock, err := s.scheduler.LockChanges(waitCtx)
|
||||
if err != nil {
|
||||
w.Header().Set("Retry-After", "5")
|
||||
http.Error(w, "Eine DNS-Aktualisierung läuft noch. Bitte die Änderung in Kürze erneut senden.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
next(w, r, session)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authenticated(next func(http.ResponseWriter, *http.Request, *model.Session)) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := s.session(r.Context(), r)
|
||||
|
||||
+111
-4
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -27,6 +28,76 @@ import (
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
func TestDNSMutationHoldsUpdaterLockAndRequiresCSRF(t *testing.T) {
|
||||
worker := scheduler.New(nil, nil, nil, nil, nil)
|
||||
server := &Server{scheduler: worker}
|
||||
session := &model.Session{CSRFToken: "valid"}
|
||||
called := false
|
||||
handler := server.dnsChange(func(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
called = true
|
||||
if _, err := worker.Run(r.Context()); !errors.Is(err, scheduler.ErrRunInProgress) {
|
||||
t.Fatalf("updater entered mutation: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
for _, csrf := range []string{"wrong", "valid"} {
|
||||
request := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("csrf="+csrf))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response := httptest.NewRecorder()
|
||||
handler(response, request, session)
|
||||
if csrf == "wrong" && (called || response.Code != http.StatusForbidden) {
|
||||
t.Fatal("invalid CSRF entered mutation")
|
||||
}
|
||||
if csrf == "valid" && (!called || response.Code != http.StatusNoContent) {
|
||||
t.Fatal("valid mutation was blocked")
|
||||
}
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
unlock, err := worker.LockChanges(waitCtx)
|
||||
if err != nil {
|
||||
t.Fatal("mutation did not release lock")
|
||||
}
|
||||
defer unlock()
|
||||
called = false
|
||||
request := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("csrf=valid"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
stop()
|
||||
response := httptest.NewRecorder()
|
||||
handler(response, request.WithContext(ctx), session)
|
||||
if called || response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatal("canceled edit entered lock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnchangedEndpointsCanBeSavedWhileDNSIsUnavailable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
settings, err := database.GetSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateSettings(ctx, 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// No resolver/provider: unchanged endpoints must not require a network call.
|
||||
server := &Server{store: database, scheduler: scheduler.New(database, nil, nil, nil, nil)}
|
||||
form := url.Values{"csrf": {"valid"}, "interval": {"600"}, "ipv4_endpoints": {strings.Join(settings.IPv4Endpoints, "\n")}, "ipv6_endpoints": {strings.Join(settings.IPv6Endpoints, "\n")}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response := httptest.NewRecorder()
|
||||
server.dnsChange(server.settingsSave)(response, request, &model.Session{CSRFToken: "valid", Role: "admin"})
|
||||
settings, err = database.GetSettings(ctx)
|
||||
if err != nil || response.Code != http.StatusSeeOther || settings.UpdaterEnabled || settings.UpdateIntervalSeconds != 600 {
|
||||
t.Fatalf("offline settings save failed: status=%d enabled=%v err=%v", response.Code, settings.UpdaterEnabled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputNormalizers(t *testing.T) {
|
||||
t.Parallel()
|
||||
username, err := normalizeUsername("Alice_Admin")
|
||||
@@ -293,7 +364,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
|
||||
`data-dashboard-ipv6`,
|
||||
`data-recent-runs aria-live="polite" aria-busy="true"`,
|
||||
`Aktualisierung läuft`,
|
||||
`features-29`,
|
||||
`features-30`,
|
||||
`data-local-time="1788001920"`,
|
||||
`dashboard-zone-item`,
|
||||
`DNS-Zone · example.de`,
|
||||
@@ -414,6 +485,7 @@ func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) {
|
||||
{method: http.MethodGet, path: "/users"},
|
||||
{method: http.MethodGet, path: "/users/new"},
|
||||
{method: http.MethodGet, path: "/system"},
|
||||
{method: http.MethodGet, path: "/errors/diagnostics"},
|
||||
{method: http.MethodPost, path: "/system/restart", body: "csrf=member-csrf"},
|
||||
{method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"},
|
||||
}
|
||||
@@ -541,7 +613,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
||||
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", "Mit Gitea verbunden", "@registry-user", "Zugang ändern", "Passwort/Token", `<details class="update-credential-editor">`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`, `action="/system/restart"`, `data-container-restart-form`, `data-instance-id="instance-before-restart"`, `data-container-restart-overlay`, "Container neu starten"} {
|
||||
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Privater Zugang gespeichert", "@registry-user", "Zugang ändern", "Passwort/Token", `<details class="update-credential-editor">`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`, `action="/system/restart"`, `data-container-restart-form`, `data-instance-id="instance-before-restart"`, `data-container-restart-overlay`, "Container neu starten"} {
|
||||
if !strings.Contains(page.String(), expected) {
|
||||
t.Errorf("software update page is missing %q", expected)
|
||||
}
|
||||
@@ -551,7 +623,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
||||
if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"Nach Update suchen", "Installiert", "Status", "Zugang fehlt", "Update-Zugang einrichten"} {
|
||||
for _, expected := range []string{"Nach Update suchen", "Installiert", "Status", "Bereit", "Privater Zugang (optional)"} {
|
||||
if !strings.Contains(freshPage.String(), expected) {
|
||||
t.Errorf("fresh software update page is missing %q", expected)
|
||||
}
|
||||
@@ -857,14 +929,23 @@ func TestPiHoleDisconnectedPageCentersConnectFormWithoutEmptyState(t *testing.T)
|
||||
|
||||
func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
var phase atomic.Int32
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if phase.Load() == 2 {
|
||||
http.Error(w, "Pi-hole unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == "/api/auth" && r.Method == http.MethodPost:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"handler-sid","validity":1800,"message":"ok","totp":false}}`))
|
||||
case r.URL.Path == "/api/config/dns/hosts" && r.Method == http.MethodGet && r.Header.Get("X-FTL-SID") == "handler-sid":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.20 nas.lan"]}}}`))
|
||||
if phase.Load() == 1 {
|
||||
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.21 nas.lan"]}}}`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.20 nas.lan"]}}}`))
|
||||
}
|
||||
case r.URL.Path == "/api/auth" && r.Method == http.MethodDelete && r.Header.Get("X-FTL-SID") == "handler-sid":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
@@ -909,6 +990,10 @@ func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T
|
||||
if err != nil || len(integrations) != 1 || integrations[0].Name != "Pi-hole" || integrations[0].BaseURL != api.URL+"/api" {
|
||||
t.Fatalf("integration not stored after validation: %#v err=%v", integrations, err)
|
||||
}
|
||||
links, err := database.ListPiHoleRecordLinks(ctx, integrations[0].ID)
|
||||
if err != nil || len(links) != 1 || links[0].Value != "192.168.1.20 nas.lan" {
|
||||
t.Fatalf("initial import not persisted: %#v %v", links, err)
|
||||
}
|
||||
_, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integrations[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -917,6 +1002,28 @@ func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T
|
||||
if err != nil || decrypted != "separate-app-password" || strings.Contains(string(credential.Ciphertext), "separate-app-password") {
|
||||
t.Fatalf("application password not safely encrypted: decrypted=%q err=%v", decrypted, err)
|
||||
}
|
||||
// A successful refresh persists the new state without a subsequent page
|
||||
// load; an outage must retain that state instead of treating it as empty.
|
||||
for _, step := range []int32{1, 2} {
|
||||
phase.Store(step)
|
||||
id := strconv.FormatInt(integrations[0].ID, 10)
|
||||
request := httptest.NewRequest(http.MethodPost, "/pihole/"+id+"/refresh", strings.NewReader("csrf=csrf-token"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.SetPathValue("id", id)
|
||||
response := httptest.NewRecorder()
|
||||
server.piHoleRefresh(response, request, &model.Session{CSRFToken: "csrf-token", Username: "admin"})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("refresh response: %d", response.Code)
|
||||
}
|
||||
links, err := database.ListPiHoleRecordLinks(ctx, integrations[0].ID)
|
||||
if err != nil || len(links) != 2 || links[0].Active || !links[1].Active || links[1].Value != "192.168.1.21 nas.lan" {
|
||||
t.Fatalf("refresh lost cached records in phase %d: %#v %v", step, links, err)
|
||||
}
|
||||
integration, err := database.GetPiHoleIntegration(ctx, integrations[0].ID)
|
||||
if err != nil || (integration.LastError != "") != (step == 2) {
|
||||
t.Fatalf("refresh status incorrect: %#v %v", integration, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHolePasswordRevealRequiresAdminPasswordAndDoesNotAuditSecret(t *testing.T) {
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div><a class="back-link" href="/">← Übersicht</a><span class="page-kicker">Diagnose</span><h1>Fehlerzentrale</h1><p>Aktuelle DynDNS-, Zonen- und Pi-hole-Probleme sowie fehlgeschlagene oder partielle Update-Läufe.</p></div>
|
||||
<div class="form-actions"><a class="button button-quiet" href="/errors/diagnostics" download>Diagnose-Logs herunterladen</a>
|
||||
<form action="/errors/clear" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
|
||||
</form>
|
||||
</form></div>
|
||||
</header>
|
||||
|
||||
{{if eq .ErrorCount 0}}
|
||||
|
||||
@@ -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-29">
|
||||
<script src="/static/app.js?v=features-29" defer></script>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=features-30">
|
||||
<script src="/static/app.js?v=features-30" defer></script>
|
||||
</head>
|
||||
<body class="page-{{.Page}}">
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{{$integration := $view.Integration}}
|
||||
<section class="zone-summary pihole-summary" aria-label="Pi-hole-Status">
|
||||
<div><span>Provider</span><strong>Pi-hole API v6</strong></div>
|
||||
<div><span>Letzter Import</span><strong><time data-local-time="{{unixSeconds $integration.LastSyncAt}}">{{formatTime $integration.LastSyncAt}}</time></strong></div>
|
||||
<div><span>Letzte Prüfung</span><strong><time data-local-time="{{unixSeconds $integration.LastSyncAt}}">{{formatTime $integration.LastSyncAt}}</time></strong></div>
|
||||
<div><span>Verknüpfte Einträge</span><strong>{{len $view.Records}}</strong></div>
|
||||
<div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div>
|
||||
</section>
|
||||
@@ -21,6 +21,8 @@
|
||||
<div class="pihole-connection-error"><strong>Pi-hole konnte nicht neu eingelesen werden</strong><p>{{$view.FetchError}}</p></div>
|
||||
{{end}}
|
||||
|
||||
<p class="muted">Gespeicherter Stand. „Records neu einlesen“ prüft die Verbindung und aktualisiert die Einträge. Die Verbindung lässt sich auch ohne erreichbaren Pi-hole <a href="#pihole-connection">verwalten und entfernen</a>.</p>
|
||||
|
||||
<section class="targets-section" id="local-dns">
|
||||
<div class="section-heading"><div><span class="section-label pihole-kicker">Verknüpfte Records</span><h2>Lokale DNS-Einträge</h2></div><span class="record-count">{{len $view.Records}} Einträge</span></div>
|
||||
{{if $view.Records}}
|
||||
@@ -67,7 +69,7 @@
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label pihole-kicker">Record anlegen</span><h2>Neuer lokaler DNS-Eintrag</h2></div></div>
|
||||
<form action="/pihole/{{$integration.ID}}/records" method="post" class="stacked-form pihole-record-form pihole-create-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><div class="toggle-row"><label class="switch pihole-switch"><input type="checkbox" name="active" checked><span></span> Eintrag aktivieren</label></div><div class="form-grid"><label>Hostname(n)<input name="hostnames" placeholder="nas.lan" required></label><label>IP-Adresse<input name="ip" inputmode="text" placeholder="192.168.1.20 oder fd00::20" required></label></div><button class="button button-pihole button-compact pihole-record-save" type="submit">Record anlegen</button></form>
|
||||
</article>
|
||||
<article class="panel" id="pihole-connection"><div class="panel-heading"><div><span class="section-label pihole-kicker">API-Zugang</span><h2>Verbindung verwalten</h2></div><span class="status-pill {{if $view.FetchError}}status-bad{{else}}pihole-status-pill{{end}}">{{if $view.FetchError}}Nicht erreichbar{{else}}Verbunden{{end}}</span></div>
|
||||
<article class="panel" id="pihole-connection"><div class="panel-heading"><div><span class="section-label pihole-kicker">API-Zugang</span><h2>Verbindung verwalten</h2></div><span class="status-pill {{if $view.FetchError}}status-bad{{else}}pihole-status-pill{{end}}">{{if $view.FetchError}}Nicht erreichbar{{else}}Zuletzt erreichbar{{end}}</span></div>
|
||||
<p class="panel-description">Das Anwendungspasswort bleibt verschlüsselt und standardmäßig verborgen. Eine Anzeige erfordert erneut dein Admin-Passwort.</p>
|
||||
<form action="/pihole/{{$integration.ID}}/save" method="post" class="stacked-form" data-dirty-form><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Pi-hole-Adresse <input name="base_url" type="text" inputmode="url" autocapitalize="none" spellcheck="false" value="{{$integration.BaseURL}}" required><small>IPv4-Adresse oder Basis-URL; <code>/api</code> wird automatisch ergänzt.</small></label><label>Neues Anwendungspasswort <input name="password" type="password" autocomplete="new-password"><small>Leer lassen, um das gespeicherte Passwort beizubehalten.</small></label><label class="switch pihole-switch"><input type="checkbox" name="insecure_tls" {{if $integration.InsecureTLS}}checked{{end}}><span></span> TLS-Zertifikat nicht prüfen</label><button class="button button-pihole" type="submit" data-dirty-submit disabled aria-disabled="true">Verbindung prüfen und speichern</button></form>
|
||||
<div class="panel-divider"></div>
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
<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>{{if .SoftwareUpdate.UpdateAvailable}}Verfügbar{{else}}Status{{end}}</span><strong>{{if .SoftwareUpdate.UpdateAvailable}}{{.SoftwareUpdate.LatestVersion}}{{else if .SoftwareUpdate.LastError}}Unbekannt{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}–{{end}}</strong></div>
|
||||
<div class="software-version-cell is-latest"><span>{{if .SoftwareUpdate.UpdateAvailable}}Verfügbar{{else}}Status{{end}}</span><strong>{{if .SoftwareUpdate.UpdateAvailable}}{{.SoftwareUpdate.LatestVersion}}{{else if .SoftwareUpdate.LastError}}Unbekannt{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Aktuell{{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>
|
||||
<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 not .SoftwareUpdate.LastCheckedAt.IsZero}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p>
|
||||
<div class="software-version-meta"><span>Letzte Prüfung <b><time data-local-time="{{unixSeconds .SoftwareUpdate.LastCheckedAt}}">{{formatTime .SoftwareUpdate.LastCheckedAt}}</time></b></span><span>Update-Agent <b>{{if not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{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}}
|
||||
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die sichere Update-Basis noch nicht.</small></p></div>{{end}}
|
||||
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die benötigte Update-Funktion noch nicht. Für Updates ohne Anmeldung muss auch der Agent diese Funktion unterstützen.</small></p></div>{{end}}
|
||||
</div>
|
||||
<div class="software-version-action">
|
||||
{{if and .SoftwareUpdate.UpdateAvailable (not .SoftwareUpdate.ComposeSyncRequired)}}
|
||||
@@ -28,7 +28,7 @@
|
||||
{{else if .SoftwareUpdate.UpdateAvailable}}
|
||||
<span class="status-pill status-warn">Agent nicht erreichbar</span>
|
||||
{{else}}
|
||||
<span class="status-pill {{if or .SoftwareUpdate.LastError (not .SoftwareUpdate.CredentialConfigured) .SoftwareUpdate.ComposeSyncRequired}}status-warn{{else}}status-good{{end}}">{{if not .SoftwareUpdate.CredentialConfigured}}Zugang fehlt{{else if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.ComposeSyncRequired}}Compose aktualisieren{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}Bereit{{end}}</span>
|
||||
<span class="status-pill {{if or .SoftwareUpdate.LastError .SoftwareUpdate.ComposeSyncRequired}}status-warn{{else}}status-good{{end}}">{{if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.ComposeSyncRequired}}Compose aktualisieren{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Aktuell{{else}}Bereit{{end}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
@@ -43,16 +43,17 @@
|
||||
</section>
|
||||
|
||||
<section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
|
||||
<p>Öffentliche Releases und Container-Images werden ohne Anmeldung geladen. Für private Repositories oder Images kannst du einen Zugang hinterlegen; er wird nur bei Bedarf verwendet.</p>
|
||||
{{if .SoftwareUpdate.CredentialConfigured}}
|
||||
<div class="update-access-state">
|
||||
<span class="update-access-state-icon" aria-hidden="true">✓</span>
|
||||
<div><span class="section-label">Update-Zugang</span><h2>Mit Gitea verbunden</h2><p>Angemeldet als <strong>@{{.SoftwareUpdate.CredentialUsername}}</strong></p></div>
|
||||
<span class="status-pill status-good"><i aria-hidden="true"></i> Verbunden</span>
|
||||
<div><span class="section-label">Update-Zugang</span><h2>Privater Zugang gespeichert</h2><p>Hinterlegt für <strong>@{{.SoftwareUpdate.CredentialUsername}}</strong></p></div>
|
||||
<span class="status-pill status-good"><i aria-hidden="true"></i> Gespeichert</span>
|
||||
</div>
|
||||
<details class="update-credential-editor">
|
||||
<summary><span><strong>Zugang ändern</strong><small>Benutzername oder Passwort/Token ersetzen</small></span><i aria-hidden="true">⌄</i></summary>
|
||||
{{else}}
|
||||
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>Update-Zugang einrichten</h2></div></div>
|
||||
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>Privater Zugang (optional)</h2></div></div>
|
||||
{{end}}
|
||||
<form action="/system/update-credential" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
|
||||
Reference in new issue
Block a user