diff --git a/cmd/pdns-admin/main.go b/cmd/pdns-admin/main.go index c278c4a..b27b4fd 100644 --- a/cmd/pdns-admin/main.go +++ b/cmd/pdns-admin/main.go @@ -7,6 +7,7 @@ import ( "os" "time" + "pdns_admin/internal/appdb" "pdns_admin/internal/auth" "pdns_admin/internal/config" "pdns_admin/internal/pdns" @@ -21,6 +22,24 @@ func main() { 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) metadataCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -50,6 +69,7 @@ func main() { app, err := server.New(server.Config{ Addr: cfg.Addr, Authenticator: authenticator, + DynamicStore: store, }, pdnsClient, logger) if err != nil { logger.Fatalf("server initialization error: %v", err) diff --git a/config.example.yaml b/config.example.yaml index c840276..596f31c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -15,3 +15,6 @@ auth: user_filter: "({username_attribute}={username})" group_base_dn: ou=groups,dc=example,dc=com 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 diff --git a/go.mod b/go.mod index 0f2824a..3b3ba60 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,12 @@ toolchain go1.24.4 require ( github.com/go-ldap/ldap/v3 v3.4.13 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 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/BurntSushi/toml v1.2.1 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect diff --git a/go.sum b/go.sum index 25bfa67..9d09ae3 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= 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/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-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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= diff --git a/internal/appdb/mysql.go b/internal/appdb/mysql.go new file mode 100644 index 0000000..77f2a9d --- /dev/null +++ b/internal/appdb/mysql.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go index cd93059..55d19e0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,17 @@ type Config struct { PDNSAPIKey string `yaml:"pdns_api_key" env:"PDNS_API_KEY"` PDNSServerID string `yaml:"pdns_server_id" env:"PDNS_SERVER_ID" env-default:"localhost"` 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 { @@ -63,6 +74,9 @@ func LoadFile(path string) (Config, error) { if cfg.PDNSAPIKey == "" { 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 { return Config{}, err } @@ -113,6 +127,7 @@ func normalize(cfg *Config) { cfg.PDNSAPIURL = strings.TrimSpace(cfg.PDNSAPIURL) cfg.PDNSAPIKey = strings.TrimSpace(cfg.PDNSAPIKey) 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.BindDN = strings.TrimSpace(cfg.Auth.LDAP.BindDN) cfg.Auth.LDAP.BindPassword = strings.TrimSpace(cfg.Auth.LDAP.BindPassword) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 32e7364..ce99445 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -13,6 +13,9 @@ addr: ":9000" pdns_api_url: "http://pdns.example.test:8081" pdns_api_key: "from-file" pdns_server_id: "authoritative" +database: + mysql: + dsn: "user:pass@tcp(mysql:3306)/pdns_admin" auth: disabled: true `) @@ -34,16 +37,23 @@ auth: if cfg.PDNSServerID != "authoritative" { 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) { clearConfigEnv(t) t.Setenv("PDNS_API_KEY", "from-env") 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, ` pdns_api_url: "http://file.example.test:8081" pdns_api_key: "from-file" pdns_server_id: "from-file" +database: + mysql: + dsn: "file:pass@tcp(mysql:3306)/pdns_admin" auth: disabled: true `) @@ -62,11 +72,15 @@ auth: if cfg.PDNSServerID != "from-file" { 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) { clearConfigEnv(t) t.Setenv("PDNS_API_KEY", "secret") + t.Setenv("DATABASE_MYSQL_DSN", "user:pass@tcp(mysql:3306)/pdns_admin") t.Setenv("AUTH_DISABLED", "true") cfg, err := LoadFile("") @@ -87,6 +101,7 @@ func TestLoadFileUsesDefaultsWithoutYAML(t *testing.T) { func TestLoadFileRequiresAPIKey(t *testing.T) { clearConfigEnv(t) + t.Setenv("DATABASE_MYSQL_DSN", "user:pass@tcp(mysql:3306)/pdns_admin") _, err := LoadFile("") 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) { clearConfigEnv(t) path := writeConfig(t, ` pdns_api_key: "secret" +database: + mysql: + dsn: "user:pass@tcp(mysql:3306)/pdns_admin" auth: ldap: url: ldap://ldap.example.com:389 @@ -139,6 +168,9 @@ func TestLoadFileLDAPEnvironmentOverridesYAML(t *testing.T) { t.Setenv("AUTH_LDAP_BIND_PASSWORD", "from-env") path := writeConfig(t, ` pdns_api_key: "secret" +database: + mysql: + dsn: "user:pass@tcp(mysql:3306)/pdns_admin" auth: ldap: url: ldap://file.example.com:389 @@ -162,6 +194,7 @@ auth: func TestLoadFileRequiresLDAPUnlessAuthDisabled(t *testing.T) { clearConfigEnv(t) t.Setenv("PDNS_API_KEY", "secret") + t.Setenv("DATABASE_MYSQL_DSN", "user:pass@tcp(mysql:3306)/pdns_admin") _, err := LoadFile("") if err == nil { @@ -252,6 +285,7 @@ func clearConfigEnv(t *testing.T) { "PDNS_API_URL", "PDNS_API_KEY", "PDNS_SERVER_ID", + "DATABASE_MYSQL_DSN", "AUTH_DISABLED", "AUTH_LDAP_URL", "AUTH_LDAP_START_TLS", diff --git a/internal/pdns/client.go b/internal/pdns/client.go index 10d1b0b..b18b9b0 100644 --- a/internal/pdns/client.go +++ b/internal/pdns/client.go @@ -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 { switch strings.ToUpper(strings.TrimSpace(recordType)) { case "A", "AAAA", "CAA", "MX", "NS", "SRV", "TXT": diff --git a/internal/server/server.go b/internal/server/server.go index 327845e..e4ea087 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,19 +3,26 @@ package server import ( "context" "crypto/rand" + "crypto/sha256" "crypto/subtle" "embed" "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" "fmt" "html/template" "log" + "net" "net/http" + "net/netip" "net/url" "strconv" "strings" "sync" "time" + "pdns_admin/internal/appdb" "pdns_admin/internal/dnsrecord" "pdns_admin/internal/pdns" ) @@ -25,6 +32,7 @@ var assets embed.FS const ( csrfFieldName = "csrf_token" + flashCookieName = "__Host-pdns_admin_flash" sessionCookieName = "__Host-pdns_admin_session" sessionTTL = 12 * time.Hour ) @@ -36,9 +44,19 @@ type PDNSClient interface { DeleteZone(context.Context, string) error GetZone(context.Context, string) (pdns.Zone, error) CreateRRSet(context.Context, string, pdns.RRSet) error + ReplaceRRSet(context.Context, string, pdns.RRSet) 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 { Authenticate(context.Context, string, string) (bool, error) } @@ -46,6 +64,7 @@ type Authenticator interface { type Config struct { Addr string Authenticator Authenticator + DynamicStore DynamicStore } type Server struct { @@ -54,9 +73,12 @@ type Server struct { logger *log.Logger templates map[string]*template.Template validator *dnsrecord.Validator + dynamic DynamicStore auth Authenticator sessions map[string]session sessionsM sync.Mutex + flashes map[string]flash + flashesM sync.Mutex } type pageData struct { @@ -72,6 +94,8 @@ type pageData struct { Zone pdns.Zone RecordForm recordForm RecordTypes []string + Dynamic map[string]appdb.DynamicRecord + Flash flash } type session struct { @@ -85,11 +109,17 @@ type recordForm struct { Type string TTL uint32 Records string + DynamicDNS bool IsEdit bool Title string SubmitLabel string } +type flash struct { + Message string + Token string +} + func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) { if client == nil { 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) funcs := template.FuncMap{ - "isSOA": isSOA, - "urlQuery": url.QueryEscape, + "canDynamicRRSet": canDynamicRRSet, + "dynamicRRSet": dynamicRRSet, + "isSOA": isSOA, + "urlQuery": url.QueryEscape, } 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) @@ -124,8 +156,10 @@ func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) { logger: logger, templates: templates, validator: recordValidator, + dynamic: cfg.DynamicStore, auth: cfg.Authenticator, sessions: make(map[string]session), + flashes: make(map[string]flash), }, 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/edit", s.saveEditedRRSet) 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))) } @@ -316,11 +352,13 @@ func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request) { func (s *Server) showZone(w http.ResponseWriter, r *http.Request) { zoneID := r.PathValue("zoneID") zone, err := s.client.GetZone(r.Context(), zoneID) + dynamicRecords, dynamicErr := s.dynamicRecords(r.Context(), zoneID) data := pageData{ - Title: "Zona " + zoneID, - ZoneID: zoneID, - Zone: zone, - Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)), + Title: "Zona " + zoneID, + ZoneID: zoneID, + Zone: zone, + Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err), errorText(dynamicErr)), + Dynamic: dynamicRecords, } s.render(w, r, "zone.html", data) } @@ -365,6 +403,7 @@ func (s *Server) editRRSet(w http.ResponseWriter, r *http.Request) { Type: rrset.Type, TTL: rrset.TTL, Records: recordValues(rrset), + DynamicDNS: s.dynamicEnabled(r.Context(), zoneID, rrset.Name, rrset.Type), IsEdit: true, Title: "Editar 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()) 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 { 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, + }) + } 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()) 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()) return } @@ -462,16 +537,123 @@ func (s *Server) deleteRRSet(w http.ResponseWriter, r *http.Request) { s.redirectZoneError(w, r, zoneID, err.Error()) 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) } +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) { data.AuthEnabled = s.auth != nil if sess, ok := s.currentSession(r); ok { data.CurrentUser = sess.Username data.CSRFToken = sess.CSRFToken } + data.Flash = s.consumeFlash(w, r) w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl, ok := s.templates[name] if !ok { @@ -578,7 +760,7 @@ func (s *Server) pruneExpiredSessionsLocked(now time.Time) { } 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 { @@ -615,6 +797,99 @@ func validCSRFToken(r *http.Request, expected string) bool { 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) { tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { @@ -623,6 +898,11 @@ func randomToken() (string, error) { 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 { 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'") @@ -670,6 +950,72 @@ func parseFQDNLines(raw, label string) ([]string, error) { 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 { switch kind { case "Native", "Master", "Slave": @@ -694,6 +1040,31 @@ func isSOA(recordType string) bool { 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 { values := make([]string, 0, len(rrset.Records)) for _, record := range rrset.Records { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index cffcdad..e8baf2b 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -5,15 +5,20 @@ import ( "errors" "net/http" "net/http/httptest" + "net/url" "strings" "testing" + "pdns_admin/internal/appdb" "pdns_admin/internal/pdns" ) type fakeClient struct { deletedRRSetType string createdRRSet pdns.RRSet + replacedRRSet pdns.RRSet + replacedZoneID string + zone pdns.Zone } type fakeAuth struct { @@ -45,8 +50,8 @@ func (fakeClient) DeleteZone(context.Context, string) error { return nil } -func (fakeClient) GetZone(context.Context, string) (pdns.Zone, error) { - return pdns.Zone{}, nil +func (f *fakeClient) GetZone(context.Context, string) (pdns.Zone, error) { + return f.zone, nil } 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 } +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 { f.deletedRRSetType = recordType 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) { if _, err := New(Config{}, nil, nil); err == nil { 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) { client := &fakeClient{} srv, err := New(Config{}, client, nil) @@ -314,10 +406,245 @@ func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) { if rec.Code != http.StatusSeeOther { t.Fatalf("unexpected status: %d", rec.Code) } - if client.createdRRSet.Name != "www.example.org." { - t.Fatalf("name came from form: %#v", client.createdRRSet) + if client.replacedRRSet.Name != "www.example.org." { + t.Fatalf("name came from form: %#v", client.replacedRRSet) } - if client.createdRRSet.Type != "A" { - t.Fatalf("type came from form: %#v", client.createdRRSet) + if client.replacedRRSet.Type != "A" { + 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) } } diff --git a/internal/server/templates/base.html b/internal/server/templates/base.html index 9a1ba5a..04e695b 100644 --- a/internal/server/templates/base.html +++ b/internal/server/templates/base.html @@ -50,6 +50,20 @@
{{ .Flash.Message }}
+{{ .Flash.Token }}
+