initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
package web
import (
"encoding/base64"
"encoding/json"
"net/http"
"strings"
)
// Flash is a one-shot notification shown after a redirect.
//
// The interface uses HTTP Basic authentication and therefore has no session to
// hang messages off, so a flash travels in a short-lived cookie that is
// cleared as soon as it is rendered. This keeps the post/redirect/get pattern
// intact: a refresh after saving never re-submits the form.
type Flash struct {
Level string `json:"l"` // success, danger, warning, info
Message string `json:"m"`
}
const flashCookie = "vibedns_flash"
// maxFlashCookie bounds the cookie so a very long error message cannot exceed
// what browsers accept.
const maxFlashCookie = 3500
// setFlash queues a message for the next page render.
func setFlash(w http.ResponseWriter, r *http.Request, level, message string) {
f := Flash{Level: level, Message: message}
raw, err := json.Marshal([]Flash{f})
if err != nil {
return
}
value := base64.RawURLEncoding.EncodeToString(raw)
if len(value) > maxFlashCookie {
short := Flash{Level: level, Message: truncate(600, message)}
raw, _ = json.Marshal([]Flash{short})
value = base64.RawURLEncoding.EncodeToString(raw)
}
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: value,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
MaxAge: 60,
})
}
// takeFlashes reads and clears any queued messages.
func takeFlashes(w http.ResponseWriter, r *http.Request) []Flash {
c, err := r.Cookie(flashCookie)
if err != nil || c.Value == "" {
return nil
}
// Clear it immediately so a refresh does not show the message twice.
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: "",
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
raw, err := base64.RawURLEncoding.DecodeString(c.Value)
if err != nil {
return nil
}
var out []Flash
if err := json.Unmarshal(raw, &out); err != nil {
return nil
}
for i := range out {
switch out[i].Level {
case "success", "danger", "warning", "info":
default:
out[i].Level = "info"
}
}
return out
}
// jsonMarshal encodes a value for embedding inside a <script> block.
//
// Go's html/template will not escape inside a script context, so the sequences
// that could terminate the element or be reinterpreted by a JavaScript parser
// are escaped here.
func jsonMarshal(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
s := string(b)
r := strings.NewReplacer(
"<", `<`,
">", `>`,
"&", `&`,
"", ``,
"", ``,
)
return []byte(r.Replace(s)), nil
}
+321
View File
@@ -0,0 +1,321 @@
package web
import (
"fmt"
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/version"
)
// handleDashboard renders the operational overview.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) error {
dash, err := s.app.Dashboard(r.Context(), 10)
if err != nil {
return err
}
dash.Version = version.Version
udp, tcp := s.app.DNS.ListenAddrs()
data := s.base(r, "Dashboard", "dashboard")
data.Data = map[string]any{
"D": dash,
"UDPAddr": udp,
"TCPAddr": tcp,
"DBPath": s.app.DB.Path(),
"Snapshot": s.app.Snapshot(),
}
s.render(w, r, "dashboard", data)
return nil
}
// handleNotFound renders the 404 page for unmatched paths.
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) error {
s.renderError(w, r, http.StatusNotFound, "That page does not exist.")
return nil
}
func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/static/img/favicon.svg", http.StatusFound)
}
// --- observability ------------------------------------------------------
// handleHealthz reports process liveness. It never touches the database, so a
// database problem does not cause an orchestrator to kill a process that could
// still be serving cached and authoritative answers.
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprintf(w, "ok\nversion=%s\nuptime=%s\n", version.Version, app.FormatDuration(s.app.Uptime()))
}
// handleReadyz reports whether the server can actually answer queries.
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := s.app.Ready(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, "not ready: %v\n", err)
return
}
fmt.Fprintln(w, "ready")
}
// handleMetrics exposes Prometheus metrics.
//
// The endpoint reveals query volumes and cache behaviour, so it requires
// authentication unless the operator has explicitly made it public — which is
// reasonable when it is bound to a private interface behind a scraper.
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
settings := s.app.Settings()
if !settings.HTTP.MetricsEnabled {
http.Error(w, "The metrics endpoint is disabled.", http.StatusNotFound)
return
}
if !settings.HTTP.MetricsPublic {
if _, err := s.app.Auth.Authenticate(r, true); err != nil {
w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q", auth.Realm))
http.Error(w, "Authentication required.", http.StatusUnauthorized)
return
}
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
s.app.Metrics.WritePrometheus(w)
}
// --- resolver -----------------------------------------------------------
func (s *Server) handleResolver(w http.ResponseWriter, r *http.Request) error {
data := s.base(r, "Resolver", "resolver")
data.Data = map[string]any{
"Settings": s.app.Settings(),
"Upstreams": s.app.Resolver.Statuses(),
"Stats": s.app.Resolver.Stats(),
"ACL": s.app.Snapshot().ACL,
}
s.render(w, r, "resolver", data)
return nil
}
func (s *Server) handleResolverTest(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
msg, err := s.app.TestUpstream(r.Context(), formString(r, "address"), formString(r, "name"))
if err != nil {
return err
}
setFlash(w, r, "success", msg)
return s.redirect(w, r, "/resolver")
}
// --- cache --------------------------------------------------------------
func (s *Server) handleCache(w http.ResponseWriter, r *http.Request) error {
search := formString(r, "q")
p := newPagination(r, 50)
entries, total := s.app.CacheEntries(search, p.PerPage, p.Offset)
data := s.base(r, "Cache", "cache")
data.Data = map[string]any{
"Stats": s.app.CacheView(),
"Entries": entries,
"Pagination": p.withTotal(total),
"Search": search,
}
s.render(w, r, "cache", data)
return nil
}
func (s *Server) handleCacheFlush(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
if name := formString(r, "name"); name != "" {
n, err := s.app.FlushCacheName(r.Context(), s.actor(r), name)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Removed %d cached entries for %s.", n, name))
return s.redirect(w, r, "/cache")
}
n, err := s.app.FlushCache(r.Context(), s.actor(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Cache flushed: %s entries removed.", humanNumber(n)))
return s.redirect(w, r, "/cache")
}
func (s *Server) handleCacheDelete(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
name := formString(r, "name")
qtype := formString(r, "type")
do := formBool(r, "dnssec")
if err := s.app.DeleteCacheEntry(r.Context(), s.actor(r), name, qtype, do); err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Removed %s %s from the cache.", strings.TrimSuffix(name, "."), strings.ToUpper(qtype)))
s.redirectBack(w, r)
return nil
}
// --- query log ----------------------------------------------------------
func (s *Server) handleQueryLog(w http.ResponseWriter, r *http.Request) error {
p := newPagination(r, 50)
f := database.QueryLogFilter{
Domain: formString(r, "domain"),
ClientIP: formString(r, "client"),
QType: formString(r, "type"),
Rcode: formString(r, "rcode"),
Source: formString(r, "source"),
Blocked: formString(r, "blocked"),
Limit: p.PerPage,
Offset: p.Offset,
}
if v := formString(r, "network"); v != "" {
if id, err := parseInt64(v); err == nil {
f.NetworkID = id
}
}
if from, ok := parseDate(formString(r, "from"), false); ok {
f.From = from
}
if to, ok := parseDate(formString(r, "to"), true); ok {
f.To = to
}
entries, total, err := s.app.QueryLogs(r.Context(), f)
if err != nil {
return err
}
networks, _ := s.app.Networks(r.Context(), "")
data := s.base(r, "Query Log", "querylog")
data.Data = map[string]any{
"Entries": entries,
"Pagination": p.withTotal(total),
"Filter": f,
"Networks": networks,
"Enabled": s.app.Settings().QueryLog.Enabled,
"Stats": s.app.QueryLog.Stats(),
}
s.render(w, r, "querylog", data)
return nil
}
func (s *Server) handleQueryLogClear(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
n, err := s.app.ClearQueryLog(r.Context(), s.actor(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Cleared %s query log rows.", humanNumber(n)))
return s.redirect(w, r, "/querylog")
}
// --- audit log ----------------------------------------------------------
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) error {
p := newPagination(r, 50)
f := database.AuditFilter{
Search: formString(r, "q"),
ObjectType: formString(r, "object"),
Source: formString(r, "source"),
Limit: p.PerPage,
Offset: p.Offset,
}
entries, total, err := s.app.AuditLogs(r.Context(), f)
if err != nil {
return err
}
data := s.base(r, "Audit Log", "audit")
data.Data = map[string]any{
"Entries": entries,
"Pagination": p.withTotal(total),
"Filter": f,
}
s.render(w, r, "audit", data)
return nil
}
// --- tools --------------------------------------------------------------
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) error {
data := s.base(r, "Tools", "tools")
// The form map is always present, even empty: the template indexes into it
// to repopulate the fields after a submission.
data.Data = map[string]any{
"Result": nil,
"Form": map[string]string{"name": "", "type": "A", "client": ""},
}
s.render(w, r, "tools", data)
return nil
}
func (s *Server) handleToolsLookup(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
name := formString(r, "name")
qtype := formString(r, "type")
client := formString(r, "client")
dnssec := formBool(r, "dnssec")
result, lookupErr := s.app.Lookup(r.Context(), name, qtype, client, dnssec)
hits, _ := s.app.LookupDomain(r.Context(), name)
data := s.base(r, "Tools", "tools")
form := map[string]string{"name": name, "type": qtype, "client": client}
if dnssec {
form["dnssec"] = "on"
}
payload := map[string]any{"Result": result, "Form": form, "ListHits": hits}
if lookupErr != nil {
payload["Error"] = app.MessageOf(lookupErr)
}
data.Data = payload
s.render(w, r, "tools", data)
return nil
}
// --- account ------------------------------------------------------------
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) error {
admin, err := s.app.Admin(r.Context())
if err != nil {
return err
}
data := s.base(r, "Account", "account")
data.Data = map[string]any{"Admin": admin}
s.render(w, r, "account", data)
return nil
}
func (s *Server) handleAccountSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
err := s.app.ChangeCredentials(r.Context(), s.actor(r),
r.FormValue("current_password"),
formString(r, "username"),
r.FormValue("new_password"),
r.FormValue("confirm_password"))
if err != nil {
return err
}
setFlash(w, r, "success",
"Credentials updated. Your browser will ask you to sign in again with the new details.")
return s.redirect(w, r, "/account")
}
+583
View File
@@ -0,0 +1,583 @@
package web
import (
"fmt"
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/models"
)
// --- Client networks ----------------------------------------------------
func (s *Server) handleNetworks(w http.ResponseWriter, r *http.Request) error {
search := formString(r, "q")
networks, err := s.app.Networks(r.Context(), search)
if err != nil {
return err
}
policies, err := s.app.Policies(r.Context())
if err != nil {
return err
}
data := s.base(r, "Client Networks", "networks")
data.Data = map[string]any{
"Networks": networks,
"Policies": policies,
"Search": search,
}
s.render(w, r, "networks", data)
return nil
}
func (s *Server) handleNetworkNew(w http.ResponseWriter, r *http.Request) error {
policies, err := s.app.Policies(r.Context())
if err != nil {
return err
}
data := s.base(r, "New Client Network", "networks")
data.Data = map[string]any{
"Network": models.Network{Enabled: true},
"Policies": policies,
"Selected": map[int64]bool{},
"IsNew": true,
"FormAction": "/policies/networks/new",
}
s.render(w, r, "network_form", data)
return nil
}
func networkInputFromForm(r *http.Request) app.NetworkInput {
enabled := formBool(r, "enabled")
return app.NetworkInput{
Name: formString(r, "name"),
CIDR: formString(r, "cidr"),
Description: formString(r, "description"),
Enabled: &enabled,
PolicyIDs: formInt64s(r, "policy_id"),
}
}
func (s *Server) handleNetworkCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
n, err := s.app.CreateNetwork(r.Context(), s.actor(r), networkInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Network %s created.", n.Name))
return s.redirect(w, r, "/policies/networks")
}
func (s *Server) handleNetworkEdit(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
network, err := s.app.Network(r.Context(), id)
if err != nil {
return err
}
policies, err := s.app.Policies(r.Context())
if err != nil {
return err
}
selected := map[int64]bool{}
for _, p := range network.Policies {
selected[p.ID] = true
}
data := s.base(r, network.Name, "networks")
data.Data = map[string]any{
"Network": network,
"Policies": policies,
"Selected": selected,
"IsNew": false,
"FormAction": fmt.Sprintf("/policies/networks/%d", id),
}
s.render(w, r, "network_form", data)
return nil
}
func (s *Server) handleNetworkUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
n, err := s.app.UpdateNetwork(r.Context(), s.actor(r), id, networkInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Network %s saved.", n.Name))
return s.redirect(w, r, "/policies/networks")
}
func (s *Server) handleNetworkToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetNetworkEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
setFlash(w, r, "success", "Network "+enabledWord(enabled)+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handleNetworkDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeleteNetwork(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Network deleted.")
return s.redirect(w, r, "/policies/networks")
}
func enabledWord(enabled bool) string {
if enabled {
return "enabled"
}
return "disabled"
}
// --- Policies -----------------------------------------------------------
func (s *Server) handlePolicies(w http.ResponseWriter, r *http.Request) error {
policies, err := s.app.Policies(r.Context())
if err != nil {
return err
}
data := s.base(r, "Policy Rules", "policies")
data.Data = map[string]any{"Policies": policies}
s.render(w, r, "policies", data)
return nil
}
func (s *Server) handlePolicyNew(w http.ResponseWriter, r *http.Request) error {
lists, err := s.app.DomainLists(r.Context(), "", "")
if err != nil {
return err
}
data := s.base(r, "New Policy", "policies")
data.Data = map[string]any{
"Policy": models.Policy{
Enabled: true, BlockAction: models.BlockNXDOMAIN, BlockTTL: 60,
SinkholeIPv4: "0.0.0.0", SinkholeIPv6: "::",
},
"Blacklists": filterLists(lists, models.KindBlacklist),
"Allowlists": filterLists(lists, models.KindAllowlist),
"Selected": map[int64]bool{},
"IsNew": true,
"FormAction": "/policies/rules/new",
}
s.render(w, r, "policy_form", data)
return nil
}
func filterLists(lists []models.DomainList, kind string) []models.DomainList {
var out []models.DomainList
for _, l := range lists {
if l.Kind == kind {
out = append(out, l)
}
}
return out
}
func policyInputFromForm(r *http.Request) app.PolicyInput {
enabled := formBool(r, "enabled")
return app.PolicyInput{
Name: formString(r, "name"),
Description: formString(r, "description"),
Enabled: &enabled,
BlockAction: formString(r, "block_action"),
SinkholeIPv4: formString(r, "sinkhole_ipv4"),
SinkholeIPv6: formString(r, "sinkhole_ipv6"),
BlockTTL: formUint32(r, "block_ttl", 0),
ListIDs: formInt64s(r, "list_id"),
}
}
func (s *Server) handlePolicyCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
p, err := s.app.CreatePolicy(r.Context(), s.actor(r), policyInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Policy %s created.", p.Name))
return s.redirect(w, r, "/policies")
}
func (s *Server) handlePolicyEdit(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
policy, err := s.app.Policy(r.Context(), id)
if err != nil {
return err
}
lists, err := s.app.DomainLists(r.Context(), "", "")
if err != nil {
return err
}
selected := map[int64]bool{}
for _, lid := range policy.BlacklistIDs {
selected[lid] = true
}
for _, lid := range policy.AllowlistIDs {
selected[lid] = true
}
data := s.base(r, policy.Name, "policies")
data.Data = map[string]any{
"Policy": policy,
"Blacklists": filterLists(lists, models.KindBlacklist),
"Allowlists": filterLists(lists, models.KindAllowlist),
"Selected": selected,
"IsNew": false,
"FormAction": fmt.Sprintf("/policies/rules/%d", id),
}
s.render(w, r, "policy_form", data)
return nil
}
func (s *Server) handlePolicyUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
p, err := s.app.UpdatePolicy(r.Context(), s.actor(r), id, policyInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Policy %s saved.", p.Name))
return s.redirect(w, r, "/policies")
}
func (s *Server) handlePolicyToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetPolicyEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
setFlash(w, r, "success", "Policy "+enabledWord(enabled)+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handlePolicyDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeletePolicy(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Policy deleted.")
return s.redirect(w, r, "/policies")
}
// --- Domain lists -------------------------------------------------------
func (s *Server) handleBlacklists(w http.ResponseWriter, r *http.Request) error {
return s.renderLists(w, r, models.KindBlacklist, "Blacklists", "blacklists")
}
func (s *Server) handleAllowlists(w http.ResponseWriter, r *http.Request) error {
return s.renderLists(w, r, models.KindAllowlist, "Allowlists", "allowlists")
}
func (s *Server) renderLists(w http.ResponseWriter, r *http.Request, kind, title, nav string) error {
search := formString(r, "q")
lists, err := s.app.DomainLists(r.Context(), kind, search)
if err != nil {
return err
}
data := s.base(r, title, nav)
data.Data = map[string]any{
"Lists": lists,
"Kind": kind,
"Search": search,
"IsBlacklist": kind == models.KindBlacklist,
}
s.render(w, r, "lists", data)
return nil
}
func (s *Server) handleListCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
in := app.ListInput{
Kind: formString(r, "kind"),
Name: formString(r, "name"),
Description: formString(r, "description"),
SourceURL: formString(r, "source_url"),
Enabled: &enabled,
}
l, err := s.app.CreateDomainList(r.Context(), s.actor(r), in)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("%s created.", l.Name))
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", l.ID))
}
func (s *Server) handleListDetail(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
list, err := s.app.DomainList(r.Context(), id)
if err != nil {
return err
}
p := newPagination(r, 100)
search := formString(r, "q")
entries, total, err := s.app.DomainEntries(r.Context(), id, search, p.PerPage, p.Offset)
if err != nil {
return err
}
nav := "blacklists"
if list.Kind == models.KindAllowlist {
nav = "allowlists"
}
data := s.base(r, list.Name, nav)
data.Data = map[string]any{
"List": list,
"Entries": entries,
"Pagination": p.withTotal(total),
"Search": search,
}
s.render(w, r, "list_detail", data)
return nil
}
func (s *Server) handleListUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
in := app.ListInput{
Name: formString(r, "name"),
Description: formString(r, "description"),
SourceURL: formString(r, "source_url"),
Enabled: &enabled,
}
l, err := s.app.UpdateDomainList(r.Context(), s.actor(r), id, in)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("%s saved.", l.Name))
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id))
}
func (s *Server) handleListToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetDomainListEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
setFlash(w, r, "success", "List "+enabledWord(enabled)+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handleListDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
list, err := s.app.DomainList(r.Context(), id)
if err != nil {
return err
}
if err := s.app.DeleteDomainList(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("%s deleted.", list.Name))
target := "/policies/blacklists"
if list.Kind == models.KindAllowlist {
target = "/policies/allowlists"
}
return s.redirect(w, r, target)
}
func (s *Server) handleListClear(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
n, err := s.app.ClearDomains(r.Context(), s.actor(r), id)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Removed %s domains.", humanNumber(n)))
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id))
}
// handleListImport accepts a pasted list or an uploaded file.
func (s *Server) handleListImport(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
maxMB := s.app.Settings().HTTP.MaxUploadMB
if err := parseMultipart(r, 8); err != nil {
return err
}
matchSubdomains := formBool(r, "match_subdomains")
pasted := r.FormValue("content")
var reader = strings.NewReader(pasted)
if file, header, ferr := r.FormFile("file"); ferr == nil {
defer file.Close()
// The upload is streamed straight into the parser rather than being
// buffered as a string: blocklists routinely run to tens of megabytes.
summary, err := s.app.ImportDomains(r.Context(), s.actor(r), id, file, matchSubdomains)
if err != nil {
return err
}
s.flashImportSummary(w, r, header.Filename, summary)
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id))
}
if strings.TrimSpace(pasted) == "" {
return app.Invalid("Paste a list of domains or choose a file to upload (up to %d MB).", maxMB)
}
summary, err := s.app.ImportDomains(r.Context(), s.actor(r), id, reader, matchSubdomains)
if err != nil {
return err
}
s.flashImportSummary(w, r, "", summary)
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id))
}
// flashImportSummary reports exactly what the import did, which is the only
// way an operator can tell a 300,000 line file was handled correctly.
func (s *Server) flashImportSummary(w http.ResponseWriter, r *http.Request, filename string, sum models.ImportSummary) {
var b strings.Builder
if filename != "" {
fmt.Fprintf(&b, "Imported %s: ", filename)
} else {
b.WriteString("Import complete: ")
}
fmt.Fprintf(&b, "%s lines processed, %s domains added",
humanNumber(sum.LinesProcessed), humanNumber(sum.Imported))
if sum.Duplicates > 0 {
fmt.Fprintf(&b, ", %s duplicates skipped", humanNumber(sum.Duplicates))
}
if sum.Invalid > 0 {
fmt.Fprintf(&b, ", %s invalid entries", humanNumber(sum.Invalid))
}
if sum.Ignored > 0 {
fmt.Fprintf(&b, ", %s comments or blank lines ignored", humanNumber(sum.Ignored))
}
b.WriteString(".")
setFlash(w, r, "success", b.String())
}
func (s *Server) handleListExport(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
list, err := s.app.DomainList(r.Context(), id)
if err != nil {
return err
}
filename := strings.ReplaceAll(strings.ToLower(list.Name), " ", "-") + ".txt"
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
// Streamed straight to the client: a large list must not be buffered.
if _, err := s.app.ExportDomains(r.Context(), id, w); err != nil {
s.log.Error("list export failed mid-stream", "list", list.Name, "error", err)
}
return nil
}
func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
entry, err := s.app.AddDomain(r.Context(), s.actor(r), id,
formString(r, "domain"), formBool(r, "match_subdomains"), formString(r, "comment"))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Added %s.", entry.Domain))
s.redirectBack(w, r)
return nil
}
func (s *Server) handleDomainDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeleteDomain(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Domain removed.")
s.redirectBack(w, r)
return nil
}
+165
View File
@@ -0,0 +1,165 @@
package web
import (
"fmt"
"io"
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/validate"
)
// recordInputFromForm assembles a record from either the type-specific editor
// (field_* inputs) or the advanced raw editor.
func recordInputFromForm(r *http.Request) app.RecordInput {
enabled := formBool(r, "enabled")
in := app.RecordInput{
Name: formString(r, "name"),
Type: formString(r, "type"),
TTL: formUint32Ptr(r, "ttl"),
Enabled: &enabled,
Comment: formString(r, "comment"),
}
if formBool(r, "advanced") {
// The advanced editor supplies rdata verbatim, and may override the
// record type with one that has no dedicated editor.
in.Data = formString(r, "data")
if t := formString(r, "field_rtype"); t != "" {
in.Type = t
}
if d := formString(r, "field_rdata"); d != "" {
in.Data = d
}
return in
}
fields := map[string]string{}
for key, values := range r.Form {
if !strings.HasPrefix(key, "field_") || len(values) == 0 {
continue
}
fields[strings.TrimPrefix(key, "field_")] = values[0]
}
if len(fields) > 0 {
in.Fields = fields
} else {
in.Data = formString(r, "data")
}
return in
}
func (s *Server) handleRecordCreate(w http.ResponseWriter, r *http.Request) error {
zoneID, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
rec, err := s.app.CreateRecord(r.Context(), s.actor(r), zoneID, recordInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Added %s %s record.", rec.Name, rec.Type))
s.redirectBack(w, r)
return nil
}
func (s *Server) handleRecordUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
rec, err := s.app.UpdateRecord(r.Context(), s.actor(r), id, recordInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Saved %s %s record.", rec.Name, rec.Type))
s.redirectBack(w, r)
return nil
}
func (s *Server) handleRecordDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeleteRecord(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Record deleted.")
s.redirectBack(w, r)
return nil
}
func (s *Server) handleRecordToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetRecordEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
state := "disabled"
if enabled {
state = "enabled"
}
setFlash(w, r, "success", "Record "+state+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handleRecordBulk(w http.ResponseWriter, r *http.Request) error {
zoneID, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
ids := formInt64s(r, "record_id")
action := app.BulkAction(formString(r, "action"))
n, err := s.app.BulkRecords(r.Context(), s.actor(r), zoneID, ids, action)
if err != nil {
return err
}
verb := map[app.BulkAction]string{
app.BulkDelete: "deleted",
app.BulkEnable: "enabled",
app.BulkDisable: "disabled",
}[action]
setFlash(w, r, "success", fmt.Sprintf("%d record%s %s.", n, plural(n), verb))
s.redirectBack(w, r)
return nil
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
// recordFieldValues splits a stored record back into editor fields, so an
// existing record opens in its dedicated form rather than as raw rdata.
func recordFieldValues(rtype, data string) map[string]string {
return validate.SplitRData(rtype, data)
}
// copyLimited copies at most n bytes, reporting an error past the limit.
func copyLimited(dst io.Writer, src io.Reader, n int64) (int64, error) {
return io.Copy(dst, io.LimitReader(src, n))
}
+398
View File
@@ -0,0 +1,398 @@
package web
import (
"fmt"
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/config"
)
// settingsPage renders one settings tab with the current configuration.
func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request, page, subnav, title string, extra map[string]any) {
data := s.base(r, title, "settings")
data.Subnav = subnav
payload := map[string]any{"S": s.app.Settings()}
for k, v := range extra {
payload[k] = v
}
data.Data = payload
s.render(w, r, page, data)
}
// --- DNS ----------------------------------------------------------------
func (s *Server) handleSettingsDNS(w http.ResponseWriter, r *http.Request) error {
udp, tcp := s.app.DNS.ListenAddrs()
s.settingsPage(w, r, "settings_dns", "dns", "DNS Settings", map[string]any{
"BoundUDP": udp,
"BoundTCP": tcp,
"Running": s.app.DNS.Running(),
})
return nil
}
func (s *Server) handleSettingsDNSSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.DNS.UDPListen = formString(r, "udp_listen")
next.DNS.TCPListen = formString(r, "tcp_listen")
next.DNS.Recursion = formBool(r, "recursion")
next.DNS.EDNSEnabled = formBool(r, "edns_enabled")
next.DNS.EDNSUDPSize = formInt(r, "edns_udp_size", next.DNS.EDNSUDPSize)
next.DNS.MaxUDPResponse = formInt(r, "max_udp_response", next.DNS.MaxUDPResponse)
next.DNS.DefaultTTL = formUint32(r, "default_ttl", next.DNS.DefaultTTL)
next.DNS.TCPIdleSeconds = formInt(r, "tcp_idle", next.DNS.TCPIdleSeconds)
next.DNS.ExposeVersion = formBool(r, "expose_version")
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupDNS, next); err != nil {
return err
}
setFlash(w, r, "success", "DNS settings saved.")
return s.redirect(w, r, "/settings/dns")
}
// --- Resolver -----------------------------------------------------------
func (s *Server) handleSettingsResolver(w http.ResponseWriter, r *http.Request) error {
s.settingsPage(w, r, "settings_resolver", "resolver", "Resolver Settings", map[string]any{
"Upstreams": s.app.Resolver.Statuses(),
"Strategies": []string{
config.StrategyFastest, config.StrategySequential,
config.StrategyRoundRobin, config.StrategyRandom,
},
})
return nil
}
func (s *Server) handleSettingsResolverSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.Resolver.Upstreams = config.SplitLines(r.FormValue("upstreams"))
next.Resolver.TimeoutMS = formInt(r, "timeout_ms", next.Resolver.TimeoutMS)
next.Resolver.Retries = formInt(r, "retries", next.Resolver.Retries)
next.Resolver.Strategy = formString(r, "strategy")
next.Resolver.AllowNetworks = config.SplitLines(r.FormValue("allow_networks"))
next.Resolver.DenyNetworks = config.SplitLines(r.FormValue("deny_networks"))
next.Resolver.PreferIPv6 = formBool(r, "prefer_ipv6")
next.Resolver.DNSSEC = formBool(r, "dnssec")
next.Resolver.MaxConcurrent = formInt(r, "max_concurrent", next.Resolver.MaxConcurrent)
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupResolver, next); err != nil {
return err
}
setFlash(w, r, "success", "Resolver settings saved and applied.")
return s.redirect(w, r, "/settings/resolver")
}
// --- Cache --------------------------------------------------------------
func (s *Server) handleSettingsCache(w http.ResponseWriter, r *http.Request) error {
s.settingsPage(w, r, "settings_cache", "cache", "Cache Settings", map[string]any{
"Stats": s.app.CacheStats(),
})
return nil
}
func (s *Server) handleSettingsCacheSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.Cache.Enabled = formBool(r, "enabled")
next.Cache.MaxEntries = formInt(r, "max_entries", next.Cache.MaxEntries)
next.Cache.MinTTL = formInt(r, "min_ttl", next.Cache.MinTTL)
next.Cache.MaxTTL = formInt(r, "max_ttl", next.Cache.MaxTTL)
next.Cache.NegativeTTL = formInt(r, "negative_ttl", next.Cache.NegativeTTL)
next.Cache.ServeStale = formBool(r, "serve_stale")
next.Cache.StaleTTL = formInt(r, "stale_ttl", next.Cache.StaleTTL)
next.Cache.Prefetch = formBool(r, "prefetch")
next.Cache.PrefetchPercent = formInt(r, "prefetch_pct", next.Cache.PrefetchPercent)
next.Cache.CleanupSeconds = formInt(r, "cleanup_seconds", next.Cache.CleanupSeconds)
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupCache, next); err != nil {
return err
}
setFlash(w, r, "success", "Cache settings saved and applied.")
return s.redirect(w, r, "/settings/cache")
}
// --- Logging ------------------------------------------------------------
func (s *Server) handleSettingsLogging(w http.ResponseWriter, r *http.Request) error {
rows, _ := s.app.DB.QueryLogCount(r.Context())
s.settingsPage(w, r, "settings_logging", "logging", "Logging Settings", map[string]any{
"Stats": s.app.QueryLog.Stats(),
"QueryLogRows": rows,
})
return nil
}
func (s *Server) handleSettingsLoggingSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.QueryLog.Enabled = formBool(r, "querylog_enabled")
next.QueryLog.RetentionDays = formInt(r, "retention_days", next.QueryLog.RetentionDays)
next.QueryLog.MaxRows = formInt(r, "max_rows", next.QueryLog.MaxRows)
next.QueryLog.CleanupMinutes = formInt(r, "cleanup_minutes", next.QueryLog.CleanupMinutes)
next.QueryLog.IgnoreNetworks = config.SplitLines(r.FormValue("ignore_networks"))
next.QueryLog.IgnoreDomains = config.SplitLines(r.FormValue("ignore_domains"))
next.Logging.Level = formString(r, "log_level")
next.Logging.Format = formString(r, "log_format")
next.Logging.AuditMaxRows = formInt(r, "audit_max_rows", next.Logging.AuditMaxRows)
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupLogging, next); err != nil {
return err
}
setFlash(w, r, "success", "Logging settings saved and applied.")
return s.redirect(w, r, "/settings/logging")
}
// --- HTTP and rate limiting ---------------------------------------------
func (s *Server) handleSettingsHTTP(w http.ResponseWriter, r *http.Request) error {
s.settingsPage(w, r, "settings_http", "http", "Web Server Settings", map[string]any{
"RateLimit": s.app.Limiter.Stats(),
"Bound": s.app.Boot.HTTPAddr,
})
return nil
}
func (s *Server) handleSettingsHTTPSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.HTTP.Listen = formString(r, "listen")
next.HTTP.BaseURL = formString(r, "base_url")
next.HTTP.TrustedProxies = config.SplitLines(r.FormValue("trusted_proxies"))
next.HTTP.MetricsEnabled = formBool(r, "metrics_enabled")
next.HTTP.MetricsPublic = formBool(r, "metrics_public")
next.HTTP.MaxUploadMB = formInt(r, "max_upload_mb", next.HTTP.MaxUploadMB)
next.HTTP.RateLimitPerMin = formInt(r, "http_rate_limit", next.HTTP.RateLimitPerMin)
next.RateLimit.Enabled = formBool(r, "dns_ratelimit_enabled")
next.RateLimit.QPS = formInt(r, "dns_qps", next.RateLimit.QPS)
next.RateLimit.Burst = formInt(r, "dns_burst", next.RateLimit.Burst)
next.RateLimit.ExemptNetworks = config.SplitLines(r.FormValue("exempt_networks"))
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupHTTP, next); err != nil {
return err
}
setFlash(w, r, "success", "Web server settings saved.")
return s.redirect(w, r, "/settings/http")
}
// --- Database and backups -----------------------------------------------
func (s *Server) handleSettingsDatabase(w http.ResponseWriter, r *http.Request) error {
stats, err := s.app.DatabaseStats(r.Context())
if err != nil {
return err
}
backups, err := s.app.BackupList()
if err != nil {
// A missing or unreadable directory should not hide the whole page.
s.log.Warn("could not list backups", "error", err)
}
migrations, _ := s.app.DB.MigrationStatuses(r.Context())
s.settingsPage(w, r, "settings_database", "database", "Database Settings", map[string]any{
"DBStats": stats,
"Backups": backups,
"BackupStatus": s.app.BackupStatus(),
"Migrations": migrations,
"PendingRestore": s.app.PendingRestore(),
})
return nil
}
func (s *Server) handleSettingsDatabaseSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.Backup.Enabled = formBool(r, "backup_enabled")
next.Backup.Directory = formString(r, "backup_dir")
next.Backup.IntervalHours = formInt(r, "interval_hours", next.Backup.IntervalHours)
next.Backup.Retention = formInt(r, "retention", next.Backup.Retention)
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupBackup, next); err != nil {
return err
}
setFlash(w, r, "success", "Backup settings saved.")
return s.redirect(w, r, "/settings/database")
}
func (s *Server) handleBackupNow(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
info, err := s.app.RunBackup(r.Context(), s.actor(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Backup %s created (%s).", info.Name, humanBytes(info.SizeBytes)))
return s.redirect(w, r, "/settings/database")
}
func (s *Server) handleBackupDownload(w http.ResponseWriter, r *http.Request) error {
name := r.PathValue("name")
f, info, err := s.app.OpenBackup(name)
if err != nil {
return err
}
defer f.Close()
w.Header().Set("Content-Type", "application/vnd.sqlite3")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", info.Name))
w.Header().Set("Content-Length", fmt.Sprint(info.SizeBytes))
if _, err := copyLimited(w, f, info.SizeBytes); err != nil {
s.log.Warn("backup download interrupted", "backup", info.Name, "error", err)
}
return nil
}
func (s *Server) handleBackupDelete(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
name := r.PathValue("name")
if err := s.app.DeleteBackup(r.Context(), s.actor(r), name); err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Backup %s deleted.", name))
return s.redirect(w, r, "/settings/database")
}
func (s *Server) handleBackupRestore(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
name := r.PathValue("name")
// Restoring replaces every zone, record and policy. Requiring the file
// name to be retyped makes it very hard to do by accident.
if formString(r, "confirm") != name {
return app.Invalid("Type the backup file name exactly (%s) to confirm the restore.", name)
}
if err := s.app.StageRestore(r.Context(), s.actor(r), name); err != nil {
return err
}
setFlash(w, r, "warning", fmt.Sprintf(
"Backup %s is staged. It replaces the live database the next time this server starts. "+
"Restart now to apply it, or cancel below.", name))
return s.redirect(w, r, "/settings/database")
}
func (s *Server) handleRestoreCancel(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
if err := s.app.CancelRestore(r.Context(), s.actor(r)); err != nil {
return err
}
setFlash(w, r, "success", "The staged restore was cancelled.")
return s.redirect(w, r, "/settings/database")
}
// --- API tokens and configuration transfer ------------------------------
func (s *Server) handleSettingsAPI(w http.ResponseWriter, r *http.Request) error {
tokens, err := s.app.APITokens(r.Context())
if err != nil {
return err
}
// A token secret is shown exactly once, immediately after creation, via a
// single-use flash carried across the redirect.
s.settingsPage(w, r, "settings_api", "api", "API Settings", map[string]any{
"Tokens": tokens,
"BaseURL": s.app.Settings().HTTP.BaseURL,
})
return nil
}
func (s *Server) handleTokenCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
tok, err := s.app.CreateAPIToken(r.Context(), s.actor(r), formString(r, "name"), formString(r, "description"))
if err != nil {
return err
}
setFlash(w, r, "warning", fmt.Sprintf(
"Token %q created. Copy it now, it is not shown again: %s", tok.Name, tok.Secret))
return s.redirect(w, r, "/settings/api")
}
func (s *Server) handleTokenToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetAPITokenEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
setFlash(w, r, "success", "Token "+enabledWord(enabled)+".")
return s.redirect(w, r, "/settings/api")
}
func (s *Server) handleTokenDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeleteAPIToken(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Token revoked.")
return s.redirect(w, r, "/settings/api")
}
func (s *Server) handleConfigExport(w http.ResponseWriter, r *http.Request) error {
includeDomains := formBool(r, "include_domains")
filename := fmt.Sprintf("vibedns-config-%s.json", s.app.StartedAt().UTC().Format("20060102"))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
return s.app.WriteConfigExport(r.Context(), w, includeDomains)
}
func (s *Server) handleConfigImport(w http.ResponseWriter, r *http.Request) error {
if err := parseMultipart(r, 16); err != nil {
return err
}
file, _, ferr := r.FormFile("file")
if ferr != nil {
return app.Invalid("Choose a configuration export file to import.")
}
defer file.Close()
report, err := s.app.ImportConfig(r.Context(), s.actor(r), file, formBool(r, "apply_settings"))
if err != nil {
return err
}
msg := fmt.Sprintf("Imported %d zones, %d records, %d networks, %d policies, %d lists and %s domains.",
report.Zones, report.Records, report.Networks, report.Policies, report.Lists, humanNumber(report.Domains))
if len(report.Conflicts) > 0 {
msg += " Existing objects were left unchanged: " + strings.Join(report.Conflicts, "; ") + "."
}
setFlash(w, r, "success", msg)
return s.redirect(w, r, "/settings/database")
}
+336
View File
@@ -0,0 +1,336 @@
package web
import (
"fmt"
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
"github.com/owen/vibedns/internal/zonefile"
)
func (s *Server) handleZones(w http.ResponseWriter, r *http.Request) error {
return s.renderZoneList(w, r, "forward", "Forward Zones", "zones")
}
func (s *Server) handleZonesReverse(w http.ResponseWriter, r *http.Request) error {
return s.renderZoneList(w, r, "reverse", "Reverse Zones", "zones-reverse")
}
func (s *Server) renderZoneList(w http.ResponseWriter, r *http.Request, kind, title, nav string) error {
search := formString(r, "q")
zones, err := s.app.Zones(r.Context(), database.ZoneFilter{Kind: kind, Search: search})
if err != nil {
return err
}
data := s.base(r, title, nav)
data.Data = map[string]any{
"Zones": zones,
"Kind": kind,
"Search": search,
"Reverse": kind == "reverse",
}
s.render(w, r, "zones", data)
return nil
}
func (s *Server) handleZoneNew(w http.ResponseWriter, r *http.Request) error {
kind := formString(r, "kind")
if kind == "" {
kind = "forward"
}
data := s.base(r, "New Zone", navForKind(kind))
data.Data = map[string]any{
"Zone": models.Zone{DefaultTTL: s.app.Settings().DNS.DefaultTTL, Enabled: true, AutoSerial: true, Refresh: 7200, Retry: 3600, Expire: 1209600, Minimum: 3600},
"Kind": kind,
"IsNew": true,
"FormAction": "/zones/new",
}
s.render(w, r, "zone_form", data)
return nil
}
func navForKind(kind string) string {
if strings.HasPrefix(kind, "reverse") {
return "zones-reverse"
}
return "zones"
}
func (s *Server) handleZoneCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
in := zoneInputFromForm(r)
zone, err := s.app.CreateZone(r.Context(), s.actor(r), in)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Zone %s created.", strings.TrimSuffix(zone.Name, ".")))
return s.redirect(w, r, fmt.Sprintf("/zones/%d", zone.ID))
}
func zoneInputFromForm(r *http.Request) app.ZoneInput {
enabled := formBool(r, "enabled")
autoSerial := formBool(r, "auto_serial")
in := app.ZoneInput{
Name: formString(r, "name"),
Kind: formString(r, "kind"),
CIDR: formString(r, "cidr"),
Description: formString(r, "description"),
Enabled: &enabled,
DefaultTTL: formUint32(r, "default_ttl", 0),
PrimaryNS: formString(r, "primary_ns"),
AdminEmail: formString(r, "admin_email"),
Refresh: formUint32(r, "refresh", 0),
Retry: formUint32(r, "retry", 0),
Expire: formUint32(r, "expire", 0),
Minimum: formUint32(r, "minimum", 0),
AutoSerial: &autoSerial,
}
// A serial is only taken from the form when the operator asked to override
// it, so a normal save never rewinds an automatically managed serial.
if formBool(r, "override_serial") {
in.Serial = formUint32Ptr(r, "serial")
}
return in
}
func (s *Server) handleZoneEdit(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
zone, err := s.app.Zone(r.Context(), id)
if err != nil {
return err
}
data := s.base(r, "Edit "+strings.TrimSuffix(zone.Name, "."), navForKind(string(zone.Kind)))
data.Data = map[string]any{
"Zone": zone,
"Kind": string(zone.Kind),
"IsNew": false,
"FormAction": fmt.Sprintf("/zones/%d/edit", id),
}
s.render(w, r, "zone_form", data)
return nil
}
func (s *Server) handleZoneUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
zone, err := s.app.UpdateZone(r.Context(), s.actor(r), id, zoneInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Zone %s saved.", strings.TrimSuffix(zone.Name, ".")))
return s.redirect(w, r, fmt.Sprintf("/zones/%d", zone.ID))
}
func (s *Server) handleZoneToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetZoneEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
state := "disabled"
if enabled {
state = "enabled"
}
setFlash(w, r, "success", "Zone "+state+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handleZoneDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
zone, err := s.app.Zone(r.Context(), id)
if err != nil {
return err
}
if err := s.app.DeleteZone(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Zone %s and its records were deleted.", strings.TrimSuffix(zone.Name, ".")))
target := "/zones"
if zone.Kind != models.ZoneForward {
target = "/zones/reverse"
}
return s.redirect(w, r, target)
}
func (s *Server) handleZoneClone(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
clone, err := s.app.CloneZone(r.Context(), s.actor(r), id, formString(r, "name"), formString(r, "description"))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Zone cloned to %s.", strings.TrimSuffix(clone.Name, ".")))
return s.redirect(w, r, fmt.Sprintf("/zones/%d", clone.ID))
}
func (s *Server) handleZoneExport(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
zone, body, err := s.app.ExportZoneFile(r.Context(), id)
if err != nil {
return err
}
filename := zonefile.SuggestFilename(zone.Name)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.Header().Set("Content-Length", fmt.Sprint(len(body)))
_, _ = w.Write(body)
return nil
}
func (s *Server) handleZoneImport(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseMultipart(r, s.app.Settings().HTTP.MaxUploadMB); err != nil {
return err
}
mode := app.ImportMode(formString(r, "mode"))
body := formString(r, "content")
var reader = strings.NewReader(body)
if file, header, ferr := r.FormFile("file"); ferr == nil {
defer file.Close()
buf := new(strings.Builder)
if _, err := copyLimited(buf, file, int64(s.app.Settings().HTTP.MaxUploadMB)*1024*1024); err != nil {
return app.Invalid("The uploaded file %q could not be read.", header.Filename)
}
reader = strings.NewReader(buf.String())
} else if strings.TrimSpace(body) == "" {
return app.Invalid("Choose a zone file to upload, or paste its contents.")
}
result, err := s.app.ImportZoneFile(r.Context(), s.actor(r), id, reader, mode)
if err != nil {
return err
}
msg := fmt.Sprintf("Imported %d records into %s.",
result.Summary.RecordsParsed, strings.TrimSuffix(result.Zone.Name, "."))
if result.Summary.Skipped > 0 {
msg += fmt.Sprintf(" %d entries were skipped.", result.Summary.Skipped)
}
setFlash(w, r, "success", msg)
return s.redirect(w, r, fmt.Sprintf("/zones/%d", id))
}
// handleZoneRecords renders one zone's record table.
func (s *Server) handleZoneRecords(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
zone, err := s.app.Zone(r.Context(), id)
if err != nil {
return err
}
p := newPagination(r, 100)
f := database.RecordFilter{
ZoneID: id,
Search: formString(r, "q"),
Type: formString(r, "type"),
Enabled: formString(r, "status"),
Limit: p.PerPage,
Offset: p.Offset,
}
records, total, err := s.app.Records(r.Context(), f)
if err != nil {
return err
}
types, _ := s.app.RecordTypesInUse(r.Context(), id)
data := s.base(r, strings.TrimSuffix(zone.Name, "."), navForKind(string(zone.Kind)))
data.Data = map[string]any{
"Zone": zone,
"Records": records,
"Pagination": p.withTotal(total),
"Filter": f,
"TypesInUse": types,
"RecordTypes": s.app.RecordTypes(),
"Problems": s.app.Runtime.ZoneProblems(id),
"CommonTypes": commonTypes(s.app.RecordTypes()),
}
s.render(w, r, "records", data)
return nil
}
func commonTypes(all []validate.TypeInfo) []validate.TypeInfo {
var out []validate.TypeInfo
for _, t := range all {
if t.Common {
out = append(out, t)
}
}
return out
}
// handleRecordsAll is the cross-zone record search.
func (s *Server) handleRecordsAll(w http.ResponseWriter, r *http.Request) error {
p := newPagination(r, 50)
f := database.RecordFilter{
Search: formString(r, "q"),
Type: formString(r, "type"),
Enabled: formString(r, "status"),
Limit: p.PerPage,
Offset: p.Offset,
}
if v := formString(r, "zone"); v != "" {
if id, err := parseInt64(v); err == nil {
f.ZoneID = id
}
}
records, total, err := s.app.Records(r.Context(), f)
if err != nil {
return err
}
zones, _ := s.app.Zones(r.Context(), database.ZoneFilter{})
types, _ := s.app.RecordTypesInUse(r.Context(), 0)
data := s.base(r, "Records", "records")
data.Data = map[string]any{
"Records": records,
"Pagination": p.withTotal(total),
"Filter": f,
"Zones": zones,
"TypesInUse": types,
}
s.render(w, r, "records_all", data)
return nil
}
+365
View File
@@ -0,0 +1,365 @@
package web_test
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/owen/vibedns/internal/api"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/web"
)
const testPassword = "an-adequate-test-phrase"
// newTestServer builds the whole HTTP stack against a temporary database and
// seeds one of every object, so page rendering is exercised with real data
// rather than only against empty tables.
func newTestServer(t *testing.T) (http.Handler, *app.App) {
t.Helper()
ctx := context.Background()
path := filepath.Join(t.TempDir(), "web.db")
db, err := database.Open(path)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
boot := config.DefaultBootstrap()
boot.DBPath = path
a, err := app.New(ctx, boot, db, log)
if err != nil {
t.Fatalf("build app: %v", err)
}
if _, _, err := a.Auth.EnsureAdmin(ctx, "admin", testPassword); err != nil {
t.Fatalf("create admin: %v", err)
}
actor := auditlog.Actor{Name: "test", Source: auditlog.SourceCLI}
zone, err := a.CreateZone(ctx, actor, app.ZoneInput{Name: "example.com"})
if err != nil {
t.Fatalf("seed zone: %v", err)
}
for _, in := range []app.RecordInput{
{Name: "@", Type: "A", Data: "192.0.2.10"},
{Name: "www", Type: "CNAME", Data: "example.com."},
{Name: "txt", Type: "TXT", Data: `"hello"`},
} {
if _, err := a.CreateRecord(ctx, actor, zone.ID, in); err != nil {
t.Fatalf("seed record: %v", err)
}
}
if _, err := a.CreateZone(ctx, actor, app.ZoneInput{CIDR: "192.168.1.0/24", Kind: "reverse4"}); err != nil {
t.Fatalf("seed reverse zone: %v", err)
}
list, err := a.CreateDomainList(ctx, actor, app.ListInput{Kind: models.KindBlacklist, Name: "Seeded"})
if err != nil {
t.Fatalf("seed list: %v", err)
}
if _, err := a.ImportDomains(ctx, actor, list.ID, strings.NewReader("ads.example\n"), true); err != nil {
t.Fatalf("seed domains: %v", err)
}
policy, err := a.CreatePolicy(ctx, actor, app.PolicyInput{
Name: "Seeded Policy", BlockAction: "nxdomain", ListIDs: []int64{list.ID},
})
if err != nil {
t.Fatalf("seed policy: %v", err)
}
if _, err := a.CreateNetwork(ctx, actor, app.NetworkInput{
Name: "Seeded Net", CIDR: "100.64.30.0/24", PolicyIDs: []int64{policy.ID},
}); err != nil {
t.Fatalf("seed network: %v", err)
}
if _, err := a.CreateAPIToken(ctx, actor, "seeded-token", ""); err != nil {
t.Fatalf("seed token: %v", err)
}
srv, err := web.New(web.Options{App: a, Log: log, API: api.New(a, log).Handler()})
if err != nil {
t.Fatalf("build web server: %v", err)
}
return srv.Handler(), a
}
func get(t *testing.T, h http.Handler, path string, auth bool) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if auth {
req.SetBasicAuth("admin", testPassword)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// TestEveryPageRenders walks every GET route in the interface. A template that
// indexes a value the handler forgot to supply fails here rather than as a 500
// the first time an operator opens that page.
func TestEveryPageRenders(t *testing.T) {
h, _ := newTestServer(t)
paths := []string{
"/", "/dashboard",
"/zones", "/zones/reverse", "/zones/new", "/zones/new?kind=reverse4",
"/zones/1", "/zones/1/edit", "/zones/1?q=www&type=A&status=enabled",
"/records", "/records?search=192.0.2&type=A",
"/resolver", "/cache", "/cache?q=example",
"/policies", "/policies/networks", "/policies/networks/new", "/policies/networks/1",
"/policies/rules/new", "/policies/rules/1",
"/policies/blacklists", "/policies/allowlists", "/policies/lists/1",
"/policies/lists/1?q=ads",
"/querylog", "/querylog?domain=example&blocked=blocked",
"/audit", "/audit?q=zone",
"/tools", "/account",
"/settings", "/settings/dns", "/settings/resolver", "/settings/cache",
"/settings/logging", "/settings/http", "/settings/database", "/settings/api",
}
for _, p := range paths {
t.Run(p, func(t *testing.T) {
rec := get(t, h, p, true)
if rec.Code >= 500 {
t.Fatalf("GET %s = %d\n%s", p, rec.Code, truncateBody(rec.Body.String()))
}
if rec.Code != http.StatusOK && rec.Code != http.StatusSeeOther &&
rec.Code != http.StatusFound {
t.Errorf("GET %s = %d, want 200 or a redirect", p, rec.Code)
}
// A rendered page must actually contain the layout, not a stub.
if rec.Code == http.StatusOK && strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
body := rec.Body.String()
if !strings.Contains(body, "</html>") {
t.Errorf("GET %s produced a truncated page", p)
}
}
})
}
}
func truncateBody(s string) string {
if len(s) > 800 {
return s[:800] + "..."
}
return s
}
// TestPagesRequireAuthentication is the guard that every administrative route
// is actually protected.
func TestPagesRequireAuthentication(t *testing.T) {
h, _ := newTestServer(t)
protected := []string{
"/", "/zones", "/records", "/resolver", "/cache", "/policies",
"/policies/networks", "/policies/blacklists", "/querylog", "/audit",
"/tools", "/account", "/settings/dns", "/settings/api",
"/api/v1/zones", "/api/v1/settings", "/api/v1/stats",
}
for _, p := range protected {
t.Run(p, func(t *testing.T) {
if rec := get(t, h, p, false); rec.Code != http.StatusUnauthorized {
t.Errorf("GET %s without credentials = %d, want 401", p, rec.Code)
}
})
}
}
func TestPublicEndpoints(t *testing.T) {
h, _ := newTestServer(t)
t.Run("healthz", func(t *testing.T) {
rec := get(t, h, "/healthz", false)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "ok") {
t.Errorf("body = %q", rec.Body.String())
}
})
t.Run("readyz reports not ready without listeners", func(t *testing.T) {
// The DNS listeners are not started in this test, so readiness must
// report that rather than claiming everything is fine.
rec := get(t, h, "/readyz", false)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503 when the DNS listeners are down", rec.Code)
}
})
t.Run("static assets", func(t *testing.T) {
for _, p := range []string{
"/static/css/app.css", "/static/css/bootstrap.min.css",
"/static/js/app.js", "/static/fonts/bootstrap-icons.woff2",
} {
rec := get(t, h, p, false)
if rec.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200", p, rec.Code)
}
if rec.Body.Len() == 0 {
t.Errorf("GET %s returned an empty body", p)
}
}
})
t.Run("metrics requires auth by default", func(t *testing.T) {
if rec := get(t, h, "/metrics", false); rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401: metrics must not be public by default", rec.Code)
}
rec := get(t, h, "/metrics", true)
if rec.Code != http.StatusOK {
t.Fatalf("authenticated status = %d, want 200", rec.Code)
}
for _, want := range []string{"vibedns_dns_queries_total", "vibedns_build_info", "vibedns_cache_entries"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("metrics output is missing %q", want)
}
}
})
}
func TestSecurityHeaders(t *testing.T) {
h, _ := newTestServer(t)
rec := get(t, h, "/", true)
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "same-origin",
}
for k, v := range want {
if got := rec.Header().Get(k); got != v {
t.Errorf("header %s = %q, want %q", k, got, v)
}
}
csp := rec.Header().Get("Content-Security-Policy")
if csp == "" {
t.Fatal("no Content-Security-Policy header")
}
// The policy must not permit inline scripts: page data travels in data-
// attributes precisely so it does not have to.
if strings.Contains(csp, "script-src") && strings.Contains(csp, "'unsafe-inline' 'self'") {
t.Error("the CSP allows inline scripts")
}
for _, want := range []string{"default-src 'self'", "frame-ancestors 'none'", "object-src 'none'"} {
if !strings.Contains(csp, want) {
t.Errorf("CSP is missing %q: %s", want, csp)
}
}
}
// TestStateChangingRequestNeedsCSRF confirms the protection is actually wired
// up, not merely present in the code.
func TestStateChangingRequestNeedsCSRF(t *testing.T) {
h, _ := newTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/zones/1/delete", nil)
req.SetBasicAuth("admin", testPassword)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("POST without a CSRF token = %d, want 403", rec.Code)
}
}
func TestAPIReturnsJSON(t *testing.T) {
h, _ := newTestServer(t)
rec := get(t, h, "/api/v1/zones", true)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Errorf("content type = %q, want JSON", ct)
}
if !strings.Contains(rec.Body.String(), "example.com.") {
t.Errorf("the seeded zone is missing from the response: %s", truncateBody(rec.Body.String()))
}
// A missing object must be a JSON 404, not an HTML error page.
rec = get(t, h, "/api/v1/zones/9999", true)
if rec.Code != http.StatusNotFound {
t.Errorf("missing zone status = %d, want 404", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"code"`) {
t.Errorf("error body is not the standard shape: %s", rec.Body.String())
}
}
func TestAPITokenAuthentication(t *testing.T) {
h, a := newTestServer(t)
ctx := context.Background()
tok, err := a.CreateAPIToken(ctx, auditlog.Actor{Name: "test"}, "auth-test", "")
if err != nil {
t.Fatalf("create token: %v", err)
}
if tok.Secret == "" {
t.Fatal("no secret returned at creation")
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/zones", nil)
req.Header.Set("Authorization", "Bearer "+tok.Secret)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("bearer token status = %d, want 200", rec.Code)
}
// A wrong token must be rejected.
req = httptest.NewRequest(http.MethodGet, "/api/v1/zones", nil)
req.Header.Set("Authorization", "Bearer vibedns_thisisnotarealtokenvalue")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("invalid token status = %d, want 401", rec.Code)
}
}
// TestAPIWriteWithTokenSkipsCSRF: automation must be able to write without
// obtaining a CSRF token, since a bearer token cannot be replayed by a browser.
func TestAPIWriteWithTokenSkipsCSRF(t *testing.T) {
h, a := newTestServer(t)
ctx := context.Background()
tok, err := a.CreateAPIToken(ctx, auditlog.Actor{Name: "test"}, "write-test", "")
if err != nil {
t.Fatalf("create token: %v", err)
}
body := strings.NewReader(`{"name":"api-created.example"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/zones", body)
req.Header.Set("Authorization", "Bearer "+tok.Secret)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("status = %d, want 201: %s", rec.Code, truncateBody(rec.Body.String()))
}
}
func TestNotFoundPage(t *testing.T) {
h, _ := newTestServer(t)
rec := get(t, h, "/no/such/page", true)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
+592
View File
@@ -0,0 +1,592 @@
package web
import (
"bytes"
"fmt"
"html/template"
"io/fs"
"math"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/validate"
"github.com/owen/vibedns/internal/version"
webui "github.com/owen/vibedns/web"
)
// PageData is the envelope every template receives. Page-specific values live
// under .Data; everything else is chrome the layout needs.
type PageData struct {
Title string
Nav string
Subnav string
User auth.Principal
CSRF string
Flashes []Flash
Alerts []Alert
Version string
Now time.Time
Data any
Query url.Values
BasePath string
}
// Alert is a persistent banner such as "a restart is required".
type Alert struct {
Level string // warning, danger, info
Title string
Message string
Link string
LinkText string
}
// templates holds one parsed template set per page.
type templates struct {
sets map[string]*template.Template
}
// layoutFiles are parsed into every page set.
var layoutFiles = []string{"layout.html", "partials.html"}
// loadTemplates parses each page against the shared layout.
//
// Each page gets its own template set rather than one global set, because Go
// templates are keyed by name: two pages both defining "content" in a single
// set would silently overwrite each other.
func loadTemplates(funcs template.FuncMap) (*templates, error) {
src := webui.Templates()
pages, err := fs.Glob(src, "pages/*.html")
if err != nil {
return nil, fmt.Errorf("list page templates: %w", err)
}
if len(pages) == 0 {
return nil, fmt.Errorf("no page templates were found in the binary")
}
t := &templates{sets: make(map[string]*template.Template, len(pages))}
for _, page := range pages {
name := strings.TrimSuffix(path.Base(page), ".html")
files := append(append([]string{}, layoutFiles...), page)
set, err := template.New("layout.html").Funcs(funcs).ParseFS(src, files...)
if err != nil {
return nil, fmt.Errorf("parse template %s: %w", page, err)
}
t.sets[name] = set
}
return t, nil
}
// render executes a page template into a buffer first, so a template error
// produces a proper error page instead of a half-written response.
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data PageData) {
set, ok := s.tmpl.sets[page]
if !ok {
s.log.Error("template not found", "page", page)
s.renderError(w, r, http.StatusInternalServerError, "This page could not be rendered.")
return
}
data.Version = version.Version
data.Now = time.Now()
if data.Query == nil {
data.Query = r.URL.Query()
}
data.Flashes = append(data.Flashes, takeFlashes(w, r)...)
data.Alerts = append(data.Alerts, s.systemAlerts(r)...)
var buf bytes.Buffer
if err := set.ExecuteTemplate(&buf, "layout.html", data); err != nil {
s.log.Error("could not render page", "page", page, "error", err)
s.renderError(w, r, http.StatusInternalServerError, "This page could not be rendered.")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if _, err := buf.WriteTo(w); err != nil {
s.log.Debug("could not write response", "error", err)
}
}
// renderError shows a friendly error page. Stack traces and internal error
// text never reach the browser.
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
set, ok := s.tmpl.sets["error"]
if !ok {
http.Error(w, message, status)
return
}
data := PageData{
Title: http.StatusText(status),
Version: version.Version,
Now: time.Now(),
Data: map[string]any{
"Status": status,
"Text": http.StatusText(status),
"Message": message,
},
}
if p, ok := auth.PrincipalFrom(r.Context()); ok {
data.User = p
data.CSRF = s.app.Auth.IssueCSRFToken(p.Name)
}
var buf bytes.Buffer
if err := set.ExecuteTemplate(&buf, "layout.html", data); err != nil {
http.Error(w, message, status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}
// systemAlerts assembles the banners shown across every page.
func (s *Server) systemAlerts(r *http.Request) []Alert {
var out []Alert
if admin, err := s.app.Admin(r.Context()); err == nil && admin.MustChangePassword {
out = append(out, Alert{
Level: "warning",
Title: "Change the generated password",
Message: "This account still uses the password printed at first startup. Set your own before exposing the interface.",
Link: "/account",
LinkText: "Change it now",
})
}
if pending := s.app.PendingRestart(r.Context()); len(pending) > 0 {
out = append(out, Alert{
Level: "info",
Title: "Restart required",
Message: "These settings are saved but will not take effect until the server restarts: " + strings.Join(pending, "; ") + ".",
})
}
if s.app.PendingRestore() {
out = append(out, Alert{
Level: "danger",
Title: "Database restore staged",
Message: "A backup will replace the live database the next time this server starts.",
Link: "/settings/database",
LinkText: "Review",
})
}
if problems := s.app.Snapshot().Problems; len(problems) > 0 {
out = append(out, Alert{
Level: "warning",
Title: fmt.Sprintf("%d record(s) could not be loaded", len(problems)),
Message: "Some records are invalid and are not being served. " +
"Open the affected zone to see which ones.",
Link: "/zones",
LinkText: "Review zones",
})
}
return out
}
// templateFuncs are the helpers available to every template.
func templateFuncs() template.FuncMap {
return template.FuncMap{
"num": humanNumber,
"bytes": humanBytes,
"pct": formatPercent,
"ms": formatMillis,
"duration": formatDuration,
"timeAgo": timeAgo,
"datetime": formatDateTime,
"dateOnly": func(t time.Time) string { return t.Local().Format("2006-01-02") },
"timeOnly": func(t time.Time) string { return t.Local().Format("15:04:05") },
"rfc3339": func(t time.Time) string { return t.UTC().Format(time.RFC3339) },
"zeroTime": func(t time.Time) bool { return t.IsZero() },
"dict": dict,
"list": func(v ...any) []any { return v },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"seq": seq,
"join": strings.Join,
"hasPrefix": strings.HasPrefix,
"hasSuffix": strings.HasSuffix,
"contains": strings.Contains,
"lower": strings.ToLower,
"upper": strings.ToUpper,
"title": titleCase,
"trimDot": func(s string) string { return strings.TrimSuffix(s, ".") },
"truncate": truncate,
"default": defaultValue,
"yesno": func(b bool) string {
if b {
return "Yes"
}
return "No"
},
"badgeFor": badgeFor,
"rcodeBadge": rcodeBadge,
"sourceBadge": sourceBadge,
"typeBadge": typeBadge,
"withQuery": withQuery,
"pages": paginationRange,
"json": toJSON,
"rdataFields": validate.SplitRData,
"boolstr": boolString,
"toggleIcon": toggleIcon,
"toggleVerb": toggleVerb,
"statusWord": statusWord,
"pick": ternary,
"nl2br": nl2br,
"lines": func(s string) []string { return strings.Split(strings.TrimSpace(s), "\n") },
"joinLines": func(v []string) string { return strings.Join(v, "\n") },
}
}
func humanNumber(v any) string {
var n int64
switch t := v.(type) {
case int:
n = int64(t)
case int32:
n = int64(t)
case int64:
n = t
case uint32:
n = int64(t)
case float64:
n = int64(t)
default:
return fmt.Sprint(v)
}
s := strconv.FormatInt(n, 10)
neg := strings.HasPrefix(s, "-")
s = strings.TrimPrefix(s, "-")
var out []string
for len(s) > 3 {
out = append([]string{s[len(s)-3:]}, out...)
s = s[:len(s)-3]
}
out = append([]string{s}, out...)
res := strings.Join(out, ",")
if neg {
return "-" + res
}
return res
}
func humanBytes(v any) string {
var n float64
switch t := v.(type) {
case int:
n = float64(t)
case int64:
n = float64(t)
case float64:
n = t
default:
return fmt.Sprint(v)
}
const unit = 1024.0
if n < unit {
return fmt.Sprintf("%.0f B", n)
}
units := []string{"KB", "MB", "GB", "TB"}
for _, u := range units {
n /= unit
if n < unit {
return fmt.Sprintf("%.1f %s", n, u)
}
}
return fmt.Sprintf("%.1f PB", n)
}
func formatPercent(v float64) string {
if math.IsNaN(v) || math.IsInf(v, 0) {
return "0.0%"
}
return fmt.Sprintf("%.1f%%", v)
}
func formatMillis(v float64) string {
if v < 1 {
return fmt.Sprintf("%.2f ms", v)
}
return fmt.Sprintf("%.1f ms", v)
}
func formatDuration(d time.Duration) string {
if d < time.Millisecond {
return fmt.Sprintf("%.0f µs", float64(d.Microseconds()))
}
if d < time.Second {
return fmt.Sprintf("%.1f ms", float64(d.Microseconds())/1000)
}
return d.Round(time.Millisecond).String()
}
func formatDateTime(t time.Time) string {
if t.IsZero() {
return "never"
}
return t.Local().Format("2006-01-02 15:04:05")
}
func timeAgo(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
switch {
case d < 0:
return "just now"
case d < time.Minute:
return fmt.Sprintf("%d seconds ago", int(d.Seconds()))
case d < time.Hour:
m := int(d.Minutes())
if m == 1 {
return "a minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
case d < 24*time.Hour:
h := int(d.Hours())
if h == 1 {
return "an hour ago"
}
return fmt.Sprintf("%d hours ago", h)
case d < 30*24*time.Hour:
days := int(d.Hours() / 24)
if days == 1 {
return "yesterday"
}
return fmt.Sprintf("%d days ago", days)
default:
return t.Local().Format("2006-01-02")
}
}
func dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict needs an even number of arguments")
}
m := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict keys must be strings")
}
m[key] = values[i+1]
}
return m, nil
}
func seq(from, to int) []int {
if to < from {
return nil
}
out := make([]int, 0, to-from+1)
for i := from; i <= to; i++ {
out = append(out, i)
}
return out
}
func titleCase(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func truncate(n int, s string) string {
if len(s) <= n {
return s
}
if n <= 1 {
return s[:n]
}
return s[:n-1] + "…"
}
func defaultValue(def, v any) any {
switch t := v.(type) {
case string:
if strings.TrimSpace(t) == "" {
return def
}
case nil:
return def
case int:
if t == 0 {
return def
}
}
return v
}
// badgeFor maps an enabled flag to a Bootstrap badge class.
func badgeFor(enabled bool) string {
if enabled {
return "text-bg-success"
}
return "text-bg-secondary"
}
func rcodeBadge(rcode string) string {
switch strings.ToUpper(rcode) {
case "NOERROR":
return "text-bg-success"
case "NXDOMAIN":
return "text-bg-warning"
case "REFUSED", "SERVFAIL", "DROPPED":
return "text-bg-danger"
default:
return "text-bg-secondary"
}
}
func sourceBadge(source string) string {
switch source {
case "authoritative":
return "text-bg-primary"
case "cache":
return "text-bg-info"
case "stale":
return "text-bg-warning"
case "recursive":
return "text-bg-secondary"
case "blocked":
return "text-bg-danger"
case "refused", "ratelimited":
return "text-bg-dark"
case "error":
return "text-bg-danger"
default:
return "text-bg-light text-dark"
}
}
// typeBadge colours a record type so the record table scans quickly.
func typeBadge(t string) string {
switch strings.ToUpper(t) {
case "A", "AAAA":
return "type-addr"
case "CNAME", "DNAME":
return "type-alias"
case "MX", "SRV", "NAPTR", "SVCB", "HTTPS":
return "type-service"
case "NS", "SOA":
return "type-auth"
case "TXT", "SPF", "CAA":
return "type-text"
case "DS", "DNSKEY", "RRSIG", "NSEC", "NSEC3", "TLSA", "SSHFP":
return "type-sec"
case "PTR":
return "type-ptr"
default:
return "type-other"
}
}
// withQuery rebuilds the current query string with one key replaced, which is
// what pagination and sort links need.
func withQuery(q url.Values, pairs ...any) template.URL {
next := url.Values{}
for k, v := range q {
next[k] = append([]string{}, v...)
}
for i := 0; i+1 < len(pairs); i += 2 {
key := fmt.Sprint(pairs[i])
val := fmt.Sprint(pairs[i+1])
if val == "" {
next.Del(key)
} else {
next.Set(key, val)
}
}
if len(next) == 0 {
return template.URL("?")
}
return template.URL("?" + next.Encode())
}
// paginationRange returns the page numbers to show around the current page.
func paginationRange(current, total int) []int {
if total <= 1 {
return nil
}
const window = 2
start := current - window
if start < 1 {
start = 1
}
end := current + window
if end > total {
end = total
}
return seq(start, end)
}
// toJSON renders a value as JSON for a data- attribute.
//
// It deliberately returns a plain string rather than template.JS: the value is
// always placed in an HTML attribute, where html/template escapes it, and the
// page reads it back with JSON.parse. That keeps every byte of page data out
// of inline <script> blocks, which the Content-Security-Policy forbids.
func toJSON(v any) (string, error) {
b, err := jsonMarshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
// ternary picks between two values, for the small either/or choices templates
// make inline (an icon name, a CSS class) where a full if/else is noise.
// It is registered as "pick" because "if" is a template keyword.
func ternary(cond bool, whenTrue, whenFalse any) any {
if cond {
return whenTrue
}
return whenFalse
}
// boolString renders a bool as a form value the server will parse back.
func boolString(b bool) string {
if b {
return "true"
}
return "false"
}
// toggleIcon picks the switch icon showing the current state.
func toggleIcon(enabled bool) string {
if enabled {
return "bi-toggle-on"
}
return "bi-toggle-off"
}
// toggleVerb names the action a toggle button performs.
func toggleVerb(currentlyEnabled bool) string {
if currentlyEnabled {
return "Disable"
}
return "Enable"
}
// statusWord labels the current state.
func statusWord(enabled bool) string {
if enabled {
return "Enabled"
}
return "Disabled"
}
func nl2br(s string) template.HTML {
escaped := template.HTMLEscapeString(s)
return template.HTML(strings.ReplaceAll(escaped, "\n", "<br>"))
}
+512
View File
@@ -0,0 +1,512 @@
// Package web serves the Bootstrap management interface, the observability
// endpoints and the static assets. The REST API is mounted underneath it.
package web
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/netutil"
webui "github.com/owen/vibedns/web"
)
// Server is the HTTP management interface.
type Server struct {
app *app.App
log *slog.Logger
tmpl *templates
http *http.Server
apiMount http.Handler
limiter *httpLimiter
mu sync.Mutex
addr string
}
// Options configures the HTTP server.
type Options struct {
App *app.App
Log *slog.Logger
// API is mounted at /api/v1 when non-nil.
API http.Handler
}
// New creates the HTTP server and parses the embedded templates.
func New(opts Options) (*Server, error) {
tmpl, err := loadTemplates(templateFuncs())
if err != nil {
return nil, err
}
s := &Server{
app: opts.App,
log: opts.Log,
tmpl: tmpl,
apiMount: opts.API,
limiter: newHTTPLimiter(),
}
return s, nil
}
// Handler builds the fully wrapped HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
s.routes(mux)
var h http.Handler = mux
h = s.withBodyLimit(h)
h = s.withRateLimit(h)
h = s.withRequestLog(h)
h = s.withSecurityHeaders(h)
h = s.withRecover(h)
return h
}
// Start binds the management listener.
func (s *Server) Start(addr string) error {
h := s.Handler()
s.mu.Lock()
s.addr = addr
s.http = &http.Server{
Addr: addr,
Handler: h,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 2 * time.Minute, // large blocklist uploads
WriteTimeout: 5 * time.Minute, // large exports
IdleTimeout: 120 * time.Second,
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelDebug),
}
srv := s.http
s.mu.Unlock()
ln, err := listen(addr)
if err != nil {
return err
}
go func() {
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("management HTTP server stopped", "error", err)
}
}()
s.log.Info("management interface started", "address", addr)
return nil
}
// Shutdown stops the management listener.
func (s *Server) Shutdown(ctx context.Context) error {
s.mu.Lock()
srv := s.http
s.mu.Unlock()
if srv == nil {
return nil
}
return srv.Shutdown(ctx)
}
// routes registers every endpoint.
func (s *Server) routes(mux *http.ServeMux) {
// Public endpoints. Static assets are unauthenticated because they are
// Bootstrap and our own CSS: requiring credentials for them would force an
// Argon2 verification on every asset request for no benefit.
static := http.FileServer(http.FS(webui.Static()))
mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(static)))
mux.HandleFunc("GET /favicon.ico", s.handleFavicon)
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /readyz", s.handleReadyz)
mux.HandleFunc("GET /metrics", s.handleMetrics)
if s.apiMount != nil {
mux.Handle("/api/v1/", s.apiMount)
}
// Everything below requires the administrator.
page := s.protect
mux.Handle("GET /{$}", page(s.handleDashboard))
mux.Handle("GET /dashboard", page(s.handleDashboard))
// Zones.
mux.Handle("GET /zones", page(s.handleZones))
mux.Handle("GET /zones/reverse", page(s.handleZonesReverse))
mux.Handle("GET /zones/new", page(s.handleZoneNew))
mux.Handle("POST /zones/new", page(s.handleZoneCreate))
mux.Handle("GET /zones/{id}", page(s.handleZoneRecords))
mux.Handle("GET /zones/{id}/edit", page(s.handleZoneEdit))
mux.Handle("POST /zones/{id}/edit", page(s.handleZoneUpdate))
mux.Handle("POST /zones/{id}/delete", page(s.handleZoneDelete))
mux.Handle("POST /zones/{id}/toggle", page(s.handleZoneToggle))
mux.Handle("POST /zones/{id}/clone", page(s.handleZoneClone))
mux.Handle("GET /zones/{id}/export", page(s.handleZoneExport))
mux.Handle("POST /zones/{id}/import", page(s.handleZoneImport))
// Records.
mux.Handle("GET /records", page(s.handleRecordsAll))
mux.Handle("POST /zones/{id}/records", page(s.handleRecordCreate))
mux.Handle("POST /zones/{id}/records/bulk", page(s.handleRecordBulk))
mux.Handle("POST /records/{id}/edit", page(s.handleRecordUpdate))
mux.Handle("POST /records/{id}/delete", page(s.handleRecordDelete))
mux.Handle("POST /records/{id}/toggle", page(s.handleRecordToggle))
// Resolver and cache.
mux.Handle("GET /resolver", page(s.handleResolver))
mux.Handle("POST /resolver/test", page(s.handleResolverTest))
mux.Handle("GET /cache", page(s.handleCache))
mux.Handle("POST /cache/flush", page(s.handleCacheFlush))
mux.Handle("POST /cache/delete", page(s.handleCacheDelete))
// Policies.
mux.Handle("GET /policies", page(s.handlePolicies))
mux.Handle("GET /policies/networks", page(s.handleNetworks))
mux.Handle("GET /policies/networks/new", page(s.handleNetworkNew))
mux.Handle("POST /policies/networks/new", page(s.handleNetworkCreate))
mux.Handle("GET /policies/networks/{id}", page(s.handleNetworkEdit))
mux.Handle("POST /policies/networks/{id}", page(s.handleNetworkUpdate))
mux.Handle("POST /policies/networks/{id}/delete", page(s.handleNetworkDelete))
mux.Handle("POST /policies/networks/{id}/toggle", page(s.handleNetworkToggle))
mux.Handle("GET /policies/rules/new", page(s.handlePolicyNew))
mux.Handle("POST /policies/rules/new", page(s.handlePolicyCreate))
mux.Handle("GET /policies/rules/{id}", page(s.handlePolicyEdit))
mux.Handle("POST /policies/rules/{id}", page(s.handlePolicyUpdate))
mux.Handle("POST /policies/rules/{id}/delete", page(s.handlePolicyDelete))
mux.Handle("POST /policies/rules/{id}/toggle", page(s.handlePolicyToggle))
mux.Handle("GET /policies/blacklists", page(s.handleBlacklists))
mux.Handle("GET /policies/allowlists", page(s.handleAllowlists))
mux.Handle("POST /policies/lists/new", page(s.handleListCreate))
mux.Handle("GET /policies/lists/{id}", page(s.handleListDetail))
mux.Handle("POST /policies/lists/{id}", page(s.handleListUpdate))
mux.Handle("POST /policies/lists/{id}/delete", page(s.handleListDelete))
mux.Handle("POST /policies/lists/{id}/toggle", page(s.handleListToggle))
mux.Handle("POST /policies/lists/{id}/clear", page(s.handleListClear))
mux.Handle("POST /policies/lists/{id}/import", page(s.handleListImport))
mux.Handle("GET /policies/lists/{id}/export", page(s.handleListExport))
mux.Handle("POST /policies/lists/{id}/domains", page(s.handleDomainAdd))
mux.Handle("POST /policies/domains/{id}/delete", page(s.handleDomainDelete))
// Logs.
mux.Handle("GET /querylog", page(s.handleQueryLog))
mux.Handle("POST /querylog/clear", page(s.handleQueryLogClear))
mux.Handle("GET /audit", page(s.handleAuditLog))
// Tools.
mux.Handle("GET /tools", page(s.handleTools))
mux.Handle("POST /tools/lookup", page(s.handleToolsLookup))
// Settings.
mux.Handle("GET /settings", page(s.redirectTo("/settings/dns")))
mux.Handle("GET /settings/dns", page(s.handleSettingsDNS))
mux.Handle("POST /settings/dns", page(s.handleSettingsDNSSave))
mux.Handle("GET /settings/resolver", page(s.handleSettingsResolver))
mux.Handle("POST /settings/resolver", page(s.handleSettingsResolverSave))
mux.Handle("GET /settings/cache", page(s.handleSettingsCache))
mux.Handle("POST /settings/cache", page(s.handleSettingsCacheSave))
mux.Handle("GET /settings/logging", page(s.handleSettingsLogging))
mux.Handle("POST /settings/logging", page(s.handleSettingsLoggingSave))
mux.Handle("GET /settings/http", page(s.handleSettingsHTTP))
mux.Handle("POST /settings/http", page(s.handleSettingsHTTPSave))
mux.Handle("GET /settings/database", page(s.handleSettingsDatabase))
mux.Handle("POST /settings/database", page(s.handleSettingsDatabaseSave))
mux.Handle("POST /settings/database/backup", page(s.handleBackupNow))
mux.Handle("GET /settings/database/backup/{name}", page(s.handleBackupDownload))
mux.Handle("POST /settings/database/backup/{name}/delete", page(s.handleBackupDelete))
mux.Handle("POST /settings/database/backup/{name}/restore", page(s.handleBackupRestore))
mux.Handle("POST /settings/database/restore/cancel", page(s.handleRestoreCancel))
mux.Handle("GET /settings/api", page(s.handleSettingsAPI))
mux.Handle("POST /settings/api/tokens", page(s.handleTokenCreate))
mux.Handle("POST /settings/api/tokens/{id}/delete", page(s.handleTokenDelete))
mux.Handle("POST /settings/api/tokens/{id}/toggle", page(s.handleTokenToggle))
mux.Handle("GET /settings/config/export", page(s.handleConfigExport))
mux.Handle("POST /settings/config/import", page(s.handleConfigImport))
// Account.
mux.Handle("GET /account", page(s.handleAccount))
mux.Handle("POST /account", page(s.handleAccountSave))
// Anything unmatched. Registered without a method so it does not conflict
// with the "/api/v1/" prefix pattern: ServeMux rejects a method-specific
// catch-all that is more general in path than an existing prefix route.
mux.Handle("/", page(s.handleNotFound))
}
// handlerFunc is a page handler that may return an error for central handling.
type handlerFunc func(http.ResponseWriter, *http.Request) error
// protect wraps a page handler with authentication and CSRF enforcement.
func (s *Server) protect(fn handlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, err := s.app.Auth.Authenticate(r, false)
if err != nil {
s.challenge(w, r, err)
return
}
ctx := auth.WithPrincipal(r.Context(), p)
r = r.WithContext(ctx)
// The CSRF cookie is refreshed on every page view so it never expires
// out from under an open tab.
token := s.app.Auth.IssueCSRFToken(p.Name)
if r.Method == http.MethodGet {
auth.SetCSRFCookie(w, r, token)
} else if err := s.app.Auth.CheckCSRF(r, p); err != nil {
s.log.Warn("rejected a request that failed the CSRF check",
"path", r.URL.Path, "client", p.ClientIP)
s.renderError(w, r, http.StatusForbidden, err.Error())
return
}
if err := fn(w, r); err != nil {
s.handleError(w, r, err)
}
})
}
// challenge sends the Basic authentication challenge, or a lockout message.
func (s *Server) challenge(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, auth.ErrLockedOut):
w.Header().Set("Retry-After", "300")
s.renderError(w, r, http.StatusTooManyRequests,
"Too many failed sign-in attempts from this address. Try again in a few minutes.")
case errors.Is(err, auth.ErrNoAdmin):
s.renderError(w, r, http.StatusServiceUnavailable,
"No administrator account exists yet. Restart the server to create one.")
default:
w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", auth.Realm))
s.renderError(w, r, http.StatusUnauthorized, "Sign in to continue.")
}
}
// handleError turns a service error into a flash or an error page.
func (s *Server) handleError(w http.ResponseWriter, r *http.Request, err error) {
if app.IsInternal(err) {
s.log.Error("request failed", "path", r.URL.Path, "method", r.Method, "error", err)
}
status := app.StatusOf(err)
message := app.MessageOf(err)
// A failed form submission returns the operator to the page they were on
// with the reason shown, rather than dumping them on an error page.
if r.Method == http.MethodPost && status < 500 {
setFlash(w, r, "danger", message)
s.redirectBack(w, r)
return
}
s.renderError(w, r, status, message)
}
// redirectBack returns to the referring page, or a supplied fallback.
func (s *Server) redirectBack(w http.ResponseWriter, r *http.Request) {
target := r.FormValue("return_to")
if target == "" {
target = safeReferer(r)
}
if target == "" {
target = "/"
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
// safeReferer only accepts a same-origin path, so a crafted Referer cannot
// turn an error into an open redirect.
func safeReferer(r *http.Request) string {
ref := r.Header.Get("Referer")
if ref == "" {
return ""
}
if strings.HasPrefix(ref, "/") && !strings.HasPrefix(ref, "//") {
return ref
}
if u, err := parseURL(ref); err == nil && u.Host == r.Host {
return u.RequestURI()
}
return ""
}
func (s *Server) redirectTo(target string) handlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
}
// redirect issues a see-other redirect after a successful mutation.
func (s *Server) redirect(w http.ResponseWriter, r *http.Request, target string) error {
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
// actor builds the audit actor for the current request.
func (s *Server) actor(r *http.Request) auditlog.Actor {
p, _ := auth.PrincipalFrom(r.Context())
source := auditlog.SourceWeb
if p.Kind == auth.KindToken {
source = auditlog.SourceAPI
}
return auditlog.Actor{Name: p.Name, Source: source, ClientIP: p.ClientIP}
}
// base builds the common page envelope.
func (s *Server) base(r *http.Request, title, nav string) PageData {
p, _ := auth.PrincipalFrom(r.Context())
return PageData{
Title: title,
Nav: nav,
User: p,
CSRF: s.app.Auth.IssueCSRFToken(p.Name),
Query: r.URL.Query(),
}
}
// pathID reads an {id} path parameter.
func pathID(r *http.Request, name string) (int64, error) {
raw := r.PathValue(name)
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
return 0, app.Invalid("%q is not a valid identifier.", raw)
}
return id, nil
}
// --- middleware ---------------------------------------------------------
func (s *Server) withRecover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic while handling request",
"path", r.URL.Path, "panic", rec, "stack", string(debug.Stack()))
// The stack trace goes to the log, never to the browser.
s.renderError(w, r, http.StatusInternalServerError,
"An unexpected error occurred. Check the server log for details.")
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), interest-cohort=()")
// Everything is served from this origin: no CDN, no inline event
// handlers, no eval. 'unsafe-inline' is allowed for style attributes
// only, which Bootstrap components set programmatically.
h.Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
"font-src 'self'; "+
"connect-src 'self'; "+
"form-action 'self'; "+
"frame-ancestors 'none'; "+
"base-uri 'none'; "+
"object-src 'none'")
if r.TLS != nil {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
// statusRecorder captures the response status for logging.
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (w *statusRecorder) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusRecorder) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
func (s *Server) withRequestLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
if strings.HasPrefix(r.URL.Path, "/static/") {
return // asset noise
}
level := slog.LevelDebug
if rec.status >= 500 {
level = slog.LevelError
} else if rec.status >= 400 {
level = slog.LevelWarn
}
s.log.Log(r.Context(), level, "http request",
"method", r.Method, "path", r.URL.Path, "status", rec.status,
"bytes", rec.bytes, "duration_ms", time.Since(start).Milliseconds(),
"client", s.app.Auth.ClientIP(r))
})
}
// withBodyLimit caps request bodies so an oversized upload cannot exhaust
// memory. The limit follows the configured upload size.
func (s *Server) withBodyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead {
limit := int64(s.app.Settings().HTTP.MaxUploadMB) * 1024 * 1024
if limit <= 0 {
limit = 64 * 1024 * 1024
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
}
next.ServeHTTP(w, r)
})
}
// withRateLimit applies a coarse per-address request limit to the management
// interface. It is a brute-force and runaway-script guard, not a DoS defence.
func (s *Server) withRateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
perMin := s.app.Settings().HTTP.RateLimitPerMin
addr, ok := netutil.AddrFromHostPort(s.app.Auth.ClientIP(r))
if ok && !s.limiter.allow(addr.String(), perMin) {
w.Header().Set("Retry-After", "60")
http.Error(w, "Too many requests. Slow down and try again shortly.", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// cacheStatic marks embedded assets as immutable. They only change when the
// binary changes, and the binary is what serves them.
func cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
next.ServeHTTP(w, r)
})
}
+264
View File
@@ -0,0 +1,264 @@
package web
import (
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/owen/vibedns/internal/app"
)
// listen binds the management address, explaining common failures.
func listen(addr string) (net.Listener, error) {
ln, err := net.Listen("tcp", addr)
if err == nil {
return ln, nil
}
msg := err.Error()
switch {
case strings.Contains(msg, "address already in use"):
return nil, fmt.Errorf("management interface cannot bind %s: the address is already in use", addr)
case strings.Contains(msg, "permission denied"):
return nil, fmt.Errorf("management interface cannot bind %s: permission denied "+
"(ports below 1024 need root or CAP_NET_BIND_SERVICE)", addr)
default:
return nil, fmt.Errorf("management interface cannot bind %s: %w", addr, err)
}
}
func parseURL(s string) (*url.URL, error) { return url.Parse(s) }
// httpLimiter is a coarse fixed-window request counter per client address.
type httpLimiter struct {
mu sync.Mutex
windows map[string]*window
lastGC time.Time
}
type window struct {
count int
start time.Time
}
func newHTTPLimiter() *httpLimiter {
return &httpLimiter{windows: map[string]*window{}}
}
func (l *httpLimiter) allow(key string, perMinute int) bool {
if perMinute <= 0 {
return true
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
if now.Sub(l.lastGC) > 5*time.Minute {
for k, w := range l.windows {
if now.Sub(w.start) > 2*time.Minute {
delete(l.windows, k)
}
}
l.lastGC = now
}
w, ok := l.windows[key]
if !ok || now.Sub(w.start) >= time.Minute {
l.windows[key] = &window{count: 1, start: now}
return true
}
w.count++
return w.count <= perMinute
}
// --- form helpers -------------------------------------------------------
// formString reads a trimmed form value.
func formString(r *http.Request, key string) string {
return strings.TrimSpace(r.FormValue(key))
}
// formBool reads a checkbox. Boolean fields pair a hidden false value with a
// checkbox true value, so a checked field arrives as ["false", "true"]. Treat
// the field as true when any submitted value is true instead of relying on
// FormValue, which only returns the first value.
func formBool(r *http.Request, key string) bool {
if err := r.ParseForm(); err != nil {
return false
}
for _, raw := range r.Form[key] {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "on", "yes":
return true
}
}
return false
}
// formBoolPtr returns nil when the field was not submitted at all.
func formBoolPtr(r *http.Request, key string) *bool {
if err := r.ParseForm(); err != nil {
return nil
}
if _, ok := r.Form[key]; !ok {
return nil
}
v := formBool(r, key)
return &v
}
// formInt reads an integer form field, falling back to def when empty.
func formInt(r *http.Request, key string, def int) int {
raw := formString(r, key)
if raw == "" {
return def
}
v, err := strconv.Atoi(raw)
if err != nil {
return def
}
return v
}
// formUint32 reads an unsigned form field.
func formUint32(r *http.Request, key string, def uint32) uint32 {
raw := formString(r, key)
if raw == "" {
return def
}
v, err := strconv.ParseUint(raw, 10, 32)
if err != nil {
return def
}
return uint32(v)
}
// formUint32Ptr returns nil when the field is empty, which distinguishes
// "inherit the zone default" from an explicit value.
func formUint32Ptr(r *http.Request, key string) *uint32 {
raw := formString(r, key)
if raw == "" {
return nil
}
v, err := strconv.ParseUint(raw, 10, 32)
if err != nil {
return nil
}
out := uint32(v)
return &out
}
// formInt64s reads a repeated integer field, such as a set of checkboxes.
func formInt64s(r *http.Request, key string) []int64 {
var out []int64
for _, raw := range r.Form[key] {
v, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err == nil && v > 0 {
out = append(out, v)
}
}
return out
}
// parseForm parses the request body, reporting an oversized upload clearly.
func parseForm(r *http.Request) error {
if err := r.ParseForm(); err != nil {
if strings.Contains(err.Error(), "http: request body too large") {
return app.Invalid("The submitted data is larger than the configured upload limit.")
}
return app.Invalid("The form data could not be read: %v", err)
}
return nil
}
// parseMultipart parses a file upload up to the configured limit.
func parseMultipart(r *http.Request, maxMemoryMB int) error {
if maxMemoryMB <= 0 {
maxMemoryMB = 8
}
if err := r.ParseMultipartForm(int64(maxMemoryMB) * 1024 * 1024); err != nil {
if strings.Contains(err.Error(), "http: request body too large") {
return app.Invalid("The uploaded file is larger than the configured upload limit.")
}
return app.Invalid("The upload could not be read: %v", err)
}
return nil
}
// pagination computes offsets from query parameters.
type pagination struct {
Page int
PerPage int
Offset int
Total int
Pages int
HasPrev bool
HasNext bool
From int
To int
}
func newPagination(r *http.Request, defaultPerPage int) pagination {
page := formInt(r, "page", 1)
if page < 1 {
page = 1
}
per := formInt(r, "per_page", defaultPerPage)
switch {
case per < 10:
per = 10
case per > 500:
per = 500
}
return pagination{Page: page, PerPage: per, Offset: (page - 1) * per}
}
// withTotal fills in the derived fields once the row count is known.
func (p pagination) withTotal(total int) pagination {
p.Total = total
p.Pages = (total + p.PerPage - 1) / p.PerPage
if p.Pages < 1 {
p.Pages = 1
}
p.HasPrev = p.Page > 1
p.HasNext = p.Page < p.Pages
p.From = p.Offset + 1
p.To = p.Offset + p.PerPage
if p.To > total {
p.To = total
}
if total == 0 {
p.From = 0
}
return p
}
// parseInt64 parses a numeric query parameter.
func parseInt64(s string) (int64, error) {
return strconv.ParseInt(strings.TrimSpace(s), 10, 64)
}
// parseDate reads a date or datetime filter from a form field. endOfDay
// extends a bare date to 23:59:59 so a "to" filter includes that whole day.
func parseDate(s string, endOfDay bool) (time.Time, bool) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, false
}
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02"} {
t, err := time.ParseInLocation(layout, s, time.Local)
if err != nil {
continue
}
if endOfDay && layout == "2006-01-02" {
t = t.Add(24*time.Hour - time.Second)
}
return t, true
}
return time.Time{}, false
}
+33
View File
@@ -0,0 +1,33 @@
package web
import (
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestFormBool(t *testing.T) {
tests := []struct {
name string
values url.Values
want bool
}{
{name: "missing", values: url.Values{}, want: false},
{name: "hidden unchecked value", values: url.Values{"enabled": {"false"}}, want: false},
{name: "hidden and checked values", values: url.Values{"enabled": {"false", "true"}}, want: true},
{name: "checked value first", values: url.Values{"enabled": {"true", "false"}}, want: true},
{name: "browser checkbox value", values: url.Values{"enabled": {"on"}}, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := tt.values.Encode()
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if got := formBool(req, "enabled"); got != tt.want {
t.Fatalf("formBool() = %t, want %t for %q", got, tt.want, body)
}
})
}
}
+120
View File
@@ -0,0 +1,120 @@
package web
import (
"io/fs"
"path"
"strings"
"testing"
webui "github.com/owen/vibedns/web"
)
// TestTemplatesParse fails the build if any embedded template has a syntax
// error or calls a function that is not registered. Without this, a typo in a
// template only surfaces when a user opens that particular page.
func TestTemplatesParse(t *testing.T) {
tmpl, err := loadTemplates(templateFuncs())
if err != nil {
t.Fatalf("templates did not parse: %v", err)
}
pages, err := fs.Glob(webui.Templates(), "pages/*.html")
if err != nil {
t.Fatalf("could not list pages: %v", err)
}
if len(pages) == 0 {
t.Fatal("no page templates were embedded")
}
for _, p := range pages {
name := strings.TrimSuffix(path.Base(p), ".html")
if _, ok := tmpl.sets[name]; !ok {
t.Errorf("page %s produced no template set", name)
}
}
t.Logf("parsed %d page templates", len(pages))
}
// TestEveryRoutedPageHasTemplate guards against a handler rendering a page name
// that does not exist, which would otherwise be a 500 at runtime.
func TestEveryRoutedPageHasTemplate(t *testing.T) {
tmpl, err := loadTemplates(templateFuncs())
if err != nil {
t.Fatalf("templates did not parse: %v", err)
}
// Every name passed to s.render anywhere in the package.
rendered := []string{
"dashboard", "error", "zones", "zone_form", "records", "records_all",
"resolver", "cache", "networks", "network_form", "policies", "policy_form",
"lists", "list_detail", "querylog", "audit", "tools", "account",
"settings_dns", "settings_resolver", "settings_cache", "settings_logging",
"settings_http", "settings_database", "settings_api",
}
for _, name := range rendered {
if _, ok := tmpl.sets[name]; !ok {
t.Errorf("handler renders %q but no such template exists", name)
}
}
}
// TestStaticAssetsEmbedded confirms the offline assets actually made it into
// the binary. The management interface must work without Internet access.
func TestStaticAssetsEmbedded(t *testing.T) {
static := webui.Static()
required := []string{
"css/bootstrap.min.css",
"css/bootstrap-icons.min.css",
"css/app.css",
"js/bootstrap.bundle.min.js",
"js/chart.umd.js",
"js/app.js",
"fonts/bootstrap-icons.woff2",
"img/favicon.svg",
}
for _, name := range required {
f, err := static.Open(name)
if err != nil {
t.Errorf("static asset %s is missing from the binary: %v", name, err)
continue
}
info, err := f.Stat()
if err == nil && info.Size() == 0 {
t.Errorf("static asset %s is empty", name)
}
f.Close()
}
}
// TestNoCDNReferences guards the offline guarantee: a stray absolute URL in a
// template or stylesheet would silently break the UI on an air-gapped network.
func TestNoCDNReferences(t *testing.T) {
check := func(fsys fs.FS, label string) {
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
switch path.Ext(p) {
case ".html", ".css", ".js":
default:
return nil
}
body, err := fs.ReadFile(fsys, p)
if err != nil {
return err
}
for _, bad := range []string{"https://cdn.", "http://cdn.", "//cdn.jsdelivr", "//unpkg.com"} {
if strings.Contains(string(body), bad) {
t.Errorf("%s/%s references an external CDN (%q); assets must be served from the binary",
label, p, bad)
}
}
return nil
})
if err != nil {
t.Fatalf("walking %s: %v", label, err)
}
}
check(webui.Templates(), "templates")
check(webui.Static(), "static")
}