suporte a dynamic dns
This commit is contained in:
173
internal/appdb/mysql.go
Normal file
173
internal/appdb/mysql.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}">
|
||||
|
||||
Reference in New Issue
Block a user