fix: harden DynDNS recovery and publish v1.5.1

This commit is contained in:
admin committed 2026-10-10 02:26:47 +02:00
1 parent 3383b91e49
commit 81def77e3c
37 files changed
+2415 -250

No files matched your search

+111 -4
View File
@@ -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) {