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