Files
jarl/internal/server/routes.go
T
adamandClaude Opus 5.5 c37de31f4c Prometheus metrics at /metrics, configured in jarl.yaml
New metrics: section (enabled, listen, allow, token). /metrics is served on
the main listeners, the local socket (root and jarld's user) and optional
metrics-only addresses; a scrape needs an allowed address or the bearer
token. Exports health (named up, problems, drift, backups, blocklists,
DNSSEC phases, token expiry) and, with stats on, named's and Jarl's query
counters since jarld started plus named's process and cache gauges.
docs/metrics.md has example queries and alerts checked with promtool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:33:41 +01:00

316 lines
14 KiB
Go

package server
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"io/fs"
"net/http"
"strconv"
"strings"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/model"
)
func (s *Server) routes() {
m := s.mux
p := api.Prefix
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.cfg.Version})
})
m.HandleFunc("GET /metrics", s.metrics)
// Authentication
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
m.HandleFunc("POST "+p+"/auth/login/totp", s.public(s.loginTOTP))
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
m.HandleFunc("GET "+p+"/auth/whoami", s.authed(s.whoami))
m.HandleFunc("POST "+p+"/me/password", s.authed(s.changeOwnPassword))
m.HandleFunc("GET "+p+"/me/totp", s.authed(s.getOwnTOTP))
m.HandleFunc("POST "+p+"/me/totp/setup", s.authed(s.setupOwnTOTP))
m.HandleFunc("POST "+p+"/me/totp/enable", s.authed(s.enableOwnTOTP))
m.HandleFunc("POST "+p+"/me/totp/disable", s.authed(s.disableOwnTOTP))
m.HandleFunc("POST "+p+"/me/totp/recovery-codes", s.authed(s.newRecoveryCodes))
m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens))
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
m.HandleFunc("POST "+p+"/tokens/{id}/rotate", s.authed(s.rotateToken))
m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens))
// Users (admin)
m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers))
m.HandleFunc("POST "+p+"/users", s.authed(s.createUser))
m.HandleFunc("GET "+p+"/users/{id}", s.authed(s.getUser))
m.HandleFunc("PATCH "+p+"/users/{id}", s.authed(s.updateUser))
m.HandleFunc("DELETE "+p+"/users/{id}", s.authed(s.deleteUser))
m.HandleFunc("GET "+p+"/users/{id}/tokens", s.authed(s.listUserTokens))
m.HandleFunc("POST "+p+"/users/{id}/tokens", s.authed(s.createUserToken))
m.HandleFunc("DELETE "+p+"/users/{id}/totp", s.authed(s.resetUserTOTP))
// Views
m.HandleFunc("GET "+p+"/views", s.authed(s.listViews))
m.HandleFunc("POST "+p+"/views", s.authed(s.createView))
m.HandleFunc("POST "+p+"/views/order", s.authed(s.reorderViews))
m.HandleFunc("GET "+p+"/views/{id}", s.authed(s.getView))
m.HandleFunc("GET "+p+"/views/{id}/key", s.authed(s.viewKey))
m.HandleFunc("PATCH "+p+"/views/{id}", s.authed(s.updateView))
m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView))
m.HandleFunc("PUT "+p+"/views/{id}/color", s.authed(s.setViewColor))
// ACLs
// Blocking
m.HandleFunc("GET "+p+"/blocking/lists", s.authed(s.listBlocklists))
m.HandleFunc("POST "+p+"/blocking/lists", s.authed(s.createBlocklist))
m.HandleFunc("GET "+p+"/blocking/lists/{id}", s.authed(s.getBlocklist))
m.HandleFunc("PATCH "+p+"/blocking/lists/{id}", s.authed(s.updateBlocklist))
m.HandleFunc("DELETE "+p+"/blocking/lists/{id}", s.authed(s.deleteBlocklist))
m.HandleFunc("POST "+p+"/blocking/lists/{id}/refresh", s.authed(s.refreshBlocklist))
m.HandleFunc("GET "+p+"/blocking/rules", s.authed(s.listBlockRules))
m.HandleFunc("POST "+p+"/blocking/rules", s.authed(s.createBlockRule))
m.HandleFunc("DELETE "+p+"/blocking/rules", s.authed(s.deleteBlockRulesByName))
m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule))
m.HandleFunc("DELETE "+p+"/blocking/rules/{id}", s.authed(s.deleteBlockRule))
m.HandleFunc("GET "+p+"/blocking/steps", s.authed(s.listBlockSteps))
m.HandleFunc("POST "+p+"/blocking/steps", s.authed(s.createBlockStep))
m.HandleFunc("POST "+p+"/blocking/steps/order", s.authed(s.orderBlockSteps))
m.HandleFunc("PATCH "+p+"/blocking/steps/{id}", s.authed(s.updateBlockStep))
m.HandleFunc("DELETE "+p+"/blocking/steps/{id}", s.authed(s.deleteBlockStep))
m.HandleFunc("GET "+p+"/blocking/groups", s.authed(s.listClientGroups))
m.HandleFunc("POST "+p+"/blocking/groups", s.authed(s.createClientGroup))
m.HandleFunc("PATCH "+p+"/blocking/groups/{id}", s.authed(s.updateClientGroup))
m.HandleFunc("DELETE "+p+"/blocking/groups/{id}", s.authed(s.deleteClientGroup))
m.HandleFunc("GET "+p+"/blocking/check", s.authed(s.checkBlocking))
m.HandleFunc("GET "+p+"/blocking/status", s.authed(s.blockingStatus))
m.HandleFunc("POST "+p+"/blocking/pause", s.authed(s.pauseBlocking(false)))
m.HandleFunc("POST "+p+"/blocking/resume", s.authed(s.pauseBlocking(true)))
m.HandleFunc("GET "+p+"/acls", s.authed(s.listACLs))
m.HandleFunc("POST "+p+"/acls", s.authed(s.createACL))
m.HandleFunc("GET "+p+"/acls/{id}", s.authed(s.getACL))
m.HandleFunc("PATCH "+p+"/acls/{id}", s.authed(s.updateACL))
m.HandleFunc("DELETE "+p+"/acls/{id}", s.authed(s.deleteACL))
// TSIG keys
m.HandleFunc("GET "+p+"/tsig-keys", s.authed(s.listKeys))
m.HandleFunc("POST "+p+"/tsig-keys", s.authed(s.createKey))
m.HandleFunc("GET "+p+"/tsig-keys/{id}", s.authed(s.getKey))
m.HandleFunc("PATCH "+p+"/tsig-keys/{id}", s.authed(s.updateKey))
m.HandleFunc("DELETE "+p+"/tsig-keys/{id}", s.authed(s.deleteKey))
// Zones and records
m.HandleFunc("GET "+p+"/zones", s.authed(s.listZones))
m.HandleFunc("POST "+p+"/zones", s.authed(s.createZone))
m.HandleFunc("GET "+p+"/zones/{zone}", s.authed(s.getZone))
m.HandleFunc("PATCH "+p+"/zones/{zone}", s.authed(s.updateZone))
m.HandleFunc("DELETE "+p+"/zones/{zone}", s.authed(s.deleteZone))
m.HandleFunc("GET "+p+"/zones/{zone}/serials", s.authed(s.zoneSerials))
m.HandleFunc("GET "+p+"/zones/{zone}/delegation", s.authed(s.zoneDelegation))
m.HandleFunc("GET "+p+"/zones/{zone}/dnssec", s.authed(s.zoneDNSSEC))
m.HandleFunc("POST "+p+"/zones/{zone}/dnssec/check-ds", s.authed(s.zoneCheckDS))
m.HandleFunc("POST "+p+"/zones/{zone}/dnssec/wipe", s.authed(s.zoneDNSSECWipe))
m.HandleFunc("GET "+p+"/zones/{zone}/registrar", s.authed(s.zoneRegistrar))
m.HandleFunc("POST "+p+"/zones/{zone}/registrar/glue", s.authed(s.registrarSyncGlue))
m.HandleFunc("POST "+p+"/zones/{zone}/registrar/ds", s.authed(s.registrarPublishDS))
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
m.HandleFunc("DELETE "+p+"/zones/{zone}/records", s.authed(s.deleteRecordsByMatch))
m.HandleFunc("POST "+p+"/zones/{zone}/records/disable", s.authed(s.setRecordsDisabled(true)))
m.HandleFunc("POST "+p+"/zones/{zone}/records/enable", s.authed(s.setRecordsDisabled(false)))
m.HandleFunc("PUT "+p+"/zones/{zone}/rrsets/{name}/{type}", s.authed(s.putRRSet))
m.HandleFunc("DELETE "+p+"/zones/{zone}/rrsets/{name}/{type}", s.authed(s.deleteRRSet))
m.HandleFunc("GET "+p+"/records/{id}", s.authed(s.getRecord))
m.HandleFunc("PATCH "+p+"/records/{id}", s.authed(s.updateRecord))
m.HandleFunc("DELETE "+p+"/records/{id}", s.authed(s.deleteRecord))
m.HandleFunc("POST "+p+"/records/{id}/disable", s.authed(s.setRecordDisabled(true)))
m.HandleFunc("POST "+p+"/records/{id}/enable", s.authed(s.setRecordDisabled(false)))
// Options and settings
m.HandleFunc("GET "+p+"/options", s.authed(s.listOptions))
m.HandleFunc("POST "+p+"/options", s.authed(s.createOption))
m.HandleFunc("GET "+p+"/options/{id}", s.authed(s.getOption))
m.HandleFunc("PATCH "+p+"/options/{id}", s.authed(s.updateOption))
m.HandleFunc("DELETE "+p+"/options/{id}", s.authed(s.deleteOption))
m.HandleFunc("GET "+p+"/settings", s.authed(s.getSettings))
m.HandleFunc("PATCH "+p+"/settings", s.authed(s.updateSettings))
// Catalogs
m.HandleFunc("GET "+p+"/catalog/options", s.authed(s.catalogOptions))
m.HandleFunc("GET "+p+"/catalog/rrtypes", s.authed(s.catalogRRTypes))
m.HandleFunc("GET "+p+"/catalog/grants", s.authed(s.catalogGrants))
m.HandleFunc("GET "+p+"/search", s.authed(s.search))
// Changesets
m.HandleFunc("POST "+p+"/apply", s.authed(s.applyBatch))
m.HandleFunc("GET "+p+"/changesets", s.authed(s.listChangesets))
m.HandleFunc("POST "+p+"/changesets", s.authed(s.createChangeset))
m.HandleFunc("GET "+p+"/changesets/{id}", s.authed(s.getChangeset))
m.HandleFunc("PATCH "+p+"/changesets/{id}", s.authed(s.updateChangeset))
m.HandleFunc("POST "+p+"/changesets/{id}/ops", s.authed(s.stageOps))
m.HandleFunc("DELETE "+p+"/changesets/{id}/ops/{seq}", s.authed(s.unstageOp))
m.HandleFunc("GET "+p+"/changesets/{id}/preview", s.authed(s.previewChangeset))
m.HandleFunc("POST "+p+"/changesets/{id}/apply", s.authed(s.applyChangeset))
m.HandleFunc("POST "+p+"/changesets/{id}/discard", s.authed(s.discardChangeset))
m.HandleFunc("POST "+p+"/changesets/{id}/revert", s.authed(s.revertChangeset))
// Status, system and audit
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
m.HandleFunc("POST "+p+"/system/check", s.authed(s.systemCheck))
m.HandleFunc("POST "+p+"/system/resync", s.authed(s.systemResync))
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
// lego's httpreq DNS provider (h_acme.go)
m.HandleFunc("GET "+p+"/acme/httpreq", s.public(s.httpreqStatus))
m.HandleFunc("POST "+p+"/acme/httpreq/present", s.httpreq(true))
m.HandleFunc("POST "+p+"/acme/httpreq/cleanup", s.httpreq(false))
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/present", s.httpreq(true))
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false))
// Query statistics
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary))
m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop))
m.HandleFunc("GET "+p+"/stats/timeline", s.authed(s.statsTimeline))
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown))
m.HandleFunc("GET "+p+"/stats/server", s.authed(s.statsServer))
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
})
// Web UI
if s.web != nil {
files := http.FileServer(http.FS(s.web))
etags := contentETags(s.web)
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
b, err := fs.ReadFile(s.web, "index.html")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", etags["index.html"])
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(b))
return
}
if strings.HasPrefix(r.URL.Path, "/assets/") {
// no-cache: the browser asks again each time, but with
// the ETag an unchanged file costs a 304 and no body.
w.Header().Set("Cache-Control", "no-cache")
if tag, ok := etags[strings.TrimPrefix(r.URL.Path, "/")]; ok {
w.Header().Set("ETag", tag)
}
files.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
})
}
}
// via names the interface a request came through.
func via(r *http.Request, p *auth.Principal) string {
if strings.Contains(r.UserAgent(), "jarl-cli") {
return "cli"
}
if p.Method == "session" {
return "web"
}
return "api"
}
// changesetTarget returns the changeset to stage into, if any.
func changesetTarget(r *http.Request) (int64, error) {
v := r.URL.Query().Get("changeset")
if v == "" {
v = r.Header.Get("X-Jarl-Changeset")
}
if v == "" {
return 0, nil
}
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
return 0, badRequest("invalid changeset %q", v)
}
return id, nil
}
// mutate applies (or stages, or previews) requests and writes the
// response. object, if set, returns the resulting object to include.
func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any) error {
csID, err := changesetTarget(r)
if err != nil {
return err
}
if csID != 0 {
res, err := s.eng.Stage(r.Context(), csID, reqs, p)
if err != nil {
return err
}
res.Changeset.Ops = engine.RedactOps(res.Changeset.Ops)
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
return nil
}
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force")}
res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts)
if err != nil {
s.writeErr(w, r, err, res)
return nil
}
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: res.Warnings}
if object != nil && res.Applied {
st, err := s.store.LoadState(r.Context())
if err == nil {
var created int64
if len(res.Ops) > 0 {
created = res.Ops[0].ID
}
out.Object = raw(object(st, created))
}
}
status := http.StatusOK
if res.Applied && r.Method == http.MethodPost {
status = http.StatusCreated
}
writeJSON(w, status, out)
return nil
}
// request builds an engine request from a raw JSON body.
func request(op string, id int64, body []byte) engine.Request {
return engine.Request{Op: op, ID: id, Data: body}
}
func requireAdmin(p *auth.Principal) error {
if !p.IsAdmin() {
return forbidden("this needs an administrator (and not a restricted token)")
}
return nil
}
func require(p *auth.Principal, a auth.Action) error {
if !p.Can(a) {
return forbidden("permission denied: this needs %s", a)
}
return nil
}
// contentETags returns an ETag for every file of the embedded web UI,
// from its content (embedded files have no modification time).
func contentETags(fsys fs.FS) map[string]string {
tags := map[string]string{}
fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
b, err := fs.ReadFile(fsys, path)
if err != nil {
return nil
}
sum := sha256.Sum256(b)
tags[path] = `"` + hex.EncodeToString(sum[:12]) + `"`
return nil
})
return tags
}