suporte a dynamic dns

This commit is contained in:
2026-07-12 19:13:40 -03:00
parent e629b829a3
commit d184bcbdc2
13 changed files with 1020 additions and 31 deletions

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"time" "time"
"pdns_admin/internal/appdb"
"pdns_admin/internal/auth" "pdns_admin/internal/auth"
"pdns_admin/internal/config" "pdns_admin/internal/config"
"pdns_admin/internal/pdns" "pdns_admin/internal/pdns"
@@ -21,6 +22,24 @@ func main() {
logger.Fatalf("configuration error: %v", err) logger.Fatalf("configuration error: %v", err)
} }
store, err := appdb.NewMySQLStore(cfg.Database.MySQL.DSN)
if err != nil {
logger.Fatalf("database initialization error: %v", err)
}
defer func() {
if err := store.Close(); err != nil {
logger.Printf("database close error: %v", err)
}
}()
dbCtx, dbCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer dbCancel()
if err := store.Ping(dbCtx); err != nil {
logger.Fatalf("database connection error: %v", err)
}
if err := store.EnsureSchema(dbCtx); err != nil {
logger.Fatalf("database schema error: %v", err)
}
pdnsClient := pdns.NewClient(cfg.PDNSAPIURL, cfg.PDNSAPIKey, cfg.PDNSServerID, http.DefaultClient) pdnsClient := pdns.NewClient(cfg.PDNSAPIURL, cfg.PDNSAPIKey, cfg.PDNSServerID, http.DefaultClient)
metadataCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) metadataCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@@ -50,6 +69,7 @@ func main() {
app, err := server.New(server.Config{ app, err := server.New(server.Config{
Addr: cfg.Addr, Addr: cfg.Addr,
Authenticator: authenticator, Authenticator: authenticator,
DynamicStore: store,
}, pdnsClient, logger) }, pdnsClient, logger)
if err != nil { if err != nil {
logger.Fatalf("server initialization error: %v", err) logger.Fatalf("server initialization error: %v", err)

View File

@@ -15,3 +15,6 @@ auth:
user_filter: "({username_attribute}={username})" user_filter: "({username_attribute}={username})"
group_base_dn: ou=groups,dc=example,dc=com group_base_dn: ou=groups,dc=example,dc=com
group_filter: "(&(objectClass=groupOfNames)(|(cn=pdns-users)(cn=pdns-admins))(member={user_dn}))" group_filter: "(&(objectClass=groupOfNames)(|(cn=pdns-users)(cn=pdns-admins))(member={user_dn}))"
database:
mysql:
dsn: user:pass@tcp(mysql:3306)/pdns_admin?parseTime=true

2
go.mod
View File

@@ -7,10 +7,12 @@ toolchain go1.24.4
require ( require (
github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.25.0 github.com/go-playground/validator/v10 v10.25.0
github.com/go-sql-driver/mysql v1.9.0
github.com/ilyakaznacheev/cleanenv v1.5.0 github.com/ilyakaznacheev/cleanenv v1.5.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/BurntSushi/toml v1.2.1 // indirect github.com/BurntSushi/toml v1.2.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect

4
go.sum
View File

@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
@@ -20,6 +22,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=

173
internal/appdb/mysql.go Normal file
View File

@@ -0,0 +1,173 @@
// 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
}

View File

@@ -17,6 +17,17 @@ type Config struct {
PDNSAPIKey string `yaml:"pdns_api_key" env:"PDNS_API_KEY"` PDNSAPIKey string `yaml:"pdns_api_key" env:"PDNS_API_KEY"`
PDNSServerID string `yaml:"pdns_server_id" env:"PDNS_SERVER_ID" env-default:"localhost"` PDNSServerID string `yaml:"pdns_server_id" env:"PDNS_SERVER_ID" env-default:"localhost"`
Auth AuthConfig `yaml:"auth"` Auth AuthConfig `yaml:"auth"`
Database Database `yaml:"database"`
}
// Database contains application database configuration.
type Database struct {
MySQL MySQLDatabase `yaml:"mysql"`
}
// MySQLDatabase contains MySQL connection settings.
type MySQLDatabase struct {
DSN string `yaml:"dsn" env:"DATABASE_MYSQL_DSN"`
} }
type AuthConfig struct { type AuthConfig struct {
@@ -63,6 +74,9 @@ func LoadFile(path string) (Config, error) {
if cfg.PDNSAPIKey == "" { if cfg.PDNSAPIKey == "" {
return Config{}, errors.New("PDNS_API_KEY is required") return Config{}, errors.New("PDNS_API_KEY is required")
} }
if cfg.Database.MySQL.DSN == "" {
return Config{}, errors.New("DATABASE_MYSQL_DSN is required")
}
if err := validateAuth(cfg.Auth); err != nil { if err := validateAuth(cfg.Auth); err != nil {
return Config{}, err return Config{}, err
} }
@@ -113,6 +127,7 @@ func normalize(cfg *Config) {
cfg.PDNSAPIURL = strings.TrimSpace(cfg.PDNSAPIURL) cfg.PDNSAPIURL = strings.TrimSpace(cfg.PDNSAPIURL)
cfg.PDNSAPIKey = strings.TrimSpace(cfg.PDNSAPIKey) cfg.PDNSAPIKey = strings.TrimSpace(cfg.PDNSAPIKey)
cfg.PDNSServerID = strings.TrimSpace(cfg.PDNSServerID) cfg.PDNSServerID = strings.TrimSpace(cfg.PDNSServerID)
cfg.Database.MySQL.DSN = strings.TrimSpace(cfg.Database.MySQL.DSN)
cfg.Auth.LDAP.URL = strings.TrimSpace(cfg.Auth.LDAP.URL) cfg.Auth.LDAP.URL = strings.TrimSpace(cfg.Auth.LDAP.URL)
cfg.Auth.LDAP.BindDN = strings.TrimSpace(cfg.Auth.LDAP.BindDN) cfg.Auth.LDAP.BindDN = strings.TrimSpace(cfg.Auth.LDAP.BindDN)
cfg.Auth.LDAP.BindPassword = strings.TrimSpace(cfg.Auth.LDAP.BindPassword) cfg.Auth.LDAP.BindPassword = strings.TrimSpace(cfg.Auth.LDAP.BindPassword)

View File

@@ -13,6 +13,9 @@ addr: ":9000"
pdns_api_url: "http://pdns.example.test:8081" pdns_api_url: "http://pdns.example.test:8081"
pdns_api_key: "from-file" pdns_api_key: "from-file"
pdns_server_id: "authoritative" pdns_server_id: "authoritative"
database:
mysql:
dsn: "user:pass@tcp(mysql:3306)/pdns_admin"
auth: auth:
disabled: true disabled: true
`) `)
@@ -34,16 +37,23 @@ auth:
if cfg.PDNSServerID != "authoritative" { if cfg.PDNSServerID != "authoritative" {
t.Fatalf("unexpected server id: %q", cfg.PDNSServerID) t.Fatalf("unexpected server id: %q", cfg.PDNSServerID)
} }
if cfg.Database.MySQL.DSN != "user:pass@tcp(mysql:3306)/pdns_admin" {
t.Fatalf("unexpected mysql dsn: %q", cfg.Database.MySQL.DSN)
}
} }
func TestLoadFileEnvironmentOverridesYAML(t *testing.T) { func TestLoadFileEnvironmentOverridesYAML(t *testing.T) {
clearConfigEnv(t) clearConfigEnv(t)
t.Setenv("PDNS_API_KEY", "from-env") t.Setenv("PDNS_API_KEY", "from-env")
t.Setenv("PDNS_API_URL", "http://env.example.test:8081") t.Setenv("PDNS_API_URL", "http://env.example.test:8081")
t.Setenv("DATABASE_MYSQL_DSN", "env:pass@tcp(mysql:3306)/pdns_admin")
path := writeConfig(t, ` path := writeConfig(t, `
pdns_api_url: "http://file.example.test:8081" pdns_api_url: "http://file.example.test:8081"
pdns_api_key: "from-file" pdns_api_key: "from-file"
pdns_server_id: "from-file" pdns_server_id: "from-file"
database:
mysql:
dsn: "file:pass@tcp(mysql:3306)/pdns_admin"
auth: auth:
disabled: true disabled: true
`) `)
@@ -62,11 +72,15 @@ auth:
if cfg.PDNSServerID != "from-file" { if cfg.PDNSServerID != "from-file" {
t.Fatalf("expected yaml server id, got %q", cfg.PDNSServerID) t.Fatalf("expected yaml server id, got %q", cfg.PDNSServerID)
} }
if cfg.Database.MySQL.DSN != "env:pass@tcp(mysql:3306)/pdns_admin" {
t.Fatalf("expected env mysql dsn, got %q", cfg.Database.MySQL.DSN)
}
} }
func TestLoadFileUsesDefaultsWithoutYAML(t *testing.T) { func TestLoadFileUsesDefaultsWithoutYAML(t *testing.T) {
clearConfigEnv(t) clearConfigEnv(t)
t.Setenv("PDNS_API_KEY", "secret") t.Setenv("PDNS_API_KEY", "secret")
t.Setenv("DATABASE_MYSQL_DSN", "user:pass@tcp(mysql:3306)/pdns_admin")
t.Setenv("AUTH_DISABLED", "true") t.Setenv("AUTH_DISABLED", "true")
cfg, err := LoadFile("") cfg, err := LoadFile("")
@@ -87,6 +101,7 @@ func TestLoadFileUsesDefaultsWithoutYAML(t *testing.T) {
func TestLoadFileRequiresAPIKey(t *testing.T) { func TestLoadFileRequiresAPIKey(t *testing.T) {
clearConfigEnv(t) clearConfigEnv(t)
t.Setenv("DATABASE_MYSQL_DSN", "user:pass@tcp(mysql:3306)/pdns_admin")
_, err := LoadFile("") _, err := LoadFile("")
if err == nil { if err == nil {
@@ -94,10 +109,24 @@ func TestLoadFileRequiresAPIKey(t *testing.T) {
} }
} }
func TestLoadFileRequiresMySQLDSN(t *testing.T) {
clearConfigEnv(t)
t.Setenv("PDNS_API_KEY", "secret")
t.Setenv("AUTH_DISABLED", "true")
_, err := LoadFile("")
if err == nil {
t.Fatal("expected missing mysql dsn error")
}
}
func TestLoadFileReadsLDAPConfig(t *testing.T) { func TestLoadFileReadsLDAPConfig(t *testing.T) {
clearConfigEnv(t) clearConfigEnv(t)
path := writeConfig(t, ` path := writeConfig(t, `
pdns_api_key: "secret" pdns_api_key: "secret"
database:
mysql:
dsn: "user:pass@tcp(mysql:3306)/pdns_admin"
auth: auth:
ldap: ldap:
url: ldap://ldap.example.com:389 url: ldap://ldap.example.com:389
@@ -139,6 +168,9 @@ func TestLoadFileLDAPEnvironmentOverridesYAML(t *testing.T) {
t.Setenv("AUTH_LDAP_BIND_PASSWORD", "from-env") t.Setenv("AUTH_LDAP_BIND_PASSWORD", "from-env")
path := writeConfig(t, ` path := writeConfig(t, `
pdns_api_key: "secret" pdns_api_key: "secret"
database:
mysql:
dsn: "user:pass@tcp(mysql:3306)/pdns_admin"
auth: auth:
ldap: ldap:
url: ldap://file.example.com:389 url: ldap://file.example.com:389
@@ -162,6 +194,7 @@ auth:
func TestLoadFileRequiresLDAPUnlessAuthDisabled(t *testing.T) { func TestLoadFileRequiresLDAPUnlessAuthDisabled(t *testing.T) {
clearConfigEnv(t) clearConfigEnv(t)
t.Setenv("PDNS_API_KEY", "secret") t.Setenv("PDNS_API_KEY", "secret")
t.Setenv("DATABASE_MYSQL_DSN", "user:pass@tcp(mysql:3306)/pdns_admin")
_, err := LoadFile("") _, err := LoadFile("")
if err == nil { if err == nil {
@@ -252,6 +285,7 @@ func clearConfigEnv(t *testing.T) {
"PDNS_API_URL", "PDNS_API_URL",
"PDNS_API_KEY", "PDNS_API_KEY",
"PDNS_SERVER_ID", "PDNS_SERVER_ID",
"DATABASE_MYSQL_DSN",
"AUTH_DISABLED", "AUTH_DISABLED",
"AUTH_LDAP_URL", "AUTH_LDAP_URL",
"AUTH_LDAP_START_TLS", "AUTH_LDAP_START_TLS",

View File

@@ -173,6 +173,17 @@ func (c *Client) CreateRRSet(ctx context.Context, zoneID string, rrset RRSet) er
}}) }})
} }
// ReplaceRRSet replaces an RRset without merging it with existing records.
func (c *Client) ReplaceRRSet(ctx context.Context, zoneID string, rrset RRSet) error {
return c.patchZone(ctx, zoneID, []changeRRSet{{
Name: rrset.Name,
Type: rrset.Type,
TTL: rrset.TTL,
ChangeType: "REPLACE",
Records: rrset.Records,
}})
}
func allowsMultipleRecords(recordType string) bool { func allowsMultipleRecords(recordType string) bool {
switch strings.ToUpper(strings.TrimSpace(recordType)) { switch strings.ToUpper(strings.TrimSpace(recordType)) {
case "A", "AAAA", "CAA", "MX", "NS", "SRV", "TXT": case "A", "AAAA", "CAA", "MX", "NS", "SRV", "TXT":

View File

@@ -3,19 +3,26 @@ package server
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/subtle" "crypto/subtle"
"embed" "embed"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt" "fmt"
"html/template" "html/template"
"log" "log"
"net"
"net/http" "net/http"
"net/netip"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"pdns_admin/internal/appdb"
"pdns_admin/internal/dnsrecord" "pdns_admin/internal/dnsrecord"
"pdns_admin/internal/pdns" "pdns_admin/internal/pdns"
) )
@@ -25,6 +32,7 @@ var assets embed.FS
const ( const (
csrfFieldName = "csrf_token" csrfFieldName = "csrf_token"
flashCookieName = "__Host-pdns_admin_flash"
sessionCookieName = "__Host-pdns_admin_session" sessionCookieName = "__Host-pdns_admin_session"
sessionTTL = 12 * time.Hour sessionTTL = 12 * time.Hour
) )
@@ -36,9 +44,19 @@ type PDNSClient interface {
DeleteZone(context.Context, string) error DeleteZone(context.Context, string) error
GetZone(context.Context, string) (pdns.Zone, error) GetZone(context.Context, string) (pdns.Zone, error)
CreateRRSet(context.Context, string, pdns.RRSet) error CreateRRSet(context.Context, string, pdns.RRSet) error
ReplaceRRSet(context.Context, string, pdns.RRSet) error
DeleteRRSet(context.Context, string, string, string) error DeleteRRSet(context.Context, string, string, string) error
} }
// DynamicStore persists dynamic DNS record settings.
type DynamicStore interface {
ListDynamicRecords(context.Context, string) ([]appdb.DynamicRecord, error)
FindDynamicRecordByNameAndTokenHash(context.Context, string, string) (appdb.DynamicRecord, error)
GetDynamicRecord(context.Context, string, string, string) (appdb.DynamicRecord, error)
UpsertDynamicRecord(context.Context, appdb.DynamicRecord) error
DisableDynamicRecord(context.Context, string, string, string) error
}
type Authenticator interface { type Authenticator interface {
Authenticate(context.Context, string, string) (bool, error) Authenticate(context.Context, string, string) (bool, error)
} }
@@ -46,6 +64,7 @@ type Authenticator interface {
type Config struct { type Config struct {
Addr string Addr string
Authenticator Authenticator Authenticator Authenticator
DynamicStore DynamicStore
} }
type Server struct { type Server struct {
@@ -54,9 +73,12 @@ type Server struct {
logger *log.Logger logger *log.Logger
templates map[string]*template.Template templates map[string]*template.Template
validator *dnsrecord.Validator validator *dnsrecord.Validator
dynamic DynamicStore
auth Authenticator auth Authenticator
sessions map[string]session sessions map[string]session
sessionsM sync.Mutex sessionsM sync.Mutex
flashes map[string]flash
flashesM sync.Mutex
} }
type pageData struct { type pageData struct {
@@ -72,6 +94,8 @@ type pageData struct {
Zone pdns.Zone Zone pdns.Zone
RecordForm recordForm RecordForm recordForm
RecordTypes []string RecordTypes []string
Dynamic map[string]appdb.DynamicRecord
Flash flash
} }
type session struct { type session struct {
@@ -85,11 +109,17 @@ type recordForm struct {
Type string Type string
TTL uint32 TTL uint32
Records string Records string
DynamicDNS bool
IsEdit bool IsEdit bool
Title string Title string
SubmitLabel string SubmitLabel string
} }
type flash struct {
Message string
Token string
}
func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) { func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
if client == nil { if client == nil {
return nil, fmt.Errorf("pdns client is required") return nil, fmt.Errorf("pdns client is required")
@@ -107,8 +137,10 @@ func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
templates := make(map[string]*template.Template) templates := make(map[string]*template.Template)
funcs := template.FuncMap{ funcs := template.FuncMap{
"isSOA": isSOA, "canDynamicRRSet": canDynamicRRSet,
"urlQuery": url.QueryEscape, "dynamicRRSet": dynamicRRSet,
"isSOA": isSOA,
"urlQuery": url.QueryEscape,
} }
for _, page := range []string{"dashboard.html", "login.html", "zones.html", "zone.html", "record_form.html"} { for _, page := range []string{"dashboard.html", "login.html", "zones.html", "zone.html", "record_form.html"} {
tmpl, err := template.New("base.html").Funcs(funcs).ParseFS(assets, "templates/base.html", "templates/"+page) tmpl, err := template.New("base.html").Funcs(funcs).ParseFS(assets, "templates/base.html", "templates/"+page)
@@ -124,8 +156,10 @@ func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
logger: logger, logger: logger,
templates: templates, templates: templates,
validator: recordValidator, validator: recordValidator,
dynamic: cfg.DynamicStore,
auth: cfg.Authenticator, auth: cfg.Authenticator,
sessions: make(map[string]session), sessions: make(map[string]session),
flashes: make(map[string]flash),
}, nil }, nil
} }
@@ -150,6 +184,8 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /zones/{zoneID}/rrsets", s.saveRRSet) mux.HandleFunc("POST /zones/{zoneID}/rrsets", s.saveRRSet)
mux.HandleFunc("POST /zones/{zoneID}/rrsets/edit", s.saveEditedRRSet) mux.HandleFunc("POST /zones/{zoneID}/rrsets/edit", s.saveEditedRRSet)
mux.HandleFunc("POST /zones/{zoneID}/rrsets/delete", s.deleteRRSet) mux.HandleFunc("POST /zones/{zoneID}/rrsets/delete", s.deleteRRSet)
mux.HandleFunc("POST /zones/{zoneID}/rrsets/dyndns/regenerate", s.regenerateDynamicToken)
mux.HandleFunc("POST /api/dyndns", s.updateDynamicRecord)
return s.withLogging(s.withSecurityHeaders(s.withSessionAuth(mux))) return s.withLogging(s.withSecurityHeaders(s.withSessionAuth(mux)))
} }
@@ -316,11 +352,13 @@ func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request) {
func (s *Server) showZone(w http.ResponseWriter, r *http.Request) { func (s *Server) showZone(w http.ResponseWriter, r *http.Request) {
zoneID := r.PathValue("zoneID") zoneID := r.PathValue("zoneID")
zone, err := s.client.GetZone(r.Context(), zoneID) zone, err := s.client.GetZone(r.Context(), zoneID)
dynamicRecords, dynamicErr := s.dynamicRecords(r.Context(), zoneID)
data := pageData{ data := pageData{
Title: "Zona " + zoneID, Title: "Zona " + zoneID,
ZoneID: zoneID, ZoneID: zoneID,
Zone: zone, Zone: zone,
Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)), Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err), errorText(dynamicErr)),
Dynamic: dynamicRecords,
} }
s.render(w, r, "zone.html", data) s.render(w, r, "zone.html", data)
} }
@@ -365,6 +403,7 @@ func (s *Server) editRRSet(w http.ResponseWriter, r *http.Request) {
Type: rrset.Type, Type: rrset.Type,
TTL: rrset.TTL, TTL: rrset.TTL,
Records: recordValues(rrset), Records: recordValues(rrset),
DynamicDNS: s.dynamicEnabled(r.Context(), zoneID, rrset.Name, rrset.Type),
IsEdit: true, IsEdit: true,
Title: "Editar registro", Title: "Editar registro",
SubmitLabel: "Salvar registro", SubmitLabel: "Salvar registro",
@@ -398,11 +437,27 @@ func (s *Server) saveRRSet(w http.ResponseWriter, r *http.Request) {
s.redirectZoneError(w, r, zoneID, err.Error()) s.redirectZoneError(w, r, zoneID, err.Error())
return return
} }
dynamicWanted := dynamicDNSWanted(r)
if dynamicWanted && !isDynamicRecordType(rrset.Type) {
s.redirectZoneError(w, r, zoneID, "DNS dinamico esta disponivel apenas para registros A e AAAA")
return
}
if err := s.client.CreateRRSet(r.Context(), zoneID, rrset); err != nil { if err := s.client.CreateRRSet(r.Context(), zoneID, rrset); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error()) s.redirectZoneError(w, r, zoneID, err.Error())
return return
} }
if dynamicWanted {
token, err := s.enableDynamicRecord(r.Context(), zoneID, rrset)
if err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
s.setFlash(w, flash{
Message: "DNS dinamico habilitado. Copie o token agora; ele nao sera exibido novamente.",
Token: token,
})
}
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther) http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
} }
@@ -432,7 +487,27 @@ func (s *Server) saveEditedRRSet(w http.ResponseWriter, r *http.Request) {
s.redirectZoneError(w, r, zoneID, err.Error()) s.redirectZoneError(w, r, zoneID, err.Error())
return return
} }
if err := s.client.CreateRRSet(r.Context(), zoneID, rrset); err != nil { dynamicWanted := dynamicDNSWanted(r)
if dynamicWanted && !isDynamicRecordType(rrset.Type) {
s.redirectZoneError(w, r, zoneID, "DNS dinamico esta disponivel apenas para registros A e AAAA")
return
}
if err := s.client.ReplaceRRSet(r.Context(), zoneID, rrset); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
if dynamicWanted {
token, err := s.enableDynamicRecord(r.Context(), zoneID, rrset)
if err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
s.setFlash(w, flash{
Message: "DNS dinamico habilitado. Copie o token agora; ele nao sera exibido novamente.",
Token: token,
})
} else if err := s.disableDynamicRecord(r.Context(), zoneID, rrset.Name, rrset.Type); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error()) s.redirectZoneError(w, r, zoneID, err.Error())
return return
} }
@@ -462,16 +537,123 @@ func (s *Server) deleteRRSet(w http.ResponseWriter, r *http.Request) {
s.redirectZoneError(w, r, zoneID, err.Error()) s.redirectZoneError(w, r, zoneID, err.Error())
return return
} }
if err := s.disableDynamicRecord(r.Context(), zoneID, name, recordType); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther) http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
} }
func (s *Server) regenerateDynamicToken(w http.ResponseWriter, r *http.Request) {
zoneID := r.PathValue("zoneID")
if err := r.ParseForm(); err != nil {
s.redirectZoneError(w, r, zoneID, "dados do formulario invalidos")
return
}
name := dnsrecord.EnsureTrailingDot(r.FormValue("name"))
recordType := strings.ToUpper(strings.TrimSpace(r.FormValue("type")))
if !isDynamicRecordType(recordType) || !dnsrecord.IsDNSName(name) {
s.redirectZoneError(w, r, zoneID, "registro dinamico invalido")
return
}
if s.dynamic == nil {
s.redirectZoneError(w, r, zoneID, "banco de dados da aplicacao nao configurado")
return
}
record, err := s.dynamic.GetDynamicRecord(r.Context(), zoneID, name, recordType)
if err != nil {
if errors.Is(err, appdb.ErrNotFound) {
s.redirectZoneError(w, r, zoneID, "DNS dinamico nao esta habilitado para este registro")
return
}
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
token, err := randomToken()
if err != nil {
s.redirectZoneError(w, r, zoneID, "falha ao gerar token")
return
}
record.TokenHash = tokenHash(token)
if err := s.dynamic.UpsertDynamicRecord(r.Context(), record); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
s.setFlash(w, flash{
Message: "Token do DNS dinamico regenerado. Copie o token agora; ele nao sera exibido novamente.",
Token: token,
})
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
}
func (s *Server) updateDynamicRecord(w http.ResponseWriter, r *http.Request) {
var req dynamicUpdateRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
writeDynamicResponse(w, http.StatusBadRequest, "JSON invalido")
return
}
name := dnsrecord.EnsureTrailingDot(req.Name)
token := strings.TrimSpace(req.Token)
if !dnsrecord.IsDNSName(name) {
writeDynamicResponse(w, http.StatusBadRequest, "nome do registro invalido")
return
}
if token == "" || len(token) > 128 {
writeDynamicResponse(w, http.StatusBadRequest, "token invalido")
return
}
if s.dynamic == nil {
writeDynamicResponse(w, http.StatusServiceUnavailable, "banco de dados da aplicacao nao configurado")
return
}
record, err := s.dynamic.FindDynamicRecordByNameAndTokenHash(r.Context(), name, tokenHash(token))
if err != nil {
if errors.Is(err, appdb.ErrNotFound) {
writeDynamicResponse(w, http.StatusUnauthorized, "nome ou token invalido")
return
}
writeDynamicResponse(w, http.StatusInternalServerError, "falha ao consultar registro dinamico")
return
}
addr, err := dynamicUpdateAddress(req.Address, r)
if err != nil {
writeDynamicResponse(w, http.StatusBadRequest, err.Error())
return
}
if err := validateDynamicAddressType(record.Type, addr); err != nil {
writeDynamicResponse(w, http.StatusBadRequest, err.Error())
return
}
rrset := pdns.RRSet{
Name: record.Name,
Type: record.Type,
TTL: record.TTL,
Records: []pdns.Record{{
Content: addr.String(),
}},
}
if err := s.client.ReplaceRRSet(r.Context(), record.ZoneID, rrset); err != nil {
writeDynamicResponse(w, http.StatusBadGateway, "falha ao atualizar PowerDNS")
return
}
writeDynamicResponse(w, http.StatusOK, "")
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data pageData) { func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data pageData) {
data.AuthEnabled = s.auth != nil data.AuthEnabled = s.auth != nil
if sess, ok := s.currentSession(r); ok { if sess, ok := s.currentSession(r); ok {
data.CurrentUser = sess.Username data.CurrentUser = sess.Username
data.CSRFToken = sess.CSRFToken data.CSRFToken = sess.CSRFToken
} }
data.Flash = s.consumeFlash(w, r)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl, ok := s.templates[name] tmpl, ok := s.templates[name]
if !ok { if !ok {
@@ -578,7 +760,7 @@ func (s *Server) pruneExpiredSessionsLocked(now time.Time) {
} }
func isPublicPath(path string) bool { func isPublicPath(path string) bool {
return path == "/login" || path == "/healthz" || strings.HasPrefix(path, "/static/") return path == "/api/dyndns" || path == "/login" || path == "/healthz" || strings.HasPrefix(path, "/static/")
} }
func safeRedirectPath(value string) string { func safeRedirectPath(value string) string {
@@ -615,6 +797,99 @@ func validCSRFToken(r *http.Request, expected string) bool {
return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1 return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
} }
func (s *Server) dynamicRecords(ctx context.Context, zoneID string) (map[string]appdb.DynamicRecord, error) {
records := make(map[string]appdb.DynamicRecord)
if s.dynamic == nil {
return records, nil
}
list, err := s.dynamic.ListDynamicRecords(ctx, zoneID)
if err != nil {
return records, err
}
for _, record := range list {
records[dynamicRecordKey(record.Name, record.Type)] = record
}
return records, nil
}
func (s *Server) dynamicEnabled(ctx context.Context, zoneID, name, recordType string) bool {
if s.dynamic == nil || !isDynamicRecordType(recordType) {
return false
}
_, err := s.dynamic.GetDynamicRecord(ctx, zoneID, dnsrecord.EnsureTrailingDot(name), strings.ToUpper(strings.TrimSpace(recordType)))
return err == nil
}
func (s *Server) enableDynamicRecord(ctx context.Context, zoneID string, rrset pdns.RRSet) (string, error) {
if s.dynamic == nil {
return "", fmt.Errorf("banco de dados da aplicacao nao configurado")
}
token, err := randomToken()
if err != nil {
return "", fmt.Errorf("gerar token dinamico: %w", err)
}
record := appdb.DynamicRecord{
ZoneID: zoneID,
Name: rrset.Name,
Type: rrset.Type,
TTL: rrset.TTL,
TokenHash: tokenHash(token),
Enabled: true,
}
if err := s.dynamic.UpsertDynamicRecord(ctx, record); err != nil {
return "", err
}
return token, nil
}
func (s *Server) disableDynamicRecord(ctx context.Context, zoneID, name, recordType string) error {
if s.dynamic == nil || !isDynamicRecordType(recordType) {
return nil
}
return s.dynamic.DisableDynamicRecord(ctx, zoneID, dnsrecord.EnsureTrailingDot(name), strings.ToUpper(strings.TrimSpace(recordType)))
}
func (s *Server) setFlash(w http.ResponseWriter, flash flash) {
id, err := randomToken()
if err != nil {
return
}
s.flashesM.Lock()
s.flashes[id] = flash
s.flashesM.Unlock()
http.SetCookie(w, &http.Cookie{
Name: flashCookieName,
Value: id,
Path: "/",
MaxAge: 300,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func (s *Server) consumeFlash(w http.ResponseWriter, r *http.Request) flash {
cookie, err := r.Cookie(flashCookieName)
if err != nil || cookie.Value == "" || len(cookie.Value) > 128 {
return flash{}
}
http.SetCookie(w, &http.Cookie{
Name: flashCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
s.flashesM.Lock()
defer s.flashesM.Unlock()
flash := s.flashes[cookie.Value]
delete(s.flashes, cookie.Value)
return flash
}
func randomToken() (string, error) { func randomToken() (string, error) {
tokenBytes := make([]byte, 32) tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil { if _, err := rand.Read(tokenBytes); err != nil {
@@ -623,6 +898,11 @@ func randomToken() (string, error) {
return base64.RawURLEncoding.EncodeToString(tokenBytes), nil return base64.RawURLEncoding.EncodeToString(tokenBytes), nil
} }
func tokenHash(token string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
return hex.EncodeToString(sum[:])
}
func (s *Server) withSecurityHeaders(next http.Handler) http.Handler { func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
@@ -670,6 +950,72 @@ func parseFQDNLines(raw, label string) ([]string, error) {
return values, nil return values, nil
} }
type dynamicUpdateRequest struct {
Name string `json:"name"`
Token string `json:"token"`
Address string `json:"address"`
}
func writeDynamicResponse(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if status == http.StatusOK {
_ = json.NewEncoder(w).Encode(map[string]string{"status": "OK"})
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func dynamicUpdateAddress(rawAddress string, r *http.Request) (netip.Addr, error) {
value := strings.TrimSpace(rawAddress)
if value == "" {
value = forwardedAddress(r)
}
addr, err := netip.ParseAddr(value)
if err != nil {
return netip.Addr{}, fmt.Errorf("endereco IP invalido")
}
return addr.Unmap(), nil
}
func forwardedAddress(r *http.Request) string {
if forwardedFor := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwardedFor != "" {
parts := strings.Split(forwardedFor, ",")
if len(parts) > 0 {
return strings.TrimSpace(parts[0])
}
}
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
return realIP
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
return strings.Trim(host, "[]")
}
func validateDynamicAddressType(recordType string, addr netip.Addr) error {
switch strings.ToUpper(strings.TrimSpace(recordType)) {
case "A":
if !addr.Is4() {
return fmt.Errorf("registro A exige um endereco IPv4")
}
case "AAAA":
if !addr.Is6() {
return fmt.Errorf("registro AAAA exige um endereco IPv6")
}
default:
return fmt.Errorf("DNS dinamico esta disponivel apenas para registros A e AAAA")
}
return nil
}
func dynamicDNSWanted(r *http.Request) bool {
return r.FormValue("dynamic_dns") == "on"
}
func validZoneKind(kind string) bool { func validZoneKind(kind string) bool {
switch kind { switch kind {
case "Native", "Master", "Slave": case "Native", "Master", "Slave":
@@ -694,6 +1040,31 @@ func isSOA(recordType string) bool {
return strings.EqualFold(recordType, "SOA") return strings.EqualFold(recordType, "SOA")
} }
func isDynamicRecordType(recordType string) bool {
switch strings.ToUpper(strings.TrimSpace(recordType)) {
case "A", "AAAA":
return true
default:
return false
}
}
func canDynamicRRSet(rrset pdns.RRSet) bool {
return isDynamicRecordType(rrset.Type)
}
func dynamicRRSet(records map[string]appdb.DynamicRecord, rrset pdns.RRSet) bool {
if records == nil {
return false
}
_, ok := records[dynamicRecordKey(rrset.Name, rrset.Type)]
return ok
}
func dynamicRecordKey(name, recordType string) string {
return dnsrecord.EnsureTrailingDot(name) + "\x00" + strings.ToUpper(strings.TrimSpace(recordType))
}
func recordValues(rrset pdns.RRSet) string { func recordValues(rrset pdns.RRSet) string {
values := make([]string, 0, len(rrset.Records)) values := make([]string, 0, len(rrset.Records))
for _, record := range rrset.Records { for _, record := range rrset.Records {

View File

@@ -5,15 +5,20 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
"pdns_admin/internal/appdb"
"pdns_admin/internal/pdns" "pdns_admin/internal/pdns"
) )
type fakeClient struct { type fakeClient struct {
deletedRRSetType string deletedRRSetType string
createdRRSet pdns.RRSet createdRRSet pdns.RRSet
replacedRRSet pdns.RRSet
replacedZoneID string
zone pdns.Zone
} }
type fakeAuth struct { type fakeAuth struct {
@@ -45,8 +50,8 @@ func (fakeClient) DeleteZone(context.Context, string) error {
return nil return nil
} }
func (fakeClient) GetZone(context.Context, string) (pdns.Zone, error) { func (f *fakeClient) GetZone(context.Context, string) (pdns.Zone, error) {
return pdns.Zone{}, nil return f.zone, nil
} }
func (f *fakeClient) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet) error { func (f *fakeClient) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet) error {
@@ -54,11 +59,66 @@ func (f *fakeClient) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet)
return nil return nil
} }
func (f *fakeClient) ReplaceRRSet(_ context.Context, zoneID string, rrset pdns.RRSet) error {
f.replacedZoneID = zoneID
f.replacedRRSet = rrset
return nil
}
func (f *fakeClient) DeleteRRSet(_ context.Context, _, _, recordType string) error { func (f *fakeClient) DeleteRRSet(_ context.Context, _, _, recordType string) error {
f.deletedRRSetType = recordType f.deletedRRSetType = recordType
return nil return nil
} }
type fakeDynamicStore struct {
records map[string]appdb.DynamicRecord
}
func (f *fakeDynamicStore) ListDynamicRecords(context.Context, string) ([]appdb.DynamicRecord, error) {
records := make([]appdb.DynamicRecord, 0, len(f.records))
for _, record := range f.records {
if record.Enabled {
records = append(records, record)
}
}
return records, nil
}
func (f *fakeDynamicStore) FindDynamicRecordByNameAndTokenHash(_ context.Context, name, tokenHash string) (appdb.DynamicRecord, error) {
for _, record := range f.records {
if record.Name == name && record.TokenHash == tokenHash && record.Enabled {
return record, nil
}
}
return appdb.DynamicRecord{}, appdb.ErrNotFound
}
func (f *fakeDynamicStore) GetDynamicRecord(_ context.Context, zoneID, name, recordType string) (appdb.DynamicRecord, error) {
record, ok := f.records[dynamicRecordKey(name, recordType)]
if !ok || record.ZoneID != zoneID || !record.Enabled {
return appdb.DynamicRecord{}, appdb.ErrNotFound
}
return record, nil
}
func (f *fakeDynamicStore) UpsertDynamicRecord(_ context.Context, record appdb.DynamicRecord) error {
if f.records == nil {
f.records = make(map[string]appdb.DynamicRecord)
}
record.Enabled = true
f.records[dynamicRecordKey(record.Name, record.Type)] = record
return nil
}
func (f *fakeDynamicStore) DisableDynamicRecord(_ context.Context, _, name, recordType string) error {
record, ok := f.records[dynamicRecordKey(name, recordType)]
if ok {
record.Enabled = false
f.records[dynamicRecordKey(name, recordType)] = record
}
return nil
}
func TestNewRequiresClient(t *testing.T) { func TestNewRequiresClient(t *testing.T) {
if _, err := New(Config{}, nil, nil); err == nil { if _, err := New(Config{}, nil, nil); err == nil {
t.Fatal("expected error for missing client") t.Fatal("expected error for missing client")
@@ -297,6 +357,38 @@ func TestDeleteRRSetRejectsSOA(t *testing.T) {
} }
} }
func TestDeleteRRSetDisablesDynamicDNS(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 300,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader("name=www.example.org.&type=A")
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/delete", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
record := store.records[dynamicRecordKey("www.example.org.", "A")]
if record.Enabled {
t.Fatalf("dynamic record was not disabled: %#v", record)
}
}
func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) { func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) {
client := &fakeClient{} client := &fakeClient{}
srv, err := New(Config{}, client, nil) srv, err := New(Config{}, client, nil)
@@ -314,10 +406,245 @@ func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) {
if rec.Code != http.StatusSeeOther { if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code) t.Fatalf("unexpected status: %d", rec.Code)
} }
if client.createdRRSet.Name != "www.example.org." { if client.replacedRRSet.Name != "www.example.org." {
t.Fatalf("name came from form: %#v", client.createdRRSet) t.Fatalf("name came from form: %#v", client.replacedRRSet)
} }
if client.createdRRSet.Type != "A" { if client.replacedRRSet.Type != "A" {
t.Fatalf("type came from form: %#v", client.createdRRSet) t.Fatalf("type came from form: %#v", client.replacedRRSet)
}
}
func TestSaveRRSetEnablesDynamicDNS(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
form := url.Values{}
form.Set("name", "www.example.org.")
form.Set("type", "A")
form.Set("ttl", "300")
form.Set("records", "192.0.2.10")
form.Set("dynamic_dns", "on")
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
record, err := store.GetDynamicRecord(context.Background(), "example.org.", "www.example.org.", "A")
if err != nil {
t.Fatalf("GetDynamicRecord returned error: %v", err)
}
if record.TokenHash == "" || record.TTL != 300 {
t.Fatalf("unexpected dynamic record: %#v", record)
}
if len(rec.Result().Cookies()) == 0 {
t.Fatal("expected flash cookie")
}
}
func TestRegenerateDynamicToken(t *testing.T) {
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 300,
TokenHash: tokenHash("old-token"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, &fakeClient{}, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
form := url.Values{}
form.Set("name", "www.example.org.")
form.Set("type", "A")
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/dyndns/regenerate", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
record := store.records[dynamicRecordKey("www.example.org.", "A")]
if record.TokenHash == tokenHash("old-token") || record.TokenHash == "" {
t.Fatalf("token hash was not regenerated: %#v", record)
}
}
func TestDynamicUpdateEndpointUpdatesRecord(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
req.RemoteAddr = "198.51.100.20:12345"
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if client.replacedZoneID != "example.org." {
t.Fatalf("unexpected zone id: %q", client.replacedZoneID)
}
if client.replacedRRSet.Name != "www.example.org." || client.replacedRRSet.Type != "A" || client.replacedRRSet.TTL != 120 {
t.Fatalf("unexpected rrset: %#v", client.replacedRRSet)
}
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.20" {
t.Fatalf("unexpected update address: %q", got)
}
if !strings.Contains(rec.Body.String(), `"status":"OK"`) {
t.Fatalf("unexpected response: %s", rec.Body.String())
}
}
func TestDynamicUpdateEndpointUsesForwardedFor(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
req.RemoteAddr = "10.0.0.10:12345"
req.Header.Set("X-Forwarded-For", "198.51.100.30, 10.0.0.10")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.30" {
t.Fatalf("unexpected update address: %q", got)
}
}
func TestDynamicUpdateEndpointUsesRealIP(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
req.RemoteAddr = "10.0.0.10:12345"
req.Header.Set("X-Real-IP", "198.51.100.31")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.31" {
t.Fatalf("unexpected update address: %q", got)
}
}
func TestDynamicUpdateEndpointAddressOverridesForwardedHeaders(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret","address":"198.51.100.40"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
req.RemoteAddr = "10.0.0.10:12345"
req.Header.Set("X-Forwarded-For", "198.51.100.30")
req.Header.Set("X-Real-IP", "198.51.100.31")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.40" {
t.Fatalf("unexpected update address: %q", got)
}
}
func TestDynamicUpdateEndpointRejectsAddressTypeMismatch(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret","address":"2001:db8::1"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if client.replacedRRSet.Name != "" {
t.Fatalf("unexpected PowerDNS update: %#v", client.replacedRRSet)
} }
} }

View File

@@ -50,6 +50,20 @@
<div class="alert alert-danger" role="alert">{{ .Error }}</div> <div class="alert alert-danger" role="alert">{{ .Error }}</div>
{{ end }} {{ end }}
{{ template "content" . }} {{ template "content" . }}
{{ if .Flash.Token }}
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto">DNS dinamico</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Fechar"></button>
</div>
<div class="toast-body">
<p>{{ .Flash.Message }}</p>
<code class="d-block text-break">{{ .Flash.Token }}</code>
</div>
</div>
</div>
{{ end }}
</div> </div>
</main> </main>
</div> </div>

View File

@@ -54,18 +54,7 @@
<label class="form-label">Tipo</label> <label class="form-label">Tipo</label>
<select class="form-select" name="type" required> <select class="form-select" name="type" required>
{{ range .RecordTypes }} {{ range .RecordTypes }}
<option <option {{ if eq . $.RecordForm.Type }}selected{{ end }}>{{ . }}</option>
{{
if
eq
.
$.RecordForm.Type
}}selected{{
end
}}
>
{{ . }}
</option>
{{ end }} {{ end }}
</select> </select>
</div> </div>
@@ -89,9 +78,7 @@
rows="8" rows="8"
placeholder="Um valor de registro por linha" placeholder="Um valor de registro por linha"
required required
> >{{ .RecordForm.Records }}</textarea>
{{ .RecordForm.Records }}</textarea
>
<div class="form-hint"> <div class="form-hint">
Formato estrito exigido: Valores de texto TXT e CAA Formato estrito exigido: Valores de texto TXT e CAA
devem estar entre aspas. Adicionar registros A, devem estar entre aspas. Adicionar registros A,
@@ -101,6 +88,22 @@
ser excluidos. ser excluidos.
</div> </div>
</div> </div>
<div class="mb-3">
<label class="form-check">
<input
class="form-check-input"
name="dynamic_dns"
type="checkbox"
{{ if .RecordForm.DynamicDNS }}checked{{ end }}
/>
<span class="form-check-label">Habilitar atualizacoes de DNS dinamico</span>
</label>
<div class="form-hint">
Disponivel apenas para registros A e AAAA. Ao
salvar com esta opcao marcada, um novo token sera
gerado e exibido uma unica vez.
</div>
</div>
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
{{ .RecordForm.SubmitLabel }} {{ .RecordForm.SubmitLabel }}
</button> </button>

View File

@@ -33,9 +33,13 @@
</thead> </thead>
<tbody> <tbody>
{{ range .Zone.RRSets }} {{ range .Zone.RRSets }}
<tr> {{ $dynamic := dynamicRRSet $.Dynamic . }}
<tr {{ if $dynamic }}class="table-info"{{ end }}>
<td class="text-nowrap">{{ .Name }}</td> <td class="text-nowrap">{{ .Name }}</td>
<td><span class="badge bg-azure-lt">{{ .Type }}</span></td> <td>
<span class="badge bg-azure-lt">{{ .Type }}</span>
{{ if $dynamic }}<span class="badge bg-green-lt">DNS dinamico</span>{{ end }}
</td>
<td>{{ .TTL }}</td> <td>{{ .TTL }}</td>
<td> <td>
<div class="record-values"> <div class="record-values">
@@ -47,6 +51,14 @@
<td> <td>
<div class="btn-list flex-nowrap"> <div class="btn-list flex-nowrap">
<a class="btn btn-outline-primary btn-sm" href="/zones/{{ $.ZoneID }}/rrsets/edit?name={{ urlQuery .Name }}&type={{ urlQuery .Type }}">Editar</a> <a class="btn btn-outline-primary btn-sm" href="/zones/{{ $.ZoneID }}/rrsets/edit?name={{ urlQuery .Name }}&type={{ urlQuery .Type }}">Editar</a>
{{ if $dynamic }}
<form method="post" action="/zones/{{ $.ZoneID }}/rrsets/dyndns/regenerate">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="name" value="{{ .Name }}">
<input type="hidden" name="type" value="{{ .Type }}">
<button class="btn btn-outline-success btn-sm" type="submit">Regenerar token</button>
</form>
{{ end }}
{{ if not (isSOA .Type) }} {{ if not (isSOA .Type) }}
<form method="post" action="/zones/{{ $.ZoneID }}/rrsets/delete"> <form method="post" action="/zones/{{ $.ZoneID }}/rrsets/delete">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}"> <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">