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