Files
2026-08-16 21:18:45 -05:00

322 lines
9.6 KiB
Go

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