primeiro commit

This commit is contained in:
2026-06-18 22:32:42 -03:00
commit 968f4ef5d9
25 changed files with 3222 additions and 0 deletions

72
.gitignore vendored Normal file
View File

@@ -0,0 +1,72 @@
# Build outputs
/bin/
/build/
/dist/
/out/
/release/
/tmp/
/coverage/
/pdns-admin
/pdns-admin.exe
*.test
*.prof
*.cover
*.coverage
coverage.out
# Cross-compiled binaries and archives
*.exe
*.dll
*.so
*.dylib
*.a
*.o
# Local configuration and secrets
/config.yaml
/config.yml
/config.*.yaml
/config.*.yml
!/config.example.yaml
!.gitignore
.env
.env.*
!.env.example
*.pem
*.key
*.crt
*.p12
*.pfx
# Logs and runtime state
*.log
logs/
*.pid
*.sock
# Go workspace/cache files
/vendor/
go.work
go.work.sum
# Editor and IDE metadata
.idea/
.vscode/
.codex/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
desktop.ini
# Tooling caches
.cache/
.pytest_cache/
.ruff_cache/
.tmp/
AGENTS.md
README.md

21
Makefile Normal file
View File

@@ -0,0 +1,21 @@
GOPATH ?= /tmp/go
GOCACHE ?= /tmp/go-build
GOMODCACHE ?= /tmp/go-mod-cache
CGO_ENABLED ?= 0
BUILD_FLAGS ?= -buildvcs=false
.PHONY: build
build:
CGO_ENABLED=$(CGO_ENABLED) GOPATH=$(GOPATH) GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go build $(BUILD_FLAGS) ./cmd/pdns-admin
.PHONY: test
test:
CGO_ENABLED=$(CGO_ENABLED) GOPATH=$(GOPATH) GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go test ./...
.PHONY: fmt
fmt:
gofmt -w cmd/pdns-admin/main.go internal/config/config.go internal/config/config_test.go internal/dnsrecord/validator.go internal/dnsrecord/validator_test.go internal/pdns/client.go internal/pdns/client_test.go internal/server/server.go internal/server/server_test.go
.PHONY: run
run:
CGO_ENABLED=$(CGO_ENABLED) GOPATH=$(GOPATH) GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go run $(BUILD_FLAGS) ./cmd/pdns-admin

54
cmd/pdns-admin/main.go Normal file
View File

@@ -0,0 +1,54 @@
package main
import (
"log"
"net/http"
"os"
"pdns_admin/internal/auth"
"pdns_admin/internal/config"
"pdns_admin/internal/pdns"
"pdns_admin/internal/server"
)
func main() {
logger := log.New(os.Stdout, "pdns-admin ", log.LstdFlags|log.LUTC)
cfg, err := config.Load()
if err != nil {
logger.Fatalf("configuration error: %v", err)
}
pdnsClient := pdns.NewClient(cfg.PDNSAPIURL, cfg.PDNSAPIKey, cfg.PDNSServerID, http.DefaultClient)
var authenticator server.Authenticator
if !cfg.Auth.Disabled {
authenticator, err = auth.NewLDAPAuthenticator(auth.LDAPConfig{
URL: cfg.Auth.LDAP.URL,
StartTLS: cfg.Auth.LDAP.StartTLS,
InsecureSkipVerify: cfg.Auth.LDAP.InsecureSkipVerify,
BindDN: cfg.Auth.LDAP.BindDN,
BindPassword: cfg.Auth.LDAP.BindPassword,
UserBaseDN: cfg.Auth.LDAP.UserBaseDN,
UsernameAttribute: cfg.Auth.LDAP.UsernameAttribute,
UserFilter: cfg.Auth.LDAP.UserFilter,
GroupBaseDN: cfg.Auth.LDAP.GroupBaseDN,
GroupFilter: cfg.Auth.LDAP.GroupFilter,
})
if err != nil {
logger.Fatalf("authentication initialization error: %v", err)
}
}
app, err := server.New(server.Config{
Addr: cfg.Addr,
Authenticator: authenticator,
}, pdnsClient, logger)
if err != nil {
logger.Fatalf("server initialization error: %v", err)
}
logger.Printf("listening on %s", cfg.Addr)
if err := app.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatalf("server stopped: %v", err)
}
}

17
config.example.yaml Normal file
View File

@@ -0,0 +1,17 @@
addr: ":8080"
pdns_api_url: "http://localhost:8081"
pdns_api_key: "secret"
pdns_server_id: "localhost"
auth:
disabled: false
ldap:
url: ldap://ldap.example.com:389
start_tls: true
insecure_skip_verify: false
bind_dn: cn=dashboard-reader,ou=service,dc=example,dc=com
bind_password: change-me
user_base_dn: ou=users,dc=example,dc=com
username_attribute: uid
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}))"

29
go.mod Normal file
View File

@@ -0,0 +1,29 @@
module pdns_admin
go 1.24.0
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/ilyakaznacheev/cleanenv v1.5.0
)
require (
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
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
)

62
go.sum Normal file
View File

@@ -0,0 +1,62 @@
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=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
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/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=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=

182
internal/auth/ldap.go Normal file
View File

