Files
pdns-admin/internal/appdb/mysql.go

174 lines
5.3 KiB
Go

// 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
}