suporte a dynamic dns
This commit is contained in:
@@ -3,19 +3,26 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pdns_admin/internal/appdb"
|
||||
"pdns_admin/internal/dnsrecord"
|
||||
"pdns_admin/internal/pdns"
|
||||
)
|
||||
@@ -25,6 +32,7 @@ var assets embed.FS
|
||||
|
||||
const (
|
||||
csrfFieldName = "csrf_token"
|
||||
flashCookieName = "__Host-pdns_admin_flash"
|
||||
sessionCookieName = "__Host-pdns_admin_session"
|
||||
sessionTTL = 12 * time.Hour
|
||||
)
|
||||
@@ -36,9 +44,19 @@ type PDNSClient interface {
|
||||
DeleteZone(context.Context, string) error
|
||||
GetZone(context.Context, string) (pdns.Zone, error)
|
||||
CreateRRSet(context.Context, string, pdns.RRSet) error
|
||||
ReplaceRRSet(context.Context, string, pdns.RRSet) error
|
||||
DeleteRRSet(context.Context, string, string, string) error
|
||||
}
|
||||
|
||||
// DynamicStore persists dynamic DNS record settings.
|
||||
type DynamicStore interface {
|
||||
ListDynamicRecords(context.Context, string) ([]appdb.DynamicRecord, error)
|
||||
FindDynamicRecordByNameAndTokenHash(context.Context, string, string) (appdb.DynamicRecord, error)
|
||||
GetDynamicRecord(context.Context, string, string, string) (appdb.DynamicRecord, error)
|
||||
UpsertDynamicRecord(context.Context, appdb.DynamicRecord) error
|
||||
DisableDynamicRecord(context.Context, string, string, string) error
|
||||
}
|
||||
|
||||
type Authenticator interface {
|
||||
Authenticate(context.Context, string, string) (bool, error)
|
||||
}
|
||||
@@ -46,6 +64,7 @@ type Authenticator interface {
|
||||
type Config struct {
|
||||
Addr string
|
||||
Authenticator Authenticator
|
||||
DynamicStore DynamicStore
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -54,9 +73,12 @@ type Server struct {
|
||||
logger *log.Logger
|
||||
templates map[string]*template.Template
|
||||
validator *dnsrecord.Validator
|
||||
dynamic DynamicStore
|
||||
auth Authenticator
|
||||
sessions map[string]session
|
||||
sessionsM sync.Mutex
|
||||
flashes map[string]flash
|
||||
flashesM sync.Mutex
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
@@ -72,6 +94,8 @@ type pageData struct {
|
||||
Zone pdns.Zone
|
||||
RecordForm recordForm
|
||||
RecordTypes []string
|
||||
Dynamic map[string]appdb.DynamicRecord
|
||||
Flash flash
|
||||
}
|
||||
|
||||
type session struct {
|
||||
@@ -85,11 +109,17 @@ type recordForm struct {
|
||||
Type string
|
||||
TTL uint32
|
||||
Records string
|
||||
DynamicDNS bool
|
||||
IsEdit bool
|
||||
Title string
|
||||
SubmitLabel string
|
||||
}
|
||||
|
||||
type flash struct {
|
||||
Message string
|
||||
Token string
|
||||
}
|
||||
|
||||
func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
|
||||
if client == nil {
|
||||
return nil, fmt.Errorf("pdns client is required")
|
||||
@@ -107,8 +137,10 @@ func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
|
||||
|
||||
templates := make(map[string]*template.Template)
|
||||
funcs := template.FuncMap{
|
||||
"isSOA": isSOA,
|
||||
"urlQuery": url.QueryEscape,
|
||||
"canDynamicRRSet": canDynamicRRSet,
|
||||
"dynamicRRSet": dynamicRRSet,
|
||||
"isSOA": isSOA,
|
||||
"urlQuery": url.QueryEscape,
|
||||
}
|
||||
for _, page := range []string{"dashboard.html", "login.html", "zones.html", "zone.html", "record_form.html"} {
|
||||
tmpl, err := template.New("base.html").Funcs(funcs).ParseFS(assets, "templates/base.html", "templates/"+page)
|
||||
@@ -124,8 +156,10 @@ func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
|
||||
logger: logger,
|
||||
templates: templates,
|
||||
validator: recordValidator,
|
||||
dynamic: cfg.DynamicStore,
|
||||
auth: cfg.Authenticator,
|
||||
sessions: make(map[string]session),
|
||||
flashes: make(map[string]flash),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -150,6 +184,8 @@ func (s *Server) routes() http.Handler {
|
||||
mux.HandleFunc("POST /zones/{zoneID}/rrsets", s.saveRRSet)
|
||||
mux.HandleFunc("POST /zones/{zoneID}/rrsets/edit", s.saveEditedRRSet)
|
||||
mux.HandleFunc("POST /zones/{zoneID}/rrsets/delete", s.deleteRRSet)
|
||||
mux.HandleFunc("POST /zones/{zoneID}/rrsets/dyndns/regenerate", s.regenerateDynamicToken)
|
||||
mux.HandleFunc("POST /api/dyndns", s.updateDynamicRecord)
|
||||
return s.withLogging(s.withSecurityHeaders(s.withSessionAuth(mux)))
|
||||
}
|
||||
|
||||
@@ -316,11 +352,13 @@ func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) showZone(w http.ResponseWriter, r *http.Request) {
|
||||
zoneID := r.PathValue("zoneID")
|
||||
zone, err := s.client.GetZone(r.Context(), zoneID)
|
||||
dynamicRecords, dynamicErr := s.dynamicRecords(r.Context(), zoneID)
|
||||
data := pageData{
|
||||
Title: "Zona " + zoneID,
|
||||
ZoneID: zoneID,
|
||||
Zone: zone,
|
||||
Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)),
|
||||
Title: "Zona " + zoneID,
|
||||
ZoneID: zoneID,
|
||||
Zone: zone,
|
||||
Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err), errorText(dynamicErr)),
|
||||
Dynamic: dynamicRecords,
|
||||
}
|
||||
s.render(w, r, "zone.html", data)
|
||||
}
|
||||
@@ -365,6 +403,7 @@ func (s *Server) editRRSet(w http.ResponseWriter, r *http.Request) {
|
||||
Type: rrset.Type,
|
||||
TTL: rrset.TTL,
|
||||
Records: recordValues(rrset),
|
||||
DynamicDNS: s.dynamicEnabled(r.Context(), zoneID, rrset.Name, rrset.Type),
|
||||
IsEdit: true,
|
||||
Title: "Editar registro",
|
||||
SubmitLabel: "Salvar registro",
|
||||
@@ -398,11 +437,27 @@ func (s *Server) saveRRSet(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
dynamicWanted := dynamicDNSWanted(r)
|
||||
if dynamicWanted && !isDynamicRecordType(rrset.Type) {
|
||||
s.redirectZoneError(w, r, zoneID, "DNS dinamico esta disponivel apenas para registros A e AAAA")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.client.CreateRRSet(r.Context(), zoneID, rrset); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
if dynamicWanted {
|
||||
token, err := s.enableDynamicRecord(r.Context(), zoneID, rrset)
|
||||
if err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
s.setFlash(w, flash{
|
||||
Message: "DNS dinamico habilitado. Copie o token agora; ele nao sera exibido novamente.",
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
|
||||
}
|
||||
@@ -432,7 +487,27 @@ func (s *Server) saveEditedRRSet(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.client.CreateRRSet(r.Context(), zoneID, rrset); err != nil {
|
||||
dynamicWanted := dynamicDNSWanted(r)
|
||||
if dynamicWanted && !isDynamicRecordType(rrset.Type) {
|
||||
s.redirectZoneError(w, r, zoneID, "DNS dinamico esta disponivel apenas para registros A e AAAA")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.client.ReplaceRRSet(r.Context(), zoneID, rrset); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
if dynamicWanted {
|
||||
token, err := s.enableDynamicRecord(r.Context(), zoneID, rrset)
|
||||
if err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
s.setFlash(w, flash{
|
||||
Message: "DNS dinamico habilitado. Copie o token agora; ele nao sera exibido novamente.",
|
||||
Token: token,
|
||||
})
|
||||
} else if err := s.disableDynamicRecord(r.Context(), zoneID, rrset.Name, rrset.Type); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -462,16 +537,123 @@ func (s *Server) deleteRRSet(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.disableDynamicRecord(r.Context(), zoneID, name, recordType); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) regenerateDynamicToken(w http.ResponseWriter, r *http.Request) {
|
||||
zoneID := r.PathValue("zoneID")
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, "dados do formulario invalidos")
|
||||
return
|
||||
}
|
||||
|
||||
name := dnsrecord.EnsureTrailingDot(r.FormValue("name"))
|
||||
recordType := strings.ToUpper(strings.TrimSpace(r.FormValue("type")))
|
||||
if !isDynamicRecordType(recordType) || !dnsrecord.IsDNSName(name) {
|
||||
s.redirectZoneError(w, r, zoneID, "registro dinamico invalido")
|
||||
return
|
||||
}
|
||||
if s.dynamic == nil {
|
||||
s.redirectZoneError(w, r, zoneID, "banco de dados da aplicacao nao configurado")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := s.dynamic.GetDynamicRecord(r.Context(), zoneID, name, recordType)
|
||||
if err != nil {
|
||||
if errors.Is(err, appdb.ErrNotFound) {
|
||||
s.redirectZoneError(w, r, zoneID, "DNS dinamico nao esta habilitado para este registro")
|
||||
return
|
||||
}
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
token, err := randomToken()
|
||||
if err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, "falha ao gerar token")
|
||||
return
|
||||
}
|
||||
record.TokenHash = tokenHash(token)
|
||||
if err := s.dynamic.UpsertDynamicRecord(r.Context(), record); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
s.setFlash(w, flash{
|
||||
Message: "Token do DNS dinamico regenerado. Copie o token agora; ele nao sera exibido novamente.",
|
||||
Token: token,
|
||||
})
|
||||
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) updateDynamicRecord(w http.ResponseWriter, r *http.Request) {
|
||||
var req dynamicUpdateRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
|
||||
writeDynamicResponse(w, http.StatusBadRequest, "JSON invalido")
|
||||
return
|
||||
}
|
||||
|
||||
name := dnsrecord.EnsureTrailingDot(req.Name)
|
||||
token := strings.TrimSpace(req.Token)
|
||||
if !dnsrecord.IsDNSName(name) {
|
||||
writeDynamicResponse(w, http.StatusBadRequest, "nome do registro invalido")
|
||||
return
|
||||
}
|
||||
if token == "" || len(token) > 128 {
|
||||
writeDynamicResponse(w, http.StatusBadRequest, "token invalido")
|
||||
return
|
||||
}
|
||||
if s.dynamic == nil {
|
||||
writeDynamicResponse(w, http.StatusServiceUnavailable, "banco de dados da aplicacao nao configurado")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := s.dynamic.FindDynamicRecordByNameAndTokenHash(r.Context(), name, tokenHash(token))
|
||||
if err != nil {
|
||||
if errors.Is(err, appdb.ErrNotFound) {
|
||||
writeDynamicResponse(w, http.StatusUnauthorized, "nome ou token invalido")
|
||||
return
|
||||
}
|
||||
writeDynamicResponse(w, http.StatusInternalServerError, "falha ao consultar registro dinamico")
|
||||
return
|
||||
}
|
||||
|
||||
addr, err := dynamicUpdateAddress(req.Address, r)
|
||||
if err != nil {
|
||||
writeDynamicResponse(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateDynamicAddressType(record.Type, addr); err != nil {
|
||||
writeDynamicResponse(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rrset := pdns.RRSet{
|
||||
Name: record.Name,
|
||||
Type: record.Type,
|
||||
TTL: record.TTL,
|
||||
Records: []pdns.Record{{
|
||||
Content: addr.String(),
|
||||
}},
|
||||
}
|
||||
if err := s.client.ReplaceRRSet(r.Context(), record.ZoneID, rrset); err != nil {
|
||||
writeDynamicResponse(w, http.StatusBadGateway, "falha ao atualizar PowerDNS")
|
||||
return
|
||||
}
|
||||
writeDynamicResponse(w, http.StatusOK, "")
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data pageData) {
|
||||
data.AuthEnabled = s.auth != nil
|
||||
if sess, ok := s.currentSession(r); ok {
|
||||
data.CurrentUser = sess.Username
|
||||
data.CSRFToken = sess.CSRFToken
|
||||
}
|
||||
data.Flash = s.consumeFlash(w, r)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl, ok := s.templates[name]
|
||||
if !ok {
|
||||
@@ -578,7 +760,7 @@ func (s *Server) pruneExpiredSessionsLocked(now time.Time) {
|
||||
}
|
||||
|
||||
func isPublicPath(path string) bool {
|
||||
return path == "/login" || path == "/healthz" || strings.HasPrefix(path, "/static/")
|
||||
return path == "/api/dyndns" || path == "/login" || path == "/healthz" || strings.HasPrefix(path, "/static/")
|
||||
}
|
||||
|
||||
func safeRedirectPath(value string) string {
|
||||
@@ -615,6 +797,99 @@ func validCSRFToken(r *http.Request, expected string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
func (s *Server) dynamicRecords(ctx context.Context, zoneID string) (map[string]appdb.DynamicRecord, error) {
|
||||
records := make(map[string]appdb.DynamicRecord)
|
||||
if s.dynamic == nil {
|
||||
return records, nil
|
||||
}
|
||||
list, err := s.dynamic.ListDynamicRecords(ctx, zoneID)
|
||||
if err != nil {
|
||||
return records, err
|
||||
}
|
||||
for _, record := range list {
|
||||
records[dynamicRecordKey(record.Name, record.Type)] = record
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *Server) dynamicEnabled(ctx context.Context, zoneID, name, recordType string) bool {
|
||||
if s.dynamic == nil || !isDynamicRecordType(recordType) {
|
||||
return false
|
||||
}
|
||||
_, err := s.dynamic.GetDynamicRecord(ctx, zoneID, dnsrecord.EnsureTrailingDot(name), strings.ToUpper(strings.TrimSpace(recordType)))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Server) enableDynamicRecord(ctx context.Context, zoneID string, rrset pdns.RRSet) (string, error) {
|
||||
if s.dynamic == nil {
|
||||
return "", fmt.Errorf("banco de dados da aplicacao nao configurado")
|
||||
}
|
||||
token, err := randomToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("gerar token dinamico: %w", err)
|
||||
}
|
||||
record := appdb.DynamicRecord{
|
||||
ZoneID: zoneID,
|
||||
Name: rrset.Name,
|
||||
Type: rrset.Type,
|
||||
TTL: rrset.TTL,
|
||||
TokenHash: tokenHash(token),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := s.dynamic.UpsertDynamicRecord(ctx, record); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *Server) disableDynamicRecord(ctx context.Context, zoneID, name, recordType string) error {
|
||||
if s.dynamic == nil || !isDynamicRecordType(recordType) {
|
||||
return nil
|
||||
}
|
||||
return s.dynamic.DisableDynamicRecord(ctx, zoneID, dnsrecord.EnsureTrailingDot(name), strings.ToUpper(strings.TrimSpace(recordType)))
|
||||
}
|
||||
|
||||
func (s *Server) setFlash(w http.ResponseWriter, flash flash) {
|
||||
id, err := randomToken()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.flashesM.Lock()
|
||||
s.flashes[id] = flash
|
||||
s.flashesM.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: flashCookieName,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
MaxAge: 300,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) consumeFlash(w http.ResponseWriter, r *http.Request) flash {
|
||||
cookie, err := r.Cookie(flashCookieName)
|
||||
if err != nil || cookie.Value == "" || len(cookie.Value) > 128 {
|
||||
return flash{}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: flashCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
s.flashesM.Lock()
|
||||
defer s.flashesM.Unlock()
|
||||
flash := s.flashes[cookie.Value]
|
||||
delete(s.flashes, cookie.Value)
|
||||
return flash
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
@@ -623,6 +898,11 @@ func randomToken() (string, error) {
|
||||
return base64.RawURLEncoding.EncodeToString(tokenBytes), nil
|
||||
}
|
||||
|
||||
func tokenHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
|
||||
@@ -670,6 +950,72 @@ func parseFQDNLines(raw, label string) ([]string, error) {
|
||||
return values, nil
|
||||
}
|
||||
|
||||
type dynamicUpdateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
func writeDynamicResponse(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if status == http.StatusOK {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "OK"})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func dynamicUpdateAddress(rawAddress string, r *http.Request) (netip.Addr, error) {
|
||||
value := strings.TrimSpace(rawAddress)
|
||||
if value == "" {
|
||||
value = forwardedAddress(r)
|
||||
}
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("endereco IP invalido")
|
||||
}
|
||||
return addr.Unmap(), nil
|
||||
}
|
||||
|
||||
func forwardedAddress(r *http.Request) string {
|
||||
if forwardedFor := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwardedFor != "" {
|
||||
parts := strings.Split(forwardedFor, ",")
|
||||
if len(parts) > 0 {
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
}
|
||||
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
|
||||
return realIP
|
||||
}
|
||||
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
func validateDynamicAddressType(recordType string, addr netip.Addr) error {
|
||||
switch strings.ToUpper(strings.TrimSpace(recordType)) {
|
||||
case "A":
|
||||
if !addr.Is4() {
|
||||
return fmt.Errorf("registro A exige um endereco IPv4")
|
||||
}
|
||||
case "AAAA":
|
||||
if !addr.Is6() {
|
||||
return fmt.Errorf("registro AAAA exige um endereco IPv6")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("DNS dinamico esta disponivel apenas para registros A e AAAA")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dynamicDNSWanted(r *http.Request) bool {
|
||||
return r.FormValue("dynamic_dns") == "on"
|
||||
}
|
||||
|
||||
func validZoneKind(kind string) bool {
|
||||
switch kind {
|
||||
case "Native", "Master", "Slave":
|
||||
@@ -694,6 +1040,31 @@ func isSOA(recordType string) bool {
|
||||
return strings.EqualFold(recordType, "SOA")
|
||||
}
|
||||
|
||||
func isDynamicRecordType(recordType string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(recordType)) {
|
||||
case "A", "AAAA":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func canDynamicRRSet(rrset pdns.RRSet) bool {
|
||||
return isDynamicRecordType(rrset.Type)
|
||||
}
|
||||
|
||||
func dynamicRRSet(records map[string]appdb.DynamicRecord, rrset pdns.RRSet) bool {
|
||||
if records == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := records[dynamicRecordKey(rrset.Name, rrset.Type)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func dynamicRecordKey(name, recordType string) string {
|
||||
return dnsrecord.EnsureTrailingDot(name) + "\x00" + strings.ToUpper(strings.TrimSpace(recordType))
|
||||
}
|
||||
|
||||
func recordValues(rrset pdns.RRSet) string {
|
||||
values := make([]string, 0, len(rrset.Records))
|
||||
for _, record := range rrset.Records {
|
||||
|
||||
@@ -5,15 +5,20 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pdns_admin/internal/appdb"
|
||||
"pdns_admin/internal/pdns"
|
||||
)
|
||||
|
||||
type fakeClient struct {
|
||||
deletedRRSetType string
|
||||
createdRRSet pdns.RRSet
|
||||
replacedRRSet pdns.RRSet
|
||||
replacedZoneID string
|
||||
zone pdns.Zone
|
||||
}
|
||||
|
||||
type fakeAuth struct {
|
||||
@@ -45,8 +50,8 @@ func (fakeClient) DeleteZone(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeClient) GetZone(context.Context, string) (pdns.Zone, error) {
|
||||
return pdns.Zone{}, nil
|
||||
func (f *fakeClient) GetZone(context.Context, string) (pdns.Zone, error) {
|
||||
return f.zone, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet) error {
|
||||
@@ -54,11 +59,66 @@ func (f *fakeClient) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) ReplaceRRSet(_ context.Context, zoneID string, rrset pdns.RRSet) error {
|
||||
f.replacedZoneID = zoneID
|
||||
f.replacedRRSet = rrset
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) DeleteRRSet(_ context.Context, _, _, recordType string) error {
|
||||
f.deletedRRSetType = recordType
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeDynamicStore struct {
|
||||
records map[string]appdb.DynamicRecord
|
||||
}
|
||||
|
||||
func (f *fakeDynamicStore) ListDynamicRecords(context.Context, string) ([]appdb.DynamicRecord, error) {
|
||||
records := make([]appdb.DynamicRecord, 0, len(f.records))
|
||||
for _, record := range f.records {
|
||||
if record.Enabled {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (f *fakeDynamicStore) FindDynamicRecordByNameAndTokenHash(_ context.Context, name, tokenHash string) (appdb.DynamicRecord, error) {
|
||||
for _, record := range f.records {
|
||||
if record.Name == name && record.TokenHash == tokenHash && record.Enabled {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
return appdb.DynamicRecord{}, appdb.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeDynamicStore) GetDynamicRecord(_ context.Context, zoneID, name, recordType string) (appdb.DynamicRecord, error) {
|
||||
record, ok := f.records[dynamicRecordKey(name, recordType)]
|
||||
if !ok || record.ZoneID != zoneID || !record.Enabled {
|
||||
return appdb.DynamicRecord{}, appdb.ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (f *fakeDynamicStore) UpsertDynamicRecord(_ context.Context, record appdb.DynamicRecord) error {
|
||||
if f.records == nil {
|
||||
f.records = make(map[string]appdb.DynamicRecord)
|
||||
}
|
||||
record.Enabled = true
|
||||
f.records[dynamicRecordKey(record.Name, record.Type)] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDynamicStore) DisableDynamicRecord(_ context.Context, _, name, recordType string) error {
|
||||
record, ok := f.records[dynamicRecordKey(name, recordType)]
|
||||
if ok {
|
||||
record.Enabled = false
|
||||
f.records[dynamicRecordKey(name, recordType)] = record
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewRequiresClient(t *testing.T) {
|
||||
if _, err := New(Config{}, nil, nil); err == nil {
|
||||
t.Fatal("expected error for missing client")
|
||||
@@ -297,6 +357,38 @@ func TestDeleteRRSetRejectsSOA(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRRSetDisablesDynamicDNS(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
|
||||
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
|
||||
ZoneID: "example.org.",
|
||||
Name: "www.example.org.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
TokenHash: tokenHash("secret"),
|
||||
Enabled: true,
|
||||
}
|
||||
srv, err := New(Config{DynamicStore: store}, client, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader("name=www.example.org.&type=A")
|
||||
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/delete", body)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
record := store.records[dynamicRecordKey("www.example.org.", "A")]
|
||||
if record.Enabled {
|
||||
t.Fatalf("dynamic record was not disabled: %#v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
srv, err := New(Config{}, client, nil)
|
||||
@@ -314,10 +406,245 @@ func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) {
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
if client.createdRRSet.Name != "www.example.org." {
|
||||
t.Fatalf("name came from form: %#v", client.createdRRSet)
|
||||
if client.replacedRRSet.Name != "www.example.org." {
|
||||
t.Fatalf("name came from form: %#v", client.replacedRRSet)
|
||||
}
|
||||
if client.createdRRSet.Type != "A" {
|
||||
t.Fatalf("type came from form: %#v", client.createdRRSet)
|
||||
if client.replacedRRSet.Type != "A" {
|
||||
t.Fatalf("type came from form: %#v", client.replacedRRSet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRRSetEnablesDynamicDNS(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
store := &fakeDynamicStore{}
|
||||
srv, err := New(Config{DynamicStore: store}, client, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", "www.example.org.")
|
||||
form.Set("type", "A")
|
||||
form.Set("ttl", "300")
|
||||
form.Set("records", "192.0.2.10")
|
||||
form.Set("dynamic_dns", "on")
|
||||
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
record, err := store.GetDynamicRecord(context.Background(), "example.org.", "www.example.org.", "A")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDynamicRecord returned error: %v", err)
|
||||
}
|
||||
if record.TokenHash == "" || record.TTL != 300 {
|
||||
t.Fatalf("unexpected dynamic record: %#v", record)
|
||||
}
|
||||
if len(rec.Result().Cookies()) == 0 {
|
||||
t.Fatal("expected flash cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateDynamicToken(t *testing.T) {
|
||||
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
|
||||
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
|
||||
ZoneID: "example.org.",
|
||||
Name: "www.example.org.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
TokenHash: tokenHash("old-token"),
|
||||
Enabled: true,
|
||||
}
|
||||
srv, err := New(Config{DynamicStore: store}, &fakeClient{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", "www.example.org.")
|
||||
form.Set("type", "A")
|
||||
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/dyndns/regenerate", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
record := store.records[dynamicRecordKey("www.example.org.", "A")]
|
||||
if record.TokenHash == tokenHash("old-token") || record.TokenHash == "" {
|
||||
t.Fatalf("token hash was not regenerated: %#v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicUpdateEndpointUpdatesRecord(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
|
||||
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
|
||||
ZoneID: "example.org.",
|
||||
Name: "www.example.org.",
|
||||
Type: "A",
|
||||
TTL: 120,
|
||||
TokenHash: tokenHash("secret"),
|
||||
Enabled: true,
|
||||
}
|
||||
srv, err := New(Config{DynamicStore: store}, client, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
|
||||
req.RemoteAddr = "198.51.100.20:12345"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if client.replacedZoneID != "example.org." {
|
||||
t.Fatalf("unexpected zone id: %q", client.replacedZoneID)
|
||||
}
|
||||
if client.replacedRRSet.Name != "www.example.org." || client.replacedRRSet.Type != "A" || client.replacedRRSet.TTL != 120 {
|
||||
t.Fatalf("unexpected rrset: %#v", client.replacedRRSet)
|
||||
}
|
||||
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.20" {
|
||||
t.Fatalf("unexpected update address: %q", got)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"status":"OK"`) {
|
||||
t.Fatalf("unexpected response: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicUpdateEndpointUsesForwardedFor(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
|
||||
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
|
||||
ZoneID: "example.org.",
|
||||
Name: "www.example.org.",
|
||||
Type: "A",
|
||||
TTL: 120,
|
||||
TokenHash: tokenHash("secret"),
|
||||
Enabled: true,
|
||||
}
|
||||
srv, err := New(Config{DynamicStore: store}, client, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
|
||||
req.RemoteAddr = "10.0.0.10:12345"
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.30, 10.0.0.10")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.30" {
|
||||
t.Fatalf("unexpected update address: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicUpdateEndpointUsesRealIP(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
|
||||
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
|
||||
ZoneID: "example.org.",
|
||||
Name: "www.example.org.",
|
||||
Type: "A",
|
||||
TTL: 120,
|
||||
TokenHash: tokenHash("secret"),
|
||||
Enabled: true,
|
||||
}
|
||||
srv, err := New(Config{DynamicStore: store}, client, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
|
||||
req.RemoteAddr = "10.0.0.10:12345"
|
||||
req.Header.Set("X-Real-IP", "198.51.100.31")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.31" {
|
||||
t.Fatalf("unexpected update address: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicUpdateEndpointAddressOverridesForwardedHeaders(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
|
||||
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
|
||||
ZoneID: "example.org.",
|
||||
Name: "www.example.org.",
|
||||
Type: "A",
|
||||
TTL: 120,
|
||||
TokenHash: tokenHash("secret"),
|
||||
Enabled: true,
|
||||
}
|
||||
srv, err := New(Config{DynamicStore: store}, client, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{"name":"www.example.org","token":"secret","address":"198.51.100.40"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
|
||||
req.RemoteAddr = "10.0.0.10:12345"
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.30")
|
||||
req.Header.Set("X-Real-IP", "198.51.100.31")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.40" {
|
||||
t.Fatalf("unexpected update address: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicUpdateEndpointRejectsAddressTypeMismatch(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
|
||||
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
|
||||
ZoneID: "example.org.",
|
||||
Name: "www.example.org.",
|
||||
Type: "A",
|
||||
TTL: 120,
|
||||
TokenHash: tokenHash("secret"),
|
||||
Enabled: true,
|
||||
}
|
||||
srv, err := New(Config{DynamicStore: store}, client, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{"name":"www.example.org","token":"secret","address":"2001:db8::1"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if client.replacedRRSet.Name != "" {
|
||||
t.Fatalf("unexpected PowerDNS update: %#v", client.replacedRRSet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,20 @@
|
||||
<div class="alert alert-danger" role="alert">{{ .Error }}</div>
|
||||
{{ end }}
|
||||
{{ template "content" . }}
|
||||
{{ if .Flash.Token }}
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">DNS dinamico</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Fechar"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<p>{{ .Flash.Message }}</p>
|
||||
<code class="d-block text-break">{{ .Flash.Token }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -54,18 +54,7 @@
|
||||
<label class="form-label">Tipo</label>
|
||||
<select class="form-select" name="type" required>
|
||||
{{ range .RecordTypes }}
|
||||
<option
|
||||
{{
|
||||
if
|
||||
eq
|
||||
.
|
||||
$.RecordForm.Type
|
||||
}}selected{{
|
||||
end
|
||||
}}
|
||||
>
|
||||
{{ . }}
|
||||
</option>
|
||||
<option {{ if eq . $.RecordForm.Type }}selected{{ end }}>{{ . }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
@@ -89,9 +78,7 @@
|
||||
rows="8"
|
||||
placeholder="Um valor de registro por linha"
|
||||
required
|
||||
>
|
||||
{{ .RecordForm.Records }}</textarea
|
||||
>
|
||||
>{{ .RecordForm.Records }}</textarea>
|
||||
<div class="form-hint">
|
||||
Formato estrito exigido: Valores de texto TXT e CAA
|
||||
devem estar entre aspas. Adicionar registros A,
|
||||
@@ -101,6 +88,22 @@
|
||||
ser excluidos.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
name="dynamic_dns"
|
||||
type="checkbox"
|
||||
{{ if .RecordForm.DynamicDNS }}checked{{ end }}
|
||||
/>
|
||||
<span class="form-check-label">Habilitar atualizacoes de DNS dinamico</span>
|
||||
</label>
|
||||
<div class="form-hint">
|
||||
Disponivel apenas para registros A e AAAA. Ao
|
||||
salvar com esta opcao marcada, um novo token sera
|
||||
gerado e exibido uma unica vez.
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">
|
||||
{{ .RecordForm.SubmitLabel }}
|
||||
</button>
|
||||
|
||||
@@ -33,9 +33,13 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Zone.RRSets }}
|
||||
<tr>
|
||||
{{ $dynamic := dynamicRRSet $.Dynamic . }}
|
||||
<tr {{ if $dynamic }}class="table-info"{{ end }}>
|
||||
<td class="text-nowrap">{{ .Name }}</td>
|
||||
<td><span class="badge bg-azure-lt">{{ .Type }}</span></td>
|
||||
<td>
|
||||
<span class="badge bg-azure-lt">{{ .Type }}</span>
|
||||
{{ if $dynamic }}<span class="badge bg-green-lt">DNS dinamico</span>{{ end }}
|
||||
</td>
|
||||
<td>{{ .TTL }}</td>
|
||||
<td>
|
||||
<div class="record-values">
|
||||
@@ -47,6 +51,14 @@
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
<a class="btn btn-outline-primary btn-sm" href="/zones/{{ $.ZoneID }}/rrsets/edit?name={{ urlQuery .Name }}&type={{ urlQuery .Type }}">Editar</a>
|
||||
{{ if $dynamic }}
|
||||
<form method="post" action="/zones/{{ $.ZoneID }}/rrsets/dyndns/regenerate">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="name" value="{{ .Name }}">
|
||||
<input type="hidden" name="type" value="{{ .Type }}">
|
||||
<button class="btn btn-outline-success btn-sm" type="submit">Regenerar token</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
{{ if not (isSOA .Type) }}
|
||||
<form method="post" action="/zones/{{ $.ZoneID }}/rrsets/delete">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
|
||||
Reference in New Issue
Block a user