@@ -0,0 +1,182 @@
package auth
import (
"context"
"crypto/tls"
"fmt"
"net"
"regexp"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
)
type Authenticator interface {
Authenticate(context.Context, string, string) (bool, error)
}
type LDAPConfig struct {
URL string
StartTLS bool
InsecureSkipVerify bool
BindDN string
BindPassword string
UserBaseDN string
UsernameAttribute string
UserFilter string
GroupBaseDN string
GroupFilter string
}
type LDAPAuthenticator struct {
cfg LDAPConfig
}
var attributePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9.-]*$`)
func NewLDAPAuthenticator(cfg LDAPConfig) (*LDAPAuthenticator, error) {
cfg.URL = strings.TrimSpace(cfg.URL)
cfg.BindDN = strings.TrimSpace(cfg.BindDN)
cfg.BindPassword = strings.TrimSpace(cfg.BindPassword)
cfg.UserBaseDN = strings.TrimSpace(cfg.UserBaseDN)
cfg.UsernameAttribute = strings.TrimSpace(cfg.UsernameAttribute)
cfg.UserFilter = strings.TrimSpace(cfg.UserFilter)
cfg.GroupBaseDN = strings.TrimSpace(cfg.GroupBaseDN)
cfg.GroupFilter = strings.TrimSpace(cfg.GroupFilter)
if cfg.UsernameAttribute == "" {
cfg.UsernameAttribute = "uid"
}
if cfg.UserFilter == "" {
cfg.UserFilter = "({username_attribute}={username})"
}
if !attributePattern.MatchString(cfg.UsernameAttribute) {
return nil, fmt.Errorf("ldap username attribute %q is not safe for filters", cfg.UsernameAttribute)
}
return &LDAPAuthenticator{cfg: cfg}, nil
}
func (a *LDAPAuthenticator) Authenticate(ctx context.Context, username, password string) (bool, error) {
username = strings.TrimSpace(username)
if username == "" || password == "" {
return false, nil
}
conn, err := a.dial(ctx)
if err != nil {
return false, err
}
defer conn.Close()
if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPassword); err != nil {
return false, fmt.Errorf("ldap service bind failed: %w", err)
}
userDN, err := a.findUser(conn, username)
if err != nil || userDN == "" {
return false, err
}
if a.cfg.GroupFilter != "" {
ok, err := a.userInAllowedGroup(conn, username, userDN)
if err != nil || !ok {
return ok, err
}
}
if err := conn.Bind(userDN, password); err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
return false, nil
}
return false, fmt.Errorf("ldap user bind failed: %w", err)
}
return true, nil
}
func (a *LDAPAuthenticator) dial(ctx context.Context) (*ldap.Conn, error) {
dialer := &net.Dialer{Timeout: 5 * time.Second}
if deadline, ok := ctx.Deadline(); ok {
dialer.Deadline = deadline
}
conn, err := ldap.DialURL(a.cfg.URL, ldap.DialWithDialer(dialer), ldap.DialWithTLSConfig(a.tlsConfig()))
if err != nil {
return nil, fmt.Errorf("ldap dial failed: %w", err)
}
conn.SetTimeout(10 * time.Second)
if a.cfg.StartTLS {
if err := conn.StartTLS(a.tlsConfig()); err != nil {
conn.Close()
return nil, fmt.Errorf("ldap starttls failed: %w", err)
}
}
return conn, nil
}
func (a *LDAPAuthenticator) tlsConfig() *tls.Config {
return &tls.Config{InsecureSkipVerify: a.cfg.InsecureSkipVerify} //nolint:gosec // Explicit user-controlled LDAP option.
}
func (a *LDAPAuthenticator) findUser(conn *ldap.Conn, username string) (string, error) {
filter := renderFilter(a.cfg.UserFilter, map[string]string{
"username_attribute": a.cfg.UsernameAttribute,
"username": ldap.EscapeFilter(username),
})
result, err := conn.Search(ldap.NewSearchRequest(
a.cfg.UserBaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
2,
10,
false,
filter,
[]string{"dn"},
nil,
))
if err != nil {
return "", fmt.Errorf("ldap user search failed: %w", err)
}
if len(result.Entries) == 0 {
return "", nil
}
if len(result.Entries) > 1 {
return "", fmt.Errorf("ldap user search returned multiple entries")
}
return result.Entries[0].DN, nil
}
func (a *LDAPAuthenticator) userInAllowedGroup(conn *ldap.Conn, username, userDN string) (bool, error) {
filter := renderFilter(a.cfg.GroupFilter, map[string]string{
"username_attribute": a.cfg.UsernameAttribute,
"username": ldap.EscapeFilter(username),
"user_dn": ldap.EscapeFilter(userDN),
})
result, err := conn.Search(ldap.NewSearchRequest(
a.cfg.GroupBaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
1,
10,
false,
filter,
[]string{"dn"},
nil,
))
if err != nil {
return false, fmt.Errorf("ldap group search failed: %w", err)
}
return len(result.Entries) > 0, nil
}
func renderFilter(template string, values map[string]string) string {
result := template
for key, value := range values {
result = strings.ReplaceAll(result, "{"+key+"}", value)
}
return result
}

View File

@@ -0,0 +1,30 @@
package auth
import "testing"
func TestNewLDAPAuthenticatorDefaultsFilter(t *testing.T) {
authenticator, err := NewLDAPAuthenticator(LDAPConfig{UsernameAttribute: "uid"})
if err != nil {
t.Fatalf("NewLDAPAuthenticator returned error: %v", err)
}
if authenticator.cfg.UserFilter != "({username_attribute}={username})" {
t.Fatalf("unexpected filter: %q", authenticator.cfg.UserFilter)
}
}
func TestNewLDAPAuthenticatorRejectsUnsafeAttribute(t *testing.T) {
_, err := NewLDAPAuthenticator(LDAPConfig{UsernameAttribute: "uid)(|(uid=*"})
if err == nil {
t.Fatal("expected unsafe attribute error")
}
}
func TestRenderFilter(t *testing.T) {
got := renderFilter("({username_attribute}={username})", map[string]string{
"username_attribute": "uid",
"username": "alice",
})
if got != "(uid=alice)" {
t.Fatalf("unexpected filter: %q", got)
}
}

153
internal/config/config.go Normal file
View File

@@ -0,0 +1,153 @@
package config
import (
"errors"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"github.com/ilyakaznacheev/cleanenv"
)
type Config struct {
Addr string `yaml:"addr" env:"ADDR" env-default:":8080"`
PDNSAPIURL string `yaml:"pdns_api_url" env:"PDNS_API_URL" env-default:"http://localhost:8081"`
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"`
}
type AuthConfig struct {
Disabled bool `yaml:"disabled" env:"AUTH_DISABLED"`
LDAP LDAPConfig `yaml:"ldap"`
}
type LDAPConfig struct {
URL string `yaml:"url" env:"AUTH_LDAP_URL"`
StartTLS bool `yaml:"start_tls" env:"AUTH_LDAP_START_TLS"`
InsecureSkipVerify bool `yaml:"insecure_skip_verify" env:"AUTH_LDAP_INSECURE_SKIP_VERIFY"`
BindDN string `yaml:"bind_dn" env:"AUTH_LDAP_BIND_DN"`
BindPassword string `yaml:"bind_password" env:"AUTH_LDAP_BIND_PASSWORD"`
UserBaseDN string `yaml:"user_base_dn" env:"AUTH_LDAP_USER_BASE_DN"`
UsernameAttribute string `yaml:"username_attribute" env:"AUTH_LDAP_USERNAME_ATTRIBUTE" env-default:"uid"`
UserFilter string `yaml:"user_filter" env:"AUTH_LDAP_USER_FILTER" env-default:"({username_attribute}={username})"`
GroupBaseDN string `yaml:"group_base_dn" env:"AUTH_LDAP_GROUP_BASE_DN"`
GroupFilter string `yaml:"group_filter" env:"AUTH_LDAP_GROUP_FILTER"`
}
func Load() (Config, error) {
path := strings.TrimSpace(os.Getenv("CONFIG_FILE"))
if path == "" {
path = defaultConfigPath()
}
return LoadFile(path)
}
func LoadFile(path string) (Config, error) {
var cfg Config
var err error
if strings.TrimSpace(path) == "" {
err = cleanenv.ReadEnv(&cfg)
} else {
err = cleanenv.ReadConfig(path, &cfg)
}
if err != nil {
return Config{}, err
}
normalize(&cfg)
if cfg.PDNSAPIKey == "" {
return Config{}, errors.New("PDNS_API_KEY is required")
}
if err := validateAuth(cfg.Auth); err != nil {
return Config{}, err
}
return cfg, nil
}
func defaultConfigPath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = ""
}
return firstExistingConfigPath(configSearchPaths(homeDir, runtime.GOOS))
}
func configSearchPaths(homeDir, goos string) []string {
paths := make([]string, 0, 3)
if isUnixLike(goos) {
paths = append(paths, "/etc/pdns_admin/config.yaml")
}
if homeDir != "" {
paths = append(paths, filepath.Join(homeDir, "config.yaml"))
}
return append(paths, "config.yaml")
}
func isUnixLike(goos string) bool {
switch goos {
case "aix", "android", "darwin", "dragonfly", "freebsd", "hurd", "illumos", "ios", "linux", "netbsd", "openbsd", "solaris":
return true
default:
return false
}
}
func firstExistingConfigPath(paths []string) string {
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func normalize(cfg *Config) {
cfg.Addr = strings.TrimSpace(cfg.Addr)
cfg.PDNSAPIURL = strings.TrimSpace(cfg.PDNSAPIURL)
cfg.PDNSAPIKey = strings.TrimSpace(cfg.PDNSAPIKey)
cfg.PDNSServerID = strings.TrimSpace(cfg.PDNSServerID)
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)
cfg.Auth.LDAP.UserBaseDN = strings.TrimSpace(cfg.Auth.LDAP.UserBaseDN)
cfg.Auth.LDAP.UsernameAttribute = strings.TrimSpace(cfg.Auth.LDAP.UsernameAttribute)
cfg.Auth.LDAP.UserFilter = strings.TrimSpace(cfg.Auth.LDAP.UserFilter)
cfg.Auth.LDAP.GroupBaseDN = strings.TrimSpace(cfg.Auth.LDAP.GroupBaseDN)
cfg.Auth.LDAP.GroupFilter = strings.TrimSpace(cfg.Auth.LDAP.GroupFilter)
}
func validateAuth(auth AuthConfig) error {
if auth.Disabled {
return nil
}
missing := make([]string, 0)
for field, value := range map[string]string{
"AUTH_LDAP_URL": auth.LDAP.URL,
"AUTH_LDAP_BIND_DN": auth.LDAP.BindDN,
"AUTH_LDAP_BIND_PASSWORD": auth.LDAP.BindPassword,
"AUTH_LDAP_USER_BASE_DN": auth.LDAP.UserBaseDN,
"AUTH_LDAP_USERNAME_ATTRIBUTE": auth.LDAP.UsernameAttribute,
"AUTH_LDAP_USER_FILTER": auth.LDAP.UserFilter,
} {
if value == "" {
missing = append(missing, field)
}
}
if auth.LDAP.GroupFilter != "" && auth.LDAP.GroupBaseDN == "" {
missing = append(missing, "AUTH_LDAP_GROUP_BASE_DN")
}
if len(missing) > 0 {
sort.Strings(missing)
return errors.New("LDAP auth is enabled; missing required configuration: " + strings.Join(missing, ", "))
}
return nil
}

View File

