feat: add secure Pi-hole DNS management
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
1a6b4d0939
commit
1c32ca0671
18 files changed
+2159
-34
No files matched your search
@@ -2,13 +2,22 @@ package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
func TestInputNormalizers(t *testing.T) {
|
||||
@@ -70,6 +79,71 @@ func TestInputNormalizers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHoleReadOnlyErrorExplainsRequiredWritePermission(t *testing.T) {
|
||||
t.Parallel()
|
||||
message := piHoleErrorMessage(&pihole.APIError{Status: http.StatusForbidden, Key: "forbidden", Message: "Unable to change configuration (read-only)"})
|
||||
for _, expected := range []string{"webserver.api.app_sudo=true", "misc.readOnly=false"} {
|
||||
if !strings.Contains(message, expected) {
|
||||
t.Errorf("actionable Pi-hole read-only message is missing %q: %s", expected, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersProtectSensitiveResponses(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := &Server{config: config.Config{CookieSecure: true}}
|
||||
handler := server.securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
for name, expected := range map[string]string{
|
||||
"Content-Security-Policy": "default-src 'self'",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Strict-Transport-Security": "max-age=31536000",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
} {
|
||||
if !strings.Contains(response.Header().Get(name), expected) {
|
||||
t.Errorf("security header %s=%q does not contain %q", name, response.Header().Get(name), expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHoleRecordNormalization(t *testing.T) {
|
||||
t.Parallel()
|
||||
value, err := normalizePiHoleRecord("192.168.1.20", "NAS.LAN nas")
|
||||
if err != nil || value != "192.168.1.20 nas.lan nas" {
|
||||
t.Fatalf("Pi-hole IPv4 record normalization failed: %q err=%v", value, err)
|
||||
}
|
||||
value, err = normalizePiHoleRecord("2001:0db8::20", "drucker.lan")
|
||||
if err != nil || value != "2001:db8::20 drucker.lan" {
|
||||
t.Fatalf("Pi-hole IPv6 record normalization failed: %q err=%v", value, err)
|
||||
}
|
||||
for _, invalid := range [][2]string{{"not-an-ip", "nas.lan"}, {"192.168.1.20", "bad_name"}, {"192.168.1.20", ""}} {
|
||||
if _, err := normalizePiHoleRecord(invalid[0], invalid[1]); err == nil {
|
||||
t.Errorf("invalid Pi-hole record accepted: %#v", invalid)
|
||||
}
|
||||
}
|
||||
if raw, err := validatePiHoleRawRecord("192.168.1.20 nas.lan"); err != nil || raw != "192.168.1.20 nas.lan" {
|
||||
t.Fatalf("valid raw Pi-hole record rejected: %q err=%v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHoleRecordsSortActiveEntriesFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
records := []piHoleRecordView{
|
||||
{Hostnames: "aaa.lan", Active: false},
|
||||
{Hostnames: "zzz.lan", Active: true},
|
||||
{Hostnames: "bbb.lan", Active: true},
|
||||
}
|
||||
sortPiHoleRecords(records)
|
||||
if records[0].Hostnames != "bbb.lan" || records[1].Hostnames != "zzz.lan" || records[2].Hostnames != "aaa.lan" {
|
||||
t.Fatalf("Pi-hole records not sorted by active state and hostname: %#v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnixSecondsUsesZeroForUnscheduledRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := unixSeconds(time.Time{}); got != 0 {
|
||||
@@ -182,6 +256,11 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
|
||||
t.Errorf("domain detail is missing %q", expected)
|
||||
}
|
||||
}
|
||||
for _, removed := range []string{"Entfernt nur die lokale DynDNS-Verknüpfung", "Löscht das Ziel und alle zugehörigen A-/AAAA-Records", "Der CNAME-Record bei Hetzner bleibt unverändert"} {
|
||||
if strings.Contains(body, removed) {
|
||||
t.Errorf("domain record action still contains removed description %q", removed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(t *testing.T) {
|
||||
@@ -214,3 +293,349 @@ func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(t *testing.T) {
|
||||
t.Error("globally-disabled IPv6 checkbox remained enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHolePageRendersConnectionAndLocalDNSControls(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("load templates: %v", err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{
|
||||
Title: "Pi-hole",
|
||||
Page: "pihole",
|
||||
CSRF: "csrf",
|
||||
IsAdmin: true,
|
||||
User: model.User{Username: "admin", DisplayName: "Administrator", Role: "admin"},
|
||||
PiHoleView: &piHoleView{
|
||||
Integration: model.PiHoleIntegration{ID: 7, Name: "Pi-hole", BaseURL: "https://pi.hole/api"},
|
||||
Records: []piHoleRecordView{{LinkID: 11, Active: true, Raw: "192.168.1.20 nas.lan", IP: "192.168.1.20", Hostnames: "nas.lan", Valid: true}},
|
||||
ArchivedRecords: []piHoleRecordView{{LinkID: 12, Active: false, Raw: "192.168.1.30 old.lan", IP: "192.168.1.30", Hostnames: "old.lan", Valid: true, ArchivedAt: time.Now()}},
|
||||
},
|
||||
}
|
||||
if err := templates["pihole"].ExecuteTemplate(&rendered, "pihole", data); err != nil {
|
||||
t.Fatalf("render Pi-hole page: %v", err)
|
||||
}
|
||||
body := rendered.String()
|
||||
for _, expected := range []string{`href="/pihole"`, `aria-current="page"`, `action="/pihole/7/records"`, `action="/pihole/7/records/update"`, `action="/pihole/7/records/archive"`, `action="/pihole/7/records/restore"`, `action="/pihole/7/records/delete"`, `action="/pihole/7/remove"`, `action="/pihole/7/delete"`, `action="/pihole/7/save"`, `action="/pihole/7/password"`, `name="active"`, `name="link_id" value="11"`, `name="insecure_tls"`, `data-dirty-form`, `data-dirty-submit disabled aria-disabled="true"`, `Anwendungspasswort anzeigen`, `Passwort entschlüsseln`, `Nur aus der WebUI entfernen`, `Anwendungspasswort`, `nas.lan`, `old.lan`, `192.168.1.20`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("Pi-hole page is missing %q", expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "app-password") {
|
||||
t.Fatal("Pi-hole application password leaked into rendered page")
|
||||
}
|
||||
if strings.Contains(body, `action="/pihole/connect"`) {
|
||||
t.Fatal("connected Pi-hole page still offered a second integration")
|
||||
}
|
||||
if strings.Contains(body, ">A / AAAA<") {
|
||||
t.Fatal("new Pi-hole record card still renders the removed A / AAAA badge")
|
||||
}
|
||||
if strings.Contains(body, "Schreibzugriff für das Anwendungspasswort") || strings.Contains(body, "Pi-hole benötigt webserver.api.app_sudo") {
|
||||
t.Fatal("Pi-hole page still renders the permanent write-permission warning")
|
||||
}
|
||||
for _, removed := range []string{"Trennt nur die lokale Verknüpfung. Der DNS-Eintrag bei Pi-hole bleibt bestehen", "Löscht den Eintrag aus der WebUI und unmittelbar bei Pi-hole"} {
|
||||
if strings.Contains(body, removed) {
|
||||
t.Errorf("Pi-hole record action still contains removed description %q", removed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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"]}}}`))
|
||||
case r.URL.Path == "/api/auth" && r.Method == http.MethodDelete && r.Header.Get("X-FTL-SID") == "handler-sid":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
keyFile := filepath.Join(dir, "master-key")
|
||||
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)
|
||||
}
|
||||
server := &Server{store: database, cipher: cipher}
|
||||
form := url.Values{
|
||||
"csrf": {"csrf-token"},
|
||||
"base_url": {api.URL},
|
||||
"password": {"separate-app-password"},
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/pihole/connect", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response := httptest.NewRecorder()
|
||||
server.piHoleConnect(response, request, &model.Session{CSRFToken: "csrf-token", Username: "admin"})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected connect response: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
integrations, err := database.ListPiHoleIntegrations(ctx)
|
||||
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)
|
||||
}
|
||||
_, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integrations[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decrypted, err := cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHolePasswordRevealRequiresAdminPasswordAndDoesNotAuditSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
adminHash, err := secure.HashPassword("correct admin password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.CreateAdmin(ctx, "admin", "Administrator", adminHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
keyFile := filepath.Join(dir, "master-key")
|
||||
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)
|
||||
}
|
||||
const secret = "super-secret-pihole-app-password"
|
||||
ciphertext, nonce, err := cipher.Encrypt(secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: "https://pi.hole/api"}, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{store: database, cipher: cipher, templates: templates}
|
||||
session := &model.Session{UserID: 1, CSRFToken: "csrf-token", Username: "admin", DisplayName: "Administrator", Role: "admin"}
|
||||
requestFor := func(password, csrf string) *http.Request {
|
||||
form := url.Values{"csrf": {csrf}, "password": {password}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/pihole/1/password", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.SetPathValue("id", "1")
|
||||
return request
|
||||
}
|
||||
|
||||
wrong := httptest.NewRecorder()
|
||||
server.piHolePassword(wrong, requestFor("wrong admin password", "csrf-token"), session)
|
||||
if wrong.Code != http.StatusSeeOther || strings.Contains(wrong.Body.String(), secret) || strings.Contains(wrong.Header().Get("Location"), secret) {
|
||||
t.Fatalf("wrong admin password exposed secret or returned wrong status: status=%d location=%q", wrong.Code, wrong.Header().Get("Location"))
|
||||
}
|
||||
|
||||
invalidCSRF := httptest.NewRecorder()
|
||||
server.piHolePassword(invalidCSRF, requestFor("correct admin password", "wrong-csrf"), session)
|
||||
if invalidCSRF.Code != http.StatusForbidden || strings.Contains(invalidCSRF.Body.String(), secret) {
|
||||
t.Fatalf("invalid CSRF exposed secret or returned wrong status: status=%d", invalidCSRF.Code)
|
||||
}
|
||||
|
||||
revealed := httptest.NewRecorder()
|
||||
server.piHolePassword(revealed, requestFor("correct admin password", "csrf-token"), session)
|
||||
if revealed.Code != http.StatusOK || !strings.Contains(revealed.Body.String(), secret) || !strings.Contains(revealed.Body.String(), "Pi-hole-Anwendungspasswort") {
|
||||
t.Fatalf("valid reauthentication did not reveal secret: status=%d body=%s", revealed.Code, revealed.Body.String())
|
||||
}
|
||||
if cacheControl := revealed.Header().Get("Cache-Control"); !strings.Contains(cacheControl, "no-store") {
|
||||
t.Fatalf("secret response is cacheable: Cache-Control=%q", cacheControl)
|
||||
}
|
||||
loaded, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integration.ID)
|
||||
if err != nil || loaded.ID != integration.ID || strings.Contains(string(credential.Ciphertext), secret) {
|
||||
t.Fatalf("Pi-hole credential was not retained as ciphertext: %#v err=%v", loaded, err)
|
||||
}
|
||||
events, err := database.RecentAudit(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foundRevealAudit := false
|
||||
for _, event := range events {
|
||||
if strings.Contains(event.Message, secret) {
|
||||
t.Fatalf("Pi-hole secret leaked into audit log: %#v", event)
|
||||
}
|
||||
if event.Kind == "pihole.credential_revealed" {
|
||||
foundRevealAudit = true
|
||||
}
|
||||
}
|
||||
if !foundRevealAudit {
|
||||
t.Fatal("Pi-hole credential reveal was not audited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHoleRecordCanBeRemovedFromAndRestoredToWebUI(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() })
|
||||
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: "https://pi.hole/api"}, model.PiHoleCredential{Ciphertext: []byte("ciphertext"), Nonce: []byte("nonce")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const value = "192.168.1.20 nas.lan"
|
||||
if _, err := database.SyncPiHoleRecordLinks(ctx, integration.ID, []string{value}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{store: database}
|
||||
session := &model.Session{CSRFToken: "csrf-token", Username: "admin"}
|
||||
requestFor := func(path string) *http.Request {
|
||||
form := url.Values{"csrf": {"csrf-token"}, "link_id": {"1"}}
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.SetPathValue("id", "1")
|
||||
return request
|
||||
}
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
server.piHoleRecordArchive(response, requestFor("/pihole/1/records/archive"), session)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected archive response: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
links, err := database.ListPiHoleRecordLinks(ctx, integration.ID)
|
||||
if err != nil || len(links) != 1 || links[0].ArchivedAt.IsZero() {
|
||||
t.Fatalf("Pi-hole record was not removed from active WebUI list: %#v err=%v", links, err)
|
||||
}
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
server.piHoleRecordRestore(response, requestFor("/pihole/1/records/restore"), session)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected restore response: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
links, err = database.ListPiHoleRecordLinks(ctx, integration.ID)
|
||||
if err != nil || len(links) != 1 || !links[0].ArchivedAt.IsZero() {
|
||||
t.Fatalf("Pi-hole record was not restored to active WebUI list: %#v err=%v", links, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHoleRecordActiveSwitchDeletesAndRecreatesRemoteHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
remoteHosts := map[string]bool{"192.168.1.20 nas.lan": true}
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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":"switch-sid","message":"ok"}}`))
|
||||
case r.URL.Path == "/api/auth" && r.Method == http.MethodDelete:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/config/dns/hosts/") && r.Header.Get("X-FTL-SID") == "switch-sid":
|
||||
value := strings.TrimPrefix(r.URL.Path, "/api/config/dns/hosts/")
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
remoteHosts[value] = true
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case http.MethodDelete:
|
||||
if !remoteHosts[value] {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
delete(remoteHosts, value)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
||||
}
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
keyFile := filepath.Join(dir, "master-key")
|
||||
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)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("app-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: api.URL + "/api"}, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
links, err := database.SyncPiHoleRecordLinks(ctx, integration.ID, []string{"192.168.1.20 nas.lan"})
|
||||
if err != nil || len(links) != 1 {
|
||||
t.Fatalf("record link setup failed: %#v err=%v", links, err)
|
||||
}
|
||||
server := &Server{store: database, cipher: cipher}
|
||||
session := &model.Session{CSRFToken: "csrf-token", Username: "admin"}
|
||||
update := func(ip string, active bool) *httptest.ResponseRecorder {
|
||||
form := url.Values{"csrf": {"csrf-token"}, "link_id": {"1"}, "ip": {ip}, "hostnames": {"nas.lan"}}
|
||||
if active {
|
||||
form.Set("active", "on")
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/pihole/1/records/update", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.SetPathValue("id", "1")
|
||||
response := httptest.NewRecorder()
|
||||
server.piHoleRecordUpdate(response, request, session)
|
||||
return response
|
||||
}
|
||||
|
||||
if response := update("192.168.1.20", false); response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected deactivate response: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
link, err := database.GetPiHoleRecordLink(ctx, integration.ID, links[0].ID)
|
||||
if err != nil || link.Active || remoteHosts[link.Value] {
|
||||
t.Fatalf("deactivated record remained active: %#v remote=%#v err=%v", link, remoteHosts, err)
|
||||
}
|
||||
|
||||
if response := update("192.168.1.21", true); response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected activate response: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
link, err = database.GetPiHoleRecordLink(ctx, integration.ID, links[0].ID)
|
||||
if err != nil || !link.Active || link.Value != "192.168.1.21 nas.lan" || !remoteHosts[link.Value] {
|
||||
t.Fatalf("reactivated record was not recreated: %#v remote=%#v err=%v", link, remoteHosts, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user