feat: add secure Pi-hole DNS management
Container-Image veröffentlichen / container (push) Has been cancelled

This commit is contained in:
admin committed 2026-07-13 02:49:41 +02:00
1 parent 1a6b4d0939
commit 1c32ca0671
18 files changed
+2159 -34

No files matched your search

+348 -4
View File
@@ -21,6 +21,8 @@ type Store struct {
db *sql.DB
}
var ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
type ManagedTarget struct {
Target model.Target
Zone model.Zone
@@ -36,9 +38,13 @@ type AccessSelection struct {
}
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)
}
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")
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
@@ -48,6 +54,10 @@ func Open(ctx context.Context, path string) (*Store, error) {
_ = db.Close()
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}
if err := store.migrate(ctx); err != nil {
_ = db.Close()
@@ -97,6 +107,15 @@ func (s *Store) migrate(ctx context.Context) error {
if err := s.applyMigrationV7(ctx, tx); err != nil {
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, ?, ?, ?, ?)`,
300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil {
return err
@@ -172,6 +191,57 @@ func (s *Store) applyMigrationV7(ctx context.Context, tx *sql.Tx) error {
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 {
var count int
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));
`
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) {
var count int
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
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)
) 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 {
return nil, err
}
@@ -691,8 +793,209 @@ func (s *Store) MarkZoneSynced(ctx context.Context, id int64, lastError string)
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) {
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 {
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) {
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 {
return nil, err
}
@@ -1016,6 +1319,7 @@ func (s *Store) CountErrors(ctx context.Context) (int, error) {
err := s.db.QueryRowContext(ctx, `SELECT
(SELECT COUNT(*) FROM targets 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)
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 {
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 {
return err
}
@@ -1044,6 +1351,23 @@ func (s *Store) ClearErrors(ctx context.Context) error {
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 {
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(created_at, kind, message) VALUES(?, ?, ?)`, now(), kind, message)
return err
@@ -1159,6 +1483,26 @@ func scanZone(scanner interface{ Scan(...any) error }) (model.Zone, error) {
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) {
var target model.Target
var updateIPv4, updateIPv6, active int
+116 -5
View File
@@ -2,6 +2,8 @@ package store
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
@@ -10,6 +12,32 @@ import (
"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) {
t.Parallel()
ctx := context.Background()
@@ -45,10 +73,14 @@ func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
if home.StaticIPv4 != "203.0.113.10" {
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 {
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 {
t.Fatal(err)
@@ -74,7 +106,7 @@ func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
t.Fatal(err)
}
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)
}
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) {
t.Parallel()
ctx := context.Background()
@@ -263,7 +371,7 @@ func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) {
if err != nil {
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 {
t.Fatal(err)
}
@@ -277,18 +385,21 @@ func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) {
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)
}
records, err := database.ListCNAMERecords(ctx, zone.ID)
if err != nil || len(records) != 2 {
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))
for _, record := range records {
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)
}
}