322 lines
9.6 KiB
Go
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")
|
|
}
|