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
@@ -1,6 +1,6 @@
|
|||||||
# Hetzner DynDNS
|
# Hetzner DynDNS
|
||||||
|
|
||||||
Weboberfläche für mehrere Hetzner-DNS-Zonen, Subdomains und Benutzer. Aktivierte A- und AAAA-Records werden regelmäßig auf die aktuelle öffentliche IP gesetzt.
|
Weboberfläche für mehrere Hetzner-DNS-Zonen, eine Pi-hole-v6-Verbindung, Subdomains und Benutzer. Aktivierte A- und AAAA-Records werden regelmäßig auf die aktuelle öffentliche IP gesetzt; lokale DNS-Einträge lassen sich direkt über die Pi-hole-API verwalten.
|
||||||
|
|
||||||
## Starten
|
## Starten
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
dyndns:
|
dyndns:
|
||||||
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.1.0
|
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "9090:8080"
|
- "9090:8080"
|
||||||
@@ -63,5 +63,11 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü
|
|||||||
- lokales Archiv statt Löschen von Hetzner-Records
|
- lokales Archiv statt Löschen von Hetzner-Records
|
||||||
- frei einstellbares Update-Intervall mit Countdown
|
- frei einstellbares Update-Intervall mit Countdown
|
||||||
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
|
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
|
||||||
|
- eine Pi-hole-v6-Verbindung mit verschlüsseltem Anwendungspasswort und geschützter Anzeige nach erneuter Admin-Passwortprüfung
|
||||||
|
- lokale Pi-hole-DNS-Einträge für IPv4 und IPv6 anlegen, aktivieren/deaktivieren, bearbeiten, aus der WebUI trennen, wiederherstellen und vollständig löschen
|
||||||
|
|
||||||
|
Der Container muss die verbundene Pi-hole-Adresse direkt im internen Netz erreichen können. Pi-hole-Sitzungen werden nur für die jeweilige Aktion erstellt und danach aktiv beendet; dauerhaft gespeichert wird ausschließlich das mit dem Master-Key verschlüsselte Anwendungspasswort. Der Master-Key liegt getrennt mit Dateimodus `0600`; Datenverzeichnis und SQLite-Datei werden auf `0700` beziehungsweise `0600` gehärtet. Das Compose-Setup verwendet außerdem ein schreibgeschütztes Root-Dateisystem und ein begrenztes `/tmp`-Tmpfs. HTTPS mit gültigem Zertifikat ist empfohlen.
|
||||||
|
|
||||||
|
Damit ein Pi-hole-Anwendungspasswort lokale DNS-Einträge ändern darf, muss am Pi-hole `webserver.api.app_sudo=true` gesetzt sein; Pi-hole verwendet hierfür standardmäßig `false`. Zusätzlich darf der globale Schalter `misc.readOnly` nicht aktiviert sein. Bei einem Pi-hole-Docker-Container entspricht das beispielsweise `FTLCONF_webserver_api_app_sudo: "true"` und `FTLCONF_misc_readOnly: "false"`.
|
||||||
|
|
||||||
Port 9090 verwendet zunächst unverschlüsseltes HTTP. Den Port daher nur in einem vertrauenswürdigen Netz freigeben und nicht offen ins Internet stellen.
|
Port 9090 verwendet zunächst unverschlüsseltes HTTP. Den Port daher nur in einem vertrauenswürdigen Netz freigeben und nicht offen ins Internet stellen.
|
||||||
@@ -32,6 +32,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(logger *slog.Logger) error {
|
func run(logger *slog.Logger) error {
|
||||||
|
// Credentials and session data must never inherit a permissive process umask.
|
||||||
|
syscall.Umask(0o077)
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+4
-1
@@ -1,12 +1,15 @@
|
|||||||
services:
|
services:
|
||||||
dyndns:
|
dyndns:
|
||||||
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.1.0
|
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "9090:8080"
|
- "9090:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- dyndns-data:/data
|
- dyndns-data:/data
|
||||||
- dyndns-secrets:/secrets
|
- dyndns-secrets:/secrets
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
|
|||||||
@@ -72,6 +72,36 @@ type CNAMERecord struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PiHoleIntegration is one remotely managed Pi-hole v6 instance. API
|
||||||
|
// passwords are returned separately and never exposed to templates.
|
||||||
|
type PiHoleIntegration struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
BaseURL string
|
||||||
|
InsecureTLS bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
LastSyncAt time.Time
|
||||||
|
LastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PiHoleCredential struct {
|
||||||
|
Ciphertext []byte
|
||||||
|
Nonce []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
ID int64
|
||||||
|
IntegrationID int64
|
||||||
|
Active bool
|
||||||
|
Value string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
ArchivedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
Username string
|
Username string
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package pihole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxResponseBody = 1 << 20
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIError struct {
|
||||||
|
Status int
|
||||||
|
Key string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
message := strings.TrimSpace(e.Message)
|
||||||
|
if message == "" {
|
||||||
|
message = http.StatusText(e.Status)
|
||||||
|
}
|
||||||
|
if e.Key != "" {
|
||||||
|
return fmt.Sprintf("Pi-hole API %d (%s): %s", e.Status, e.Key, message)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Pi-hole API %d: %s", e.Status, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(rawURL string, insecureTLS bool) (*Client, error) {
|
||||||
|
baseURL, err := normalizeBaseURL(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dialer := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
|
||||||
|
transport := &http.Transport{
|
||||||
|
Proxy: nil,
|
||||||
|
DialContext: dialer.DialContext,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 10 * time.Second,
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
InsecureSkipVerify: insecureTLS, // Explicit admin opt-in for self-signed Pi-hole certificates.
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
http: &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Transport: transport,
|
||||||
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||||
|
return errors.New("Pi-hole API redirects are not allowed")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBaseURL(raw string) (string, error) {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||||
|
return "", errors.New("Pi-hole-Adresse muss eine vollständige HTTP- oder HTTPS-URL sein")
|
||||||
|
}
|
||||||
|
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
|
return "", errors.New("Pi-hole-Adresse darf keine Zugangsdaten, Query-Parameter oder Fragmente enthalten")
|
||||||
|
}
|
||||||
|
if parsed.Path == "" || parsed.Path == "/" {
|
||||||
|
parsed.Path = "/api"
|
||||||
|
} else {
|
||||||
|
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||||
|
if !strings.HasSuffix(parsed.Path, "/api") {
|
||||||
|
parsed.Path += "/api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed.RawPath = ""
|
||||||
|
return strings.TrimRight(parsed.String(), "/"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) BaseURL() string { return c.baseURL }
|
||||||
|
|
||||||
|
func (c *Client) ListHosts(ctx context.Context, password string) ([]string, error) {
|
||||||
|
var hosts []string
|
||||||
|
err := c.withSession(ctx, password, func(sid string) error {
|
||||||
|
var response struct {
|
||||||
|
Config struct {
|
||||||
|
DNS struct {
|
||||||
|
Hosts []string `json:"hosts"`
|
||||||
|
} `json:"dns"`
|
||||||
|
} `json:"config"`
|
||||||
|
}
|
||||||
|
if err := c.doJSON(ctx, http.MethodGet, "/config/dns/hosts", sid, nil, &response, http.StatusOK); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hosts = append([]string(nil), response.Config.DNS.Hosts...)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return hosts, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddHost(ctx context.Context, password, value string) error {
|
||||||
|
return c.withSession(ctx, password, func(sid string) error {
|
||||||
|
return c.addHost(ctx, sid, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteHost(ctx context.Context, password, value string) error {
|
||||||
|
return c.withSession(ctx, password, func(sid string) error {
|
||||||
|
return c.deleteHost(ctx, sid, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteHosts removes a complete set of local DNS entries in one authenticated
|
||||||
|
// Pi-hole session. Missing values are accepted so an interrupted deletion can
|
||||||
|
// safely be retried.
|
||||||
|
func (c *Client) DeleteHosts(ctx context.Context, password string, values []string) error {
|
||||||
|
return c.withSession(ctx, password, func(sid string) error {
|
||||||
|
for _, value := range values {
|
||||||
|
if err := c.doJSON(ctx, http.MethodDelete, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusNoContent, http.StatusNotFound); err != nil {
|
||||||
|
return fmt.Errorf("Pi-hole-Eintrag %q konnte nicht gelöscht werden: %w", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReplaceHost(ctx context.Context, password, previous, replacement string) error {
|
||||||
|
if previous == replacement {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.withSession(ctx, password, func(sid string) error {
|
||||||
|
if err := c.deleteHost(ctx, sid, previous); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.addHost(ctx, sid, replacement); err != nil {
|
||||||
|
if rollbackErr := c.addHost(ctx, sid, previous); rollbackErr != nil {
|
||||||
|
return fmt.Errorf("neuer Pi-hole-Eintrag fehlgeschlagen: %v; Wiederherstellung ebenfalls fehlgeschlagen: %w", err, rollbackErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("neuer Pi-hole-Eintrag fehlgeschlagen; ursprünglicher Eintrag wurde wiederhergestellt: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) addHost(ctx context.Context, sid, value string) error {
|
||||||
|
return c.doJSON(ctx, http.MethodPut, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) deleteHost(ctx context.Context, sid, value string) error {
|
||||||
|
return c.doJSON(ctx, http.MethodDelete, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) withSession(ctx context.Context, password string, operation func(string) error) error {
|
||||||
|
sid, err := c.authenticate(ctx, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
logoutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = c.doJSON(logoutCtx, http.MethodDelete, "/auth", sid, nil, nil, http.StatusNoContent, http.StatusNotFound)
|
||||||
|
}()
|
||||||
|
return operation(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) authenticate(ctx context.Context, password string) (string, error) {
|
||||||
|
var response struct {
|
||||||
|
Session struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
SID string `json:"sid"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"session"`
|
||||||
|
}
|
||||||
|
payload := struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}{Password: password}
|
||||||
|
if err := c.doJSON(ctx, http.MethodPost, "/auth", "", payload, &response, http.StatusOK); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !response.Session.Valid || strings.TrimSpace(response.Session.SID) == "" {
|
||||||
|
message := strings.TrimSpace(response.Session.Message)
|
||||||
|
if message == "" {
|
||||||
|
message = "Pi-hole hat keine gültige API-Session erstellt"
|
||||||
|
}
|
||||||
|
return "", errors.New(message)
|
||||||
|
}
|
||||||
|
return response.Session.SID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doJSON(ctx context.Context, method, path, sid string, requestBody, responseBody any, accepted ...int) error {
|
||||||
|
var body io.Reader
|
||||||
|
if requestBody != nil {
|
||||||
|
encoded, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(encoded)
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.Header.Set("User-Agent", "domain-control-webinterface/1")
|
||||||
|
if requestBody != nil {
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if sid != "" {
|
||||||
|
request.Header.Set("X-FTL-SID", sid)
|
||||||
|
}
|
||||||
|
response, err := c.http.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Pi-hole API nicht erreichbar: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
content, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBody))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, status := range accepted {
|
||||||
|
if response.StatusCode == status {
|
||||||
|
if responseBody != nil && len(content) > 0 {
|
||||||
|
if err := json.Unmarshal(content, responseBody); err != nil {
|
||||||
|
return fmt.Errorf("ungültige Antwort der Pi-hole API: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var apiResponse struct {
|
||||||
|
Error struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(content, &apiResponse)
|
||||||
|
return &APIError{Status: response.StatusCode, Key: apiResponse.Error.Key, Message: apiResponse.Error.Message}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package pihole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientManagesLocalDNSHostsWithShortLivedSessions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var mu sync.Mutex
|
||||||
|
hosts := []string{"192.168.1.10 nas.lan"}
|
||||||
|
logouts := 0
|
||||||
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/auth" && r.Method == http.MethodPost {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"test-sid","validity":1800,"message":"ok","totp":false}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/api/auth" && r.Method == http.MethodDelete {
|
||||||
|
if r.Header.Get("X-FTL-SID") != "test-sid" {
|
||||||
|
http.Error(w, "missing sid", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
logouts++
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("X-FTL-SID") != "test-sid" {
|
||||||
|
http.Error(w, "missing sid", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/config/dns/hosts" && r.Method == http.MethodGet:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["` + strings.Join(hosts, `","`) + `"]}}}`))
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/config/dns/hosts/") && r.Method == http.MethodPut:
|
||||||
|
hosts = append(hosts, strings.TrimPrefix(r.URL.Path, "/api/config/dns/hosts/"))
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/config/dns/hosts/") && r.Method == http.MethodDelete:
|
||||||
|
value := strings.TrimPrefix(r.URL.Path, "/api/config/dns/hosts/")
|
||||||
|
for index, host := range hosts {
|
||||||
|
if host == value {
|
||||||
|
hosts = append(hosts[:index], hosts[index+1:]...)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer api.Close()
|
||||||
|
|
||||||
|
client, err := New(api.URL, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
listed, err := client.ListHosts(ctx, "app-password")
|
||||||
|
if err != nil || len(listed) != 1 || listed[0] != "192.168.1.10 nas.lan" {
|
||||||
|
t.Fatalf("hosts not listed: %#v err=%v", listed, err)
|
||||||
|
}
|
||||||
|
if err := client.AddHost(ctx, "app-password", "192.168.1.20 printer.lan"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := client.ReplaceHost(ctx, "app-password", "192.168.1.20 printer.lan", "192.168.1.21 printer.lan"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := client.DeleteHost(ctx, "app-password", "192.168.1.21 printer.lan"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := client.DeleteHosts(ctx, "app-password", []string{"192.168.1.10 nas.lan", "192.168.1.99 already-gone.lan"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if logouts != 5 {
|
||||||
|
t.Fatalf("expected one logout per operation, got %d", logouts)
|
||||||
|
}
|
||||||
|
if len(hosts) != 0 {
|
||||||
|
t.Fatalf("batch deletion left hosts behind: %#v", hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewValidatesAndNormalizesPiHoleURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client, err := New("https://pi.hole/", false)
|
||||||
|
if err != nil || client.BaseURL() != "https://pi.hole/api" {
|
||||||
|
t.Fatalf("unexpected normalized URL %q err=%v", client.BaseURL(), err)
|
||||||
|
}
|
||||||
|
client, err = New("http://pihole.local/custom", false)
|
||||||
|
if err != nil || client.BaseURL() != "http://pihole.local/custom/api" {
|
||||||
|
t.Fatalf("custom path not normalized: %q err=%v", client.BaseURL(), err)
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"pi.hole", "ftp://pi.hole", "https://user:pass@pi.hole", "https://pi.hole?sid=secret"} {
|
||||||
|
if _, err := New(invalid, false); err == nil {
|
||||||
|
t.Errorf("invalid URL accepted: %s", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+348
-4
@@ -21,6 +21,8 @@ type Store struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
|
||||||
|
|
||||||
type ManagedTarget struct {
|
type ManagedTarget struct {
|
||||||
Target model.Target
|
Target model.Target
|
||||||
Zone model.Zone
|
Zone model.Zone
|
||||||
@@ -36,9 +38,13 @@ type AccessSelection struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Open(ctx context.Context, path string) (*Store, error) {
|
func Open(ctx context.Context, path string) (*Store, error) {
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
dataDir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||||
return nil, fmt.Errorf("create data directory: %w", err)
|
return nil, fmt.Errorf("create data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := os.Chmod(dataDir, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("secure data directory: %w", err)
|
||||||
|
}
|
||||||
db, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_foreign_keys=on")
|
db, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_foreign_keys=on")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open database: %w", err)
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
@@ -48,6 +54,10 @@ func Open(ctx context.Context, path string) (*Store, error) {
|
|||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
return nil, fmt.Errorf("ping database: %w", err)
|
return nil, fmt.Errorf("ping database: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := os.Chmod(path, 0o600); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("secure database file: %w", err)
|
||||||
|
}
|
||||||
store := &Store{db: db}
|
store := &Store{db: db}
|
||||||
if err := store.migrate(ctx); err != nil {
|
if err := store.migrate(ctx); err != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
@@ -97,6 +107,15 @@ func (s *Store) migrate(ctx context.Context) error {
|
|||||||
if err := s.applyMigrationV7(ctx, tx); err != nil {
|
if err := s.applyMigrationV7(ctx, tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.applyMigrationV8(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.applyMigrationV9(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.applyMigrationV10(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, ?, ?, ?, ?)`,
|
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 {
|
300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -172,6 +191,57 @@ func (s *Store) applyMigrationV7(ctx context.Context, tx *sql.Tx) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) applyMigrationV8(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 8`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, schemaV8); err != nil {
|
||||||
|
return fmt.Errorf("apply schema v8: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(8, ?)`, now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) applyMigrationV9(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 9`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, schemaV9); err != nil {
|
||||||
|
return fmt.Errorf("apply schema v9: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(9, ?)`, now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) applyMigrationV10(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 10`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, schemaV10); err != nil {
|
||||||
|
return fmt.Errorf("apply schema v10: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(10, ?)`, now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error {
|
func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error {
|
||||||
var count int
|
var count int
|
||||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil {
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil {
|
||||||
@@ -351,6 +421,38 @@ const schemaV7 = `
|
|||||||
ALTER TABLE cname_records ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
|
ALTER TABLE cname_records ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const schemaV8 = `
|
||||||
|
CREATE TABLE pihole_integrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
base_url TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||||
|
password_ciphertext BLOB NOT NULL,
|
||||||
|
password_nonce BLOB NOT NULL,
|
||||||
|
insecure_tls INTEGER NOT NULL DEFAULT 0 CHECK (insecure_tls IN (0,1)),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
last_sync_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
const schemaV9 = `
|
||||||
|
CREATE TABLE pihole_record_links (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
integration_id INTEGER NOT NULL REFERENCES pihole_integrations(id) ON DELETE CASCADE,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
archived_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(integration_id, value)
|
||||||
|
);
|
||||||
|
CREATE INDEX pihole_record_links_integration_archived_idx ON pihole_record_links(integration_id, archived_at);
|
||||||
|
`
|
||||||
|
|
||||||
|
const schemaV10 = `
|
||||||
|
ALTER TABLE pihole_record_links ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
|
||||||
|
`
|
||||||
|
|
||||||
func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
|
func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
|
||||||
var count int
|
var count int
|
||||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count)
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count)
|
||||||
@@ -556,7 +658,7 @@ func (s *Store) ListAccessibleTargets(ctx context.Context, userID, zoneID int64)
|
|||||||
rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.zone_id, t.record_name, t.fqdn, t.update_ipv4, t.update_ipv6, t.active, t.static_ipv4, t.static_ipv6, t.description, t.ttl, t.last_updated_at, t.last_status, t.last_error, t.created_at, t.archived_at
|
rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.zone_id, t.record_name, t.fqdn, t.update_ipv4, t.update_ipv6, t.active, t.static_ipv4, t.static_ipv6, t.description, t.ttl, t.last_updated_at, t.last_status, t.last_error, t.created_at, t.archived_at
|
||||||
FROM targets t WHERE t.zone_id = ? AND t.archived_at = 0 AND EXISTS (
|
FROM targets t WHERE t.zone_id = ? AND t.archived_at = 0 AND EXISTS (
|
||||||
SELECT 1 FROM access_grants a WHERE a.user_id = ? AND a.zone_id = t.zone_id AND (a.target_id IS NULL OR a.target_id = t.id)
|
SELECT 1 FROM access_grants a WHERE a.user_id = ? AND a.zone_id = t.zone_id AND (a.target_id IS NULL OR a.target_id = t.id)
|
||||||
) ORDER BY CASE WHEN t.record_name = '@' THEN 0 ELSE 1 END, t.record_name`, zoneID, userID)
|
) ORDER BY t.active DESC, CASE WHEN t.record_name = '@' THEN 0 ELSE 1 END, t.record_name`, zoneID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -691,8 +793,209 @@ func (s *Store) MarkZoneSynced(ctx context.Context, id int64, lastError string)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreatePiHoleIntegration(ctx context.Context, integration model.PiHoleIntegration, credential model.PiHoleCredential) (model.PiHoleIntegration, error) {
|
||||||
|
timestamp := now()
|
||||||
|
result, err := s.db.ExecContext(ctx, `INSERT INTO pihole_integrations(name, base_url, password_ciphertext, password_nonce, insecure_tls, created_at, updated_at)
|
||||||
|
SELECT ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM pihole_integrations)`,
|
||||||
|
integration.Name, integration.BaseURL, credential.Ciphertext, credential.Nonce, boolInt(integration.InsecureTLS), timestamp, timestamp)
|
||||||
|
if err != nil {
|
||||||
|
return model.PiHoleIntegration{}, err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return model.PiHoleIntegration{}, ErrPiHoleAlreadyConfigured
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return model.PiHoleIntegration{}, err
|
||||||
|
}
|
||||||
|
return s.GetPiHoleIntegration(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetPiHoleIntegration(ctx context.Context, id int64) (model.PiHoleIntegration, error) {
|
||||||
|
return scanPiHoleIntegration(s.db.QueryRowContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error FROM pihole_integrations WHERE id = ?`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetPiHoleIntegrationWithCredential(ctx context.Context, id int64) (model.PiHoleIntegration, model.PiHoleCredential, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error, password_ciphertext, password_nonce FROM pihole_integrations WHERE id = ?`, id)
|
||||||
|
var integration model.PiHoleIntegration
|
||||||
|
var credential model.PiHoleCredential
|
||||||
|
var insecureTLS int
|
||||||
|
var createdAt, updatedAt, lastSyncAt int64
|
||||||
|
err := row.Scan(&integration.ID, &integration.Name, &integration.BaseURL, &insecureTLS, &createdAt, &updatedAt, &lastSyncAt, &integration.LastError, &credential.Ciphertext, &credential.Nonce)
|
||||||
|
integration.InsecureTLS = intBool(insecureTLS)
|
||||||
|
integration.CreatedAt, integration.UpdatedAt, integration.LastSyncAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(lastSyncAt)
|
||||||
|
return integration, credential, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListPiHoleIntegrations(ctx context.Context) ([]model.PiHoleIntegration, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error FROM pihole_integrations ORDER BY name COLLATE NOCASE, id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var integrations []model.PiHoleIntegration
|
||||||
|
for rows.Next() {
|
||||||
|
integration, scanErr := scanPiHoleIntegration(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
integrations = append(integrations, integration)
|
||||||
|
}
|
||||||
|
return integrations, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdatePiHoleIntegration(ctx context.Context, integration model.PiHoleIntegration, credential model.PiHoleCredential) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `UPDATE pihole_integrations SET name = ?, base_url = ?, password_ciphertext = ?, password_nonce = ?, insecure_tls = ?, updated_at = ?, last_error = '' WHERE id = ?`,
|
||||||
|
integration.Name, integration.BaseURL, credential.Ciphertext, credential.Nonce, boolInt(integration.InsecureTLS), now(), integration.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count, _ := result.RowsAffected(); count != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkPiHoleSynced(ctx context.Context, id int64, lastError string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE pihole_integrations SET last_sync_at = ?, last_error = ? WHERE id = ?`, now(), lastError, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeletePiHoleIntegration(ctx context.Context, id int64) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `DELETE FROM pihole_integrations WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count, _ := result.RowsAffected(); count != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SyncPiHoleRecordLinks(ctx context.Context, integrationID int64, remoteValues []string) ([]model.PiHoleRecordLink, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
rows, err := tx.QueryContext(ctx, `SELECT id, integration_id, active, value, created_at, updated_at, archived_at FROM pihole_record_links WHERE integration_id = ?`, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
existing := make(map[string]model.PiHoleRecordLink)
|
||||||
|
for rows.Next() {
|
||||||
|
link, scanErr := scanPiHoleRecordLink(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
_ = rows.Close()
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
existing[link.Value] = link
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
remote := make(map[string]bool, len(remoteValues))
|
||||||
|
for _, value := range remoteValues {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || remote[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remote[value] = true
|
||||||
|
if _, found := existing[value]; !found {
|
||||||
|
timestamp := now()
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO pihole_record_links(integration_id, active, value, created_at, updated_at) VALUES(?, 1, ?, ?, ?)`, integrationID, value, timestamp, timestamp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for value, link := range existing {
|
||||||
|
active := remote[value]
|
||||||
|
if link.Active != active {
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE pihole_record_links SET active = ?, updated_at = ? WHERE id = ? AND integration_id = ?`, boolInt(active), now(), link.ID, integrationID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.ListPiHoleRecordLinks(ctx, integrationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListPiHoleRecordLinks(ctx context.Context, integrationID int64) ([]model.PiHoleRecordLink, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT id, integration_id, active, value, created_at, updated_at, archived_at FROM pihole_record_links WHERE integration_id = ? ORDER BY value COLLATE NOCASE, id`, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var links []model.PiHoleRecordLink
|
||||||
|
for rows.Next() {
|
||||||
|
link, scanErr := scanPiHoleRecordLink(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
links = append(links, link)
|
||||||
|
}
|
||||||
|
return links, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetPiHoleRecordLink(ctx context.Context, integrationID, linkID int64) (model.PiHoleRecordLink, error) {
|
||||||
|
return scanPiHoleRecordLink(s.db.QueryRowContext(ctx, `SELECT id, integration_id, active, value, created_at, updated_at, archived_at FROM pihole_record_links WHERE id = ? AND integration_id = ?`, linkID, integrationID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) LinkPiHoleRecord(ctx context.Context, integrationID int64, value string, active bool) error {
|
||||||
|
timestamp := now()
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO pihole_record_links(integration_id, active, value, created_at, updated_at, archived_at) VALUES(?, ?, ?, ?, ?, 0)
|
||||||
|
ON CONFLICT(integration_id, value) DO UPDATE SET active = excluded.active, updated_at = excluded.updated_at, archived_at = 0`, integrationID, boolInt(active), value, timestamp, timestamp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdatePiHoleRecordLink(ctx context.Context, integrationID, linkID int64, value string, active bool) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `UPDATE pihole_record_links SET active = ?, value = ?, updated_at = ? WHERE id = ? AND integration_id = ?`, boolInt(active), value, now(), linkID, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ArchivePiHoleRecordLink(ctx context.Context, integrationID, linkID int64) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `UPDATE pihole_record_links SET archived_at = ?, updated_at = ? WHERE id = ? AND integration_id = ?`, now(), now(), linkID, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RestorePiHoleRecordLink(ctx context.Context, integrationID, linkID int64) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `UPDATE pihole_record_links SET archived_at = 0, updated_at = ? WHERE id = ? AND integration_id = ?`, now(), linkID, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeletePiHoleRecordLink(ctx context.Context, integrationID, linkID int64) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `DELETE FROM pihole_record_links WHERE id = ? AND integration_id = ?`, linkID, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ListTargets(ctx context.Context, zoneID int64) ([]model.Target, error) {
|
func (s *Store) ListTargets(ctx context.Context, zoneID int64) ([]model.Target, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE zone_id = ? AND archived_at = 0 ORDER BY CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID)
|
rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE zone_id = ? AND archived_at = 0 ORDER BY active DESC, CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -819,7 +1122,7 @@ func (s *Store) RestoreTarget(ctx context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListCNAMERecords(ctx context.Context, zoneID int64) ([]model.CNAMERecord, error) {
|
func (s *Store) ListCNAMERecords(ctx context.Context, zoneID int64) ([]model.CNAMERecord, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at FROM cname_records WHERE zone_id = ? ORDER BY CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID)
|
rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at FROM cname_records WHERE zone_id = ? ORDER BY active DESC, CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1016,6 +1319,7 @@ func (s *Store) CountErrors(ctx context.Context) (int, error) {
|
|||||||
err := s.db.QueryRowContext(ctx, `SELECT
|
err := s.db.QueryRowContext(ctx, `SELECT
|
||||||
(SELECT COUNT(*) FROM targets WHERE last_error != '') +
|
(SELECT COUNT(*) FROM targets WHERE last_error != '') +
|
||||||
(SELECT COUNT(*) FROM zones WHERE last_error != '') +
|
(SELECT COUNT(*) FROM zones WHERE last_error != '') +
|
||||||
|
(SELECT COUNT(*) FROM pihole_integrations WHERE last_error != '') +
|
||||||
(SELECT COUNT(*) FROM update_runs WHERE status IN ('error', 'partial'))`).Scan(&count)
|
(SELECT COUNT(*) FROM update_runs WHERE status IN ('error', 'partial'))`).Scan(&count)
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
@@ -1032,6 +1336,9 @@ func (s *Store) ClearErrors(ctx context.Context) error {
|
|||||||
if _, err := tx.ExecContext(ctx, `UPDATE zones SET last_error = '' WHERE last_error != ''`); err != nil {
|
if _, err := tx.ExecContext(ctx, `UPDATE zones SET last_error = '' WHERE last_error != ''`); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE pihole_integrations SET last_error = '' WHERE last_error != ''`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `DELETE FROM update_runs WHERE status IN ('error', 'partial')`); err != nil {
|
if _, err := tx.ExecContext(ctx, `DELETE FROM update_runs WHERE status IN ('error', 'partial')`); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1351,23 @@ func (s *Store) ClearErrors(ctx context.Context) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListPiHoleIntegrationsWithErrors(ctx context.Context) ([]model.PiHoleIntegration, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error FROM pihole_integrations WHERE last_error != '' ORDER BY name COLLATE NOCASE`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var integrations []model.PiHoleIntegration
|
||||||
|
for rows.Next() {
|
||||||
|
integration, scanErr := scanPiHoleIntegration(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
integrations = append(integrations, integration)
|
||||||
|
}
|
||||||
|
return integrations, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) AddAudit(ctx context.Context, kind, message string) error {
|
func (s *Store) AddAudit(ctx context.Context, kind, message string) error {
|
||||||
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(created_at, kind, message) VALUES(?, ?, ?)`, now(), kind, message)
|
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(created_at, kind, message) VALUES(?, ?, ?)`, now(), kind, message)
|
||||||
return err
|
return err
|
||||||
@@ -1159,6 +1483,26 @@ func scanZone(scanner interface{ Scan(...any) error }) (model.Zone, error) {
|
|||||||
return zone, err
|
return zone, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scanPiHoleIntegration(scanner interface{ Scan(...any) error }) (model.PiHoleIntegration, error) {
|
||||||
|
var integration model.PiHoleIntegration
|
||||||
|
var insecureTLS int
|
||||||
|
var createdAt, updatedAt, lastSyncAt int64
|
||||||
|
err := scanner.Scan(&integration.ID, &integration.Name, &integration.BaseURL, &insecureTLS, &createdAt, &updatedAt, &lastSyncAt, &integration.LastError)
|
||||||
|
integration.InsecureTLS = intBool(insecureTLS)
|
||||||
|
integration.CreatedAt, integration.UpdatedAt, integration.LastSyncAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(lastSyncAt)
|
||||||
|
return integration, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPiHoleRecordLink(scanner interface{ Scan(...any) error }) (model.PiHoleRecordLink, error) {
|
||||||
|
var link model.PiHoleRecordLink
|
||||||
|
var active int
|
||||||
|
var createdAt, updatedAt, archivedAt int64
|
||||||
|
err := scanner.Scan(&link.ID, &link.IntegrationID, &active, &link.Value, &createdAt, &updatedAt, &archivedAt)
|
||||||
|
link.Active = intBool(active)
|
||||||
|
link.CreatedAt, link.UpdatedAt, link.ArchivedAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(archivedAt)
|
||||||
|
return link, err
|
||||||
|
}
|
||||||
|
|
||||||
func scanTarget(scanner interface{ Scan(...any) error }) (model.Target, error) {
|
func scanTarget(scanner interface{ Scan(...any) error }) (model.Target, error) {
|
||||||
var target model.Target
|
var target model.Target
|
||||||
var updateIPv4, updateIPv6, active int
|
var updateIPv4, updateIPv6, active int
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +12,32 @@ import (
|
|||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
dataDir := filepath.Join(t.TempDir(), "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
databasePath := filepath.Join(dataDir, "app.db")
|
||||||
|
database, err := Open(ctx, databasePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
dirInfo, err := os.Stat(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dbInfo, err := os.Stat(databasePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dirInfo.Mode().Perm() != 0o700 || dbInfo.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("insecure storage permissions: dir=%o database=%o", dirInfo.Mode().Perm(), dbInfo.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -45,10 +73,14 @@ func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
|||||||
if home.StaticIPv4 != "203.0.113.10" {
|
if home.StaticIPv4 != "203.0.113.10" {
|
||||||
t.Fatalf("static IPv4 was not persisted: %#v", home)
|
t.Fatalf("static IPv4 was not persisted: %#v", home)
|
||||||
}
|
}
|
||||||
other, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "other", FQDN: "other.example.de", UpdateIPv4: true, TTL: 60})
|
other, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "aaa", FQDN: "aaa.example.de", UpdateIPv4: true, TTL: 60})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
orderedTargets, err := database.ListTargets(ctx, zone.ID)
|
||||||
|
if err != nil || len(orderedTargets) != 2 || orderedTargets[0].ID != home.ID || orderedTargets[1].ID != other.ID {
|
||||||
|
t.Fatalf("active domain target was not sorted before inactive target: %#v err=%v", orderedTargets, err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{TargetIDs: []int64{home.ID}, ManageTargetIDs: []int64{home.ID}}); err != nil {
|
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{TargetIDs: []int64{home.ID}, ManageTargetIDs: []int64{home.ID}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -74,7 +106,7 @@ func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
targets, err = database.ListAccessibleTargets(ctx, member.ID, zone.ID)
|
targets, err = database.ListAccessibleTargets(ctx, member.ID, zone.ID)
|
||||||
if err != nil || len(targets) != 2 {
|
if err != nil || len(targets) != 2 || targets[0].ID != home.ID || targets[1].ID != other.ID {
|
||||||
t.Fatalf("domain grant did not include all targets: %d, %v", len(targets), err)
|
t.Fatalf("domain grant did not include all targets: %d, %v", len(targets), err)
|
||||||
}
|
}
|
||||||
canCreate, err := database.CanCreateTarget(ctx, member.ID, zone.ID)
|
canCreate, err := database.CanCreateTarget(ctx, member.ID, zone.ID)
|
||||||
@@ -136,6 +168,82 @@ func TestInactiveUserSessionIsRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(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() })
|
||||||
|
|
||||||
|
created, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole Zuhause", BaseURL: "https://pi.hole/api", InsecureTLS: true}, model.PiHoleCredential{Ciphertext: []byte("encrypted"), Nonce: []byte("nonce")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Zweites Pi-hole", BaseURL: "https://second.pi.hole/api"}, model.PiHoleCredential{Ciphertext: []byte("encrypted"), Nonce: []byte("nonce")}); !errors.Is(err, ErrPiHoleAlreadyConfigured) {
|
||||||
|
t.Fatalf("second Pi-hole integration was not rejected: %v", err)
|
||||||
|
}
|
||||||
|
loaded, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, created.ID)
|
||||||
|
if err != nil || loaded.Name != "Pi-hole Zuhause" || loaded.BaseURL != "https://pi.hole/api" || !loaded.InsecureTLS || string(credential.Ciphertext) != "encrypted" {
|
||||||
|
t.Fatalf("Pi-hole integration not persisted: %#v %#v err=%v", loaded, credential, err)
|
||||||
|
}
|
||||||
|
loaded.Name = "Pi-hole Büro"
|
||||||
|
loaded.InsecureTLS = false
|
||||||
|
if err := database.UpdatePiHoleIntegration(ctx, loaded, model.PiHoleCredential{Ciphertext: []byte("new-encrypted"), Nonce: []byte("new-nonce")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.MarkPiHoleSynced(ctx, loaded.ID, "connection failed"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
integrations, err := database.ListPiHoleIntegrations(ctx)
|
||||||
|
if err != nil || len(integrations) != 1 || integrations[0].Name != "Pi-hole Büro" || integrations[0].LastError != "connection failed" || integrations[0].LastSyncAt.IsZero() {
|
||||||
|
t.Fatalf("Pi-hole integration status not listed: %#v err=%v", integrations, err)
|
||||||
|
}
|
||||||
|
links, err := database.SyncPiHoleRecordLinks(ctx, loaded.ID, []string{"192.168.1.20 nas.lan", "192.168.1.30 printer.lan"})
|
||||||
|
if err != nil || len(links) != 2 || !links[0].Active || !links[1].Active {
|
||||||
|
t.Fatalf("Pi-hole record links not synchronized: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
firstID, secondID := links[0].ID, links[1].ID
|
||||||
|
if err := database.ArchivePiHoleRecordLink(ctx, loaded.ID, firstID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
links, err = database.ListPiHoleRecordLinks(ctx, loaded.ID)
|
||||||
|
if err != nil || len(links) != 2 || links[0].ArchivedAt.IsZero() {
|
||||||
|
t.Fatalf("Pi-hole record link not archived: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
if err := database.RestorePiHoleRecordLink(ctx, loaded.ID, firstID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.UpdatePiHoleRecordLink(ctx, loaded.ID, firstID, "192.168.1.21 nas.lan", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.DeletePiHoleRecordLink(ctx, loaded.ID, secondID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
links, err = database.ListPiHoleRecordLinks(ctx, loaded.ID)
|
||||||
|
if err != nil || len(links) != 1 || links[0].Value != "192.168.1.21 nas.lan" || links[0].Active || !links[0].ArchivedAt.IsZero() {
|
||||||
|
t.Fatalf("Pi-hole record link lifecycle failed: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
links, err = database.SyncPiHoleRecordLinks(ctx, loaded.ID, []string{"192.168.1.21 nas.lan"})
|
||||||
|
if err != nil || len(links) != 1 || !links[0].Active {
|
||||||
|
t.Fatalf("remote Pi-hole record did not become active: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
links, err = database.SyncPiHoleRecordLinks(ctx, loaded.ID, nil)
|
||||||
|
if err != nil || len(links) != 1 || links[0].Active {
|
||||||
|
t.Fatalf("missing remote Pi-hole record was deleted instead of retained as inactive: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
if err := database.DeletePiHoleIntegration(ctx, loaded.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.GetPiHoleIntegration(ctx, loaded.ID); err == nil {
|
||||||
|
t.Fatal("deleted Pi-hole integration still exists")
|
||||||
|
}
|
||||||
|
if links, err := database.ListPiHoleRecordLinks(ctx, loaded.ID); err != nil || len(links) != 0 {
|
||||||
|
t.Fatalf("Pi-hole record links were not removed with integration: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
|
func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -263,7 +371,7 @@ func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
created, err := database.CreateCNAMERecord(ctx, model.CNAMERecord{ZoneID: zone.ID, Active: true, RecordName: "www", FQDN: "www.example.de", Destination: "app.example.net.", Description: "Website", TTL: 300})
|
created, err := database.CreateCNAMERecord(ctx, model.CNAMERecord{ZoneID: zone.ID, Active: true, RecordName: "aaa", FQDN: "aaa.example.de", Destination: "app.example.net.", Description: "Website", TTL: 300})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -277,18 +385,21 @@ func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) {
|
|||||||
t.Fatalf("CNAME update not persisted: %#v, %v", updated, err)
|
t.Fatalf("CNAME update not persisted: %#v, %v", updated, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := database.ReplaceCNAMERecords(ctx, zone.ID, []model.CNAMERecord{{ZoneID: zone.ID, RecordName: "cdn", FQDN: "cdn.example.de", Destination: "assets.example.net.", TTL: 120}}); err != nil {
|
if err := database.ReplaceCNAMERecords(ctx, zone.ID, []model.CNAMERecord{{ZoneID: zone.ID, RecordName: "zzz", FQDN: "zzz.example.de", Destination: "assets.example.net.", TTL: 120}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
records, err := database.ListCNAMERecords(ctx, zone.ID)
|
records, err := database.ListCNAMERecords(ctx, zone.ID)
|
||||||
if err != nil || len(records) != 2 {
|
if err != nil || len(records) != 2 {
|
||||||
t.Fatalf("provider CNAME import was not mirrored: %#v, %v", records, err)
|
t.Fatalf("provider CNAME import was not mirrored: %#v, %v", records, err)
|
||||||
}
|
}
|
||||||
|
if records[0].RecordName != "zzz" || records[1].RecordName != "aaa" {
|
||||||
|
t.Fatalf("active CNAME was not sorted before inactive CNAME: %#v", records)
|
||||||
|
}
|
||||||
byName := make(map[string]model.CNAMERecord, len(records))
|
byName := make(map[string]model.CNAMERecord, len(records))
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
byName[record.RecordName] = record
|
byName[record.RecordName] = record
|
||||||
}
|
}
|
||||||
if byName["www"].Active || !byName["cdn"].Active || byName["cdn"].Destination != "assets.example.net." || byName["cdn"].TTL != 120 {
|
if byName["aaa"].Active || !byName["zzz"].Active || byName["zzz"].Destination != "assets.example.net." || byName["zzz"].TTL != 120 {
|
||||||
t.Fatalf("inactive/local CNAME lifecycle was not preserved: %#v", byName)
|
t.Fatalf("inactive/local CNAME lifecycle was not preserved: %#v", byName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"golang.org/x/net/idna"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type piHoleView struct {
|
||||||
|
Integration model.PiHoleIntegration
|
||||||
|
Records []piHoleRecordView
|
||||||
|
ArchivedRecords []piHoleRecordView
|
||||||
|
FetchError string
|
||||||
|
}
|
||||||
|
|
||||||
|
type piHoleRecordView struct {
|
||||||
|
LinkID int64
|
||||||
|
Active bool
|
||||||
|
Raw string
|
||||||
|
IP string
|
||||||
|
Hostnames string
|
||||||
|
Valid bool
|
||||||
|
ArchivedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHolePage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
integrations, err := s.store.ListPiHoleIntegrations(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
for _, link := range links {
|
||||||
|
record := parsePiHoleRecord(link.Value)
|
||||||
|
record.LinkID = link.ID
|
||||||
|
record.Active = link.Active
|
||||||
|
record.ArchivedAt = link.ArchivedAt
|
||||||
|
if link.ArchivedAt.IsZero() {
|
||||||
|
current.Records = append(current.Records, record)
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
s.render(w, "pihole", viewData{
|
||||||
|
Title: "Pi-hole",
|
||||||
|
Page: "pihole",
|
||||||
|
CSRF: session.CSRFToken,
|
||||||
|
Flash: notice(r),
|
||||||
|
Now: time.Now(),
|
||||||
|
User: userFromSession(session),
|
||||||
|
IsAdmin: true,
|
||||||
|
PiHoleView: view,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleConnect(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ültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integrations, err := s.store.ListPiHoleIntegrations(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(integrations) > 0 {
|
||||||
|
s.redirectNotice(w, r, "/pihole", "Es ist bereits ein Pi-hole verbunden. Bearbeite die bestehende Verbindung oder entferne sie zuerst.", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const name = "Pi-hole"
|
||||||
|
password := strings.TrimSpace(r.FormValue("password"))
|
||||||
|
if password == "" || len(password) > 1024 {
|
||||||
|
s.redirectNotice(w, r, "/pihole", "Ein gültiges Pi-hole-Anwendungspasswort ist erforderlich.", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
insecureTLS := checked(r, "insecure_tls")
|
||||||
|
client, err := pihole.New(r.FormValue("base_url"), insecureTLS)
|
||||||
|
if err != nil {
|
||||||
|
s.redirectNotice(w, r, "/pihole", err.Error(), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := client.ListHosts(r.Context(), password); err != nil {
|
||||||
|
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung fehlgeschlagen: "+safeMessage(err), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ciphertext, nonce, err := s.cipher.Encrypt(password)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration, err := s.store.CreatePiHoleIntegration(r.Context(), model.PiHoleIntegration{Name: name, BaseURL: client.BaseURL(), InsecureTLS: insecureTLS}, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce})
|
||||||
|
if err != nil {
|
||||||
|
message := "Pi-hole-Verbindung konnte nicht gespeichert werden: " + safeMessage(err)
|
||||||
|
if errors.Is(err, store.ErrPiHoleAlreadyConfigured) {
|
||||||
|
message = "Es ist bereits ein Pi-hole verbunden."
|
||||||
|
}
|
||||||
|
s.redirectNotice(w, r, "/pihole", message, "error")
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleSave(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ültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration, credential, err := s.store.GetPiHoleIntegrationWithCredential(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password := strings.TrimSpace(r.FormValue("password"))
|
||||||
|
if password == "" {
|
||||||
|
password, err = s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(password) > 1024 {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Das Anwendungspasswort ist zu lang.", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration.InsecureTLS = checked(r, "insecure_tls")
|
||||||
|
client, err := pihole.New(r.FormValue("base_url"), integration.InsecureTLS)
|
||||||
|
if err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := client.ListHosts(r.Context(), password); err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Neue Pi-hole-Konfiguration nicht gespeichert: "+safeMessage(err), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration.BaseURL = client.BaseURL()
|
||||||
|
ciphertext, nonce, err := s.cipher.Encrypt(password)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.UpdatePiHoleIntegration(r.Context(), integration, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce}); err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung konnte nicht gespeichert werden: "+safeMessage(err), "error")
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHolePassword(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ültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||||
|
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
||||||
|
s.redirectNotice(w, r, "/pihole#pihole-connection", "Admin-Passwort nicht korrekt; Anwendungspasswort bleibt verborgen.", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration, credential, err := s.store.GetPiHoleIntegrationWithCredential(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.SetSessionReauth(r.Context(), session.ID, time.Now().UTC().Add(5*time.Minute))
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.credential_revealed", "Pi-hole-Anwendungspasswort angezeigt durch "+session.Username)
|
||||||
|
s.render(w, "token", viewData{
|
||||||
|
Title: "Pi-hole-Passwort anzeigen",
|
||||||
|
Page: "pihole",
|
||||||
|
CSRF: session.CSRFToken,
|
||||||
|
SecretTitle: "Pi-hole-Anwendungspasswort",
|
||||||
|
SecretScope: "Pi-hole: " + integration.BaseURL,
|
||||||
|
SecretValue: password,
|
||||||
|
SecretBackURL: "/pihole#pihole-connection",
|
||||||
|
User: userFromSession(session),
|
||||||
|
IsAdmin: true,
|
||||||
|
Now: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRefresh(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ültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, password, err := s.piHoleAccess(r.Context(), id)
|
||||||
|
if err == nil {
|
||||||
|
_, err = client.ListHosts(r.Context(), password)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Lokale DNS-Einträge wurden neu eingelesen.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRemove(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ültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration, err := s.store.GetPiHoleIntegration(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeletePiHoleIntegration(r.Context(), id); err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.removed", "Pi-hole-Verbindung entfernt: "+integration.Name+" durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung entfernt; lokale DNS-Einträge auf dem Pi-hole blieben unverändert.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleDelete(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ültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration, err := s.store.GetPiHoleIntegration(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, password, err := s.piHoleAccess(r.Context(), id)
|
||||||
|
var hosts []string
|
||||||
|
if err == nil {
|
||||||
|
hosts, err = client.ListHosts(r.Context(), password)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = client.DeleteHosts(r.Context(), password, hosts)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.piHoleOperationError(w, r, id, "Pi-hole und lokale DNS-Einträge konnten nicht vollständig gelöscht werden", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeletePiHoleIntegration(r.Context(), id); err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.deleted", "Pi-hole-Verbindung und lokale DNS-Einträge gelöscht: "+integration.Name+" durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung und alle lokalen DNS-Einträge wurden vollständig gelöscht.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRecordCreate(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ültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, err := normalizePiHoleRecord(r.FormValue("ip"), r.FormValue("hostnames"))
|
||||||
|
if err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
active := checked(r, "active")
|
||||||
|
if active {
|
||||||
|
client, password, accessErr := s.piHoleAccess(r.Context(), id)
|
||||||
|
if accessErr == nil {
|
||||||
|
accessErr = client.AddHost(r.Context(), password, value)
|
||||||
|
}
|
||||||
|
if accessErr != nil {
|
||||||
|
s.piHoleOperationError(w, r, id, "Eintrag konnte nicht angelegt werden", accessErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.LinkPiHoleRecord(r.Context(), id, value, active); err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.record_created", "Lokalen DNS-Eintrag angelegt: "+value+" (aktiv: "+strconv.FormatBool(active)+") durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Lokaler DNS-Eintrag angelegt"+piHoleActivitySuffix(active)+".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRecordUpdate(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ültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link, ok := s.piHoleRecordLink(w, r, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previous, err := validatePiHoleRawRecord(link.Value)
|
||||||
|
if err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replacement, err := normalizePiHoleRecord(r.FormValue("ip"), r.FormValue("hostnames"))
|
||||||
|
if err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
active := checked(r, "active")
|
||||||
|
if link.Active || active {
|
||||||
|
client, password, accessErr := s.piHoleAccess(r.Context(), id)
|
||||||
|
if accessErr == nil {
|
||||||
|
switch {
|
||||||
|
case link.Active && active:
|
||||||
|
accessErr = client.ReplaceHost(r.Context(), password, previous, replacement)
|
||||||
|
case link.Active:
|
||||||
|
accessErr = client.DeleteHost(r.Context(), password, previous)
|
||||||
|
default:
|
||||||
|
accessErr = client.AddHost(r.Context(), password, replacement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accessErr != nil {
|
||||||
|
s.piHoleOperationError(w, r, id, "Eintrag konnte nicht geändert werden", accessErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.UpdatePiHoleRecordLink(r.Context(), id, link.ID, replacement, active); err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.record_updated", "Lokalen DNS-Eintrag geändert: "+previous+" → "+replacement+" (aktiv: "+strconv.FormatBool(active)+") durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Lokaler DNS-Eintrag gespeichert"+piHoleActivitySuffix(active)+".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRecordDelete(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ültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link, ok := s.piHoleRecordLink(w, r, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, err := validatePiHoleRawRecord(link.Value)
|
||||||
|
if err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if link.Active {
|
||||||
|
client, password, accessErr := s.piHoleAccess(r.Context(), id)
|
||||||
|
if accessErr == nil {
|
||||||
|
accessErr = client.DeleteHost(r.Context(), password, value)
|
||||||
|
}
|
||||||
|
if accessErr != nil {
|
||||||
|
s.piHoleOperationError(w, r, id, "Eintrag konnte nicht gelöscht werden", accessErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.DeletePiHoleRecordLink(r.Context(), id, link.ID); err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.record_deleted", "Lokalen DNS-Eintrag gelöscht: "+value+" durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Lokaler DNS-Eintrag bei Pi-hole gelöscht.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRecordArchive(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ültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link, ok := s.piHoleRecordLink(w, r, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.ArchivePiHoleRecordLink(r.Context(), id, link.ID); err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Eintrag konnte nicht aus der WebUI entfernt werden: "+safeMessage(err), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.record_archived", "Lokalen DNS-Eintrag aus der WebUI entfernt: "+link.Value+" durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Eintrag aus der WebUI entfernt; bei Pi-hole blieb er unverändert.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRecordRestore(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ültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link, ok := s.piHoleRecordLink(w, r, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.RestorePiHoleRecordLink(r.Context(), id, link.ID); err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Eintrag konnte nicht wiederhergestellt werden: "+safeMessage(err), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.record_restored", "Lokalen DNS-Eintrag wieder in der WebUI verknüpft: "+link.Value+" durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Eintrag wieder mit der WebUI verknüpft.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRecordLink(w http.ResponseWriter, r *http.Request, integrationID int64) (model.PiHoleRecordLink, bool) {
|
||||||
|
linkID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("link_id")), 10, 64)
|
||||||
|
if err != nil || linkID <= 0 {
|
||||||
|
http.Error(w, "Ungültiger Pi-hole-Eintrag", http.StatusBadRequest)
|
||||||
|
return model.PiHoleRecordLink{}, false
|
||||||
|
}
|
||||||
|
link, err := s.store.GetPiHoleRecordLink(r.Context(), integrationID, linkID)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return model.PiHoleRecordLink{}, false
|
||||||
|
}
|
||||||
|
return link, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func piHoleActivitySuffix(active bool) string {
|
||||||
|
if active {
|
||||||
|
return " und bei Pi-hole aktiviert"
|
||||||
|
}
|
||||||
|
return " und bei Pi-hole deaktiviert"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleAccess(ctx context.Context, id int64) (*pihole.Client, string, error) {
|
||||||
|
integration, credential, err := s.store.GetPiHoleIntegrationWithCredential(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
password, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
client, err := pihole.New(integration.BaseURL, integration.InsecureTLS)
|
||||||
|
return client, password, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleOperationError(w http.ResponseWriter, r *http.Request, id int64, prefix string, err error) {
|
||||||
|
message := piHoleErrorMessage(err)
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, message)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), prefix+": "+message, "error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func piHoleErrorMessage(err error) string {
|
||||||
|
var apiErr *pihole.APIError
|
||||||
|
if errors.As(err, &apiErr) && apiErr.Status == http.StatusForbidden && strings.Contains(strings.ToLower(apiErr.Message), "read-only") {
|
||||||
|
return "Pi-hole erlaubt dieser API-Sitzung nur Lesezugriff. Aktiviere für das Anwendungspasswort webserver.api.app_sudo=true und stelle sicher, dass misc.readOnly=false ist"
|
||||||
|
}
|
||||||
|
return safeMessage(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePiHoleRecord(rawIP, rawHostnames string) (string, error) {
|
||||||
|
address, err := netip.ParseAddr(strings.TrimSpace(rawIP))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("Bitte eine gültige IPv4- oder IPv6-Adresse angeben")
|
||||||
|
}
|
||||||
|
address = address.Unmap()
|
||||||
|
hostnames, err := normalizePiHoleHostnames(rawHostnames)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return address.String() + " " + strings.Join(hostnames, " "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePiHoleHostnames(raw string) ([]string, error) {
|
||||||
|
values := strings.Fields(strings.ToLower(strings.TrimSpace(raw)))
|
||||||
|
if len(values) == 0 || len(values) > 20 {
|
||||||
|
return nil, errors.New("Bitte mindestens einen und höchstens 20 Hostnamen angeben")
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]bool, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSuffix(value, ".")
|
||||||
|
ascii, err := idna.Lookup.ToASCII(value)
|
||||||
|
if err != nil || ascii == "" || len(ascii) > 253 {
|
||||||
|
return nil, errors.New("Bitte gültige lokale Hostnamen angeben")
|
||||||
|
}
|
||||||
|
for _, label := range strings.Split(ascii, ".") {
|
||||||
|
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||||
|
return nil, errors.New("Bitte gültige lokale Hostnamen angeben")
|
||||||
|
}
|
||||||
|
for _, character := range label {
|
||||||
|
if !(character >= 'a' && character <= 'z') && !(character >= '0' && character <= '9') && character != '-' {
|
||||||
|
return nil, errors.New("Hostnamen dürfen nur Buchstaben, Zahlen, Punkte und Bindestriche enthalten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seen[ascii] {
|
||||||
|
seen[ascii] = true
|
||||||
|
result = append(result, ascii)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePiHoleRawRecord(raw string) (string, error) {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" || len(value) > 2048 {
|
||||||
|
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if unicode.IsControl(character) {
|
||||||
|
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields := strings.Fields(value)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
|
||||||
|
}
|
||||||
|
if _, err := netip.ParseAddr(fields[0]); err != nil {
|
||||||
|
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
|
||||||
|
}
|
||||||
|
if _, err := normalizePiHoleHostnames(strings.Join(fields[1:], " ")); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePiHoleRecord(raw string) piHoleRecordView {
|
||||||
|
view := piHoleRecordView{Raw: raw}
|
||||||
|
fields := strings.Fields(raw)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
view.Hostnames = raw
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
if _, err := netip.ParseAddr(fields[0]); err != nil {
|
||||||
|
view.Hostnames = strings.Join(fields[1:], " ")
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
view.IP = fields[0]
|
||||||
|
view.Hostnames = strings.Join(fields[1:], " ")
|
||||||
|
view.Valid = true
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortPiHoleRecords(records []piHoleRecordView) {
|
||||||
|
sort.Slice(records, func(left, right int) bool {
|
||||||
|
if records[left].Active != records[right].Active {
|
||||||
|
return records[left].Active
|
||||||
|
}
|
||||||
|
return strings.ToLower(records[left].Hostnames) < strings.ToLower(records[right].Hostnames)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func piHoleURL(_ int64) string {
|
||||||
|
return "/pihole#local-dns"
|
||||||
|
}
|
||||||
+43
-4
@@ -57,7 +57,10 @@ type viewData struct {
|
|||||||
ArchivedTargets []model.Target
|
ArchivedTargets []model.Target
|
||||||
CNAMERecords []model.CNAMERecord
|
CNAMERecords []model.CNAMERecord
|
||||||
Settings model.Settings
|
Settings model.Settings
|
||||||
Token string
|
SecretTitle string
|
||||||
|
SecretScope string
|
||||||
|
SecretValue string
|
||||||
|
SecretBackURL string
|
||||||
Form map[string]string
|
Form map[string]string
|
||||||
Users []model.User
|
Users []model.User
|
||||||
ManagedUser model.User
|
ManagedUser model.User
|
||||||
@@ -68,7 +71,9 @@ type viewData struct {
|
|||||||
ErrorTargets []model.Target
|
ErrorTargets []model.Target
|
||||||
ErrorZones []model.Zone
|
ErrorZones []model.Zone
|
||||||
ErrorRuns []model.UpdateRun
|
ErrorRuns []model.UpdateRun
|
||||||
|
ErrorPiHoles []model.PiHoleIntegration
|
||||||
ErrorCount int
|
ErrorCount int
|
||||||
|
PiHoleView *piHoleView
|
||||||
}
|
}
|
||||||
|
|
||||||
type flashMessage struct {
|
type flashMessage struct {
|
||||||
@@ -124,6 +129,18 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.cnameSave))
|
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}/remove", s.adminOnly(s.cnameRemove))
|
||||||
mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.cnameDelete))
|
mux.Handle("POST /cnames/{id}/delete", s.adminOnly(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))
|
||||||
|
mux.Handle("POST /pihole/{id}/password", s.adminOnly(s.piHolePassword))
|
||||||
|
mux.Handle("POST /pihole/{id}/refresh", s.adminOnly(s.piHoleRefresh))
|
||||||
|
mux.Handle("POST /pihole/{id}/remove", s.adminOnly(s.piHoleRemove))
|
||||||
|
mux.Handle("POST /pihole/{id}/delete", s.adminOnly(s.piHoleDelete))
|
||||||
|
mux.Handle("POST /pihole/{id}/records", s.adminOnly(s.piHoleRecordCreate))
|
||||||
|
mux.Handle("POST /pihole/{id}/records/update", s.adminOnly(s.piHoleRecordUpdate))
|
||||||
|
mux.Handle("POST /pihole/{id}/records/delete", s.adminOnly(s.piHoleRecordDelete))
|
||||||
|
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("GET /settings", s.adminOnly(s.settingsPage))
|
||||||
mux.Handle("POST /settings", s.adminOnly(s.settingsSave))
|
mux.Handle("POST /settings", s.adminOnly(s.settingsSave))
|
||||||
mux.Handle("POST /run", s.adminOnly(s.runNow))
|
mux.Handle("POST /run", s.adminOnly(s.runNow))
|
||||||
@@ -640,7 +657,18 @@ func (s *Server) domainToken(w http.ResponseWriter, r *http.Request, session *mo
|
|||||||
}
|
}
|
||||||
_ = s.store.SetSessionReauth(r.Context(), session.ID, time.Now().UTC().Add(5*time.Minute))
|
_ = s.store.SetSessionReauth(r.Context(), session.ID, time.Now().UTC().Add(5*time.Minute))
|
||||||
_ = s.store.AddAudit(r.Context(), "credential.revealed", "API-Token angezeigt: "+zone.Name)
|
_ = s.store.AddAudit(r.Context(), "credential.revealed", "API-Token angezeigt: "+zone.Name)
|
||||||
s.render(w, "token", viewData{Title: "API-Token anzeigen", Page: "domains", CSRF: session.CSRFToken, Zone: zone, Token: token, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
|
s.render(w, "token", viewData{
|
||||||
|
Title: "API-Token anzeigen",
|
||||||
|
Page: "domains",
|
||||||
|
CSRF: session.CSRFToken,
|
||||||
|
SecretTitle: "Hetzner API-Token",
|
||||||
|
SecretScope: "Zone: " + zone.Name,
|
||||||
|
SecretValue: token,
|
||||||
|
SecretBackURL: domainURL(zone.ID),
|
||||||
|
User: userFromSession(session),
|
||||||
|
IsAdmin: true,
|
||||||
|
Now: time.Now(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) targetCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) targetCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
@@ -1228,7 +1256,12 @@ func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *mod
|
|||||||
s.serverError(w, err)
|
s.serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, "errors", viewData{Title: "Fehler", Page: "errors", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ErrorTargets: targets, ErrorZones: zones, ErrorRuns: runs, ErrorCount: len(targets) + len(zones) + len(runs), Now: time.Now()})
|
piHoles, err := s.store.ListPiHoleIntegrationsWithErrors(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, "errors", viewData{Title: "Fehler", Page: "errors", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ErrorTargets: targets, ErrorZones: zones, ErrorRuns: runs, ErrorPiHoles: piHoles, ErrorCount: len(targets) + len(zones) + len(runs) + len(piHoles), Now: time.Now()})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
@@ -1727,16 +1760,21 @@ func (s *Server) redirectNotice(w http.ResponseWriter, r *http.Request, target,
|
|||||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'")
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'")
|
||||||
|
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||||
|
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("X-Frame-Options", "DENY")
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||||
|
if s.config.CookieSecure {
|
||||||
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadTemplates() (map[string]*template.Template, error) {
|
func loadTemplates() (map[string]*template.Template, error) {
|
||||||
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "settings", "errors", "security", "token", "users", "user_new", "user_detail"}
|
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "token", "users", "user_new", "user_detail"}
|
||||||
functions := template.FuncMap{
|
functions := template.FuncMap{
|
||||||
"formatTime": func(value time.Time) string {
|
"formatTime": func(value time.Time) string {
|
||||||
if value.IsZero() {
|
if value.IsZero() {
|
||||||
@@ -1755,6 +1793,7 @@ func loadTemplates() (map[string]*template.Template, error) {
|
|||||||
"statusClass": statusClass,
|
"statusClass": statusClass,
|
||||||
"ttlOptions": func() []int { return []int{60, 300, 600, 1800, 3600, 86400} },
|
"ttlOptions": func() []int { return []int{60, 300, 600, 1800, 3600, 86400} },
|
||||||
"supportedTTL": supportedTTL,
|
"supportedTTL": supportedTTL,
|
||||||
|
"contains": strings.Contains,
|
||||||
"families": func(target model.Target) string {
|
"families": func(target model.Target) string {
|
||||||
var result []string
|
var result []string
|
||||||
if target.UpdateIPv4 {
|
if target.UpdateIPv4 {
|
||||||
|
|||||||
@@ -2,13 +2,22 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/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) {
|
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) {
|
func TestUnixSecondsUsesZeroForUnscheduledRun(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
if got := unixSeconds(time.Time{}); got != 0 {
|
if got := unixSeconds(time.Time{}); got != 0 {
|
||||||
@@ -182,6 +256,11 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
|
|||||||
t.Errorf("domain detail is missing %q", expected)
|
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) {
|
func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(t *testing.T) {
|
||||||
@@ -214,3 +293,349 @@ func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(t *testing.T) {
|
|||||||
t.Error("globally-disabled IPv6 checkbox remained enabled")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,6 +179,25 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const form of document.querySelectorAll('[data-dirty-form]')) {
|
||||||
|
const submit = form.querySelector('[data-dirty-submit]');
|
||||||
|
if (!submit) continue;
|
||||||
|
const state = () => new URLSearchParams(new FormData(form)).toString();
|
||||||
|
const initialState = state();
|
||||||
|
const syncDirtyState = () => {
|
||||||
|
const dirty = state() !== initialState;
|
||||||
|
submit.disabled = !dirty;
|
||||||
|
submit.setAttribute('aria-disabled', String(!dirty));
|
||||||
|
};
|
||||||
|
form.addEventListener('input', syncDirtyState);
|
||||||
|
form.addEventListener('change', syncDirtyState);
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
syncDirtyState();
|
||||||
|
if (submit.disabled) event.preventDefault();
|
||||||
|
});
|
||||||
|
syncDirtyState();
|
||||||
|
}
|
||||||
|
|
||||||
const copyButtons = document.querySelectorAll('[data-copy-target]');
|
const copyButtons = document.querySelectorAll('[data-copy-target]');
|
||||||
for (const button of copyButtons) {
|
for (const button of copyButtons) {
|
||||||
button.addEventListener('click', async () => {
|
button.addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -234,6 +234,33 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
|||||||
.archive-row small { margin-top: 2px; color: var(--muted); font-family: var(--mono); font-size: 9px; }
|
.archive-row small { margin-top: 2px; color: var(--muted); font-family: var(--mono); font-size: 9px; }
|
||||||
.admin-actions-grid { margin-top: 18px; }
|
.admin-actions-grid { margin-top: 18px; }
|
||||||
|
|
||||||
|
.pihole-kicker { color: #a4121a; }
|
||||||
|
.button-pihole { border-color: #a4121a; background: #a4121a; color: #fff; }
|
||||||
|
.button-pihole:hover { border-color: #861016; background: #861016; color: #fff; }
|
||||||
|
.button-pihole:disabled, .button-pihole:disabled:hover { border-color: var(--line); background: #e8edf2; color: #8995a3; box-shadow: none; }
|
||||||
|
.button-pihole:focus-visible { outline: 3px solid rgba(164,18,26,.2); outline-offset: 2px; }
|
||||||
|
.pihole-summary { border-top: 3px solid #a4121a; }
|
||||||
|
.target-row.pihole-target-row { border-left-color: #b9c3ce; }
|
||||||
|
.target-row.pihole-target-row.is-active { border-left-color: #a4121a; }
|
||||||
|
.target-status.pihole-on { background: #a4121a; box-shadow: 0 0 0 3px rgba(164,18,26,.1); }
|
||||||
|
.pihole-status-pill { background: #fff0f1; color: #98131b; }
|
||||||
|
.pihole-switch input:checked + span { background: #a4121a; }
|
||||||
|
.pihole-record-form { gap: 6px; }
|
||||||
|
.pihole-record-form label:not(.switch) { gap: 2px; }
|
||||||
|
.pihole-record-form .form-grid { gap: 8px; align-items: start; }
|
||||||
|
.pihole-record-form .toggle-row { min-height: 24px; }
|
||||||
|
.pihole-record-form input { min-height: 43px; }
|
||||||
|
.pihole-record-save { min-height: 36px; justify-self: start; padding-inline: 11px; font-size: 11px; }
|
||||||
|
.pihole-target-row .edit-inner { gap: 10px; padding-block: 11px; }
|
||||||
|
.pihole-action-grid { grid-template-columns: repeat(2, minmax(0,1fr)); margin-top: 14px; }
|
||||||
|
.pihole-connection-error { margin-bottom: 18px; padding: 12px; border: 1px solid #efc7ce; border-left: 3px solid #a4121a; border-radius: var(--radius-sm); background: #fff8f9; }
|
||||||
|
.pihole-connection-error strong { color: var(--danger); font-size: 11px; }
|
||||||
|
.pihole-connection-error p { margin: 3px 0 0; color: #865764; font-size: 10px; overflow-wrap: anywhere; }
|
||||||
|
.pihole-connect-panel { max-width: 720px; margin-top: 20px; border-top: 3px solid #a4121a; scroll-margin-top: 18px; }
|
||||||
|
.pihole-api-badge { padding: 4px 7px; border-radius: 4px; background: #fff0f1; color: #98131b; font-family: var(--mono); font-size: 9px; font-weight: 800; }
|
||||||
|
.pihole-empty { margin-bottom: 18px; }
|
||||||
|
.pihole-empty-icon { border-color: #e4b7ba; background: #fff2f3; color: #9d1018; }
|
||||||
|
|
||||||
.stacked-form { display: grid; gap: 13px; }
|
.stacked-form { display: grid; gap: 13px; }
|
||||||
.wide-form { width: 100%; max-width: 850px; }
|
.wide-form { width: 100%; max-width: 850px; }
|
||||||
.settings-form { display: grid; gap: 12px; }
|
.settings-form { display: grid; gap: 12px; }
|
||||||
@@ -268,6 +295,9 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.switch input:checked + span { background: var(--success); }
|
.switch input:checked + span { background: var(--success); }
|
||||||
.switch input:checked + span::after { transform: translateX(12px); }
|
.switch input:checked + span::after { transform: translateX(12px); }
|
||||||
.switch input:focus-visible + span { outline: 3px solid rgba(37,99,235,.2); outline-offset: 2px; }
|
.switch input:focus-visible + span { outline: 3px solid rgba(37,99,235,.2); outline-offset: 2px; }
|
||||||
|
.switch.pihole-switch > span { width: 32px; height: 20px; }
|
||||||
|
.switch.pihole-switch > span::after { top: 4px; left: 4px; width: 12px; height: 12px; }
|
||||||
|
.switch.pihole-switch input:checked + span::after { transform: translateX(12px); }
|
||||||
.switch-disabled { color: var(--muted); opacity: .65; }
|
.switch-disabled { color: var(--muted); opacity: .65; }
|
||||||
.action-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 12px; }
|
.action-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 12px; }
|
||||||
.info-box { display: grid; grid-template-columns: 150px 1fr; gap: 12px; padding: 11px 12px; border: 1px solid #bfd1e8; border-radius: 6px; background: #f2f7fd; font-size: 10px; }
|
.info-box { display: grid; grid-template-columns: 150px 1fr; gap: 12px; padding: 11px 12px; border: 1px solid #bfd1e8; border-radius: 6px; background: #f2f7fd; font-size: 10px; }
|
||||||
@@ -321,7 +351,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.access-target small { color: var(--muted); font-size: 8px; }
|
.access-target small { color: var(--muted); font-size: 8px; }
|
||||||
.access-manage-switch { white-space: nowrap; }
|
.access-manage-switch { white-space: nowrap; }
|
||||||
|
|
||||||
.error-summary-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
.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; }
|
.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; }
|
||||||
.error-summary-grid span { color: #865764; font-family: var(--mono); font-size: 9px; text-transform: uppercase; }
|
.error-summary-grid span { color: #865764; font-family: var(--mono); font-size: 9px; text-transform: uppercase; }
|
||||||
.error-summary-grid strong { color: var(--danger); font-size: 20px; }
|
.error-summary-grid strong { color: var(--danger); font-size: 20px; }
|
||||||
@@ -349,6 +379,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.dashboard-grid, .split-grid { grid-template-columns: 1fr; }
|
.dashboard-grid, .split-grid { grid-template-columns: 1fr; }
|
||||||
.edit-inner { grid-template-columns: 1fr; }
|
.edit-inner { grid-template-columns: 1fr; }
|
||||||
.settings-mode-grid { grid-template-columns: 1fr; }
|
.settings-mode-grid { grid-template-columns: 1fr; }
|
||||||
|
.pihole-action-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -388,6 +419,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.error-summary-grid { grid-template-columns: 1fr; }
|
.error-summary-grid { grid-template-columns: 1fr; }
|
||||||
.error-item { grid-template-columns: 26px minmax(0,1fr); }
|
.error-item { grid-template-columns: 26px minmax(0,1fr); }
|
||||||
.error-item > a { grid-column: 2; }
|
.error-item > a { grid-column: 2; }
|
||||||
|
.pihole-action-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -41,12 +41,10 @@
|
|||||||
<div class="danger-actions"><form action="/targets/{{.ID}}/archive" method="post" class="unlink-form">
|
<div class="danger-actions"><form action="/targets/{{.ID}}/archive" method="post" class="unlink-form">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<strong>Verknüpfung trennen</strong>
|
<strong>Verknüpfung trennen</strong>
|
||||||
<p>Entfernt nur die lokale DynDNS-Verknüpfung. Die DNS-Records bleiben erhalten.</p>
|
|
||||||
<button class="button button-danger-soft" type="submit">Verknüpfung trennen</button>
|
<button class="button button-danger-soft" type="submit">Verknüpfung trennen</button>
|
||||||
</form><form action="/targets/{{.ID}}/delete" method="post" class="unlink-form">
|
</form><form action="/targets/{{.ID}}/delete" method="post" class="unlink-form">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<strong>Ziel vollständig löschen</strong>
|
<strong>Ziel vollständig löschen</strong>
|
||||||
<p>Löscht das Ziel und alle zugehörigen A-/AAAA-Records bei Hetzner.</p>
|
|
||||||
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
|
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
|
||||||
</form></div>
|
</form></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -79,12 +77,10 @@
|
|||||||
<div class="danger-actions"><form action="/cnames/{{.ID}}/remove" method="post" class="unlink-form">
|
<div class="danger-actions"><form action="/cnames/{{.ID}}/remove" method="post" class="unlink-form">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<strong>Aus WebUI entfernen</strong>
|
<strong>Aus WebUI entfernen</strong>
|
||||||
<p>Entfernt nur die lokale Verknüpfung. Der CNAME-Record bei Hetzner bleibt unverändert.</p>
|
|
||||||
<button class="button button-danger-soft" type="submit">Aus WebUI entfernen</button>
|
<button class="button button-danger-soft" type="submit">Aus WebUI entfernen</button>
|
||||||
</form><form action="/cnames/{{.ID}}/delete" method="post" class="unlink-form">
|
</form><form action="/cnames/{{.ID}}/delete" method="post" class="unlink-form">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<strong>Ziel vollständig löschen</strong>
|
<strong>Ziel vollständig löschen</strong>
|
||||||
<p>Löscht das Ziel aus der WebUI und den zugehörigen CNAME-Record bei Hetzner.</p>
|
|
||||||
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
|
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
|
||||||
</form></div>
|
</form></div>
|
||||||
</div></details>
|
</div></details>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "errors"}}{{template "layout" .}}{{end}}
|
{{define "errors"}}{{template "layout" .}}{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header class="page-heading">
|
<header class="page-heading">
|
||||||
<div><a class="back-link" href="/">← Übersicht</a><span class="page-kicker">Diagnose</span><h1>Fehlerzentrale</h1><p>Aktuelle Ziel- und Zonenfehler sowie alle gespeicherten fehlgeschlagenen oder partiellen Update-Läufe.</p></div>
|
<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>
|
||||||
<form action="/errors/clear" method="post">
|
<form action="/errors/clear" method="post">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
|
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
|
||||||
@@ -9,11 +9,12 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{if eq .ErrorCount 0}}
|
{{if eq .ErrorCount 0}}
|
||||||
<section class="panel error-empty"><span class="error-empty-icon" aria-hidden="true">✓</span><div><h2>Keine Fehler gespeichert</h2><p>Aktuell sind weder Ziel- oder Zonenfehler noch problematische Update-Läufe vorhanden.</p></div></section>
|
<section class="panel error-empty"><span class="error-empty-icon" aria-hidden="true">✓</span><div><h2>Keine Fehler gespeichert</h2><p>Aktuell sind weder DNS- oder Pi-hole-Fehler noch problematische Update-Läufe vorhanden.</p></div></section>
|
||||||
{{else}}
|
{{else}}
|
||||||
<section class="error-summary-grid" aria-label="Fehlerübersicht">
|
<section class="error-summary-grid" aria-label="Fehlerübersicht">
|
||||||
<article><span>Ziele</span><strong>{{len .ErrorTargets}}</strong></article>
|
<article><span>Ziele</span><strong>{{len .ErrorTargets}}</strong></article>
|
||||||
<article><span>Zonen</span><strong>{{len .ErrorZones}}</strong></article>
|
<article><span>Zonen</span><strong>{{len .ErrorZones}}</strong></article>
|
||||||
|
<article><span>Pi-hole</span><strong>{{len .ErrorPiHoles}}</strong></article>
|
||||||
<article><span>Update-Läufe</span><strong>{{len .ErrorRuns}}</strong></article>
|
<article><span>Update-Läufe</span><strong>{{len .ErrorRuns}}</strong></article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -28,6 +29,17 @@
|
|||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ErrorPiHoles}}
|
||||||
|
<section class="panel error-section">
|
||||||
|
<div class="panel-heading"><div><span class="section-label">Lokales Netzwerk</span><h2>Pi-hole-Probleme</h2></div><span class="status-pill status-bad">{{len .ErrorPiHoles}}</span></div>
|
||||||
|
<div class="error-list">
|
||||||
|
{{range .ErrorPiHoles}}
|
||||||
|
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>{{.BaseURL}} · letzte Prüfung {{formatTime .LastSyncAt}}</small></div><a href="/pihole#local-dns">Öffnen →</a></article>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .ErrorZones}}
|
{{if .ErrorZones}}
|
||||||
<section class="panel error-section">
|
<section class="panel error-section">
|
||||||
<div class="panel-heading"><div><span class="section-label">Provider</span><h2>Zonenprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorZones}}</span></div>
|
<div class="panel-heading"><div><span class="section-label">Provider</span><h2>Zonenprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorZones}}</span></div>
|
||||||
|
|||||||
@@ -5,29 +5,30 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<title>{{.Title}} · Hetzner DynDNS</title>
|
<title>{{.Title}} · DNS Control</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=features-5">
|
<link rel="stylesheet" href="/static/styles.css?v=features-12">
|
||||||
<script src="/static/app.js?v=features-5" defer></script>
|
<script src="/static/app.js?v=features-12" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="page-{{.Page}}">
|
<body class="page-{{.Page}}">
|
||||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||||
{{if and (ne .Page "setup") (ne .Page "login")}}
|
{{if and (ne .Page "setup") (ne .Page "login")}}
|
||||||
<div class="admin-layout">
|
<div class="admin-layout">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<a class="brand" href="/" aria-label="Hetzner DynDNS Startseite">
|
<a class="brand" href="/" aria-label="DNS Control Startseite">
|
||||||
<span class="brand-mark">DD</span>
|
<span class="brand-mark">DD</span>
|
||||||
<span><strong>DynDNS Console</strong><small>Hetzner DNS</small></span>
|
<span><strong>DNS Control</strong><small>Hetzner + Pi-hole</small></span>
|
||||||
</a>
|
</a>
|
||||||
<div class="instance-state {{if not .UpdaterEnabled}}is-inactive{{end}}"><span></span> Dienst {{if .UpdaterEnabled}}aktiv{{else}}inaktiv{{end}}</div>
|
<div class="instance-state {{if not .UpdaterEnabled}}is-inactive{{end}}"><span></span> Dienst {{if .UpdaterEnabled}}aktiv{{else}}inaktiv{{end}}</div>
|
||||||
<nav aria-label="Hauptnavigation">
|
<nav aria-label="Hauptnavigation">
|
||||||
<a href="/" {{if eq .Page "dashboard"}}aria-current="page"{{end}}><span class="nav-icon">01</span>Übersicht</a>
|
<a href="/" {{if eq .Page "dashboard"}}aria-current="page"{{end}}><span class="nav-icon">01</span>Übersicht</a>
|
||||||
<a href="/domains" {{if eq .Page "domains"}}aria-current="page"{{end}}><span class="nav-icon">02</span>Domains</a>
|
<a href="/domains" {{if eq .Page "domains"}}aria-current="page"{{end}}><span class="nav-icon">02</span>Domains</a>
|
||||||
{{if .IsAdmin}}
|
{{if .IsAdmin}}
|
||||||
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Updater</a>
|
<a href="/pihole" {{if eq .Page "pihole"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Pi-hole</a>
|
||||||
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Fehler</a>
|
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Updater</a>
|
||||||
<a href="/users" {{if eq .Page "users"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Benutzer</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>
|
||||||
{{end}}
|
{{end}}
|
||||||
<a href="/security" {{if eq .Page "security"}}aria-current="page"{{end}}><span class="nav-icon">{{if .IsAdmin}}06{{else}}03{{end}}</span>Sicherheit</a>
|
<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>
|
</nav>
|
||||||
<div class="sidebar-account">
|
<div class="sidebar-account">
|
||||||
<span class="account-avatar">{{initial .User.DisplayName}}</span>
|
<span class="account-avatar">{{initial .User.DisplayName}}</span>
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
<div class="workspace">
|
<div class="workspace">
|
||||||
<header class="workspace-bar">
|
<header class="workspace-bar">
|
||||||
<div><span>DynDNS</span><strong>/ {{.Title}}</strong></div>
|
<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}}
|
{{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}}
|
||||||
</header>
|
</header>
|
||||||
<main id="main" class="app-shell">
|
<main id="main" class="app-shell">
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{{define "pihole"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<header class="page-heading">
|
||||||
|
<div><span class="page-kicker pihole-kicker">Lokales Netzwerk</span><h1>Pi-hole DNS</h1><p>Lokale A- und AAAA-Auflösungen ähnlich wie deine öffentlichen Domains verwalten.</p></div>
|
||||||
|
{{if .PiHoleView}}
|
||||||
|
<form action="/pihole/{{.PiHoleView.Integration.ID}}/refresh" method="post"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button button-quiet" type="submit">Records neu einlesen</button></form>
|
||||||
|
{{else}}
|
||||||
|
<a class="button button-pihole" href="#pihole-connect">Pi-hole verbinden</a>
|
||||||
|
{{end}}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .PiHoleView}}
|
||||||
|
{{$view := .PiHoleView}}
|
||||||
|
{{$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>{{formatTime $integration.LastSyncAt}}</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>
|
||||||
|
|
||||||
|
{{if $view.FetchError}}
|
||||||
|
<div class="pihole-connection-error"><strong>Pi-hole konnte nicht neu eingelesen werden</strong><p>{{$view.FetchError}}</p></div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<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}}
|
||||||
|
<div class="target-list">
|
||||||
|
{{range $view.Records}}
|
||||||
|
<article class="target-row pihole-target-row {{if .Active}}is-active{{end}}">
|
||||||
|
<div class="target-main"><span class="target-status {{if .Active}}pihole-on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.Hostnames}}</h3><p><span class="family-chip">{{if contains .IP ":"}}AAAA{{else}}A{{end}}</span><span>{{.IP}}</span></p></div></div>
|
||||||
|
<div class="target-meta"><span>Pi-hole DNS</span><span class="status-pill {{if .Active}}pihole-status-pill{{end}}">{{if .Active}}Aktiv{{else}}Inaktiv{{end}}</span></div>
|
||||||
|
<details class="target-edit"><summary>Einstellungen <span>⌄</span></summary><div class="edit-inner">
|
||||||
|
{{if .Valid}}
|
||||||
|
<form action="/pihole/{{$integration.ID}}/records/update" method="post" class="target-form pihole-record-form" data-dirty-form>
|
||||||
|
<input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}">
|
||||||
|
<div class="toggle-row"><label class="switch pihole-switch"><input type="checkbox" name="active" {{if .Active}}checked{{end}}><span></span> Eintrag aktiv</label></div>
|
||||||
|
<div class="form-grid"><label>IP-Adresse<input name="ip" value="{{.IP}}" inputmode="text" required></label><label>Hostname(n)<input name="hostnames" value="{{.Hostnames}}" required></label></div>
|
||||||
|
<button class="button button-pihole button-compact pihole-record-save" type="submit" data-dirty-submit disabled aria-disabled="true">Speichern</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<p class="inline-error">Der vorhandene Pi-hole-Wert kann nicht strukturiert bearbeitet werden: <code>{{.Raw}}</code></p>
|
||||||
|
{{end}}
|
||||||
|
<div class="danger-actions">
|
||||||
|
<form action="/pihole/{{$integration.ID}}/records/archive" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><strong>Nur aus der WebUI entfernen</strong><button class="button button-danger-soft" type="submit">Aus WebUI entfernen</button></form>
|
||||||
|
<form action="/pihole/{{$integration.ID}}/records/delete" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><strong>Eintrag vollständig löschen</strong><button class="button button-danger-soft" type="submit">Eintrag löschen</button></form>
|
||||||
|
</div>
|
||||||
|
</div></details>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}<div class="empty-state">Keine verknüpften lokalen DNS-Einträge vorhanden. Lege unten einen neuen Eintrag an.</div>{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if $view.ArchivedRecords}}
|
||||||
|
<section class="panel archive-panel">
|
||||||
|
<div class="panel-heading"><div><span class="section-label pihole-kicker">Archiv</span><h2>Aus der WebUI entfernte Einträge</h2></div><span class="record-count">{{len $view.ArchivedRecords}} Einträge</span></div>
|
||||||
|
<p class="panel-description">Diese lokalen DNS-Einträge bestehen weiterhin bei Pi-hole, sind aber nicht mehr mit der aktiven WebUI-Liste verknüpft.</p>
|
||||||
|
<div class="archive-list">
|
||||||
|
{{range $view.ArchivedRecords}}
|
||||||
|
<div class="archive-row"><span><strong>{{.Hostnames}}</strong><small>{{if contains .IP ":"}}AAAA{{else}}A{{end}} · {{.IP}} · {{if .Active}}aktiv{{else}}inaktiv{{end}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/pihole/{{$integration.ID}}/records/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section class="action-grid admin-actions-grid pihole-action-grid">
|
||||||
|
<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>
|
||||||
|
<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="url" value="{{$integration.BaseURL}}" required><small>Basis-URL oder vollständiger <code>/api</code>-Pfad.</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>
|
||||||
|
<details><summary class="button button-quiet">Anwendungspasswort anzeigen</summary><form action="/pihole/{{$integration.ID}}/password" method="post" class="stacked-form inset-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><label>Admin-Passwort <input type="password" name="password" autocomplete="current-password" required></label><button class="button button-danger-soft" type="submit">Passwort entschlüsseln</button></form></details>
|
||||||
|
<div class="panel-divider"></div>
|
||||||
|
<details><summary class="text-danger">Pi-hole-Verbindung entfernen</summary><div class="danger-actions inset-form"><form action="/pihole/{{$integration.ID}}/remove" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><strong>Nur aus der WebUI entfernen</strong><p>Löscht ausschließlich Zugang und lokale Verknüpfungen. Alle DNS-Einträge bei Pi-hole bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Lokale Verbindung entfernen</button></form><form action="/pihole/{{$integration.ID}}/delete" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><strong>Pi-hole vollständig leeren</strong><p>Löscht alle lokalen DNS-Einträge bei Pi-hole und entfernt anschließend die Verbindung aus der WebUI.</p><button class="button button-danger-soft" type="submit">Verbindung und Einträge löschen</button></form></div></details>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
<section class="empty-board pihole-empty"><span class="empty-icon pihole-empty-icon">PH</span><h2>Noch kein Pi-hole verbunden</h2><p>Verbinde genau einen Pi-hole über die v6-API. Danach lassen sich lokale DNS-Einträge wie Records im Domain-Tab verwalten.</p><a class="button button-pihole" href="#pihole-connect">Pi-hole verbinden</a></section>
|
||||||
|
<section class="form-panel pihole-connect-panel" id="pihole-connect">
|
||||||
|
<div class="panel-heading"><div><span class="section-label pihole-kicker">API-Verbindung</span><h2>Pi-hole verbinden</h2></div><span class="pihole-api-badge">API v6</span></div>
|
||||||
|
<p class="panel-description">Verwende ein eigenes Pi-hole-Anwendungspasswort. Es wird mit dem Master-Key dieser Anwendung verschlüsselt.</p>
|
||||||
|
<form action="/pihole/connect" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Pi-hole-Adresse <input name="base_url" type="url" placeholder="https://pi.hole" required><small><code>/api</code> wird automatisch ergänzt.</small></label><label>Anwendungspasswort <input name="password" type="password" autocomplete="new-password" required><small>Keinen Session-Token eintragen.</small></label><label class="switch pihole-switch"><input type="checkbox" name="insecure_tls"><span></span> TLS-Zertifikat nicht prüfen</label><div class="warning-box">Nur für ein selbstsigniertes Zertifikat in einem vertrauenswürdigen internen Netz verwenden. Bei HTTP wird das Passwort auf dem Netzwerkweg unverschlüsselt übertragen.</div><button class="button button-pihole" type="submit">Verbindung testen und speichern</button></form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "token"}}{{template "layout" .}}{{end}}
|
{{define "token"}}{{template "layout" .}}{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<section class="token-card"><span class="page-kicker">Sensible Zugangsdaten</span><h1>API-Token</h1><p>Zone: <strong>{{.Zone.Name}}</strong>. Der Wert wird nicht in URL, Audit-Log oder Browser-Cache gespeichert.</p><div class="token-value"><code id="api-token">{{.Token}}</code><button class="button button-primary" type="button" data-copy-target="api-token">Kopieren</button></div><p class="warning-box">Nach dem Kopieren diese Seite schließen. Eine erneute Anzeige erfordert wieder das Admin-Passwort.</p><a class="button button-quiet" href="/domains/{{.Zone.ID}}">Zur Domain zurückkehren</a></section>
|
<section class="token-card"><span class="page-kicker">Sensible Zugangsdaten</span><h1>{{.SecretTitle}}</h1><p><strong>{{.SecretScope}}</strong>. Der Wert wird nicht in URL, Audit-Log oder Browser-Cache gespeichert.</p><div class="token-value"><code id="secret-value">{{.SecretValue}}</code><button class="button button-primary" type="button" data-copy-target="secret-value">Kopieren</button></div><p class="warning-box">Nach dem Kopieren diese Seite schließen. Eine erneute Anzeige erfordert wieder das Admin-Passwort.</p><a class="button button-quiet" href="{{.SecretBackURL}}">Zurückkehren</a></section>
|
||||||
{{end}}
|
{{end}}
|
||||||
Reference in new issue
Block a user