Files
jarl/internal/server/routes.go
T
abrown38andClaude Opus 5.5 b05711b13e Link views from the zone list, add breadcrumbs and view colours
The view shown in the zone list and on the zone page now links to that
view. The zone page and changeset page get breadcrumbs, and the zone list
keeps its filters in the address so going back restores them. Views can
be given a colour (Views > Edit, `jarl view color`, PUT /views/{id}/color)
so zones of the same view are easy to spot; the colour is UI-only, kept
outside changesets, and needs no deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:43:57 +00:00

269 lines
11 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})
})
// Authentication
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
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/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))
// 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("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
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}/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("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))
// 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(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
}