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)) // 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 }