primeiro commit
This commit is contained in:
72
.gitignore
vendored
Normal file
72
.gitignore
vendored
Normal 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
21
Makefile
Normal 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
54
cmd/pdns-admin/main.go
Normal 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
17
config.example.yaml
Normal 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
29
go.mod
Normal 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
62
go.sum
Normal 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
182
internal/auth/ldap.go
Normal 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
|
||||
}
|
||||
30
internal/auth/ldap_test.go
Normal file
30
internal/auth/ldap_test.go
Normal 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
153
internal/config/config.go
Normal 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
|
||||
}
|
||||
285
internal/config/config_test.go
Normal file
285
internal/config/config_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
223
internal/dnsrecord/validator.go
Normal file
223
internal/dnsrecord/validator.go
Normal 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
|
||||
}
|
||||
75
internal/dnsrecord/validator_test.go
Normal file
75
internal/dnsrecord/validator_test.go
Normal 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
327
internal/pdns/client.go
Normal 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")
|
||||
}
|
||||
357
internal/pdns/client_test.go
Normal file
357
internal/pdns/client_test.go
Normal 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
648
internal/server/server.go
Normal 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 ""
|
||||
}
|
||||
266
internal/server/server_test.go
Normal file
266
internal/server/server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
29
internal/server/static/app.css
Normal file
29
internal/server/static/app.css
Normal 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;
|
||||
}
|
||||
9
internal/server/static/vendor/tabler.min.css
vendored
Normal file
9
internal/server/static/vendor/tabler.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
internal/server/static/vendor/tabler.min.js
vendored
Normal file
13
internal/server/static/vendor/tabler.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
57
internal/server/templates/base.html
Normal file
57
internal/server/templates/base.html
Normal 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 }}
|
||||
69
internal/server/templates/dashboard.html
Normal file
69
internal/server/templates/dashboard.html
Normal 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" . }}
|
||||
25
internal/server/templates/login.html
Normal file
25
internal/server/templates/login.html
Normal 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" . }}
|
||||
63
internal/server/templates/record_form.html
Normal file
63
internal/server/templates/record_form.html
Normal 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" . }}
|
||||
70
internal/server/templates/zone.html
Normal file
70
internal/server/templates/zone.html
Normal 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" . }}
|
||||
86
internal/server/templates/zones.html
Normal file
86
internal/server/templates/zones.html
Normal 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. 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" . }}
|
||||
Reference in New Issue
Block a user