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
+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) {
|
||||
|
||||
Reference in new issue
Block a user