@@ -0,0 +1,285 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadFileReadsYAML(t *testing.T) {
clearConfigEnv(t)
path := writeConfig(t, `
addr: ":9000"
pdns_api_url: "http://pdns.example.test:8081"
pdns_api_key: "from-file"
pdns_server_id: "authoritative"
auth:
disabled: true
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile returned error: %v", err)
}
if cfg.Addr != ":9000" {
t.Fatalf("unexpected addr: %q", cfg.Addr)
}
if cfg.PDNSAPIURL != "http://pdns.example.test:8081" {
t.Fatalf("unexpected api url: %q", cfg.PDNSAPIURL)
}
if cfg.PDNSAPIKey != "from-file" {
t.Fatalf("unexpected api key: %q", cfg.PDNSAPIKey)
}
if cfg.PDNSServerID != "authoritative" {
t.Fatalf("unexpected server id: %q", cfg.PDNSServerID)
}
}
func TestLoadFileEnvironmentOverridesYAML(t *testing.T) {
clearConfigEnv(t)
t.Setenv("PDNS_API_KEY", "from-env")
t.Setenv("PDNS_API_URL", "http://env.example.test:8081")
path := writeConfig(t, `
pdns_api_url: "http://file.example.test:8081"
pdns_api_key: "from-file"
pdns_server_id: "from-file"
auth:
disabled: true
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile returned error: %v", err)
}
if cfg.PDNSAPIKey != "from-env" {
t.Fatalf("expected env api key to win, got %q", cfg.PDNSAPIKey)
}
if cfg.PDNSAPIURL != "http://env.example.test:8081" {
t.Fatalf("expected env api url to win, got %q", cfg.PDNSAPIURL)
}
if cfg.PDNSServerID != "from-file" {
t.Fatalf("expected yaml server id, got %q", cfg.PDNSServerID)
}
}
func TestLoadFileUsesDefaultsWithoutYAML(t *testing.T) {
clearConfigEnv(t)
t.Setenv("PDNS_API_KEY", "secret")
t.Setenv("AUTH_DISABLED", "true")
cfg, err := LoadFile("")
if err != nil {
t.Fatalf("LoadFile returned error: %v", err)
}
if cfg.Addr != ":8080" {
t.Fatalf("unexpected default addr: %q", cfg.Addr)
}
if cfg.PDNSAPIURL != "http://localhost:8081" {
t.Fatalf("unexpected default api url: %q", cfg.PDNSAPIURL)
}
if cfg.PDNSServerID != "localhost" {
t.Fatalf("unexpected default server id: %q", cfg.PDNSServerID)
}
}
func TestLoadFileRequiresAPIKey(t *testing.T) {
clearConfigEnv(t)
_, err := LoadFile("")
if err == nil {
t.Fatal("expected missing api key error")
}
}
func TestLoadFileReadsLDAPConfig(t *testing.T) {
clearConfigEnv(t)
path := writeConfig(t, `
pdns_api_key: "secret"
auth:
ldap:
url: ldap://ldap.example.com:389
start_tls: true
insecure_skip_verify: true
bind_dn: cn=dashboard-reader,ou=service,dc=example,dc=com
bind_password: change-me
user_base_dn: ou=users,dc=example,dc=com
username_attribute: uid
user_filter: "({username_attribute}={username})"
group_base_dn: ou=groups,dc=example,dc=com
group_filter: "(&(objectClass=groupOfNames)(cn=media-admins)(member={user_dn}))"
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile returned error: %v", err)
}
if cfg.Auth.Disabled {
t.Fatal("auth should be enabled")
}
if cfg.Auth.LDAP.URL != "ldap://ldap.example.com:389" {
t.Fatalf("unexpected ldap url: %q", cfg.Auth.LDAP.URL)
}
if !cfg.Auth.LDAP.StartTLS {
t.Fatal("expected start_tls to be true")
}
if !cfg.Auth.LDAP.InsecureSkipVerify {
t.Fatal("expected insecure_skip_verify to be true")
}
if cfg.Auth.LDAP.GroupFilter == "" {
t.Fatal("expected group filter")
}
}
func TestLoadFileLDAPEnvironmentOverridesYAML(t *testing.T) {
clearConfigEnv(t)
t.Setenv("AUTH_LDAP_URL", "ldap://env.example.com:389")
t.Setenv("AUTH_LDAP_BIND_PASSWORD", "from-env")
path := writeConfig(t, `
pdns_api_key: "secret"
auth:
ldap:
url: ldap://file.example.com:389
bind_dn: cn=dashboard-reader,ou=service,dc=example,dc=com
bind_password: from-file
user_base_dn: ou=users,dc=example,dc=com
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile returned error: %v", err)
}
if cfg.Auth.LDAP.URL != "ldap://env.example.com:389" {
t.Fatalf("expected env ldap url, got %q", cfg.Auth.LDAP.URL)
}
if cfg.Auth.LDAP.BindPassword != "from-env" {
t.Fatalf("expected env bind password, got %q", cfg.Auth.LDAP.BindPassword)
}
}
func TestLoadFileRequiresLDAPUnlessAuthDisabled(t *testing.T) {
clearConfigEnv(t)
t.Setenv("PDNS_API_KEY", "secret")
_, err := LoadFile("")
if err == nil {
t.Fatal("expected missing ldap configuration error")
}
}
func TestConfigSearchPathsOrderOnUnix(t *testing.T) {
paths := configSearchPaths("/home/tester", "linux")
want := []string{
"/etc/pdns_admin/config.yaml",
filepath.Join("/home/tester", "config.yaml"),
"config.yaml",
}
if len(paths) != len(want) {
t.Fatalf("unexpected path count: %#v", paths)
}
for i := range want {
if paths[i] != want[i] {
t.Fatalf("path %d = %q, want %q", i, paths[i], want[i])
}
}
}
func TestConfigSearchPathsOrderOnNonUnix(t *testing.T) {
paths := configSearchPaths(`C:\Users\tester`, "windows")
want := []string{
filepath.Join(`C:\Users\tester`, "config.yaml"),
"config.yaml",
}
if len(paths) != len(want) {
t.Fatalf("unexpected path count: %#v", paths)
}
for i := range want {
if paths[i] != want[i] {
t.Fatalf("path %d = %q, want %q", i, paths[i], want[i])
}
}
}
func TestConfigSearchPathsWithoutHome(t *testing.T) {
paths := configSearchPaths("", "windows")
want := []string{"config.yaml"}
if len(paths) != len(want) {
t.Fatalf("unexpected path count: %#v", paths)
}
if paths[0] != want[0] {
t.Fatalf("path = %q, want %q", paths[0], want[0])
}
}
func TestFirstExistingConfigPathUsesOrder(t *testing.T) {
root := t.TempDir()
first := filepath.Join(root, "first.yaml")
second := filepath.Join(root, "second.yaml")
if err := os.WriteFile(second, []byte("pdns_api_key: second"), 0o600); err != nil {
t.Fatalf("write second config: %v", err)
}
if err := os.WriteFile(first, []byte("pdns_api_key: first"), 0o600); err != nil {
t.Fatalf("write first config: %v", err)
}
got := firstExistingConfigPath([]string{first, second})
if got != first {
t.Fatalf("got %q, want %q", got, first)
}
}
func writeConfig(t *testing.T, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
func clearConfigEnv(t *testing.T) {
t.Helper()
for _, key := range []string{
"CONFIG_FILE",
"ADDR",
"PDNS_API_URL",
"PDNS_API_KEY",
"PDNS_SERVER_ID",
"AUTH_DISABLED",
"AUTH_LDAP_URL",
"AUTH_LDAP_START_TLS",
"AUTH_LDAP_INSECURE_SKIP_VERIFY",
"AUTH_LDAP_BIND_DN",
"AUTH_LDAP_BIND_PASSWORD",
"AUTH_LDAP_USER_BASE_DN",
"AUTH_LDAP_USERNAME_ATTRIBUTE",
"AUTH_LDAP_USER_FILTER",
"AUTH_LDAP_GROUP_BASE_DN",
"AUTH_LDAP_GROUP_FILTER",
} {
unsetEnv(t, key)
}
}
func unsetEnv(t *testing.T, key string) {
t.Helper()
oldValue, hadValue := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
t.Cleanup(func() {
if hadValue {
_ = os.Setenv(key, oldValue)
return
}
_ = os.Unsetenv(key)
})
}

View File

@@ -0,0 +1,223 @@
package dnsrecord
import (
"fmt"
"net/netip"
"regexp"
"strconv"
"strings"
"github.com/go-playground/validator/v10"
"pdns_admin/internal/pdns"
)
var (
txtValuePattern = regexp.MustCompile(`^"([^"\\]|\\.)*"( "([^"\\]|\\.)*")*$`)
caaTagPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]*$`)
)
type Validator struct {
validate *validator.Validate
}
func NewValidator() (*Validator, error) {
v := validator.New()
if err := v.RegisterValidation("dns_fqdn", func(fl validator.FieldLevel) bool {
return IsFQDN(fl.Field().String())
}); err != nil {
return nil, err
}
return &Validator{validate: v}, nil
}
func SupportedTypes() []string {
return []string{"A", "AAAA", "CAA", "CNAME", "MX", "NS", "SOA", "SRV", "TXT"}
}
func (v *Validator) ValidateRRSet(name, recordType string, ttl uint64, contents []string) (pdns.RRSet, error) {
name = EnsureTrailingDot(name)
recordType = strings.ToUpper(strings.TrimSpace(recordType))
if ttl == 0 || ttl > 1<<32-1 {
return pdns.RRSet{}, fmt.Errorf("ttl must be between 1 and 4294967295")
}
if err := v.validate.Var(name, "required,dns_fqdn"); err != nil {
return pdns.RRSet{}, fmt.Errorf("record name must be a fully qualified domain name")
}
if !supported(recordType) {
return pdns.RRSet{}, fmt.Errorf("unsupported record type %q", recordType)
}
records := make([]pdns.Record, 0, len(contents))
for _, content := range contents {
content = strings.TrimSpace(content)
if content == "" {
continue
}
if err := validateContent(recordType, content); err != nil {
return pdns.RRSet{}, err
}
records = append(records, pdns.Record{Content: content})
}
if len(records) == 0 {
return pdns.RRSet{}, fmt.Errorf("at least one record value is required")
}
return pdns.RRSet{
Name: name,
Type: recordType,
TTL: uint32(ttl),
Records: records,
}, nil
}
func EnsureTrailingDot(value string) string {
value = strings.TrimSpace(value)
if value == "" || strings.HasSuffix(value, ".") {
return value
}
return value + "."
}
func IsFQDN(value string) bool {
value = strings.TrimSpace(value)
if value == "." || value == "" || !strings.HasSuffix(value, ".") || len(value) > 253 {
return false
}
labels := strings.Split(strings.TrimSuffix(value, "."), ".")
for i, label := range labels {
if label == "" || len(label) > 63 {
return false
}
if label == "*" && i == 0 {
continue
}
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return false
}
for _, r := range label {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
continue
}
return false
}
}
return true
}
func validateContent(recordType, content string) error {
switch recordType {
case "A":
addr, err := netip.ParseAddr(content)
if err != nil || !addr.Is4() {
return fmt.Errorf("A record content must be an IPv4 address")
}
case "AAAA":
addr, err := netip.ParseAddr(content)
if err != nil || !addr.Is6() {
return fmt.Errorf("AAAA record content must be an IPv6 address")
}
case "CAA":
return validateCAA(content)
case "CNAME", "NS":
if !IsFQDN(content) {
return fmt.Errorf("%s record content must be a fully qualified domain name", recordType)
}
case "MX":
return validateMX(content)
case "SOA":
return validateSOA(content)
case "SRV":
return validateSRV(content)
case "TXT":
if !txtValuePattern.MatchString(content) {
return fmt.Errorf("TXT record content must be one or more quoted strings")
}
}
return nil
}
func validateMX(content string) error {
fields := strings.Fields(content)
if len(fields) != 2 {
return fmt.Errorf("MX record content must be: priority target")
}
if _, err := parseUint(fields[0], 16); err != nil {
return fmt.Errorf("MX priority must be between 0 and 65535")
}
if !IsFQDN(fields[1]) {
return fmt.Errorf("MX target must be a fully qualified domain name")
}
return nil
}
func validateSRV(content string) error {
fields := strings.Fields(content)
if len(fields) != 4 {
return fmt.Errorf("SRV record content must be: priority weight port target")
}
for i, label := range []string{"priority", "weight", "port"} {
if _, err := parseUint(fields[i], 16); err != nil {
return fmt.Errorf("SRV %s must be between 0 and 65535", label)
}
}
target := strings.TrimSpace(fields[3])
if target != "." && !IsFQDN(target) {
return fmt.Errorf("SRV target must be a fully qualified domain name or .")
}
return nil
}
func validateSOA(content string) error {
fields := strings.Fields(content)
if len(fields) != 7 {
return fmt.Errorf("SOA record content must be: primary hostmaster serial refresh retry expire minimum")
}
if !IsFQDN(fields[0]) {
return fmt.Errorf("SOA primary nameserver must be a fully qualified domain name")
}
if !IsFQDN(fields[1]) {
return fmt.Errorf("SOA hostmaster must be a fully qualified domain name")
}
for i, label := range []string{"serial", "refresh", "retry", "expire", "minimum"} {
if _, err := parseUint(fields[i+2], 32); err != nil {
return fmt.Errorf("SOA %s must be between 0 and 4294967295", label)
}
}
return nil
}
func validateCAA(content string) error {
fields := strings.Fields(content)
if len(fields) < 3 {
return fmt.Errorf("CAA record content must be: flags tag \"value\"")
}
flags, err := parseUint(fields[0], 8)
if err != nil || flags > 255 {
return fmt.Errorf("CAA flags must be between 0 and 255")
}
if !caaTagPattern.MatchString(fields[1]) {
return fmt.Errorf("CAA tag must start with a letter and contain only letters, numbers, and hyphens")
}
value := strings.Join(fields[2:], " ")
if !txtValuePattern.MatchString(value) {
return fmt.Errorf("CAA value must be quoted")
}
return nil
}
func parseUint(value string, bits int) (uint64, error) {
return strconv.ParseUint(value, 10, bits)
}
func supported(recordType string) bool {
for _, candidate := range SupportedTypes() {
if candidate == recordType {
return true
}
}
return false
}

View File

@@ -0,0 +1,75 @@
package dnsrecord
import "testing"
func TestValidateRRSetAcceptsSupportedRecords(t *testing.T) {
v := newTestValidator(t)
cases := []struct {
recordType string
content string
}{
{"A", "192.0.2.10"},
{"AAAA", "2001:db8::1"},
{"CAA", `0 issue "letsencrypt.org"`},
{"CNAME", "target.example.org."},
{"MX", "10 mail.example.org."},
{"NS", "ns1.example.org."},
{"SOA", "ns1.example.org. hostmaster.example.org. 2026010101 3600 600 604800 300"},
{"SRV", "10 20 443 service.example.org."},
{"TXT", `"v=spf1 -all"`},
}
for _, tc := range cases {
t.Run(tc.recordType, func(t *testing.T) {
if _, err := v.ValidateRRSet("www.example.org", tc.recordType, 300, []string{tc.content}); err != nil {
t.Fatalf("ValidateRRSet returned error: %v", err)
}
})
}
}
func TestValidateRRSetRejectsInvalidRecords(t *testing.T) {
v := newTestValidator(t)
cases := []struct {
name string
recordType string
content string
}{
{"bad name", "A", "192.0.2.10"},
{"www.example.org.", "A", "2001:db8::1"},
{"www.example.org.", "AAAA", "192.0.2.10"},
{"www.example.org.", "MX", "mail.example.org."},
{"www.example.org.", "CNAME", "target.example.org"},
{"www.example.org.", "TXT", "not quoted"},
{"www.example.org.", "SOA", "ns1.example.org. hostmaster.example.org."},
{"www.example.org.", "UNSUPPORTED", "value"},
}
for _, tc := range cases {
t.Run(tc.recordType+" "+tc.content, func(t *testing.T) {
if _, err := v.ValidateRRSet(tc.name, tc.recordType, 300, []string{tc.content}); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestValidateRRSetRequiresTTL(t *testing.T) {
v := newTestValidator(t)
if _, err := v.ValidateRRSet("www.example.org.", "A", 0, []string{"192.0.2.10"}); err == nil {
t.Fatal("expected ttl validation error")
}
}
func newTestValidator(t *testing.T) *Validator {
t.Helper()
v, err := NewValidator()
if err != nil {
t.Fatalf("NewValidator returned error: %v", err)
}
return v
}

327
internal/pdns/client.go Normal file
View File

@@ -0,0 +1,327 @@
package pdns
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type HTTPClient interface {
Do(*http.Request) (*http.Response, error)
}
type Client struct {
baseURL string
apiKey string
serverID string
httpClient HTTPClient
}
type Server struct {
ID string `json:"id"`
Type string `json:"type"`
DaemonType string `json:"daemon_type"`
Version string `json:"version"`
URL string `json:"url"`
ConfigURL string `json:"config_url"`
ZonesURL string `json:"zones_url"`
}
type Zone struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Type string `json:"type,omitempty"`
Kind string `json:"kind,omitempty"`
Serial uint64 `json:"serial,omitempty"`
EditedSerial uint64 `json:"edited_serial,omitempty"`
SOAEditAPI string `json:"soa_edit_api,omitempty"`
Nameservers []string `json:"nameservers,omitempty"`
Masters []string `json:"masters,omitempty"`
RRSets []RRSet `json:"rrsets,omitempty"`
}
type RRSet struct {
Name string `json:"name"`
Type string `json:"type"`
TTL uint32 `json:"ttl"`
Records []Record `json:"records"`
}
type Record struct {
Content string `json:"content"`
Disabled bool `json:"disabled"`
}
type changeRRSet struct {
Name string `json:"name"`
Type string `json:"type"`
TTL uint32 `json:"ttl,omitempty"`
ChangeType string `json:"changetype"`
Records []Record `json:"records,omitempty"`
}
func (z Zone) DisplayKind() string {
if z.Kind != "" {
return z.Kind
}
return z.Type
}
func NewClient(baseURL, apiKey, serverID string, httpClient HTTPClient) *Client {
if httpClient == nil {
httpClient = &http.Client{Timeout: 15 * time.Second}
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
serverID: strings.Trim(serverID, "/"),
httpClient: httpClient,
}
}
func (c *Client) GetServer(ctx context.Context) (Server, error) {
var server Server
err := c.do(ctx, http.MethodGet, c.path(""), nil, &server)
return server, err
}
func (c *Client) ListZones(ctx context.Context) ([]Zone, error) {
var zones []Zone
err := c.do(ctx, http.MethodGet, c.path("/zones"), nil, &zones)
return zones, err
}
func (c *Client) CreateZone(ctx context.Context, zone Zone) (Zone, error) {
var created Zone
err := c.do(ctx, http.MethodPost, c.path("/zones"), zone, &created)
return created, err
}
func (c *Client) DeleteZone(ctx context.Context, zoneID string) error {
return c.do(ctx, http.MethodDelete, c.path("/zones/"+url.PathEscape(zoneID)), nil, nil)
}
func (c *Client) GetZone(ctx context.Context, zoneID string) (Zone, error) {
var zone Zone
err := c.do(ctx, http.MethodGet, c.path("/zones/"+url.PathEscape(zoneID)), nil, &zone)
return zone, err
}
func (c *Client) CreateRRSet(ctx context.Context, zoneID string, rrset RRSet) error {
var before *Zone
if allowsMultipleRecords(rrset.Type) {
zone, err := c.GetZone(ctx, zoneID)
if err != nil {
return fmt.Errorf("read zone before merging records: %w", err)
}
before = &zone
if existing, ok := findRRSet(zone, rrset.Name, rrset.Type); ok {
rrset.Records = mergeRecords(existing.Records, rrset.Records)
}
}
return c.patchZoneWithSerialBump(ctx, zoneID, before, []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":
return true
default:
return false
}
}
func findRRSet(zone Zone, name, recordType string) (RRSet, bool) {
recordType = strings.ToUpper(strings.TrimSpace(recordType))
for _, rrset := range zone.RRSets {
if strings.EqualFold(rrset.Name, name) && strings.EqualFold(rrset.Type, recordType) {
return rrset, true
}
}
return RRSet{}, false
}
func mergeRecords(existing, added []Record) []Record {
merged := make([]Record, 0, len(existing)+len(added))
seen := make(map[string]struct{}, len(existing)+len(added))
for _, record := range append(append([]Record{}, existing...), added...) {
key := recordKey(record)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
merged = append(merged, record)
}
return merged
}
func recordKey(record Record) string {
return strings.TrimSpace(record.Content) + "\x00" + strconv.FormatBool(record.Disabled)
}
func (c *Client) DeleteRRSet(ctx context.Context, zoneID, name, recordType string) error {
return c.patchZoneWithSerialBump(ctx, zoneID, nil, []changeRRSet{{
Name: name,
Type: recordType,
ChangeType: "DELETE",
}})
}
func (c *Client) patchZone(ctx context.Context, zoneID string, rrsets []changeRRSet) error {
body := struct {
RRSets []changeRRSet `json:"rrsets"`
}{RRSets: rrsets}
return c.do(ctx, http.MethodPatch, c.path("/zones/"+url.PathEscape(zoneID)), body, nil)
}
func (c *Client) patchZoneWithSerialBump(ctx context.Context, zoneID string, before *Zone, rrsets []changeRRSet) error {
if before == nil {
zone, err := c.GetZone(ctx, zoneID)
if err != nil {
return fmt.Errorf("read zone before change: %w", err)
}
before = &zone
}
if err := c.patchZone(ctx, zoneID, rrsets); err != nil {
return err
}
after, err := c.GetZone(ctx, zoneID)
if err != nil {
return fmt.Errorf("read zone after change: %w", err)
}
if serialIncreased(*before, after) {
return nil
}
soa, err := bumpedSOA(after, before.Serial+1)
if err != nil {
return fmt.Errorf("bump SOA serial: %w", err)
}
return c.patchZone(ctx, zoneID, []changeRRSet{{
Name: soa.Name,
Type: soa.Type,
TTL: soa.TTL,
ChangeType: "REPLACE",
Records: soa.Records,
}})
}
func (c *Client) path(suffix string) string {
return "/api/v1/servers/" + url.PathEscape(c.serverID) + suffix
}
func (c *Client) do(ctx context.Context, method, apiPath string, in any, out any) error {
var body io.Reader
if in != nil {
payload, err := json.Marshal(in)
if err != nil {
return fmt.Errorf("encode request: %w", err)
}
body = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+apiPath, body)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("X-API-Key", c.apiKey)
req.Header.Set("Accept", "application/json")
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("powerdns request failed: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return fmt.Errorf("read powerdns response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
message := strings.TrimSpace(string(responseBody))
if message == "" {
message = resp.Status
}
return fmt.Errorf("powerdns returned %s: %s", resp.Status, message)
}
if out == nil || len(responseBody) == 0 {
return nil
}
if err := json.Unmarshal(responseBody, out); err != nil {
return fmt.Errorf("decode powerdns response: %w", err)
}
return nil
}
func serialIncreased(before, after Zone) bool {
if after.Serial > before.Serial {
return true
}
if (before.EditedSerial != 0 || after.EditedSerial != 0) && after.EditedSerial > before.EditedSerial {
return true
}
return false
}
func bumpedSOA(zone Zone, minimum uint64) (RRSet, error) {
for _, rrset := range zone.RRSets {
if !strings.EqualFold(rrset.Type, "SOA") {
continue
}
if len(rrset.Records) != 1 {
return RRSet{}, fmt.Errorf("expected exactly one SOA record, got %d", len(rrset.Records))
}
record := rrset.Records[0]
fields := strings.Fields(record.Content)
if len(fields) != 7 {
return RRSet{}, fmt.Errorf("SOA record must have 7 fields")
}
current, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil {
return RRSet{}, fmt.Errorf("parse SOA serial: %w", err)
}
if current == 1<<32-1 {
return RRSet{}, fmt.Errorf("SOA serial is already at maximum uint32 value")
}
next := current + 1
if next < minimum {
next = minimum
}
if next > 1<<32-1 {
return RRSet{}, fmt.Errorf("next SOA serial exceeds maximum uint32 value")
}
fields[2] = strconv.FormatUint(next, 10)
record.Content = strings.Join(fields, " ")
rrset.Records[0] = record
return rrset, nil
}
return RRSet{}, fmt.Errorf("SOA record not found")
}

View File

@@ -0,0 +1,357 @@
package pdns
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
func TestListZonesSendsAPIKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/servers/localhost/zones" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.Header.Get("X-API-Key"); got != "secret" {
t.Fatalf("unexpected api key: %q", got)
}
_ = json.NewEncoder(w).Encode([]Zone{{ID: "example.org.", Name: "example.org."}})
}))
defer server.Close()
client := NewClient(server.URL, "secret", "localhost", server.Client())
zones, err := client.ListZones(context.Background())
if err != nil {
t.Fatalf("ListZones returned error: %v", err)
}
if len(zones) != 1 || zones[0].ID != "example.org." {
t.Fatalf("unexpected zones: %#v", zones)
}
}
func TestCreateRRSetPatchesZone(t *testing.T) {
var patchCount int
var getCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/v1/servers/localhost/zones/example.org.") {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
switch r.Method {
case http.MethodGet:
getCount++
serial := uint64(10)
if getCount == 2 {
serial = 11
}
writeZone(t, w, serial)
case http.MethodPatch:
patchCount++
var payload struct {
RRSets []changeRRSet `json:"rrsets"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if len(payload.RRSets) != 1 || payload.RRSets[0].ChangeType != "REPLACE" {
t.Fatalf("unexpected payload: %#v", payload)
}
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method: %s", r.Method)
}
}))
defer server.Close()
client := NewClient(server.URL, "secret", "localhost", server.Client())
err := client.CreateRRSet(context.Background(), "example.org.", RRSet{
Name: "www.example.org.",
Type: "A",
TTL: 300,
Records: []Record{{
Content: "192.0.2.10",
}},
})
if err != nil {
t.Fatalf("CreateRRSet returned error: %v", err)
}
if patchCount != 1 {
t.Fatalf("expected one patch when serial increases, got %d", patchCount)
}
}
func TestCreateRRSetBumpsSOAWhenSerialDoesNotIncrease(t *testing.T) {
var patchCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/v1/servers/localhost/zones/example.org.") {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
switch r.Method {
case http.MethodGet:
writeZone(t, w, 10)
case http.MethodPatch:
patchCount++
var payload struct {
RRSets []changeRRSet `json:"rrsets"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if len(payload.RRSets) != 1 {
t.Fatalf("unexpected payload: %#v", payload)
}
if patchCount == 2 {
rrset := payload.RRSets[0]
if rrset.Type != "SOA" {
t.Fatalf("expected SOA fallback patch, got %#v", rrset)
}
if got := rrset.Records[0].Content; !strings.Contains(got, " 11 ") {
t.Fatalf("expected bumped SOA serial, got %q", got)
}
}
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method: %s", r.Method)
}
}))
defer server.Close()
client := NewClient(server.URL, "secret", "localhost", server.Client())
err := client.CreateRRSet(context.Background(), "example.org.", RRSet{
Name: "www.example.org.",
Type: "A",
TTL: 300,
Records: []Record{{
Content: "192.0.2.10",
}},
})
if err != nil {
t.Fatalf("CreateRRSet returned error: %v", err)
}
if patchCount != 2 {
t.Fatalf("expected requested patch plus SOA fallback patch, got %d", patchCount)
}
}
func TestCreateRRSetMergesMultiValueRecordTypes(t *testing.T) {
var patched RRSet
var getCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/v1/servers/localhost/zones/example.org.") {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
switch r.Method {
case http.MethodGet:
getCount++
writeZoneWithRRSets(t, w, 10, []RRSet{{
Name: "example.org.",
Type: "NS",
TTL: 3600,
Records: []Record{{
Content: "ns1.example.org.",
}},
}})
case http.MethodPatch:
var payload struct {
RRSets []changeRRSet `json:"rrsets"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if payload.RRSets[0].Type == "NS" {
patched = RRSet{
Name: payload.RRSets[0].Name,
Type: payload.RRSets[0].Type,
TTL: payload.RRSets[0].TTL,
Records: payload.RRSets[0].Records,
}
}
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method: %s", r.Method)
}
}))
defer server.Close()
client := NewClient(server.URL, "secret", "localhost", server.Client())
err := client.CreateRRSet(context.Background(), "example.org.", RRSet{
Name: "example.org.",
Type: "NS",
TTL: 3600,
Records: []Record{{
Content: "ns2.example.org.",
}},
})
if err != nil {
t.Fatalf("CreateRRSet returned error: %v", err)
}
if getCount == 0 {
t.Fatal("expected zone read before merge")
}
if len(patched.Records) != 2 {
t.Fatalf("expected merged records, got %#v", patched.Records)
}
if patched.Records[0].Content != "ns1.example.org." || patched.Records[1].Content != "ns2.example.org." {
t.Fatalf("unexpected merged records: %#v", patched.Records)
}
}
func TestCreateRRSetDoesNotMergeSingleValueRecordTypes(t *testing.T) {
var patched RRSet
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/v1/servers/localhost/zones/www.example.org.") {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
switch r.Method {
case http.MethodGet:
writeZoneWithRRSets(t, w, 11, []RRSet{{
Name: "www.example.org.",
Type: "CNAME",
TTL: 3600,
Records: []Record{{
Content: "old.example.org.",
}},
}})
case http.MethodPatch:
var payload struct {
RRSets []changeRRSet `json:"rrsets"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if payload.RRSets[0].Type == "CNAME" {
patched = RRSet{
Name: payload.RRSets[0].Name,
Type: payload.RRSets[0].Type,
TTL: payload.RRSets[0].TTL,
Records: payload.RRSets[0].Records,
}
}
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method: %s", r.Method)
}
}))
defer server.Close()
client := NewClient(server.URL, "secret", "localhost", server.Client())
err := client.CreateRRSet(context.Background(), "www.example.org.", RRSet{
Name: "www.example.org.",
Type: "CNAME",
TTL: 3600,
Records: []Record{{
Content: "new.example.org.",
}},
})
if err != nil {
t.Fatalf("CreateRRSet returned error: %v", err)
}
if len(patched.Records) != 1 || patched.Records[0].Content != "new.example.org." {
t.Fatalf("expected replacement record only, got %#v", patched.Records)
}
}
func TestGetServerUsesConfiguredServerID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/servers/localhost" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(Server{ID: "localhost", Version: "5.0.0"})
}))
defer server.Close()
client := NewClient(server.URL, "secret", "localhost", server.Client())
got, err := client.GetServer(context.Background())
if err != nil {
t.Fatalf("GetServer returned error: %v", err)
}
if got.ID != "localhost" {
t.Fatalf("unexpected server: %#v", got)
}
}
func TestCreateZonePostsZone(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("unexpected method: %s", r.Method)
}
if r.URL.Path != "/api/v1/servers/localhost/zones" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var payload Zone
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if payload.Name != "example.org." || payload.Kind != "Native" {
t.Fatalf("unexpected payload: %#v", payload)
}
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(payload)
}))
defer server.Close()
client := NewClient(server.URL, "secret", "localhost", server.Client())
created, err := client.CreateZone(context.Background(), Zone{Name: "example.org.", Kind: "Native"})
if err != nil {
t.Fatalf("CreateZone returned error: %v", err)
}
if created.Name != "example.org." {
t.Fatalf("unexpected zone: %#v", created)
}
}
func TestDeleteZoneDeletesZone(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Fatalf("unexpected method: %s", r.Method)
}
if r.URL.Path != "/api/v1/servers/localhost/zones/example.org." {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := NewClient(server.URL, "secret", "localhost", server.Client())
if err := client.DeleteZone(context.Background(), "example.org."); err != nil {
t.Fatalf("DeleteZone returned error: %v", err)
}
}
func writeZone(t *testing.T, w http.ResponseWriter, serial uint64) {
t.Helper()
writeZoneWithRRSets(t, w, serial, nil)
}
func writeZoneWithRRSets(t *testing.T, w http.ResponseWriter, serial uint64, rrsets []RRSet) {
t.Helper()
allRRSets := []RRSet{{
Name: "example.org.",
Type: "SOA",
TTL: 3600,
Records: []Record{{
Content: "ns1.example.org. hostmaster.example.org. " + strconv.FormatUint(serial, 10) + " 3600 600 604800 300",
}},
}}
allRRSets = append(allRRSets, rrsets...)
_ = json.NewEncoder(w).Encode(Zone{
ID: "example.org.",
Name: "example.org.",
Serial: serial,
RRSets: allRRSets,
})
}

648
internal/server/server.go Normal file
View File

@@ -0,0 +1,648 @@
package server
import (
"context"
"crypto/rand"
"embed"
"encoding/base64"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"pdns_admin/internal/dnsrecord"
"pdns_admin/internal/pdns"
)
//go:embed templates/*.html static
var assets embed.FS
const (
sessionCookieName = "pdns_admin_session"
sessionTTL = 12 * time.Hour
)
type PDNSClient interface {
GetServer(context.Context) (pdns.Server, error)
ListZones(context.Context) ([]pdns.Zone, error)
CreateZone(context.Context, pdns.Zone) (pdns.Zone, error)
DeleteZone(context.Context, string) error
GetZone(context.Context, string) (pdns.Zone, error)
CreateRRSet(context.Context, string, pdns.RRSet) error
DeleteRRSet(context.Context, string, string, string) error
}
type Authenticator interface {
Authenticate(context.Context, string, string) (bool, error)
}
type Config struct {
Addr string
Authenticator Authenticator
}
type Server struct {
addr string
client PDNSClient
logger *log.Logger
templates map[string]*template.Template
validator *dnsrecord.Validator
auth Authenticator
sessions map[string]session
sessionsM sync.Mutex
}
type pageData struct {
Title string
Error string
AuthEnabled bool
CurrentUser string
Next string
Server pdns.Server
ZoneID string
Zones []pdns.Zone
Zone pdns.Zone
RecordForm recordForm
RecordTypes []string
}
type session struct {
Username string
Expires time.Time
}
type recordForm struct {
Name string
Type string
TTL uint32
Records string
OriginalName string
OriginalType string
IsEdit bool
IsSOA bool
Title string
SubmitLabel string
}
func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
if client == nil {
return nil, fmt.Errorf("pdns client is required")
}
if logger == nil {
logger = log.Default()
}
if cfg.Addr == "" {
cfg.Addr = ":8080"
}
recordValidator, err := dnsrecord.NewValidator()
if err != nil {
return nil, fmt.Errorf("create record validator: %w", err)
}
templates := make(map[string]*template.Template)
funcs := template.FuncMap{
"isSOA": isSOA,
"recordValues": recordValues,
"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)
if err != nil {
return nil, fmt.Errorf("parse template %s: %w", page, err)
}
templates[page] = tmpl
}
return &Server{
addr: cfg.Addr,
client: client,
logger: logger,
templates: templates,
validator: recordValidator,
auth: cfg.Authenticator,
sessions: make(map[string]session),
}, nil
}
func (s *Server) ListenAndServe() error {
return http.ListenAndServe(s.addr, s.routes())
}
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(assets))
mux.HandleFunc("GET /healthz", s.healthz)
mux.HandleFunc("GET /login", s.login)
mux.HandleFunc("POST /login", s.loginPost)
mux.HandleFunc("GET /logout", s.logout)
mux.HandleFunc("GET /", s.dashboard)
mux.HandleFunc("GET /zones", s.listZones)
mux.HandleFunc("POST /zones", s.createZone)
mux.HandleFunc("GET /zones/{zoneID}", s.showZone)
mux.HandleFunc("POST /zones/{zoneID}/delete", s.deleteZone)
mux.HandleFunc("GET /zones/{zoneID}/rrsets/new", s.newRRSet)
mux.HandleFunc("GET /zones/{zoneID}/rrsets/edit", s.editRRSet)
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)
return s.withLogging(s.withSessionAuth(mux))
}
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
server, serverErr := s.client.GetServer(r.Context())
zones, zonesErr := s.client.ListZones(r.Context())
data := pageData{
Title: "Dashboard",
Server: server,
Zones: zones,
Error: firstNonEmpty(errorText(serverErr), errorText(zonesErr)),
}
s.render(w, r, "dashboard.html", data)
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if _, ok := s.currentUser(r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
data := pageData{
Title: "Login",
Error: r.URL.Query().Get("error"),
Next: safeRedirectPath(r.URL.Query().Get("next")),
}
s.render(w, r, "login.html", data)
}
func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/login?error="+url.QueryEscape("invalid form data"), http.StatusSeeOther)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
allowed, err := s.auth.Authenticate(r.Context(), username, password)
if err != nil {
s.logger.Printf("authentication failed for %q: %v", username, err)
http.Redirect(w, r, "/login?error="+url.QueryEscape("authentication backend failed"), http.StatusSeeOther)
return
}
if !allowed {
http.Redirect(w, r, "/login?error="+url.QueryEscape("invalid username or password"), http.StatusSeeOther)
return
}
token, err := s.createSession(username)
if err != nil {
s.logger.Printf("create session: %v", err)
http.Error(w, "session creation failed", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
Expires: time.Now().Add(sessionTTL),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, safeRedirectPath(r.FormValue("next")), http.StatusSeeOther)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil {
s.deleteSession(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) listZones(w http.ResponseWriter, r *http.Request) {
zones, err := s.client.ListZones(r.Context())
data := pageData{
Title: "Zones",
Zones: zones,
Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)),
}
s.render(w, r, "zones.html", data)
}
func (s *Server) createZone(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectZonesError(w, r, "invalid form data")
return
}
zoneName := dnsrecord.EnsureTrailingDot(r.FormValue("name"))
if !dnsrecord.IsFQDN(zoneName) {
s.redirectZonesError(w, r, "zone name must be a fully qualified domain name")
return
}
kind := strings.TrimSpace(r.FormValue("kind"))
if !validZoneKind(kind) {
s.redirectZonesError(w, r, "zone kind must be Native, Master, or Slave")
return
}
nameservers, err := parseFQDNLines(r.FormValue("nameservers"), "nameserver")
if err != nil {
s.redirectZonesError(w, r, err.Error())
return
}
masters := parseLines(r.FormValue("masters"))
if kind == "Slave" && len(masters) == 0 {
s.redirectZonesError(w, r, "slave zones require at least one master address")
return
}
if _, err := s.client.CreateZone(r.Context(), pdns.Zone{
Name: zoneName,
Kind: kind,
Nameservers: nameservers,
Masters: masters,
}); err != nil {
s.redirectZonesError(w, r, err.Error())
return
}
http.Redirect(w, r, "/zones/"+url.PathEscape(zoneName), http.StatusSeeOther)
}
func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request) {
zoneID := r.PathValue("zoneID")
if err := s.client.DeleteZone(r.Context(), zoneID); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
http.Redirect(w, r, "/zones", http.StatusSeeOther)
}
func (s *Server) showZone(w http.ResponseWriter, r *http.Request) {
zoneID := r.PathValue("zoneID")
zone, err := s.client.GetZone(r.Context(), zoneID)
data := pageData{
Title: "Zone " + zoneID,
ZoneID: zoneID,
Zone: zone,
Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)),
}
s.render(w, r, "zone.html", data)
}
func (s *Server) newRRSet(w http.ResponseWriter, r *http.Request) {
zoneID := r.PathValue("zoneID")
zone, err := s.client.GetZone(r.Context(), zoneID)
data := pageData{
Title: "Add record",
ZoneID: zoneID,
Zone: zone,
Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)),
RecordTypes: dnsrecord.SupportedTypes(),
RecordForm: recordForm{
Type: "A",
TTL: 300,
Title: "Add record",
SubmitLabel: "Create record",
},
}
s.render(w, r, "record_form.html", data)
}
func (s *Server) editRRSet(w http.ResponseWriter, r *http.Request) {
zoneID := r.PathValue("zoneID")
name := r.URL.Query().Get("name")
recordType := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("type")))
zone, err := s.client.GetZone(r.Context(), zoneID)
if err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
rrset, ok := findRRSet(zone, name, recordType)
if !ok {
s.redirectZoneError(w, r, zoneID, "record not found")
return
}
form := recordForm{
Name: rrset.Name,
Type: rrset.Type,
TTL: rrset.TTL,
Records: recordValues(rrset),
IsEdit: true,
IsSOA: isSOA(rrset.Type),
Title: "Edit record",
SubmitLabel: "Save record",
}
data := pageData{
Title: "Edit record",
ZoneID: zoneID,
Zone: zone,
Error: r.URL.Query().Get("error"),
RecordTypes: dnsrecord.SupportedTypes(),
RecordForm: form,
}
s.render(w, r, "record_form.html", data)
}
func (s *Server) saveRRSet(w http.ResponseWriter, r *http.Request) {
zoneID := r.PathValue("zoneID")
if err := r.ParseForm(); err != nil {
s.redirectZoneError(w, r, zoneID, "invalid form data")
return
}
ttl, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("ttl")), 10, 32)
if err != nil {
s.redirectZoneError(w, r, zoneID, "ttl must be a positive integer")
return
}
rrset, err := s.validator.ValidateRRSet(r.FormValue("name"), r.FormValue("type"), ttl, parseLines(r.FormValue("records")))
if err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
if err := s.client.CreateRRSet(r.Context(), zoneID, rrset); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
}
func (s *Server) saveEditedRRSet(w http.ResponseWriter, r *http.Request) {
zoneID := r.PathValue("zoneID")
name := r.URL.Query().Get("name")
recordType := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("type")))
if name == "" || recordType == "" {
s.redirectZoneError(w, r, zoneID, "record identity is required")
return
}
if err := r.ParseForm(); err != nil {
s.redirectZoneError(w, r, zoneID, "invalid form data")
return
}
ttl, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("ttl")), 10, 32)
if err != nil {
s.redirectZoneError(w, r, zoneID, "ttl must be a positive integer")
return
}
rrset, err := s.validator.ValidateRRSet(name, recordType, ttl, parseLines(r.FormValue("records")))
if err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
if err := s.client.CreateRRSet(r.Context(), zoneID, rrset); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
}
func (s *Server) deleteRRSet(w http.ResponseWriter, r *http.Request) {
zoneID := r.PathValue("zoneID")
if err := r.ParseForm(); err != nil {
s.redirectZoneError(w, r, zoneID, "invalid form data")
return
}
name := ensureTrailingDot(r.FormValue("name"))
recordType := strings.ToUpper(strings.TrimSpace(r.FormValue("type")))
if name == "." || recordType == "" {
s.redirectZoneError(w, r, zoneID, "record name and type are required")
return
}
if isSOA(recordType) {
s.redirectZoneError(w, r, zoneID, "SOA records are required for zones and cannot be deleted")
return
}
if err := s.client.DeleteRRSet(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) render(w http.ResponseWriter, r *http.Request, name string, data pageData) {
data.AuthEnabled = s.auth != nil
if user, ok := s.currentUser(r); ok {
data.CurrentUser = user
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl, ok := s.templates[name]
if !ok {
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
s.logger.Printf("render %s: %v", name, err)
}
}
func (s *Server) redirectZoneError(w http.ResponseWriter, r *http.Request, zoneID, message string) {
http.Redirect(w, r, "/zones/"+url.PathEscape(zoneID)+"?error="+url.QueryEscape(message), http.StatusSeeOther)
}
func (s *Server) redirectZonesError(w http.ResponseWriter, r *http.Request, message string) {
http.Redirect(w, r, "/zones?error="+url.QueryEscape(message), http.StatusSeeOther)
}
func (s *Server) withSessionAuth(next http.Handler) http.Handler {
if s.auth == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isPublicPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
if _, ok := s.currentUser(r); !ok {
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) currentUser(r *http.Request) (string, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
return "", false
}
s.sessionsM.Lock()
defer s.sessionsM.Unlock()
sess, ok := s.sessions[cookie.Value]
if !ok {
return "", false
}
if time.Now().After(sess.Expires) {
delete(s.sessions, cookie.Value)
return "", false
}
return sess.Username, true
}
func (s *Server) createSession(username string) (string, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
s.sessionsM.Lock()
defer s.sessionsM.Unlock()
s.sessions[token] = session{
Username: username,
Expires: time.Now().Add(sessionTTL),
}
return token, nil
}
func (s *Server) deleteSession(token string) {
s.sessionsM.Lock()
defer s.sessionsM.Unlock()
delete(s.sessions, token)
}
func isPublicPath(path string) bool {
return path == "/login" || path == "/logout" || path == "/healthz" || strings.HasPrefix(path, "/static/")
}
func safeRedirectPath(value string) string {
if value == "" {
return "/"
}
parsed, err := url.Parse(value)
if err != nil || parsed.IsAbs() || !strings.HasPrefix(parsed.Path, "/") || strings.HasPrefix(parsed.Path, "//") {
return "/"
}
return parsed.RequestURI()
}
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
s.logger.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
})
}
func parseLines(raw string) []string {
lines := strings.Split(raw, "\n")
values := make([]string, 0, len(lines))
for _, line := range lines {
value := strings.TrimSpace(line)
if value == "" {
continue
}
values = append(values, value)
}
return values
}
func ensureTrailingDot(value string) string {
return dnsrecord.EnsureTrailingDot(value)
}
func parseFQDNLines(raw, label string) ([]string, error) {
values := parseLines(raw)
for i, value := range values {
values[i] = dnsrecord.EnsureTrailingDot(value)
if !dnsrecord.IsFQDN(values[i]) {
return nil, fmt.Errorf("%s %q must be a fully qualified domain name", label, value)
}
}
return values, nil
}
func validZoneKind(kind string) bool {
switch kind {
case "Native", "Master", "Slave":
return true
default:
return false
}
}
func findRRSet(zone pdns.Zone, name, recordType string) (pdns.RRSet, bool) {
name = dnsrecord.EnsureTrailingDot(name)
recordType = strings.ToUpper(strings.TrimSpace(recordType))
for _, rrset := range zone.RRSets {
if rrset.Name == name && rrset.Type == recordType {
return rrset, true
}
}
return pdns.RRSet{}, false
}
func isSOA(recordType string) bool {
return strings.EqualFold(recordType, "SOA")
}
func recordValues(rrset pdns.RRSet) string {
values := make([]string, 0, len(rrset.Records))
for _, record := range rrset.Records {
values = append(values, record.Content)
}
return strings.Join(values, "\n")
}
func errorText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,266 @@
package server
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"pdns_admin/internal/pdns"
)
type fakeClient struct {
deletedRRSetType string
createdRRSet pdns.RRSet
}
type fakeAuth struct {
allowed bool
err error
username string
password string
}
func (f *fakeAuth) Authenticate(_ context.Context, username, password string) (bool, error) {
f.username = username
f.password = password
return f.allowed, f.err
}
func (fakeClient) GetServer(context.Context) (pdns.Server, error) {
return pdns.Server{}, nil
}
func (fakeClient) ListZones(context.Context) ([]pdns.Zone, error) {
return nil, nil
}
func (fakeClient) CreateZone(context.Context, pdns.Zone) (pdns.Zone, error) {
return pdns.Zone{}, nil
}
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) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet) error {
f.createdRRSet = rrset
return nil
}
func (f *fakeClient) DeleteRRSet(_ context.Context, _, _, recordType string) error {
f.deletedRRSetType = recordType
return nil
}
func TestNewRequiresClient(t *testing.T) {
if _, err := New(Config{}, nil, nil); err == nil {
t.Fatal("expected error for missing client")
}
}
func TestNewBuildsServer(t *testing.T) {
srv, err := New(Config{Addr: ":0"}, &fakeClient{}, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
if srv.addr != ":0" {
t.Fatalf("unexpected addr: %s", srv.addr)
}
}
func TestAuthCanBeDisabled(t *testing.T) {
srv, err := New(Config{}, &fakeClient{}, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("unexpected status: %d", rec.Code)
}
}
func TestServesEmbeddedTablerAssets(t *testing.T) {
srv, err := New(Config{}, &fakeClient{}, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/static/vendor/tabler.min.css", nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Tabler v1.4.0") {
t.Fatal("expected vendored Tabler CSS")
}
}
func TestAuthRedirectsProtectedRoutesToLogin(t *testing.T) {
srv, err := New(Config{Authenticator: &fakeAuth{allowed: true}}, &fakeClient{}, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/zones", nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
if got := rec.Header().Get("Location"); got != "/login?next=%2Fzones" {
t.Fatalf("unexpected location: %q", got)
}
}
func TestLoginCreatesSession(t *testing.T) {
auth := &fakeAuth{allowed: true}
srv, err := New(Config{Authenticator: auth}, &fakeClient{}, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader("username=alice&password=secret&next=/zones")
req := httptest.NewRequest(http.MethodPost, "/login", 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)
}
if auth.username != "alice" || auth.password != "secret" {
t.Fatalf("unexpected credentials: %q %q", auth.username, auth.password)
}
cookies := rec.Result().Cookies()
if len(cookies) == 0 || cookies[0].Name != sessionCookieName {
t.Fatalf("expected session cookie, got %#v", cookies)
}
req = httptest.NewRequest(http.MethodGet, "/zones", nil)
req.AddCookie(cookies[0])
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected authenticated status: %d", rec.Code)
}
}
func TestLoginReportsBackendErrors(t *testing.T) {
srv, err := New(Config{Authenticator: &fakeAuth{err: errors.New("ldap down")}}, &fakeClient{}, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader("username=alice&password=secret")
req := httptest.NewRequest(http.MethodPost, "/login", 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)
}
if !strings.HasPrefix(rec.Header().Get("Location"), "/login?error=") {
t.Fatalf("unexpected location: %q", rec.Header().Get("Location"))
}
}
func TestLogoutClearsSession(t *testing.T) {
srv, err := New(Config{Authenticator: &fakeAuth{allowed: true}}, &fakeClient{}, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
token, err := srv.createSession("alice")
if err != nil {
t.Fatalf("createSession returned error: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
if _, ok := srv.sessions[token]; ok {
t.Fatal("session was not deleted")
}
}
func TestParseLinesSkipsBlankLines(t *testing.T) {
values := parseLines("192.0.2.1\n\n192.0.2.2\n")
if len(values) != 2 {
t.Fatalf("unexpected values: %#v", values)
}
}
func TestDeleteRRSetRejectsSOA(t *testing.T) {
client := &fakeClient{}
srv, err := New(Config{}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader("name=example.org.&type=SOA")
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)
}
if client.deletedRRSetType != "" {
t.Fatalf("SOA delete reached client")
}
}
func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) {
client := &fakeClient{}
srv, err := New(Config{}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader("name=evil.example.org.&type=AAAA&ttl=300&records=192.0.2.10")
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/edit?name=www.example.org.&type=A", 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)
}
if client.createdRRSet.Name != "www.example.org." {
t.Fatalf("name came from form: %#v", client.createdRRSet)
}
if client.createdRRSet.Type != "A" {
t.Fatalf("type came from form: %#v", client.createdRRSet)
}
}

View File

@@ -0,0 +1,29 @@
body {
background:
radial-gradient(circle at 10% 0%, rgba(32, 107, 196, 0.08), transparent 28rem),
var(--tblr-bg-surface-secondary);
}
.navbar-brand a {
color: inherit;
text-decoration: none;
}
.page-header {
margin-bottom: 1.5rem;
}
.record-values {
display: grid;
gap: 0.25rem;
max-width: 52rem;
overflow-wrap: anywhere;
}
.record-values code {
white-space: normal;
}
.btn-list form {
margin: 0;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,57 @@
{{ define "layout" }}
<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }} - AehooDNS</title>
<link rel="stylesheet" href="/static/vendor/tabler.min.css">
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="layout-fluid">
<div class="page">
<header class="navbar navbar-expand-md d-print-none">
<div class="container-xl">
<h1 class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<a href="/">AehooDNS</a>
</h1>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar-menu">
<div class="navbar-nav">
<a class="nav-link" href="/">
<span class="nav-link-title">Dashboard</span>
</a>
<a class="nav-link" href="/zones">
<span class="nav-link-title">Zones</span>
</a>
</div>
{{ if .AuthEnabled }}
{{ if .CurrentUser }}
<div class="navbar-nav ms-auto">
<span class="nav-link text-secondary">{{ .CurrentUser }}</span>
<a class="nav-link" href="/logout">
<span class="nav-link-title">Logout</span>
</a>
</div>
{{ end }}
{{ end }}
</div>
</div>
</header>
<div class="page-wrapper">
<main class="page-body">
<div class="container-xl">
{{ if .Error }}
<div class="alert alert-danger" role="alert">{{ .Error }}</div>
{{ end }}
{{ template "content" . }}
</div>
</main>
</div>
</div>
<script src="/static/vendor/tabler.min.js"></script>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,69 @@
{{ define "content" }}
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">PowerDNS</div>
<h2 class="page-title">Dashboard</h2>
</div>
</div>
</div>
<div class="row row-deck row-cards mb-3">
<div class="col-sm-6 col-lg-3">
<div class="card">
<div class="card-body">
<div class="subheader">Server ID</div>
<div class="h2 mb-0 text-truncate">{{ .Server.ID }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card">
<div class="card-body">
<div class="subheader">Daemon</div>
<div class="h2 mb-0 text-truncate">{{ .Server.DaemonType }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card">
<div class="card-body">
<div class="subheader">Version</div>
<div class="h2 mb-0 text-truncate">{{ .Server.Version }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card">
<div class="card-body">
<div class="subheader">Zones</div>
<div class="h2 mb-0">{{ len .Zones }}</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Recent zones</h3>
</div>
{{ if .Zones }}
<div class="list-group list-group-flush">
{{ range .Zones }}
<a class="list-group-item list-group-item-action" href="/zones/{{ .ID }}">
<div class="row align-items-center">
<div class="col text-truncate">
<strong>{{ .Name }}</strong>
<div class="text-secondary text-truncate">{{ .DisplayKind }} · serial {{ .Serial }}</div>
</div>
</div>
</a>
{{ end }}
</div>
{{ else }}
<div class="card-body text-secondary">No zones returned by PowerDNS.</div>
{{ end }}
</div>
{{ end }}
{{ template "layout" . }}

View File

@@ -0,0 +1,25 @@
{{ define "content" }}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card card-md">
<div class="card-body">
<h2 class="h2 text-center mb-2">Login</h2>
<form method="post" action="/login">
<input type="hidden" name="next" value="{{ .Next }}">
<div class="mb-3">
<label class="form-label">Username</label>
<input class="form-control" name="username" autocomplete="username" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input class="form-control" name="password" type="password" autocomplete="current-password" required>
</div>
<button class="btn btn-primary w-100" type="submit">Sign in</button>
</form>
</div>
</div>
</div>
</div>
{{ end }}
{{ template "layout" . }}

View File

@@ -0,0 +1,63 @@
{{ define "content" }}
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">{{ .Zone.Name }}</div>
<h2 class="page-title">{{ .RecordForm.Title }}</h2>
</div>
<div class="col-auto ms-auto">
<a class="btn btn-outline-secondary" href="/zones/{{ .ZoneID }}">Back to records</a>
</div>
</div>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
{{ if .RecordForm.IsEdit }}
<div class="row mb-4">
<div class="col-sm-8">
<div class="subheader">Name</div>
<div class="h3 mb-0">{{ .RecordForm.Name }}</div>
</div>
<div class="col-sm-4">
<div class="subheader">Type</div>
<div class="h3 mb-0">{{ .RecordForm.Type }}</div>
</div>
</div>
{{ end }}
<form method="post" action="{{ if .RecordForm.IsEdit }}/zones/{{ .ZoneID }}/rrsets/edit?name={{ urlQuery .RecordForm.Name }}&type={{ urlQuery .RecordForm.Type }}{{ else }}/zones/{{ .ZoneID }}/rrsets{{ end }}">
{{ if not .RecordForm.IsEdit }}
<div class="mb-3">
<label class="form-label">Name</label>
<input class="form-control" name="name" value="{{ .RecordForm.Name }}" placeholder="www.{{ .Zone.Name }}" required>
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<select class="form-select" name="type" required>
{{ range .RecordTypes }}
<option {{ if eq . $.RecordForm.Type }}selected{{ end }}>{{ . }}</option>
{{ end }}
</select>
</div>
{{ end }}
<div class="mb-3">
<label class="form-label">TTL</label>
<input class="form-control" name="ttl" type="number" min="1" value="{{ .RecordForm.TTL }}" required>
</div>
<div class="mb-3">
<label class="form-label">Records</label>
<textarea class="form-control" name="records" rows="8" placeholder="One record value per line" required>{{ .RecordForm.Records }}</textarea>
<div class="form-hint">Strict formats are enforced. TXT and CAA string values must be quoted. Adding A, AAAA, CAA, MX, NS, SRV, or TXT records appends to an existing RRset with the same name and type. SOA records can be edited here but cannot be deleted.</div>
</div>
<button class="btn btn-primary" type="submit">{{ .RecordForm.SubmitLabel }}</button>
</form>
</div>
</div>
</div>
</div>
{{ end }}
{{ template "layout" . }}

View File

@@ -0,0 +1,70 @@
{{ define "content" }}
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">Zone</div>
<h2 class="page-title">{{ .Zone.Name }}</h2>
<div class="text-secondary">{{ len .Zone.RRSets }} RRsets</div>
</div>
<div class="col-auto ms-auto">
<div class="btn-list">
<a class="btn btn-outline-secondary" href="/zones">Back to zones</a>
<a class="btn btn-primary" href="/zones/{{ .ZoneID }}/rrsets/new">Add record</a>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Records</h3>
</div>
{{ if .Zone.RRSets }}
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>TTL</th>
<th>Records</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
{{ range .Zone.RRSets }}
<tr>
<td class="text-nowrap">{{ .Name }}</td>
<td><span class="badge bg-azure-lt">{{ .Type }}</span></td>
<td>{{ .TTL }}</td>
<td>
<div class="record-values">
{{ range .Records }}
<div><code>{{ .Content }}</code></div>
{{ end }}
</div>
</td>
<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 }}">Edit</a>
{{ if not (isSOA .Type) }}
<form method="post" action="/zones/{{ $.ZoneID }}/rrsets/delete">
<input type="hidden" name="name" value="{{ .Name }}">
<input type="hidden" name="type" value="{{ .Type }}">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
{{ end }}
</div>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ else }}
<div class="card-body text-secondary">No records returned for this zone.</div>
{{ end }}
</div>
{{ end }}
{{ template "layout" . }}

View File

@@ -0,0 +1,86 @@
{{ define "content" }}
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">Authoritative DNS</div>
<h2 class="page-title">Zones</h2>
<div class="text-secondary">{{ len .Zones }} zones loaded from PowerDNS.</div>
</div>
</div>
</div>
<div class="row row-cards">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">Existing zones</h3>
</div>
{{ if .Zones }}
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Kind</th>
<th>Serial</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
{{ range .Zones }}
<tr>
<td><a href="/zones/{{ .ID }}">{{ .Name }}</a></td>
<td><span class="badge bg-blue-lt">{{ .DisplayKind }}</span></td>
<td>{{ .Serial }}</td>
<td>
<form method="post" action="/zones/{{ .ID }}/delete">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ else }}
<div class="card-body text-secondary">No zones returned by PowerDNS.</div>
{{ end }}
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h3 class="card-title">Add zone</h3>
</div>
<div class="card-body">
<form method="post" action="/zones">
<div class="mb-3">
<label class="form-label">Zone name</label>
<input class="form-control" name="name" placeholder="example.org." required>
</div>
<div class="mb-3">
<label class="form-label">Kind</label>
<select class="form-select" name="kind" required>
<option>Native</option>
<option>Master</option>
<option>Slave</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Nameservers</label>
<textarea class="form-control" name="nameservers" rows="4" placeholder="ns1.example.org.&#10;ns2.example.org."></textarea>
</div>
<div class="mb-3">
<label class="form-label">Masters</label>
<textarea class="form-control" name="masters" rows="3" placeholder="Required for Slave zones"></textarea>
</div>
<button class="btn btn-primary" type="submit">Create zone</button>
</form>
</div>
</div>
</div>
</div>
{{ end }}
{{ template "layout" . }}