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 "" }