174 lines
5.3 KiB
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
|
|
}
|