Self-service portal
halo-server serves a developer portal (“kiosk”) next to the operator API. Developers sign in with the org’s OIDC provider, see which ring and profile they are on, get a launcher for the environment they want, and ask for access. Nothing they do changes production directly: enrollment issues a scoped device credential, and access requests end as pull requests a human merges.
Turn it on
Section titled “Turn it on”Two pieces of configuration. The policy side lives in the root file halos.yaml (see policy model); the server side is a JSON file passed as --portal-config.
# halos.yaml (excerpt)identity: {issuer: https://acme.okta.com/oauth2/default, clientID: halos-portal, adminGroups: [ai-platform]}selfService: enabled: true launchers: [devcontainer, codespaces, laptop] requestable: [mcp-server, ring-opt-in] catalog: [{name: linear, url: https://mcp.linear.app/sse}] enrollmentTTLSeconds: 900{ "baseURL": "https://halo.acme.example", "registry": "ghcr.io/acme/halos", "pubKeyFile": "/etc/halos/release.pub", "devcontainerFeature": "ghcr.io/acme/features/halos:1", "codespacesURL": "https://github.com/codespaces/new?ring={ring}", "halodURL": "https://dl.acme.example/halod-{os}-{arch}", "halodSHA256": {"linux-amd64": "<64 hex>", "linux-arm64": "<64 hex>", "darwin-arm64": "<64 hex>"}, "policyRepoDir": "/policy-writer/current", "policyBase": "main", "supportURL": "https://acme.slack.com/archives/C0HELP", "sessionKeyFile": "/run/secrets/session-key", "oidcClientSecretFile": "/run/secrets/oidc-client-secret"}Other fields: devcontainerImage, coderURL, policySubdir. supportURL is the “get help” link the kiosk shows developers next to the troubleshooting steps (halod status, what a posture 403 means). Unknown fields are rejected. baseURL and halodURL must be shell-safe because they are baked into generated scripts. halodSHA256 keys are <os>-<arch> and values 64 lowercase hex digits.
halo-server -policy-dir policy-repo -token-file fleet.token -listen 127.0.0.1:8080 \ -portal-config portal.json -data-dir /var/lib/halosFlags: -policy-dir and -token-file are required; -data-dir persists the request log, device store, audit log and kill list (empty means in-memory only); -verdicts-file points at the file halo exp analyze --verdicts-file writes; -device-ttl sets how long a device token lives after enrollment (default 2160h, 90 days; an expired device must re-enroll); -trusted-proxy-cidrs lists reverse proxies whose X-Forwarded-For is believed for rate limiting. The controller and kill-switch flags are covered in the CLI reference. Without -dev-insecure-user, the session key must be at least 32 bytes. -dev-insecure-user, -dev-insecure-groups and -dev-insecure-admin disable login for local demos only and log a warning.
Launchers
Section titled “Launchers”POST /api/v1/launch/{launcher} returns what the developer needs for their ring. A launcher works only if it is in selfService.launchers and its server config is present; otherwise the API answers 409 naming what is missing.
| Launcher | Returns | Needs in --portal-config |
|---|---|---|
devcontainer |
A .devcontainer/devcontainer.json snippet: the Feature with registry, ring, firewall: true, key URL, halodUrl, gatewayHost, plus NET_ADMIN/NET_RAW |
devcontainerFeature, registry |
codespaces |
A URL from codespacesURL with {ring} and {user} query-escaped |
codespacesURL |
coder |
A URL from coderURL |
coderURL |
laptop |
A one-time enrollment token, its expiry, and bash and PowerShell commands | baseURL, registry, pubKeyFile, halodURL, halodSHA256 |
A developer with no ring gets 409 you are not assigned to any ring.
Laptop enrollment
Section titled “Laptop enrollment”The token is 32 random bytes, stored server-side only as its SHA-256, single-use, and expires after enrollmentTTLSeconds (default 900). It is burned even if presented late.
# macOS / Linux, as printed by the portalcurl -fsSL https://halo.acme.example/enroll.sh | sh -s -- <token># Windows& ([scriptblock]::Create((irm https://halo.acme.example/enroll.ps1))) -Token <token>enroll.sh refuses to run without a pinned checksum for the machine’s OS and architecture, downloads halod, verifies its sha256 against the value pinned in the server config, installs it into the root-owned per-OS location, fetches the release public key from /enroll/release.pub, exchanges the token at POST /api/v1/enroll, and writes halod.yaml (mode 0600, root-owned; the server builds the file by marshaling a config struct, not by string templating, so values cannot inject YAML). It then runs halod service install --start so the first check-in happens immediately and keeps happening; where there is no launchd/systemd (a container) it prints the two commands to run instead (halod once, then halod service install --start). The script is served over your TLS; the binary it installs is hash-pinned. CI runs enroll.sh on a hosted macOS runner (delivery-macos) and enroll.ps1 on a hosted Windows runner (delivery-windows); neither has been run on a real developer laptop (UNVERIFIED).
Device tokens
Section titled “Device tokens”Enrollment returns a halod.yaml containing a per-device bearer token, shown exactly once. The server stores only its SHA-256 (plus user, groups at enrollment, timestamps and a revoked flag) in an append-only devices.jsonl under --data-dir (mode 0600).
- The developer sees their own devices at
GET /api/v1/me/devices(the kiosk’s “Your devices” card): each device’s last report and the same posture verdict the gateway uses, so “enrolled and compliant” is confirmed on the page they enrolled from, without an admin. haloduses it to askGET /api/v1/fleet/ringwhich ring to follow (resolved live from the policy and the user recorded at enrollment, so moving a person between rings needs no re-enrollment) and toPOST /api/v1/fleet/report.- Device tokens expire
--device-ttlafter enrollment (default 90 days). Re-enroll to renew. - Failed authentications are rate-limited per client IP (429 with
Retry-After). - Admins list devices at
GET /api/v1/devicesand revoke one atPOST /api/v1/devices/{id}/revoke. A revoked device’s next call gets 401. To log a person out of the web console everywhere, an admin callsPOST /api/v1/users/{id}/revoke-sessions; it invalidates that user’s existing sessions (the revocation is persisted). - Fleets that cannot enroll individually (dev containers, MDM) can report with the shared fleet token from
--token-file.
Back up devices.jsonl and the request log; see production deployment.
Access requests become PRs
Section titled “Access requests become PRs”A developer submits POST /api/v1/requests with a kind, an item and a justification (1 to 1000 characters). kind must be in selfService.requestable; a duplicate pending request from the same user for the same item is refused. Admins (members of identity.adminGroups) approve or deny.
| Kind | On approval |
|---|---|
ring-opt-in |
PR adding the requester to the ring’s membership.users. Only for rings with membership.optIn: true |
mcp-server |
PR appending the catalog server to the requester’s ring profile. This applies to everyone on that profile, not only the requester, and the PR says so |
model, harness |
Approval is recorded; no automatic patch exists, so a human makes the change |
The PR is opened on a branch halos/request-<id> from a dedicated clone of the policy repo (policyRepoDir), never the directory the server is serving, and it is never merged. Requires git and gh on the server. If no writer is configured, approvals are recorded and need a manual policy change.
Operator console
Section titled “Operator console”Admins (members of identity.adminGroups) get more of the same web app. Developers see only the kiosk and their own assignment. The pages that matter for operations:
| Page | Shows | Source |
|---|---|---|
| Releases | Each ring’s signature-verified pointer read from the registry: digest, seq, issue and expiry times with an expiry badge (amber under 48 hours, red once expired), how many reporting devices are on the ring’s current digest versus others, and drift counts. Shows cached state, flagged, when the registry is unreachable; only verified state is ever stored. Needs registry and pubKeyFile in the portal config |
GET /api/v1/releases |
| Device (from Fleet) | One machine: user, groups, enrollment and token expiry, last report (ring, release, per-harness want versus installed, drift, error code), and up to 50 recent reports, newest first. History is in memory and resets when the server restarts. Device token hashes are never shown | GET /api/v1/devices/{id} |
| Audit | Privileged actions, newest first, filterable by exact actor and by action. A badge shows whether the whole hash chain verifies and the head hash | GET /api/v1/audit |
| Experiments | Each experiment with its latest verdict, plus actions (below) | GET /api/v1/experiments |
| Toggles | Every feature toggle: search, axis filter, and status chips (on by default, killed, stale). A detail drawer shows the rules as sentences (“10% of ring1-canary”), the payload (names only, never header or env values), the kill state and history from the audit log, and a “Who gets it?” tester. Actions below | GET /api/v1/toggles, GET /api/v1/toggles/{name} |
The Audit page proves edits and removals in the middle of the log, not truncation of the newest entries: record the head hash elsewhere if that matters (security model).
Experiment actions
Section titled “Experiment actions”Two kinds of action, with different consequences:
- Start, pause and conclude open a pull request against the policy repo (
POST /api/v1/experiments/{name}/status). Nothing changes until a human merges it. The PR is opened from the dedicatedpolicyRepoDirclone, like access-request PRs. With no writer configured the API answers 501. - Kill switch takes effect at the gateways at their next poll (about 10 seconds) with no PR. The button appears only when the server exposes the kill switch (
GET /api/v1/capabilities), asks for a reason, and confirms before acting. See experiments.
Toggle actions
Section titled “Toggle actions”- Kill and restore use the signed kill list, like the experiment kill switch: both gateways and devices turn the toggle off at their next poll, with no release and no PR. The dialog requires a reason for a kill and the action is audit-logged (
POST /api/v1/toggles/{name}/kill,/unkill). The buttons appear only when the server exposes the kill switch. - Propose change opens a pull request (
POST /api/v1/toggles/{name}/propose) from the dedicatedpolicyRepoDirclone, like the status PRs: set the default, change a rule’s rollout percent (0matches nobody, a ramp-down), add or remove rings, groups or users of a rule, or move the expiry. The change is validated against the full policy guardrails first; one that would introduce an error (an unknown ring, a forbidden value) or widen a rule by emptying a list is refused with a 422 and opens nothing. The PR carries the diff and your reason, and a human merges it. With no writer configured the API answers 501. - Who gets it? (
GET /api/v1/toggles/{name}?user=&ring=&groups=) evaluates the toggle with the same code ashalo toggle evaland the gateway, and returns the rule trace. A killed toggle previews as off.
Every action above is recorded in the audit log with the admin’s identity.
API summary
Section titled “API summary”The complete list with auth levels is the API reference.
| Route | Who |
|---|---|
GET /auth/login, GET /auth/callback, POST /auth/logout |
Anyone |
GET /api/v1/me, /catalog, /harnesses, /whoami |
Signed-in developer (whoami: self only, admins any user) |
POST /api/v1/launch/{launcher} |
Signed-in developer |
POST/GET /api/v1/requests |
Signed-in developer |
POST /api/v1/requests/{id}/approve, /deny |
Admin |
POST /api/v1/users/{id}/revoke-sessions, POST /api/v1/devices/{id}/revoke |
Admin |
GET /api/v1/fleet, /policy, /experiments, /experiments/{name}, /toggles, /toggles/{name}, /devices, /devices/{id}, /releases, /audit, /killswitch, /rollouts, /rollouts/{name} |
Admin |
POST /api/v1/experiments/{name}/status, /kill, /unkill |
Admin |
POST /api/v1/toggles/{name}/propose, /kill, /unkill |
Admin |
GET /api/v1/capabilities |
Signed-in developer |
GET /api/v1/gateway/killswitch |
Gateway token |
POST /api/v1/fleet/report |
Fleet token or device token |
GET /api/v1/fleet/ring, GET /api/v1/fleet/killswitch |
Device token |
GET /enroll.sh, /enroll.ps1, /enroll/release.pub, /enroll/killswitch.pub, POST /api/v1/enroll |
Public; the token is the credential |
Responses carry a strict Content-Security-Policy, X-Frame-Options: DENY and Cache-Control: no-store on API paths.