Files
pdns-admin/internal/server/server.go

1091 lines
30 KiB
Go

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"
)
//go:embed templates/*.html static
var assets embed.FS
const (
csrfFieldName = "csrf_token"
flashCookieName = "__Host-pdns_admin_flash"
sessionCookieName = "__Host-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
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)
}
type Config struct {
Addr string
Authenticator Authenticator
DynamicStore DynamicStore
}
type Server struct {
addr string
client PDNSClient
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 {
Title string
Error string
AuthEnabled bool
CurrentUser string
CSRFToken string
Next string
Server pdns.Server
ZoneID string
Zones []pdns.Zone
Zone pdns.Zone
RecordForm recordForm
RecordTypes []string
Dynamic map[string]appdb.DynamicRecord
Flash flash
}
type session struct {
Username string
CSRFToken string
Expires time.Time
}
type recordForm struct {
Name string
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")
}
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{
"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)
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,
dynamic: cfg.DynamicStore,
auth: cfg.Authenticator,
sessions: make(map[string]session),
flashes: make(map[string]flash),
}, 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("POST /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)
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)))
}
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.Error(w, "pagina nao encontrada", http.StatusNotFound)
return
}
server, serverErr := s.client.GetServer(r.Context())
zones, zonesErr := s.client.ListZones(r.Context())
data := pageData{
Title: "Painel",
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: "Entrar",
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("dados do formulario invalidos"), 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("falha no servico de autenticacao"), http.StatusSeeOther)
return
}
if !allowed {
http.Redirect(w, r, "/login?error="+url.QueryEscape("usuario ou senha invalidos"), http.StatusSeeOther)
return
}
token, err := s.createSession(username)
if err != nil {
s.logger.Printf("create session: %v", err)
http.Error(w, "falha ao criar sessao", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
Expires: time.Now().Add(sessionTTL),
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
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,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
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: "Zonas",
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, "dados do formulario invalidos")
return
}
zoneName := dnsrecord.EnsureTrailingDot(r.FormValue("name"))
if !dnsrecord.IsFQDN(zoneName) {
s.redirectZonesError(w, r, "o nome da zona deve ser um nome de dominio totalmente qualificado")
return
}
kind := strings.TrimSpace(r.FormValue("kind"))
if !validZoneKind(kind) {
s.redirectZonesError(w, r, "o tipo da zona deve ser Native, Master ou Slave")
return
}
nameservers, err := parseFQDNLines(r.FormValue("nameservers"), "servidor de nomes")
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, "zonas Slave exigem ao menos um endereco de servidor mestre")
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)
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), errorText(dynamicErr)),
Dynamic: dynamicRecords,
}
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: "Adicionar registro",
ZoneID: zoneID,
Zone: zone,
Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)),
RecordTypes: dnsrecord.SupportedTypes(),
RecordForm: recordForm{
Type: "A",
TTL: 300,
Title: "Adicionar registro",
SubmitLabel: "Criar registro",
},
}
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, "registro nao encontrado")
return
}
form := recordForm{
Name: rrset.Name,
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",
}
data := pageData{
Title: "Editar registro",
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, "dados do formulario invalidos")
return
}
ttl, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("ttl")), 10, 32)
if err != nil {
s.redirectZoneError(w, r, zoneID, "ttl deve ser um numero inteiro positivo")
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
}
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)
}
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, "a identidade do registro e obrigatoria")
return
}
if err := r.ParseForm(); err != nil {
s.redirectZoneError(w, r, zoneID, "dados do formulario invalidos")
return
}
ttl, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("ttl")), 10, 32)
if err != nil {
s.redirectZoneError(w, r, zoneID, "ttl deve ser um numero inteiro positivo")
return
}
rrset, err := s.validator.ValidateRRSet(name, recordType, ttl, parseLines(r.FormValue("records")))
if err != nil {
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.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
}
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, "dados do formulario invalidos")
return
}
name := dnsrecord.EnsureTrailingDot(r.FormValue("name"))
recordType := strings.ToUpper(strings.TrimSpace(r.FormValue("type")))
if name == "." || recordType == "" {
s.redirectZoneError(w, r, zoneID, "nome e tipo do registro sao obrigatorios")
return
}
if isSOA(recordType) {
s.redirectZoneError(w, r, zoneID, "registros SOA sao obrigatorios para zonas e nao podem ser excluidos")
return
}
if err := s.client.DeleteRRSet(r.Context(), zoneID, name, recordType); err != nil {
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 {
http.Error(w, "template nao encontrado", 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
}
sess, ok := s.currentSession(r)
if !ok {
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
return
}
if isUnsafeMethod(r.Method) && !validCSRFToken(r, sess.CSRFToken) {
http.Error(w, "token CSRF invalido", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) currentUser(r *http.Request) (string, bool) {
sess, ok := s.currentSession(r)
if !ok {
return "", false
}
return sess.Username, true
}
func (s *Server) currentSession(r *http.Request) (session, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" || len(cookie.Value) > 128 {
return session{}, false
}
s.sessionsM.Lock()
defer s.sessionsM.Unlock()
sess, ok := s.sessions[cookie.Value]
if !ok {
return session{}, false
}
if time.Now().After(sess.Expires) {
delete(s.sessions, cookie.Value)
return session{}, false
}
return sess, true
}
func (s *Server) createSession(username string) (string, error) {
token, err := randomToken()
if err != nil {
return "", err
}
csrfToken, err := randomToken()
if err != nil {
return "", err
}
s.sessionsM.Lock()
defer s.sessionsM.Unlock()
s.pruneExpiredSessionsLocked(time.Now())
s.sessions[token] = session{
Username: username,
CSRFToken: csrfToken,
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 (s *Server) pruneExpiredSessionsLocked(now time.Time) {
for token, sess := range s.sessions {
if now.After(sess.Expires) {
delete(s.sessions, token)
}
}
}
func isPublicPath(path string) bool {
return path == "/api/dyndns" || path == "/login" || 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 isUnsafeMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
return false
default:
return true
}
}
func validCSRFToken(r *http.Request, expected string) bool {
if expected == "" {
return false
}
if err := r.ParseForm(); err != nil {
return false
}
token := r.FormValue(csrfFieldName)
if token == "" {
token = r.Header.Get("X-CSRF-Token")
}
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 {
return "", err
}
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'")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
if !strings.HasPrefix(r.URL.Path, "/static/") {
w.Header().Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
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 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 deve ser um nome de dominio totalmente qualificado", label, value)
}
}
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":
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 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 {
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 ""
}