jarld can now answer lego's httpreq DNS provider (POST /api/v1/acme/httpreq/present and /cleanup), so lego and Traefik publish DNS-01 challenges with only environment variables, without the jarl CLI. lego's username and API token arrive as HTTP Basic auth, accepted on these routes only. The endpoint is off by default (acme.httpreq.enabled) and HTTPS-only by default: jarld's own TLS, or a trusted proxy that says so with X-Forwarded-Proto or Forwarded (the last value counts). Plain HTTP can be allowed from loopback only or everywhere. GET /api/v1/acme/httpreq shows how jarld sees a request, to check a proxy setup. jarl acme and the endpoint share their code in internal/acme. Documented in docs/acme.md, configuration.md, api.md and permissions.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
9.5 KiB
Go
235 lines
9.5 KiB
Go
package server
|
|
|
|
import (
|
|
"io/fs"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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("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))
|
|
// 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}/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))
|
|
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.Write(b)
|
|
return
|
|
}
|
|
if strings.HasPrefix(r.URL.Path, "/assets/") {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
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
|
|
}
|