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
+348
-4
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user