primeiro commit

This commit is contained in:
2026-06-18 22:32:42 -03:00
commit 968f4ef5d9
25 changed files with 3222 additions and 0 deletions

648
internal/server/server.go Normal file
View 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 ""
}

View 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)
}
}

View 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;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

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

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

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

View 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.&#10;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" . }}