// Package appdb stores application-owned state outside PowerDNS. package appdb import ( "context" "database/sql" "errors" "fmt" "strings" _ "github.com/go-sql-driver/mysql" ) // ErrNotFound indicates that a requested database record does not exist. var ErrNotFound = errors.New("not found") // DynamicRecord describes an RRset with dynamic DNS updates enabled. type DynamicRecord struct { ZoneID string Name string Type string TTL uint32 TokenHash string Enabled bool } // MySQLStore persists application state in MySQL. type MySQLStore struct { db *sql.DB } // NewMySQLStore opens a MySQL-backed application store. func NewMySQLStore(dsn string) (*MySQLStore, error) { db, err := sql.Open("mysql", strings.TrimSpace(dsn)) if err != nil { return nil, fmt.Errorf("open mysql database: %w", err) } return &MySQLStore{db: db}, nil } // Close releases the underlying database connection pool. func (s *MySQLStore) Close() error { return s.db.Close() } // Ping verifies that MySQL is reachable. func (s *MySQLStore) Ping(ctx context.Context) error { if err := s.db.PingContext(ctx); err != nil { return fmt.Errorf("ping mysql database: %w", err) } return nil } // EnsureSchema creates required application tables when they do not exist. func (s *MySQLStore) EnsureSchema(ctx context.Context) error { const query = ` CREATE TABLE IF NOT EXISTS dynamic_records ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, zone_id VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, type VARCHAR(10) NOT NULL, ttl INT UNSIGNED NOT NULL, token_hash CHAR(64) NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uniq_dynamic_record (zone_id, name, type), KEY idx_dynamic_name_token (name, token_hash) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` if _, err := s.db.ExecContext(ctx, query); err != nil { return fmt.Errorf("ensure dynamic_records schema: %w", err) } return nil } // ListDynamicRecords returns enabled dynamic DNS records for a zone. func (s *MySQLStore) ListDynamicRecords(ctx context.Context, zoneID string) ([]DynamicRecord, error) { const query = ` SELECT zone_id, name, type, ttl, token_hash, enabled FROM dynamic_records WHERE zone_id = ? AND enabled = TRUE` rows, err := s.db.QueryContext(ctx, query, zoneID) if err != nil { return nil, fmt.Errorf("list dynamic records: %w", err) } defer rows.Close() records := make([]DynamicRecord, 0) for rows.Next() { record, err := scanDynamicRecord(rows) if err != nil { return nil, err } records = append(records, record) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate dynamic records: %w", err) } return records, nil } // FindDynamicRecordByNameAndTokenHash returns the enabled record matching name and token hash. func (s *MySQLStore) FindDynamicRecordByNameAndTokenHash(ctx context.Context, name, tokenHash string) (DynamicRecord, error) { const query = ` SELECT zone_id, name, type, ttl, token_hash, enabled FROM dynamic_records WHERE name = ? AND token_hash = ? AND enabled = TRUE` row := s.db.QueryRowContext(ctx, query, name, tokenHash) record, err := scanDynamicRecord(row) if err != nil { return DynamicRecord{}, err } return record, nil } // GetDynamicRecord returns an enabled dynamic DNS record by identity. func (s *MySQLStore) GetDynamicRecord(ctx context.Context, zoneID, name, recordType string) (DynamicRecord, error) { const query = ` SELECT zone_id, name, type, ttl, token_hash, enabled FROM dynamic_records WHERE zone_id = ? AND name = ? AND type = ? AND enabled = TRUE` row := s.db.QueryRowContext(ctx, query, zoneID, name, recordType) record, err := scanDynamicRecord(row) if err != nil { return DynamicRecord{}, err } return record, nil } // UpsertDynamicRecord enables dynamic DNS for a record and stores its token hash. func (s *MySQLStore) UpsertDynamicRecord(ctx context.Context, record DynamicRecord) error { const query = ` INSERT INTO dynamic_records (zone_id, name, type, ttl, token_hash, enabled) VALUES (?, ?, ?, ?, ?, TRUE) ON DUPLICATE KEY UPDATE ttl = VALUES(ttl), token_hash = VALUES(token_hash), enabled = TRUE` if _, err := s.db.ExecContext(ctx, query, record.ZoneID, record.Name, record.Type, record.TTL, record.TokenHash); err != nil { return fmt.Errorf("upsert dynamic record: %w", err) } return nil } // DisableDynamicRecord disables dynamic DNS for a record. func (s *MySQLStore) DisableDynamicRecord(ctx context.Context, zoneID, name, recordType string) error { const query = ` UPDATE dynamic_records SET enabled = FALSE WHERE zone_id = ? AND name = ? AND type = ?` if _, err := s.db.ExecContext(ctx, query, zoneID, name, recordType); err != nil { return fmt.Errorf("disable dynamic record: %w", err) } return nil } type dynamicScanner interface { Scan(...any) error } func scanDynamicRecord(scanner dynamicScanner) (DynamicRecord, error) { var record DynamicRecord var ttl uint64 if err := scanner.Scan(&record.ZoneID, &record.Name, &record.Type, &ttl, &record.TokenHash, &record.Enabled); err != nil { if errors.Is(err, sql.ErrNoRows) { return DynamicRecord{}, ErrNotFound } return DynamicRecord{}, fmt.Errorf("scan dynamic record: %w", err) } record.TTL = uint32(ttl) return record, nil }