suporte a dynamic dns

This commit is contained in:
2026-07-12 19:13:40 -03:00
parent e629b829a3
commit d184bcbdc2
13 changed files with 1020 additions and 31 deletions

View File

@@ -3,19 +3,26 @@ package server
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"embed"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
"sync"
"time"
"pdns_admin/internal/appdb"
"pdns_admin/internal/dnsrecord"
"pdns_admin/internal/pdns"
)
@@ -25,6 +32,7 @@ var assets embed.FS
const (
csrfFieldName = "csrf_token"
flashCookieName = "__Host-pdns_admin_flash"
sessionCookieName = "__Host-pdns_admin_session"
sessionTTL = 12 * time.Hour
)
@@ -36,9 +44,19 @@ type PDNSClient interface {
DeleteZone(context.Context, string) error
GetZone(context.Context, string) (pdns.Zone, error)
CreateRRSet(context.Context, string, pdns.RRSet) error
ReplaceRRSet(context.Context, string, pdns.RRSet) error
DeleteRRSet(context.Context, string, string, string) error
}
// DynamicStore persists dynamic DNS record settings.
type DynamicStore interface {
ListDynamicRecords(context.Context, string) ([]appdb.DynamicRecord, error)
FindDynamicRecordByNameAndTokenHash(context.Context, string, string) (appdb.DynamicRecord, error)
GetDynamicRecord(context.Context, string, string, string) (appdb.DynamicRecord, error)
UpsertDynamicRecord(context.Context, appdb.DynamicRecord) error
DisableDynamicRecord(context.Context, string, string, string) error
}
type Authenticator interface {
Authenticate(context.Context, string, string) (bool, error)
}
@@ -46,6 +64,7 @@ type Authenticator interface {
type Config struct {
Addr string
Authenticator Authenticator
DynamicStore DynamicStore
}
type Server struct {
@@ -54,9 +73,12 @@ type Server struct {
logger *log.Logger
templates map[string]*template.Template
validator *dnsrecord.Validator
dynamic DynamicStore
auth Authenticator
sessions map[string]session
sessionsM sync.Mutex
flashes map[string]flash
flashesM sync.Mutex
}
type pageData struct {
@@ -72,6 +94,8 @@ type pageData struct {
Zone pdns.Zone
RecordForm recordForm
RecordTypes []string
Dynamic map[string]appdb.DynamicRecord
Flash flash
}
type session struct {
@@ -85,11 +109,17 @@ type recordForm struct {
Type string
TTL uint32
Records string
DynamicDNS bool
IsEdit bool
Title string
SubmitLabel string
}
type flash struct {
Message string
Token string
}
func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
if client == nil {
return nil, fmt.Errorf("pdns client is required")
@@ -107,8 +137,10 @@ func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
templates := make(map[string]*template.Template)
funcs := template.FuncMap{
"isSOA": isSOA,
"urlQuery": url.QueryEscape,
"canDynamicRRSet": canDynamicRRSet,
"dynamicRRSet": dynamicRRSet,
"isSOA": isSOA,
"urlQuery": url.QueryEscape,
}
for _, page := range []string{"dashboard.html", "login.html", "zones.html", "zone.html", "record_form.html"} {
tmpl, err := template.New("base.html").Funcs(funcs).ParseFS(assets, "templates/base.html", "templates/"+page)
@@ -124,8 +156,10 @@ func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
logger: logger,
templates: templates,
validator: recordValidator,
dynamic: cfg.DynamicStore,
auth: cfg.Authenticator,
sessions: make(map[string]session),
flashes: make(map[string]flash),
}, nil
}
@@ -150,6 +184,8 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /zones/{zoneID}/rrsets", s.saveRRSet)
mux.HandleFunc("POST /zones/{zoneID}/rrsets/edit", s.saveEditedRRSet)
mux.HandleFunc("POST /zones/{zoneID}/rrsets/delete", s.deleteRRSet)
mux.HandleFunc("POST /zones/{zoneID}/rrsets/dyndns/regenerate", s.regenerateDynamicToken)
mux.HandleFunc("POST /api/dyndns", s.updateDynamicRecord)
return s.withLogging(s.withSecurityHeaders(s.withSessionAuth(mux)))
}
@@ -316,11 +352,13 @@ func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request) {
func (s *Server) showZone(w http.ResponseWriter, r *http.Request) {
zoneID := r.PathValue("zoneID")
zone, err := s.client.GetZone(r.Context(), zoneID)
dynamicRecords, dynamicErr := s.dynamicRecords(r.Context(), zoneID)
data := pageData{
Title: "Zona " + zoneID,
ZoneID: zoneID,
Zone: zone,
Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)),
Title: "Zona " + zoneID,
ZoneID: zoneID,
Zone: zone,
Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err), errorText(dynamicErr)),
Dynamic: dynamicRecords,
}
s.render(w, r, "zone.html", data)
}
@@ -365,6 +403,7 @@ func (s *Server) editRRSet(w http.ResponseWriter, r *http.Request) {
Type: rrset.Type,
TTL: rrset.TTL,
Records: recordValues(rrset),
DynamicDNS: s.dynamicEnabled(r.Context(), zoneID, rrset.Name, rrset.Type),
IsEdit: true,
Title: "Editar registro",
SubmitLabel: "Salvar registro",
@@ -398,11 +437,27 @@ func (s *Server) saveRRSet(w http.ResponseWriter, r *http.Request) {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
dynamicWanted := dynamicDNSWanted(r)
if dynamicWanted && !isDynamicRecordType(rrset.Type) {
s.redirectZoneError(w, r, zoneID, "DNS dinamico esta disponivel apenas para registros A e AAAA")
return
}
if err := s.client.CreateRRSet(r.Context(), zoneID, rrset); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
if dynamicWanted {
token, err := s.enableDynamicRecord(r.Context(), zoneID, rrset)
if err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
s.setFlash(w, flash{
Message: "DNS dinamico habilitado. Copie o token agora; ele nao sera exibido novamente.",
Token: token,
})
}
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
}
@@ -432,7 +487,27 @@ func (s *Server) saveEditedRRSet(w http.ResponseWriter, r *http.Request) {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
if err := s.client.CreateRRSet(r.Context(), zoneID, rrset); err != nil {
dynamicWanted := dynamicDNSWanted(r)
if dynamicWanted && !isDynamicRecordType(rrset.Type) {
s.redirectZoneError(w, r, zoneID, "DNS dinamico esta disponivel apenas para registros A e AAAA")
return
}
if err := s.client.ReplaceRRSet(r.Context(), zoneID, rrset); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
if dynamicWanted {
token, err := s.enableDynamicRecord(r.Context(), zoneID, rrset)
if err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
s.setFlash(w, flash{
Message: "DNS dinamico habilitado. Copie o token agora; ele nao sera exibido novamente.",
Token: token,
})
} else if err := s.disableDynamicRecord(r.Context(), zoneID, rrset.Name, rrset.Type); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
@@ -462,16 +537,123 @@ func (s *Server) deleteRRSet(w http.ResponseWriter, r *http.Request) {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
if err := s.disableDynamicRecord(r.Context(), zoneID, name, recordType); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
}
func (s *Server) regenerateDynamicToken(w http.ResponseWriter, r *http.Request) {
zoneID := r.PathValue("zoneID")
if err := r.ParseForm(); err != nil {
s.redirectZoneError(w, r, zoneID, "dados do formulario invalidos")
return
}
name := dnsrecord.EnsureTrailingDot(r.FormValue("name"))
recordType := strings.ToUpper(strings.TrimSpace(r.FormValue("type")))
if !isDynamicRecordType(recordType) || !dnsrecord.IsDNSName(name) {
s.redirectZoneError(w, r, zoneID, "registro dinamico invalido")
return
}
if s.dynamic == nil {
s.redirectZoneError(w, r, zoneID, "banco de dados da aplicacao nao configurado")
return
}
record, err := s.dynamic.GetDynamicRecord(r.Context(), zoneID, name, recordType)
if err != nil {
if errors.Is(err, appdb.ErrNotFound) {
s.redirectZoneError(w, r, zoneID, "DNS dinamico nao esta habilitado para este registro")
return
}
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
token, err := randomToken()
if err != nil {
s.redirectZoneError(w, r, zoneID, "falha ao gerar token")
return
}
record.TokenHash = tokenHash(token)
if err := s.dynamic.UpsertDynamicRecord(r.Context(), record); err != nil {
s.redirectZoneError(w, r, zoneID, err.Error())
return
}
s.setFlash(w, flash{
Message: "Token do DNS dinamico regenerado. Copie o token agora; ele nao sera exibido novamente.",
Token: token,
})
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
}
func (s *Server) updateDynamicRecord(w http.ResponseWriter, r *http.Request) {
var req dynamicUpdateRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
writeDynamicResponse(w, http.StatusBadRequest, "JSON invalido")
return
}
name := dnsrecord.EnsureTrailingDot(req.Name)
token := strings.TrimSpace(req.Token)
if !dnsrecord.IsDNSName(name) {
writeDynamicResponse(w, http.StatusBadRequest, "nome do registro invalido")
return
}
if token == "" || len(token) > 128 {
writeDynamicResponse(w, http.StatusBadRequest, "token invalido")
return
}
if s.dynamic == nil {
writeDynamicResponse(w, http.StatusServiceUnavailable, "banco de dados da aplicacao nao configurado")
return
}
record, err := s.dynamic.FindDynamicRecordByNameAndTokenHash(r.Context(), name, tokenHash(token))
if err != nil {
if errors.Is(err, appdb.ErrNotFound) {
writeDynamicResponse(w, http.StatusUnauthorized, "nome ou token invalido")
return
}
writeDynamicResponse(w, http.StatusInternalServerError, "falha ao consultar registro dinamico")
return
}
addr, err := dynamicUpdateAddress(req.Address, r)
if err != nil {
writeDynamicResponse(w, http.StatusBadRequest, err.Error())
return
}
if err := validateDynamicAddressType(record.Type, addr); err != nil {
writeDynamicResponse(w, http.StatusBadRequest, err.Error())
return
}
rrset := pdns.RRSet{
Name: record.Name,
Type: record.Type,
TTL: record.TTL,
Records: []pdns.Record{{
Content: addr.String(),
}},
}
if err := s.client.ReplaceRRSet(r.Context(), record.ZoneID, rrset); err != nil {
writeDynamicResponse(w, http.StatusBadGateway, "falha ao atualizar PowerDNS")
return
}
writeDynamicResponse(w, http.StatusOK, "")
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data pageData) {
data.AuthEnabled = s.auth != nil
if sess, ok := s.currentSession(r); ok {
data.CurrentUser = sess.Username
data.CSRFToken = sess.CSRFToken
}
data.Flash = s.consumeFlash(w, r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl, ok := s.templates[name]
if !ok {
@@ -578,7 +760,7 @@ func (s *Server) pruneExpiredSessionsLocked(now time.Time) {
}
func isPublicPath(path string) bool {
return path == "/login" || path == "/healthz" || strings.HasPrefix(path, "/static/")
return path == "/api/dyndns" || path == "/login" || path == "/healthz" || strings.HasPrefix(path, "/static/")
}
func safeRedirectPath(value string) string {
@@ -615,6 +797,99 @@ func validCSRFToken(r *http.Request, expected string) bool {
return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
}
func (s *Server) dynamicRecords(ctx context.Context, zoneID string) (map[string]appdb.DynamicRecord, error) {
records := make(map[string]appdb.DynamicRecord)
if s.dynamic == nil {
return records, nil
}
list, err := s.dynamic.ListDynamicRecords(ctx, zoneID)
if err != nil {
return records, err
}
for _, record := range list {
records[dynamicRecordKey(record.Name, record.Type)] = record
}
return records, nil
}
func (s *Server) dynamicEnabled(ctx context.Context, zoneID, name, recordType string) bool {
if s.dynamic == nil || !isDynamicRecordType(recordType) {
return false
}
_, err := s.dynamic.GetDynamicRecord(ctx, zoneID, dnsrecord.EnsureTrailingDot(name), strings.ToUpper(strings.TrimSpace(recordType)))
return err == nil
}
func (s *Server) enableDynamicRecord(ctx context.Context, zoneID string, rrset pdns.RRSet) (string, error) {
if s.dynamic == nil {
return "", fmt.Errorf("banco de dados da aplicacao nao configurado")
}
token, err := randomToken()
if err != nil {
return "", fmt.Errorf("gerar token dinamico: %w", err)
}
record := appdb.DynamicRecord{
ZoneID: zoneID,
Name: rrset.Name,
Type: rrset.Type,
TTL: rrset.TTL,
TokenHash: tokenHash(token),
Enabled: true,
}
if err := s.dynamic.UpsertDynamicRecord(ctx, record); err != nil {
return "", err
}
return token, nil
}
func (s *Server) disableDynamicRecord(ctx context.Context, zoneID, name, recordType string) error {
if s.dynamic == nil || !isDynamicRecordType(recordType) {
return nil
}
return s.dynamic.DisableDynamicRecord(ctx, zoneID, dnsrecord.EnsureTrailingDot(name), strings.ToUpper(strings.TrimSpace(recordType)))
}
func (s *Server) setFlash(w http.ResponseWriter, flash flash) {
id, err := randomToken()
if err != nil {
return
}
s.flashesM.Lock()
s.flashes[id] = flash
s.flashesM.Unlock()
http.SetCookie(w, &http.Cookie{
Name: flashCookieName,
Value: id,
Path: "/",
MaxAge: 300,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func (s *Server) consumeFlash(w http.ResponseWriter, r *http.Request) flash {
cookie, err := r.Cookie(flashCookieName)
if err != nil || cookie.Value == "" || len(cookie.Value) > 128 {
return flash{}
}
http.SetCookie(w, &http.Cookie{
Name: flashCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
s.flashesM.Lock()
defer s.flashesM.Unlock()
flash := s.flashes[cookie.Value]
delete(s.flashes, cookie.Value)
return flash
}
func randomToken() (string, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
@@ -623,6 +898,11 @@ func randomToken() (string, error) {
return base64.RawURLEncoding.EncodeToString(tokenBytes), nil
}
func tokenHash(token string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
return hex.EncodeToString(sum[:])
}
func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
@@ -670,6 +950,72 @@ func parseFQDNLines(raw, label string) ([]string, error) {
return values, nil
}
type dynamicUpdateRequest struct {
Name string `json:"name"`
Token string `json:"token"`
Address string `json:"address"`
}
func writeDynamicResponse(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if status == http.StatusOK {
_ = json.NewEncoder(w).Encode(map[string]string{"status": "OK"})
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func dynamicUpdateAddress(rawAddress string, r *http.Request) (netip.Addr, error) {
value := strings.TrimSpace(rawAddress)
if value == "" {
value = forwardedAddress(r)
}
addr, err := netip.ParseAddr(value)
if err != nil {
return netip.Addr{}, fmt.Errorf("endereco IP invalido")
}
return addr.Unmap(), nil
}
func forwardedAddress(r *http.Request) string {
if forwardedFor := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwardedFor != "" {
parts := strings.Split(forwardedFor, ",")
if len(parts) > 0 {
return strings.TrimSpace(parts[0])
}
}
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
return realIP
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
return strings.Trim(host, "[]")
}
func validateDynamicAddressType(recordType string, addr netip.Addr) error {
switch strings.ToUpper(strings.TrimSpace(recordType)) {
case "A":
if !addr.Is4() {
return fmt.Errorf("registro A exige um endereco IPv4")
}
case "AAAA":
if !addr.Is6() {
return fmt.Errorf("registro AAAA exige um endereco IPv6")
}
default:
return fmt.Errorf("DNS dinamico esta disponivel apenas para registros A e AAAA")
}
return nil
}
func dynamicDNSWanted(r *http.Request) bool {
return r.FormValue("dynamic_dns") == "on"
}
func validZoneKind(kind string) bool {
switch kind {
case "Native", "Master", "Slave":
@@ -694,6 +1040,31 @@ func isSOA(recordType string) bool {
return strings.EqualFold(recordType, "SOA")
}
func isDynamicRecordType(recordType string) bool {
switch strings.ToUpper(strings.TrimSpace(recordType)) {
case "A", "AAAA":
return true
default:
return false
}
}
func canDynamicRRSet(rrset pdns.RRSet) bool {
return isDynamicRecordType(rrset.Type)
}
func dynamicRRSet(records map[string]appdb.DynamicRecord, rrset pdns.RRSet) bool {
if records == nil {
return false
}
_, ok := records[dynamicRecordKey(rrset.Name, rrset.Type)]
return ok
}
func dynamicRecordKey(name, recordType string) string {
return dnsrecord.EnsureTrailingDot(name) + "\x00" + strings.ToUpper(strings.TrimSpace(recordType))
}
func recordValues(rrset pdns.RRSet) string {
values := make([]string, 0, len(rrset.Records))
for _, record := range rrset.Records {

View File

@@ -5,15 +5,20 @@ import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"pdns_admin/internal/appdb"
"pdns_admin/internal/pdns"
)
type fakeClient struct {
deletedRRSetType string
createdRRSet pdns.RRSet
replacedRRSet pdns.RRSet
replacedZoneID string
zone pdns.Zone
}
type fakeAuth struct {
@@ -45,8 +50,8 @@ func (fakeClient) DeleteZone(context.Context, string) error {
return nil
}
func (fakeClient) GetZone(context.Context, string) (pdns.Zone, error) {
return pdns.Zone{}, nil
func (f *fakeClient) GetZone(context.Context, string) (pdns.Zone, error) {
return f.zone, nil
}
func (f *fakeClient) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet) error {
@@ -54,11 +59,66 @@ func (f *fakeClient) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet)
return nil
}
func (f *fakeClient) ReplaceRRSet(_ context.Context, zoneID string, rrset pdns.RRSet) error {
f.replacedZoneID = zoneID
f.replacedRRSet = rrset
return nil
}
func (f *fakeClient) DeleteRRSet(_ context.Context, _, _, recordType string) error {
f.deletedRRSetType = recordType
return nil
}
type fakeDynamicStore struct {
records map[string]appdb.DynamicRecord
}
func (f *fakeDynamicStore) ListDynamicRecords(context.Context, string) ([]appdb.DynamicRecord, error) {
records := make([]appdb.DynamicRecord, 0, len(f.records))
for _, record := range f.records {
if record.Enabled {
records = append(records, record)
}
}
return records, nil
}
func (f *fakeDynamicStore) FindDynamicRecordByNameAndTokenHash(_ context.Context, name, tokenHash string) (appdb.DynamicRecord, error) {
for _, record := range f.records {
if record.Name == name && record.TokenHash == tokenHash && record.Enabled {
return record, nil
}
}
return appdb.DynamicRecord{}, appdb.ErrNotFound
}
func (f *fakeDynamicStore) GetDynamicRecord(_ context.Context, zoneID, name, recordType string) (appdb.DynamicRecord, error) {
record, ok := f.records[dynamicRecordKey(name, recordType)]
if !ok || record.ZoneID != zoneID || !record.Enabled {
return appdb.DynamicRecord{}, appdb.ErrNotFound
}
return record, nil
}
func (f *fakeDynamicStore) UpsertDynamicRecord(_ context.Context, record appdb.DynamicRecord) error {
if f.records == nil {
f.records = make(map[string]appdb.DynamicRecord)
}
record.Enabled = true
f.records[dynamicRecordKey(record.Name, record.Type)] = record
return nil
}
func (f *fakeDynamicStore) DisableDynamicRecord(_ context.Context, _, name, recordType string) error {
record, ok := f.records[dynamicRecordKey(name, recordType)]
if ok {
record.Enabled = false
f.records[dynamicRecordKey(name, recordType)] = record
}
return nil
}
func TestNewRequiresClient(t *testing.T) {
if _, err := New(Config{}, nil, nil); err == nil {
t.Fatal("expected error for missing client")
@@ -297,6 +357,38 @@ func TestDeleteRRSetRejectsSOA(t *testing.T) {
}
}
func TestDeleteRRSetDisablesDynamicDNS(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 300,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader("name=www.example.org.&type=A")
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/delete", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
record := store.records[dynamicRecordKey("www.example.org.", "A")]
if record.Enabled {
t.Fatalf("dynamic record was not disabled: %#v", record)
}
}
func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) {
client := &fakeClient{}
srv, err := New(Config{}, client, nil)
@@ -314,10 +406,245 @@ func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) {
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
if client.createdRRSet.Name != "www.example.org." {
t.Fatalf("name came from form: %#v", client.createdRRSet)
if client.replacedRRSet.Name != "www.example.org." {
t.Fatalf("name came from form: %#v", client.replacedRRSet)
}
if client.createdRRSet.Type != "A" {
t.Fatalf("type came from form: %#v", client.createdRRSet)
if client.replacedRRSet.Type != "A" {
t.Fatalf("type came from form: %#v", client.replacedRRSet)
}
}
func TestSaveRRSetEnablesDynamicDNS(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
form := url.Values{}
form.Set("name", "www.example.org.")
form.Set("type", "A")
form.Set("ttl", "300")
form.Set("records", "192.0.2.10")
form.Set("dynamic_dns", "on")
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
record, err := store.GetDynamicRecord(context.Background(), "example.org.", "www.example.org.", "A")
if err != nil {
t.Fatalf("GetDynamicRecord returned error: %v", err)
}
if record.TokenHash == "" || record.TTL != 300 {
t.Fatalf("unexpected dynamic record: %#v", record)
}
if len(rec.Result().Cookies()) == 0 {
t.Fatal("expected flash cookie")
}
}
func TestRegenerateDynamicToken(t *testing.T) {
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 300,
TokenHash: tokenHash("old-token"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, &fakeClient{}, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
form := url.Values{}
form.Set("name", "www.example.org.")
form.Set("type", "A")
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/dyndns/regenerate", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
record := store.records[dynamicRecordKey("www.example.org.", "A")]
if record.TokenHash == tokenHash("old-token") || record.TokenHash == "" {
t.Fatalf("token hash was not regenerated: %#v", record)
}
}
func TestDynamicUpdateEndpointUpdatesRecord(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
req.RemoteAddr = "198.51.100.20:12345"
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if client.replacedZoneID != "example.org." {
t.Fatalf("unexpected zone id: %q", client.replacedZoneID)
}
if client.replacedRRSet.Name != "www.example.org." || client.replacedRRSet.Type != "A" || client.replacedRRSet.TTL != 120 {
t.Fatalf("unexpected rrset: %#v", client.replacedRRSet)
}
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.20" {
t.Fatalf("unexpected update address: %q", got)
}
if !strings.Contains(rec.Body.String(), `"status":"OK"`) {
t.Fatalf("unexpected response: %s", rec.Body.String())
}
}
func TestDynamicUpdateEndpointUsesForwardedFor(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
req.RemoteAddr = "10.0.0.10:12345"
req.Header.Set("X-Forwarded-For", "198.51.100.30, 10.0.0.10")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.30" {
t.Fatalf("unexpected update address: %q", got)
}
}
func TestDynamicUpdateEndpointUsesRealIP(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
req.RemoteAddr = "10.0.0.10:12345"
req.Header.Set("X-Real-IP", "198.51.100.31")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.31" {
t.Fatalf("unexpected update address: %q", got)
}
}
func TestDynamicUpdateEndpointAddressOverridesForwardedHeaders(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret","address":"198.51.100.40"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
req.RemoteAddr = "10.0.0.10:12345"
req.Header.Set("X-Forwarded-For", "198.51.100.30")
req.Header.Set("X-Real-IP", "198.51.100.31")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.40" {
t.Fatalf("unexpected update address: %q", got)
}
}
func TestDynamicUpdateEndpointRejectsAddressTypeMismatch(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret","address":"2001:db8::1"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if client.replacedRRSet.Name != "" {
t.Fatalf("unexpected PowerDNS update: %#v", client.replacedRRSet)
}
}

View File

@@ -50,6 +50,20 @@
<div class="alert alert-danger" role="alert">{{ .Error }}</div>
{{ end }}
{{ template "content" . }}
{{ if .Flash.Token }}
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto">DNS dinamico</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Fechar"></button>
</div>
<div class="toast-body">
<p>{{ .Flash.Message }}</p>
<code class="d-block text-break">{{ .Flash.Token }}</code>
</div>
</div>
</div>
{{ end }}
</div>
</main>
</div>

View File

@@ -54,18 +54,7 @@
<label class="form-label">Tipo</label>
<select class="form-select" name="type" required>
{{ range .RecordTypes }}
<option
{{
if
eq
.
$.RecordForm.Type
}}selected{{
end
}}
>
{{ . }}
</option>
<option {{ if eq . $.RecordForm.Type }}selected{{ end }}>{{ . }}</option>
{{ end }}
</select>
</div>
@@ -89,9 +78,7 @@
rows="8"
placeholder="Um valor de registro por linha"
required
>
{{ .RecordForm.Records }}</textarea
>
>{{ .RecordForm.Records }}</textarea>
<div class="form-hint">
Formato estrito exigido: Valores de texto TXT e CAA
devem estar entre aspas. Adicionar registros A,
@@ -101,6 +88,22 @@
ser excluidos.
</div>
</div>
<div class="mb-3">
<label class="form-check">
<input
class="form-check-input"
name="dynamic_dns"
type="checkbox"
{{ if .RecordForm.DynamicDNS }}checked{{ end }}
/>
<span class="form-check-label">Habilitar atualizacoes de DNS dinamico</span>
</label>
<div class="form-hint">
Disponivel apenas para registros A e AAAA. Ao
salvar com esta opcao marcada, um novo token sera
gerado e exibido uma unica vez.
</div>
</div>
<button class="btn btn-primary" type="submit">
{{ .RecordForm.SubmitLabel }}
</button>

View File

@@ -33,9 +33,13 @@
</thead>
<tbody>
{{ range .Zone.RRSets }}
<tr>
{{ $dynamic := dynamicRRSet $.Dynamic . }}
<tr {{ if $dynamic }}class="table-info"{{ end }}>
<td class="text-nowrap">{{ .Name }}</td>
<td><span class="badge bg-azure-lt">{{ .Type }}</span></td>
<td>
<span class="badge bg-azure-lt">{{ .Type }}</span>
{{ if $dynamic }}<span class="badge bg-green-lt">DNS dinamico</span>{{ end }}
</td>
<td>{{ .TTL }}</td>
<td>
<div class="record-values">
@@ -47,6 +51,14 @@
<td>
<div class="btn-list flex-nowrap">
<a class="btn btn-outline-primary btn-sm" href="/zones/{{ $.ZoneID }}/rrsets/edit?name={{ urlQuery .Name }}&type={{ urlQuery .Type }}">Editar</a>
{{ if $dynamic }}
<form method="post" action="/zones/{{ $.ZoneID }}/rrsets/dyndns/regenerate">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="name" value="{{ .Name }}">
<input type="hidden" name="type" value="{{ .Type }}">
<button class="btn btn-outline-success btn-sm" type="submit">Regenerar token</button>
</form>
{{ end }}
{{ if not (isSOA .Type) }}
<form method="post" action="/zones/{{ $.ZoneID }}/rrsets/delete">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">