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"
"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)

View File

@@ -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

2
go.mod
View File

@@ -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

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

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"`
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)

View File

@@ -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",

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 {
switch strings.ToUpper(strings.TrimSpace(recordType)) {
case "A", "AAAA", "CAA", "MX", "NS", "SRV", "TXT":

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -50,6 +50,20 @@
<div class="alert alert-danger" role="alert">{{ .Error }}</div>
{{ end }}
{{ 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>
</main>
</div>

View File

@@ -54,18 +54,7 @@
<label class="form-label">Tipo</label>
<select class="form-select" name="type" required>
{{ range .RecordTypes }}
<option
{{
if
eq
.
$.RecordForm.Type
}}selected{{
end
}}
>
{{ . }}
</option>
<option {{ if eq . $.RecordForm.Type }}selected{{ end }}>{{ . }}</option>
{{ end }}
</select>
</div>
@@ -89,9 +78,7 @@
rows="8"
placeholder="Um valor de registro por linha"
required
>
{{ .RecordForm.Records }}</textarea
>
>{{ .RecordForm.Records }}</textarea>
<div class="form-hint">
Formato estrito exigido: Valores de texto TXT e CAA
devem estar entre aspas. Adicionar registros A,
@@ -101,6 +88,22 @@
ser excluidos.
</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">
{{ .RecordForm.SubmitLabel }}
</button>

View File

@@ -33,9 +33,13 @@
</thead>
<tbody>
{{ range .Zone.RRSets }}
<tr>
{{ $dynamic := dynamicRRSet $.Dynamic . }}
<tr {{ if $dynamic }}class="table-info"{{ end }}>
<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>
<div class="record-values">
@@ -47,6 +51,14 @@
<td>
<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>
{{ 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) }}
<form method="post" action="/zones/{{ $.ZoneID }}/rrsets/delete">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">