Adding an endpoint#
An endpoint in the agent is one line in routes() and one handler function. The line
says who may call it and whether it needs a licence; the handler does the work. There is
no router library, no middleware stack to configure and no code generation — the standard
library's http.ServeMux with method-and-path patterns is the whole of it. What a new
endpoint really has to get right is the wrapper it is registered with, the status codes
it returns, and whether it writes an audit entry.
The five-minute version#
- Write the handler in the file whose subject it belongs to — a firewall read goes in
main.gobeside the other firewall reads; a new app operation goes inagent/apps.go. - Register it in
routes()inagent/main.go, inside the block that matches its plane, wrapped in the role it needs. - If it changes anything, record an audit entry.
- Add it to the typed client in
ui/src/lib/api.ts. - Add it to the endpoint table in
docs/deploy.md. - Write a test.
Choosing the wrapper#
This is the decision that matters, and it is made once, in the registration line:
// Read plane: any authenticated principal.
mux.HandleFunc("GET /api/firewall/groups", readOnly(handleFirewallGroups))
// Write plane: operator, and a licence.
licensed := func(h http.HandlerFunc) http.HandlerFunc { return writeable(requireLicense(h)) }
mux.HandleFunc("POST /api/stage", licensed(handleStage))
// Admin plane.
mux.HandleFunc("PUT /api/admin/settings", adminOnly(handleUpdateSettings))
// A licensed feature, not merely a licence.
mux.HandleFunc("POST /api/fleet/{id}/configure", writeable(requireFeature("fleet", handleFleetConfigure)))
// A read that would otherwise hand secrets to a viewer.
mux.HandleFunc("GET /api/wireguard", readOnly(redactSecrets(RoleAdmin, handleWireGuard)))| Question | Answer |
|---|---|
| Does it change router configuration or agent state? | writeable(requireLicense(…)) — use the licensed helper that is already in scope |
| Does it change accounts, tokens, settings, the licence, power or images? | adminOnly |
| Does it only read? | readOnly |
| Could its response contain a private key, a pre-shared secret or a password hash? | Wrap it in redactSecrets(RoleAdmin, …) as well |
| Is it part of a plan feature rather than the base product? | requireFeature("<name>", …) |
| Must it answer before anyone has signed in? | Leave it unwrapped, and be certain — today only /health, /api/auth/status, /api/auth/login and the three OIDC handshake routes are |
What a handler looks like#
The conventions are consistent across all 108 registrations. A read:
func handleFirewallGroups(w http.ResponseWriter, r *http.Request) {
res, err := vyos.ShowConfig([]string{"firewall", "group"})
if err != nil {
writeError(w, 502, "config retrieve failed: %v", err)
return
}
if res.Error != nil {
writeError(w, 400, "VyOS: %s", *res.Error)
return
}
writeJSON(w, res.Data)
}Three helpers, all in agent/main.go:
| Helper | Use |
|---|---|
writeJSON(w, v) | A 200 with a JSON body |
writeError(w, code, format, args…) | A status with {"error": "…"} |
writeStatus(w, code, v) | A status with an arbitrary JSON body — how 402 carries the licence state alongside the message |
Status codes, and what they mean here#
| Code | When |
|---|---|
400 | The request was wrong, or the router refused the command for a reason the caller should read |
401 | No principal, or a session that has expired |
402 | No valid licence (or the plan lacks the feature). The body names the fix |
403 | Authenticated, but the role is not enough |
409 | The state does not permit it — a restart of an app that is stopped, for instance |
501 | Genuinely not implemented, with the manual command to run instead |
502 | The router could not be reached, or answered in a way the agent could not use |
The 400/502 split is load-bearing and is asserted by
agent/test.sh: a set VyOS rejects has to come back with the
router's own words, not a generic message. That test exists because the failure once
arrived as a 500 from a nil dereference inside a handler, and the operator never saw
why the router said no.
Validate before you stage#
handleStage is the model for input checking. It refuses an operation that is not set
or delete, an operation with no path, and any path element that is blank — each with a
message naming the offending index:
if op.Op != "set" && op.Op != "delete" {
writeError(w, 400, "op %d is %q, not set or delete", i, op.Op)
return
}The comment above that block says why: the Commit Bar renders exactly set and delete,
so anything else would render dishonestly, and a valueless path only fails later at commit
with an error that no longer names this request. Refuse while the request can still say
why.
Audit what you changed#
Any endpoint that changes something writes an audit entry, attributed to the principal
that made the request. Three helpers, in agent/main.go:
recordAudit(r, "operation-name", success, errMsg)
recordAuditPath(r, "operation-name", path, success, errMsg)
recordAuditFull(r, "operation-name", path, commands, success, errMsg)They fill in the actor, the role and the client IP from the request. Record the failure too — an audit log that only holds successes cannot answer "who tried".
Body size, path parameters and redaction#
- Body size is capped by
limitBodiesbefore the mux picks a handler. The default is 1 MiB; the routes that legitimately carry a whole router configuration are named one by one inbodyLimitand get 16 MiB —agent/security.go. A new endpoint that needs the larger cap gets an entry there, not a bigger global cap. The comment abovelimitBodiesrecords why it sits in front of the mux: one anonymous POST of a 400 MB body took the agent from 85 MB to 2.2 GB resident, and a second one the login limiter refused buffered just the same, because the decode ran before the refusal. - Path parameters use Go's own patterns:
DELETE /api/admin/tokens/{id}andr.PathValue("id"). Where a parameter is interpolated into something the router will parse, wrap the handler invalidPathValue(name, re, …)so a bad value is refused at the edge. - Redaction is a wrapper rather than a line in each handler because several endpoints
reach the same secrets by different code paths. The list of secret leaf names is
secretLeavesinagent/security.go; a new kind of secret in the configuration tree belongs in that map, not in a handler.
Then the other four places#
An endpoint is not finished when it answers.
| Place | What to add |
|---|---|
ui/src/lib/api.ts | A typed function on the api object, and its wire types. The client is written by hand; there is no generator |
docs/deploy.md | A row in the endpoint table. That table is the API contract — there is no OpenAPI document |
agent/endpoints_test.go | A behaviour test against a stand-in router. TestRoutesRegisterWithoutConflict in agent/main_test.go already catches a pattern that collides with an existing one |
agent/test.sh | A live check, if the endpoint is one an operator would notice breaking |
Re-derive the current list any time:
grep -o 'mux.HandleFunc("[A-Z]* [^"]*"' agent/main.go | sed 's/mux.HandleFunc("//;s/"$//' | sortSee also#
- Architecture — the middleware chain the wrappers sit inside.
- Building the agent — the source map, so the handler lands in the right file.
- The test suites — what CI will run against it.
- Adding a page to the web UI — the other half, when the endpoint has a screen.
- Endpoint index · Role matrix · Status codes · What a read hides — the four tables your new row joins.
- Read plane, write plane, admin plane — the model the wrappers enforce.
- Audit entries — every field an entry carries.
Checked against#
agent/main.go ·
agent/security.go ·
agent/authhttp.go ·
agent/license.go ·
agent/apps.go ·
agent/endpoints_test.go ·
agent/test.sh ·
ui/src/lib/api.ts ·
docs/deploy.md ·
README.md