diff --git a/docs/docs/admin-interface.md b/docs/docs/admin-interface.md index 50d99323..ed097a8e 100644 --- a/docs/docs/admin-interface.md +++ b/docs/docs/admin-interface.md @@ -8,10 +8,6 @@ import CliCommand from '@site/src/components/CliCommand'; # Admin Interface -:::info Labs Feature -The admin interface is a [labs feature](/labs) and is disabled by default. Enable it with `--labs adminapi` or `MIREN_LABS=adminapi` when starting the server. -::: - The admin interface allows you to expose custom administrative functions in your application that can be called from the CLI or other tooling. This is useful for user management, cache clearing, database operations, and other maintenance tasks. ## How It Works @@ -49,9 +45,15 @@ Your web service must expose: ### Security -Admin calls are authenticated using a bearer token that Miren generates for your app. Your app receives this token via the `ADMIN_TOKEN` environment variable. +Admin calls are authenticated using a bearer token that Miren generates for your app. Your app receives this token via the `ADMIN_TOKEN` environment variable and must validate it on every request. + +#### The admin token -**You must validate this token on every request:** +- **Format**: 32 random bytes, a random bearer token +- **Per-version**: a fresh token is generated for every new app version at build time, so each deploy rotates the token automatically. +- **Reserved env var**: `ADMIN_TOKEN` is injected by the runtime. It is appended after your own env vars so it cannot be overridden from `miren.toml`, the CLI, or build-time env. + +**Validate the token on every request, for example in Go:** ```go func authMiddleware(token string, next http.Handler) http.Handler { @@ -62,7 +64,10 @@ func authMiddleware(token string, next http.Handler) http.Handler { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - if strings.TrimPrefix(auth, "Bearer ") != token { + if subtle.ConstantTimeCompare( + []byte(strings.TrimPrefix(auth, "Bearer ")), + []byte(token), + ) != 1 { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -72,11 +77,26 @@ func authMiddleware(token string, next http.Handler) http.Handler { } ``` -The admin endpoint also receives an `X-Miren-Access` header: -- `internal` — Request is from Miren's admin system (trusted) -- `public` — Request is from an external client (Miren strips any client-provided value) +#### Network reachability and `X-Miren-Access` + +Miren's admin proxy talks to your app over its **internal** HTTP ingress path, never via the public route. Any public request to `/.well-known/miren/admin` is rejected with `404 Not Found` by the ingress before it can reach your app, so in practice your handler only ever sees requests carrying the `X-Miren-Access: internal` header. + +For reference, the two values the runtime uses are: + +- `X-Miren-Access: internal` — request originated from `miren admin` and was routed through Miren's internal admin path. +- `X-Miren-Access: public` — request arrived through the public ingress. Miren overwrites any client-supplied value here before forwarding, so the header cannot be spoofed by external callers. + +The bearer token is the primary guard; checking for `X-Miren-Access: internal` is a defense-in-depth signal in case the well-known path is ever exposed through a custom route. + +### Auditing + +Every admin call is appended to the **app's log stream** as an out-of-band entry (`source=admin`, `method=`) including the method name, params payload size, status (`ok` / `error=...`), and duration in milliseconds. You can review the audit trail with: + +```bash +miren logs +``` -You can use this header for additional access control if your endpoint is accidentally exposed to the internet. +This is server-side bookkeeping — your handler doesn't need to log calls itself to get an audit record. ### JSON-RPC 2.0 Format @@ -91,6 +111,16 @@ Requests follow the standard JSON-RPC 2.0 format: } ``` +`params` may be a JSON **object** (named arguments, recommended) or a JSON **array** (positional arguments). When the CLI describes a method that uses positional arguments via `$methods`, it labels the entries `arg0`, `arg1`, etc. + +#### Call timeout + +Each admin call has a fixed **30-second** timeout enforced by the runtime. Handlers should return promptly — if work takes longer, enqueue it on a background worker and return a job handle the caller can poll on a subsequent admin method. + +#### HTTP status semantics + +The runtime expects a `200 OK` response carrying a JSON-RPC envelope (success *or* error). A non-200 HTTP status is surfaced to the caller as a generic `admin endpoint returned status N` message with no structured detail. Prefer returning a JSON-RPC error object over a raw HTTP error code so the CLI can render a useful message and error code. + Successful responses: ```json @@ -130,6 +160,10 @@ Error responses: When you run `miren admin --list`, Miren sends a JSON-RPC request with the reserved method name `$methods` to your admin endpoint. If your app handles this method, it should return an array of objects describing the available admin methods. This is optional — if your app doesn't handle `$methods`, the `--list` command will report an error, but regular method calls still work. +:::note Reserved method names +Names beginning with `$` are reserved for the runtime. The CLI currently uses `$methods` for discovery and filters both `$methods` and `$type` out of `--list` output, so don't expose business logic under those names. +::: + The `$methods` request has no params: ```json @@ -186,6 +220,7 @@ package main import ( "context" + "crypto/subtle" "log" "net/http" "os" @@ -252,7 +287,10 @@ func authMiddleware(token string, next http.Handler) http.Handler { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - if strings.TrimPrefix(auth, "Bearer ") != token { + if subtle.ConstantTimeCompare( + []byte(strings.TrimPrefix(auth, "Bearer ")), + []byte(token), + ) != 1 { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -336,10 +374,96 @@ can expose admin methods. The key requirements are: - Handle JSON-RPC requests and return proper responses - Optionally implement `$methods` for introspection +See [More Implementation Examples](#more-implementation-examples) at the bottom of this page for ready-to-use Python, Node.js, and Bun snippets. + +## Calling Admin Methods + +Once your app exposes the admin interface, use the CLI to call methods. All commands target the active version of the app named with `--app` / `-a` (or inferred from the current directory). + +### Discovery and help + + + +```miren +# List every method the app advertises via $methods +miren admin --list -a myapp + +# Show parameter signature for one method (--func-help or -h ) +miren admin -a myapp get-user -h +miren admin -a myapp --func-help get-user +``` + + + +### Passing parameters + +You can pass parameters three ways, and mix them freely in one call. The CLI rejects the call if the same key shows up via more than one channel. + + + +```miren +# 1. Bare key=value pairs +miren admin -a myapp get-user user_id=user-1 + +# 2. Long flags: --key=value or --key value +miren admin -a myapp list-users --limit 50 --offset 0 + +# 3. A JSON params object from a file (use - for stdin) +miren admin -a myapp update-config -f settings.json +cat settings.json | miren admin -a myapp update-config -f - + +# Mixed: file supplies defaults, flag overrides one field +miren admin -a myapp update-config -f settings.json --debug=true +``` + + + +#### Type-aware parsing + +When the app advertises a parameter type via `$methods`, the CLI coerces the supplied string into that type before sending the JSON-RPC request: + +| Declared type | Accepted CLI input | +|---------------|--------------------| +| `string` | any value, passed through | +| `number`, `integer`, `int`, `float` | numeric literal (`42`, `3.14`, `-5`) | +| `boolean`, `bool` | `true` / `false` / `1` / `0` / `yes` / `no` | +| `object` | JSON object literal (`'{"k":"v"}'`) | +| `array` | JSON array literal (`'[1,2,3]'`) | + +If the app does not advertise types (or you pass `--no-validate`), the CLI tries to parse each value as JSON and falls back to a string. + +#### Kebab-case flag names + +If your method declares a snake_case parameter like `user_id`, you can write the equivalent kebab-case flag (`--user-id`) on the CLI and it will be normalized automatically. Keys that genuinely contain hyphens are left alone. + +### Output format + +The CLI chooses an output format based on context: + +- **TTY**: human-friendly pretty rendering (tables for uniform arrays, key/value lists otherwise). +- **Non-TTY** (pipes, scripts): syntax-highlighted JSON. + +Override with `--json` to force JSON or `--pretty` to force the rendered form. + +### Skipping validation + +If your app does not implement `$methods`, the CLI silently skips validation. To suppress validation explicitly — for example to call an undeclared diagnostic method — pass `--no-validate`: + +```bash +miren admin -a myapp --no-validate debug-internal +``` + +See [Admin Commands](/command/admin) for the full CLI flag reference. + +## More Implementation Examples + ### Python Example +Using Flask: + ```python from flask import Flask, request, jsonify +import hmac import os app = Flask(__name__) @@ -349,7 +473,7 @@ ADMIN_TOKEN = os.environ.get('ADMIN_TOKEN', '') def admin_endpoint(): # Validate token auth = request.headers.get('Authorization', '') - if ADMIN_TOKEN and auth != f'Bearer {ADMIN_TOKEN}': + if ADMIN_TOKEN and not hmac.compare_digest(auth, f'Bearer {ADMIN_TOKEN}'): return 'Unauthorized', 401 data = request.json @@ -382,29 +506,163 @@ def admin_endpoint(): }) ``` -## Calling Admin Methods +### Node.js Example -Once your app exposes the admin interface, use the CLI to call methods: +Using Express: - +```javascript +const express = require('express'); +const crypto = require('crypto'); -```miren -# List available methods -miren admin --list +const app = express(); +app.use(express.json()); -# Call a method -miren admin get-user user_id=user-1 +const ADMIN_TOKEN = process.env.ADMIN_TOKEN || ''; -# Call with complex parameters -miren admin update-config settings='{"debug": true}' +function tokenMatches(supplied, expected) { + const a = Buffer.from(supplied); + const b = Buffer.from(expected); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} -# Output as JSON (for scripting) -miren admin get-stats --json | jq '.total' +app.post('/.well-known/miren/admin', (req, res) => { + const auth = req.get('authorization') || ''; + if (ADMIN_TOKEN) { + if (!auth.startsWith('Bearer ') || !tokenMatches(auth.slice(7), ADMIN_TOKEN)) { + return res.status(401).send('Unauthorized'); + } + } + + const { method, params = {}, id } = req.body; + + if (method === '$methods') { + return res.json({ + jsonrpc: '2.0', + id, + result: [ + { + name: 'get-stats', + description: 'Get app statistics', + category: 'maintenance', + }, + { + name: 'get-user', + description: 'Get a specific user by ID', + category: 'users', + params: { user_id: 'string' }, + }, + ], + }); + } + + if (method === 'get-stats') { + return res.json({ + jsonrpc: '2.0', + id, + result: { users: 42, requests: 1000 }, + }); + } + + if (method === 'get-user') { + if (!params.user_id) { + return res.json({ + jsonrpc: '2.0', + id, + error: { code: -32602, message: 'user_id is required' }, + }); + } + return res.json({ + jsonrpc: '2.0', + id, + result: { id: params.user_id, name: 'Alice' }, + }); + } + + return res.json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: 'Method not found' }, + }); +}); + +const port = process.env.PORT || 8080; +app.listen(port, () => console.log(`listening on :${port}`)); ``` - +### Bun Example + +Bun's built-in HTTP server needs no dependencies: + +```typescript +import { timingSafeEqual } from 'node:crypto'; -See [Admin Commands](/command/admin) for full CLI documentation. +const ADMIN_TOKEN = process.env.ADMIN_TOKEN ?? ''; + +function tokenMatches(supplied: string, expected: string): boolean { + const a = Buffer.from(supplied); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +type RpcRequest = { + jsonrpc: '2.0'; + method: string; + params?: Record | unknown[]; + id: number | string | null; +}; + +function rpc(id: RpcRequest['id'], body: object): Response { + return Response.json({ jsonrpc: '2.0', id, ...body }); +} + +Bun.serve({ + port: Number(process.env.PORT ?? 8080), + async fetch(req) { + const url = new URL(req.url); + if (req.method !== 'POST' || url.pathname !== '/.well-known/miren/admin') { + return new Response('Not Found', { status: 404 }); + } + + if (ADMIN_TOKEN) { + const auth = req.headers.get('authorization') ?? ''; + if (!auth.startsWith('Bearer ') || !tokenMatches(auth.slice(7), ADMIN_TOKEN)) { + return new Response('Unauthorized', { status: 401 }); + } + } + + const { method, params = {}, id } = (await req.json()) as RpcRequest; + const p = params as Record; + + switch (method) { + case '$methods': + return rpc(id, { + result: [ + { name: 'get-stats', description: 'Get app statistics', category: 'maintenance' }, + { + name: 'get-user', + description: 'Get a specific user by ID', + category: 'users', + params: { user_id: 'string' }, + }, + ], + }); + + case 'get-stats': + return rpc(id, { result: { users: 42, requests: 1000 } }); + + case 'get-user': + if (!p.user_id) { + return rpc(id, { error: { code: -32602, message: 'user_id is required' } }); + } + return rpc(id, { result: { id: p.user_id, name: 'Alice' } }); + + default: + return rpc(id, { error: { code: -32601, message: 'Method not found' } }); + } + }, +}); +``` ## Next Steps