Both branches added database migration 3: DNSSEC (merged first) keeps 3 and TOTP becomes migration 4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
12 KiB
Go
283 lines
12 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/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("PATCH "+p+"/views/{id}", s.authed(s.updateView))
|
|
m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView))
|
|
// 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}/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("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
|
|
}
|