package server import ( "context" "crypto/rand" "crypto/subtle" "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 ( csrfFieldName = "csrf_token" 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 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 CSRFToken string Next string Server pdns.Server ZoneID string Zones []pdns.Zone Zone pdns.Zone RecordForm recordForm RecordTypes []string } type session struct { Username string CSRFToken string Expires time.Time } type recordForm struct { Name string Type string TTL uint32 Records string IsEdit 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, "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("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) 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.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), 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: "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, 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 := dnsrecord.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 sess, ok := s.currentSession(r); ok { data.CurrentUser = sess.Username data.CSRFToken = sess.CSRFToken } 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 } 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, "invalid CSRF token", 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 == "/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 randomToken() (string, error) { tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(tokenBytes), nil } 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 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 "" }