initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>"))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user