primeiro commit
This commit is contained in:
648
internal/server/server.go
Normal file
648
internal/server/server.go
Normal file
@@ -0,0 +1,648 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pdns_admin/internal/dnsrecord"
|
||||
"pdns_admin/internal/pdns"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static
|
||||
var assets embed.FS
|
||||
|
||||
const (
|
||||
sessionCookieName = "pdns_admin_session"
|
||||
sessionTTL = 12 * time.Hour
|
||||
)
|
||||
|
||||
type PDNSClient interface {
|
||||
GetServer(context.Context) (pdns.Server, error)
|
||||
ListZones(context.Context) ([]pdns.Zone, error)
|
||||
CreateZone(context.Context, pdns.Zone) (pdns.Zone, error)
|
||||
DeleteZone(context.Context, string) error
|
||||
GetZone(context.Context, string) (pdns.Zone, error)
|
||||
CreateRRSet(context.Context, string, pdns.RRSet) error
|
||||
DeleteRRSet(context.Context, string, string, string) error
|
||||
}
|
||||
|
||||
type Authenticator interface {
|
||||
Authenticate(context.Context, string, string) (bool, error)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
Authenticator Authenticator
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
addr string
|
||||
client PDNSClient
|
||||
logger *log.Logger
|
||||
templates map[string]*template.Template
|
||||
validator *dnsrecord.Validator
|
||||
auth Authenticator
|
||||
sessions map[string]session
|
||||
sessionsM sync.Mutex
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
Error string
|
||||
AuthEnabled bool
|
||||
CurrentUser string
|
||||
Next string
|
||||
Server pdns.Server
|
||||
ZoneID string
|
||||
Zones []pdns.Zone
|
||||
Zone pdns.Zone
|
||||
RecordForm recordForm
|
||||
RecordTypes []string
|
||||
}
|
||||
|
||||
type session struct {
|
||||
Username string
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
type recordForm struct {
|
||||
Name string
|
||||
Type string
|
||||
TTL uint32
|
||||
Records string
|
||||
OriginalName string
|
||||
OriginalType string
|
||||
IsEdit bool
|
||||
IsSOA bool
|
||||
Title string
|
||||
SubmitLabel string
|
||||
}
|
||||
|
||||
func New(cfg Config, client PDNSClient, logger *log.Logger) (*Server, error) {
|
||||
if client == nil {
|
||||
return nil, fmt.Errorf("pdns client is required")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = log.Default()
|
||||
}
|
||||
if cfg.Addr == "" {
|
||||
cfg.Addr = ":8080"
|
||||
}
|
||||
recordValidator, err := dnsrecord.NewValidator()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create record validator: %w", err)
|
||||
}
|
||||
|
||||
templates := make(map[string]*template.Template)
|
||||
funcs := template.FuncMap{
|
||||
"isSOA": isSOA,
|
||||
"recordValues": recordValues,
|
||||
"urlQuery": url.QueryEscape,
|
||||
}
|
||||
for _, page := range []string{"dashboard.html", "login.html", "zones.html", "zone.html", "record_form.html"} {
|
||||
tmpl, err := template.New("base.html").Funcs(funcs).ParseFS(assets, "templates/base.html", "templates/"+page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse template %s: %w", page, err)
|
||||
}
|
||||
templates[page] = tmpl
|
||||
}
|
||||
|
||||
return &Server{
|
||||
addr: cfg.Addr,
|
||||
client: client,
|
||||
logger: logger,
|
||||
templates: templates,
|
||||
validator: recordValidator,
|
||||
auth: cfg.Authenticator,
|
||||
sessions: make(map[string]session),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListenAndServe() error {
|
||||
return http.ListenAndServe(s.addr, s.routes())
|
||||
}
|
||||
|
||||
func (s *Server) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.FileServerFS(assets))
|
||||
mux.HandleFunc("GET /healthz", s.healthz)
|
||||
mux.HandleFunc("GET /login", s.login)
|
||||
mux.HandleFunc("POST /login", s.loginPost)
|
||||
mux.HandleFunc("GET /logout", s.logout)
|
||||
mux.HandleFunc("GET /", s.dashboard)
|
||||
mux.HandleFunc("GET /zones", s.listZones)
|
||||
mux.HandleFunc("POST /zones", s.createZone)
|
||||
mux.HandleFunc("GET /zones/{zoneID}", s.showZone)
|
||||
mux.HandleFunc("POST /zones/{zoneID}/delete", s.deleteZone)
|
||||
mux.HandleFunc("GET /zones/{zoneID}/rrsets/new", s.newRRSet)
|
||||
mux.HandleFunc("GET /zones/{zoneID}/rrsets/edit", s.editRRSet)
|
||||
mux.HandleFunc("POST /zones/{zoneID}/rrsets", s.saveRRSet)
|
||||
mux.HandleFunc("POST /zones/{zoneID}/rrsets/edit", s.saveEditedRRSet)
|
||||
mux.HandleFunc("POST /zones/{zoneID}/rrsets/delete", s.deleteRRSet)
|
||||
return s.withLogging(s.withSessionAuth(mux))
|
||||
}
|
||||
|
||||
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
server, serverErr := s.client.GetServer(r.Context())
|
||||
zones, zonesErr := s.client.ListZones(r.Context())
|
||||
data := pageData{
|
||||
Title: "Dashboard",
|
||||
Server: server,
|
||||
Zones: zones,
|
||||
Error: firstNonEmpty(errorText(serverErr), errorText(zonesErr)),
|
||||
}
|
||||
s.render(w, r, "dashboard.html", data)
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth == nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, ok := s.currentUser(r); ok {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
data := pageData{
|
||||
Title: "Login",
|
||||
Error: r.URL.Query().Get("error"),
|
||||
Next: safeRedirectPath(r.URL.Query().Get("next")),
|
||||
}
|
||||
s.render(w, r, "login.html", data)
|
||||
}
|
||||
|
||||
func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth == nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/login?error="+url.QueryEscape("invalid form data"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
allowed, err := s.auth.Authenticate(r.Context(), username, password)
|
||||
if err != nil {
|
||||
s.logger.Printf("authentication failed for %q: %v", username, err)
|
||||
http.Redirect(w, r, "/login?error="+url.QueryEscape("authentication backend failed"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
http.Redirect(w, r, "/login?error="+url.QueryEscape("invalid username or password"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.createSession(username)
|
||||
if err != nil {
|
||||
s.logger.Printf("create session: %v", err)
|
||||
http.Error(w, "session creation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(sessionTTL),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
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,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) listZones(w http.ResponseWriter, r *http.Request) {
|
||||
zones, err := s.client.ListZones(r.Context())
|
||||
data := pageData{
|
||||
Title: "Zones",
|
||||
Zones: zones,
|
||||
Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)),
|
||||
}
|
||||
s.render(w, r, "zones.html", data)
|
||||
}
|
||||
|
||||
func (s *Server) createZone(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectZonesError(w, r, "invalid form data")
|
||||
return
|
||||
}
|
||||
|
||||
zoneName := dnsrecord.EnsureTrailingDot(r.FormValue("name"))
|
||||
if !dnsrecord.IsFQDN(zoneName) {
|
||||
s.redirectZonesError(w, r, "zone name must be a fully qualified domain name")
|
||||
return
|
||||
}
|
||||
|
||||
kind := strings.TrimSpace(r.FormValue("kind"))
|
||||
if !validZoneKind(kind) {
|
||||
s.redirectZonesError(w, r, "zone kind must be Native, Master, or Slave")
|
||||
return
|
||||
}
|
||||
|
||||
nameservers, err := parseFQDNLines(r.FormValue("nameservers"), "nameserver")
|
||||
if err != nil {
|
||||
s.redirectZonesError(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
masters := parseLines(r.FormValue("masters"))
|
||||
if kind == "Slave" && len(masters) == 0 {
|
||||
s.redirectZonesError(w, r, "slave zones require at least one master address")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.client.CreateZone(r.Context(), pdns.Zone{
|
||||
Name: zoneName,
|
||||
Kind: kind,
|
||||
Nameservers: nameservers,
|
||||
Masters: masters,
|
||||
}); err != nil {
|
||||
s.redirectZonesError(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/zones/"+url.PathEscape(zoneName), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request) {
|
||||
zoneID := r.PathValue("zoneID")
|
||||
if err := s.client.DeleteZone(r.Context(), zoneID); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/zones", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) showZone(w http.ResponseWriter, r *http.Request) {
|
||||
zoneID := r.PathValue("zoneID")
|
||||
zone, err := s.client.GetZone(r.Context(), zoneID)
|
||||
data := pageData{
|
||||
Title: "Zone " + zoneID,
|
||||
ZoneID: zoneID,
|
||||
Zone: zone,
|
||||
Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)),
|
||||
}
|
||||
s.render(w, r, "zone.html", data)
|
||||
}
|
||||
|
||||
func (s *Server) newRRSet(w http.ResponseWriter, r *http.Request) {
|
||||
zoneID := r.PathValue("zoneID")
|
||||
zone, err := s.client.GetZone(r.Context(), zoneID)
|
||||
data := pageData{
|
||||
Title: "Add record",
|
||||
ZoneID: zoneID,
|
||||
Zone: zone,
|
||||
Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)),
|
||||
RecordTypes: dnsrecord.SupportedTypes(),
|
||||
RecordForm: recordForm{
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
Title: "Add record",
|
||||
SubmitLabel: "Create record",
|
||||
},
|
||||
}
|
||||
s.render(w, r, "record_form.html", data)
|
||||
}
|
||||
|
||||
func (s *Server) editRRSet(w http.ResponseWriter, r *http.Request) {
|
||||
zoneID := r.PathValue("zoneID")
|
||||
name := r.URL.Query().Get("name")
|
||||
recordType := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("type")))
|
||||
zone, err := s.client.GetZone(r.Context(), zoneID)
|
||||
if err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rrset, ok := findRRSet(zone, name, recordType)
|
||||
if !ok {
|
||||
s.redirectZoneError(w, r, zoneID, "record not found")
|
||||
return
|
||||
}
|
||||
|
||||
form := recordForm{
|
||||
Name: rrset.Name,
|
||||
Type: rrset.Type,
|
||||
TTL: rrset.TTL,
|
||||
Records: recordValues(rrset),
|
||||
IsEdit: true,
|
||||
IsSOA: isSOA(rrset.Type),
|
||||
Title: "Edit record",
|
||||
SubmitLabel: "Save record",
|
||||
}
|
||||
data := pageData{
|
||||
Title: "Edit record",
|
||||
ZoneID: zoneID,
|
||||
Zone: zone,
|
||||
Error: r.URL.Query().Get("error"),
|
||||
RecordTypes: dnsrecord.SupportedTypes(),
|
||||
RecordForm: form,
|
||||
}
|
||||
s.render(w, r, "record_form.html", data)
|
||||
}
|
||||
|
||||
func (s *Server) saveRRSet(w http.ResponseWriter, r *http.Request) {
|
||||
zoneID := r.PathValue("zoneID")
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, "invalid form data")
|
||||
return
|
||||
}
|
||||
|
||||
ttl, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("ttl")), 10, 32)
|
||||
if err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, "ttl must be a positive integer")
|
||||
return
|
||||
}
|
||||
|
||||
rrset, err := s.validator.ValidateRRSet(r.FormValue("name"), r.FormValue("type"), ttl, parseLines(r.FormValue("records")))
|
||||
if err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.client.CreateRRSet(r.Context(), zoneID, rrset); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) saveEditedRRSet(w http.ResponseWriter, r *http.Request) {
|
||||
zoneID := r.PathValue("zoneID")
|
||||
name := r.URL.Query().Get("name")
|
||||
recordType := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("type")))
|
||||
if name == "" || recordType == "" {
|
||||
s.redirectZoneError(w, r, zoneID, "record identity is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, "invalid form data")
|
||||
return
|
||||
}
|
||||
|
||||
ttl, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("ttl")), 10, 32)
|
||||
if err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, "ttl must be a positive integer")
|
||||
return
|
||||
}
|
||||
|
||||
rrset, err := s.validator.ValidateRRSet(name, recordType, ttl, parseLines(r.FormValue("records")))
|
||||
if err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.client.CreateRRSet(r.Context(), zoneID, rrset); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) deleteRRSet(w http.ResponseWriter, r *http.Request) {
|
||||
zoneID := r.PathValue("zoneID")
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, "invalid form data")
|
||||
return
|
||||
}
|
||||
|
||||
name := ensureTrailingDot(r.FormValue("name"))
|
||||
recordType := strings.ToUpper(strings.TrimSpace(r.FormValue("type")))
|
||||
if name == "." || recordType == "" {
|
||||
s.redirectZoneError(w, r, zoneID, "record name and type are required")
|
||||
return
|
||||
}
|
||||
if isSOA(recordType) {
|
||||
s.redirectZoneError(w, r, zoneID, "SOA records are required for zones and cannot be deleted")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.client.DeleteRRSet(r.Context(), zoneID, name, recordType); err != nil {
|
||||
s.redirectZoneError(w, r, zoneID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/zones/"+zoneID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data pageData) {
|
||||
data.AuthEnabled = s.auth != nil
|
||||
if user, ok := s.currentUser(r); ok {
|
||||
data.CurrentUser = user
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl, ok := s.templates[name]
|
||||
if !ok {
|
||||
http.Error(w, "template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
s.logger.Printf("render %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) redirectZoneError(w http.ResponseWriter, r *http.Request, zoneID, message string) {
|
||||
http.Redirect(w, r, "/zones/"+url.PathEscape(zoneID)+"?error="+url.QueryEscape(message), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) redirectZonesError(w http.ResponseWriter, r *http.Request, message string) {
|
||||
http.Redirect(w, r, "/zones?error="+url.QueryEscape(message), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) withSessionAuth(next http.Handler) http.Handler {
|
||||
if s.auth == nil {
|
||||
return next
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isPublicPath(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if _, ok := s.currentUser(r); !ok {
|
||||
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) currentUser(r *http.Request) (string, bool) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
s.sessionsM.Lock()
|
||||
defer s.sessionsM.Unlock()
|
||||
|
||||
sess, ok := s.sessions[cookie.Value]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if time.Now().After(sess.Expires) {
|
||||
delete(s.sessions, cookie.Value)
|
||||
return "", false
|
||||
}
|
||||
return sess.Username, true
|
||||
}
|
||||
|
||||
func (s *Server) createSession(username string) (string, error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
|
||||
|
||||
s.sessionsM.Lock()
|
||||
defer s.sessionsM.Unlock()
|
||||
s.sessions[token] = session{
|
||||
Username: username,
|
||||
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 isPublicPath(path string) bool {
|
||||
return path == "/login" || path == "/logout" || 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 (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 ensureTrailingDot(value string) string {
|
||||
return dnsrecord.EnsureTrailingDot(value)
|
||||
}
|
||||
|
||||
func parseFQDNLines(raw, label string) ([]string, error) {
|
||||
values := parseLines(raw)
|
||||
for i, value := range values {
|
||||
values[i] = dnsrecord.EnsureTrailingDot(value)
|
||||
if !dnsrecord.IsFQDN(values[i]) {
|
||||
return nil, fmt.Errorf("%s %q must be a fully qualified domain name", label, value)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func validZoneKind(kind string) bool {
|
||||
switch kind {
|
||||
case "Native", "Master", "Slave":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func findRRSet(zone pdns.Zone, name, recordType string) (pdns.RRSet, bool) {
|
||||
name = dnsrecord.EnsureTrailingDot(name)
|
||||
recordType = strings.ToUpper(strings.TrimSpace(recordType))
|
||||
for _, rrset := range zone.RRSets {
|
||||
if rrset.Name == name && rrset.Type == recordType {
|
||||
return rrset, true
|
||||
}
|
||||
}
|
||||
return pdns.RRSet{}, false
|
||||
}
|
||||
|
||||
func isSOA(recordType string) bool {
|
||||
return strings.EqualFold(recordType, "SOA")
|
||||
}
|
||||
|
||||
func recordValues(rrset pdns.RRSet) string {
|
||||
values := make([]string, 0, len(rrset.Records))
|
||||
for _, record := range rrset.Records {
|
||||
values = append(values, record.Content)
|
||||
}
|
||||
return strings.Join(values, "\n")
|
||||
}
|
||||
|
||||
func errorText(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
266
internal/server/server_test.go
Normal file
266
internal/server/server_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pdns_admin/internal/pdns"
|
||||
)
|
||||
|
||||
type fakeClient struct {
|
||||
deletedRRSetType string
|
||||
createdRRSet pdns.RRSet
|
||||
}
|
||||
|
||||
type fakeAuth struct {
|
||||
allowed bool
|
||||
err error
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (f *fakeAuth) Authenticate(_ context.Context, username, password string) (bool, error) {
|
||||
f.username = username
|
||||
f.password = password
|
||||
return f.allowed, f.err
|
||||
}
|
||||
|
||||
func (fakeClient) GetServer(context.Context) (pdns.Server, error) {
|
||||
return pdns.Server{}, nil
|
||||
}
|
||||
|
||||
func (fakeClient) ListZones(context.Context) ([]pdns.Zone, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeClient) CreateZone(context.Context, pdns.Zone) (pdns.Zone, error) {
|
||||
return pdns.Zone{}, nil
|
||||
}
|
||||
|
||||
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) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet) error {
|
||||
f.createdRRSet = rrset
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) DeleteRRSet(_ context.Context, _, _, recordType string) error {
|
||||
f.deletedRRSetType = recordType
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewRequiresClient(t *testing.T) {
|
||||
if _, err := New(Config{}, nil, nil); err == nil {
|
||||
t.Fatal("expected error for missing client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBuildsServer(t *testing.T) {
|
||||
srv, err := New(Config{Addr: ":0"}, &fakeClient{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
if srv.addr != ":0" {
|
||||
t.Fatalf("unexpected addr: %s", srv.addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCanBeDisabled(t *testing.T) {
|
||||
srv, err := New(Config{}, &fakeClient{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServesEmbeddedTablerAssets(t *testing.T) {
|
||||
srv, err := New(Config{}, &fakeClient{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/static/vendor/tabler.min.css", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Tabler v1.4.0") {
|
||||
t.Fatal("expected vendored Tabler CSS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRedirectsProtectedRoutesToLogin(t *testing.T) {
|
||||
srv, err := New(Config{Authenticator: &fakeAuth{allowed: true}}, &fakeClient{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/zones", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "/login?next=%2Fzones" {
|
||||
t.Fatalf("unexpected location: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCreatesSession(t *testing.T) {
|
||||
auth := &fakeAuth{allowed: true}
|
||||
srv, err := New(Config{Authenticator: auth}, &fakeClient{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader("username=alice&password=secret&next=/zones")
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", 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)
|
||||
}
|
||||
if auth.username != "alice" || auth.password != "secret" {
|
||||
t.Fatalf("unexpected credentials: %q %q", auth.username, auth.password)
|
||||
}
|
||||
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) == 0 || cookies[0].Name != sessionCookieName {
|
||||
t.Fatalf("expected session cookie, got %#v", cookies)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/zones", nil)
|
||||
req.AddCookie(cookies[0])
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected authenticated status: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginReportsBackendErrors(t *testing.T) {
|
||||
srv, err := New(Config{Authenticator: &fakeAuth{err: errors.New("ldap down")}}, &fakeClient{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader("username=alice&password=secret")
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", 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)
|
||||
}
|
||||
if !strings.HasPrefix(rec.Header().Get("Location"), "/login?error=") {
|
||||
t.Fatalf("unexpected location: %q", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutClearsSession(t *testing.T) {
|
||||
srv, err := New(Config{Authenticator: &fakeAuth{allowed: true}}, &fakeClient{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
token, err := srv.createSession("alice")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession returned error: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
if _, ok := srv.sessions[token]; ok {
|
||||
t.Fatal("session was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLinesSkipsBlankLines(t *testing.T) {
|
||||
values := parseLines("192.0.2.1\n\n192.0.2.2\n")
|
||||
if len(values) != 2 {
|
||||
t.Fatalf("unexpected values: %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRRSetRejectsSOA(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
srv, err := New(Config{}, client, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader("name=example.org.&type=SOA")
|
||||
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)
|
||||
}
|
||||
if client.deletedRRSetType != "" {
|
||||
t.Fatalf("SOA delete reached client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
srv, err := New(Config{}, client, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader("name=evil.example.org.&type=AAAA&ttl=300&records=192.0.2.10")
|
||||
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/edit?name=www.example.org.&type=A", 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)
|
||||
}
|
||||
if client.createdRRSet.Name != "www.example.org." {
|
||||
t.Fatalf("name came from form: %#v", client.createdRRSet)
|
||||
}
|
||||
if client.createdRRSet.Type != "A" {
|
||||
t.Fatalf("type came from form: %#v", client.createdRRSet)
|
||||
}
|
||||
}
|
||||
29
internal/server/static/app.css
Normal file
29
internal/server/static/app.css
Normal file
@@ -0,0 +1,29 @@
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 10% 0%, rgba(32, 107, 196, 0.08), transparent 28rem),
|
||||
var(--tblr-bg-surface-secondary);
|
||||
}
|
||||
|
||||
.navbar-brand a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.record-values {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
max-width: 52rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.record-values code {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.btn-list form {
|
||||
margin: 0;
|
||||
}
|
||||
9
internal/server/static/vendor/tabler.min.css
vendored
Normal file
9
internal/server/static/vendor/tabler.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
internal/server/static/vendor/tabler.min.js
vendored
Normal file
13
internal/server/static/vendor/tabler.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
57
internal/server/templates/base.html
Normal file
57
internal/server/templates/base.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{{ define "layout" }}
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ .Title }} - AehooDNS</title>
|
||||
<link rel="stylesheet" href="/static/vendor/tabler.min.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body class="layout-fluid">
|
||||
<div class="page">
|
||||
<header class="navbar navbar-expand-md d-print-none">
|
||||
<div class="container-xl">
|
||||
<h1 class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
|
||||
<a href="/">AehooDNS</a>
|
||||
</h1>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbar-menu">
|
||||
<div class="navbar-nav">
|
||||
<a class="nav-link" href="/">
|
||||
<span class="nav-link-title">Dashboard</span>
|
||||
</a>
|
||||
<a class="nav-link" href="/zones">
|
||||
<span class="nav-link-title">Zones</span>
|
||||
</a>
|
||||
</div>
|
||||
{{ if .AuthEnabled }}
|
||||
{{ if .CurrentUser }}
|
||||
<div class="navbar-nav ms-auto">
|
||||
<span class="nav-link text-secondary">{{ .CurrentUser }}</span>
|
||||
<a class="nav-link" href="/logout">
|
||||
<span class="nav-link-title">Logout</span>
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="page-wrapper">
|
||||
<main class="page-body">
|
||||
<div class="container-xl">
|
||||
{{ if .Error }}
|
||||
<div class="alert alert-danger" role="alert">{{ .Error }}</div>
|
||||
{{ end }}
|
||||
{{ template "content" . }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/vendor/tabler.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
69
internal/server/templates/dashboard.html
Normal file
69
internal/server/templates/dashboard.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{{ define "content" }}
|
||||
<div class="page-header d-print-none">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">PowerDNS</div>
|
||||
<h2 class="page-title">Dashboard</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-deck row-cards mb-3">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">Server ID</div>
|
||||
<div class="h2 mb-0 text-truncate">{{ .Server.ID }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">Daemon</div>
|
||||
<div class="h2 mb-0 text-truncate">{{ .Server.DaemonType }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">Version</div>
|
||||
<div class="h2 mb-0 text-truncate">{{ .Server.Version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">Zones</div>
|
||||
<div class="h2 mb-0">{{ len .Zones }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Recent zones</h3>
|
||||
</div>
|
||||
{{ if .Zones }}
|
||||
<div class="list-group list-group-flush">
|
||||
{{ range .Zones }}
|
||||
<a class="list-group-item list-group-item-action" href="/zones/{{ .ID }}">
|
||||
<div class="row align-items-center">
|
||||
<div class="col text-truncate">
|
||||
<strong>{{ .Name }}</strong>
|
||||
<div class="text-secondary text-truncate">{{ .DisplayKind }} · serial {{ .Serial }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="card-body text-secondary">No zones returned by PowerDNS.</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ template "layout" . }}
|
||||
25
internal/server/templates/login.html
Normal file
25
internal/server/templates/login.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{{ define "content" }}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card card-md">
|
||||
<div class="card-body">
|
||||
<h2 class="h2 text-center mb-2">Login</h2>
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="next" value="{{ .Next }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input class="form-control" name="username" autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input class="form-control" name="password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100" type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ template "layout" . }}
|
||||
63
internal/server/templates/record_form.html
Normal file
63
internal/server/templates/record_form.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{{ define "content" }}
|
||||
<div class="page-header d-print-none">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">{{ .Zone.Name }}</div>
|
||||
<h2 class="page-title">{{ .RecordForm.Title }}</h2>
|
||||
</div>
|
||||
<div class="col-auto ms-auto">
|
||||
<a class="btn btn-outline-secondary" href="/zones/{{ .ZoneID }}">Back to records</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{{ if .RecordForm.IsEdit }}
|
||||
<div class="row mb-4">
|
||||
<div class="col-sm-8">
|
||||
<div class="subheader">Name</div>
|
||||
<div class="h3 mb-0">{{ .RecordForm.Name }}</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="subheader">Type</div>
|
||||
<div class="h3 mb-0">{{ .RecordForm.Type }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<form method="post" action="{{ if .RecordForm.IsEdit }}/zones/{{ .ZoneID }}/rrsets/edit?name={{ urlQuery .RecordForm.Name }}&type={{ urlQuery .RecordForm.Type }}{{ else }}/zones/{{ .ZoneID }}/rrsets{{ end }}">
|
||||
{{ if not .RecordForm.IsEdit }}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control" name="name" value="{{ .RecordForm.Name }}" placeholder="www.{{ .Zone.Name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Type</label>
|
||||
<select class="form-select" name="type" required>
|
||||
{{ range .RecordTypes }}
|
||||
<option {{ if eq . $.RecordForm.Type }}selected{{ end }}>{{ . }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">TTL</label>
|
||||
<input class="form-control" name="ttl" type="number" min="1" value="{{ .RecordForm.TTL }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Records</label>
|
||||
<textarea class="form-control" name="records" rows="8" placeholder="One record value per line" required>{{ .RecordForm.Records }}</textarea>
|
||||
<div class="form-hint">Strict formats are enforced. TXT and CAA string values must be quoted. Adding A, AAAA, CAA, MX, NS, SRV, or TXT records appends to an existing RRset with the same name and type. SOA records can be edited here but cannot be deleted.</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">{{ .RecordForm.SubmitLabel }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ template "layout" . }}
|
||||
70
internal/server/templates/zone.html
Normal file
70
internal/server/templates/zone.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{{ define "content" }}
|
||||
<div class="page-header d-print-none">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">Zone</div>
|
||||
<h2 class="page-title">{{ .Zone.Name }}</h2>
|
||||
<div class="text-secondary">{{ len .Zone.RRSets }} RRsets</div>
|
||||
</div>
|
||||
<div class="col-auto ms-auto">
|
||||
<div class="btn-list">
|
||||
<a class="btn btn-outline-secondary" href="/zones">Back to zones</a>
|
||||
<a class="btn btn-primary" href="/zones/{{ .ZoneID }}/rrsets/new">Add record</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Records</h3>
|
||||
</div>
|
||||
{{ if .Zone.RRSets }}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>TTL</th>
|
||||
<th>Records</th>
|
||||
<th class="w-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Zone.RRSets }}
|
||||
<tr>
|
||||
<td class="text-nowrap">{{ .Name }}</td>
|
||||
<td><span class="badge bg-azure-lt">{{ .Type }}</span></td>
|
||||
<td>{{ .TTL }}</td>
|
||||
<td>
|
||||
<div class="record-values">
|
||||
{{ range .Records }}
|
||||
<div><code>{{ .Content }}</code></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</td>
|
||||
<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 }}">Edit</a>
|
||||
{{ if not (isSOA .Type) }}
|
||||
<form method="post" action="/zones/{{ $.ZoneID }}/rrsets/delete">
|
||||
<input type="hidden" name="name" value="{{ .Name }}">
|
||||
<input type="hidden" name="type" value="{{ .Type }}">
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="card-body text-secondary">No records returned for this zone.</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ template "layout" . }}
|
||||
86
internal/server/templates/zones.html
Normal file
86
internal/server/templates/zones.html
Normal file
@@ -0,0 +1,86 @@
|
||||
{{ define "content" }}
|
||||
<div class="page-header d-print-none">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">Authoritative DNS</div>
|
||||
<h2 class="page-title">Zones</h2>
|
||||
<div class="text-secondary">{{ len .Zones }} zones loaded from PowerDNS.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Existing zones</h3>
|
||||
</div>
|
||||
{{ if .Zones }}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Kind</th>
|
||||
<th>Serial</th>
|
||||
<th class="w-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Zones }}
|
||||
<tr>
|
||||
<td><a href="/zones/{{ .ID }}">{{ .Name }}</a></td>
|
||||
<td><span class="badge bg-blue-lt">{{ .DisplayKind }}</span></td>
|
||||
<td>{{ .Serial }}</td>
|
||||
<td>
|
||||
<form method="post" action="/zones/{{ .ID }}/delete">
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="card-body text-secondary">No zones returned by PowerDNS.</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Add zone</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/zones">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Zone name</label>
|
||||
<input class="form-control" name="name" placeholder="example.org." required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Kind</label>
|
||||
<select class="form-select" name="kind" required>
|
||||
<option>Native</option>
|
||||
<option>Master</option>
|
||||
<option>Slave</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nameservers</label>
|
||||
<textarea class="form-control" name="nameservers" rows="4" placeholder="ns1.example.org. ns2.example.org."></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Masters</label>
|
||||
<textarea class="form-control" name="masters" rows="3" placeholder="Required for Slave zones"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Create zone</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ template "layout" . }}
|
||||
Reference in New Issue
Block a user