1091 lines
30 KiB
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 ""
|
|
}
|