Dashboard
krellbot ui binds a tiny dashboard to 127.0.0.1 on a random (or user-chosen) port and prints a one-shot URL like:
Dashboard running at http://127.0.0.1:53182/264a3861588691fc018fb24d303ce081530fc2593be9fb2b2781992930ab4d42/
The 64-hex-char path is a 32-byte token from secrets.token_hex(32). The same token becomes the krellbot_session cookie on the first GET. The server binds only to loopback; GET status does not probe Kraken, Coinbase, or the venue and shows historical local metadata. The Exchange form is different: its handler sends an authenticated HTTPS permission request to the selected venue.
krellbot ui # random port, prints URL krellbot ui --port 8080 # fixed port (still loopback)
Ctrl-C stops the server and returns 0.
What the server reads
The server is a ThreadingHTTPServer from the standard library, bound to 127.0.0.1 only. There is no host argument that can widen it. A request with a Host header other than 127.0.0.1:<port> or localhost:<port> is rejected with 403 Forbidden and sets no cookie.
The first page is /{token}/. That response sets two cookies:
krellbot_session— the gate token.HttpOnly; SameSite=Strict; Path=/.krellbot_csrf— a CSRF token.HttpOnly; SameSite=Strict; Path=/.
Both cookies are issued on the first good GET. They are not Secure because the page is served over plain HTTP on the loopback address. The session cookie's value matches the URL token; that match is checked with hmac.compare_digest. A request whose path token does not match the server's token is 403 and sets no cookie.
POSTs require:
- The
krellbot_sessioncookie to equal the URL token. - The
Originheader to behttp://127.0.0.1:<port>orhttp://localhost:<port>. - A
csrfform field whose value matches thekrellbot_csrfcookie.
Any of those three failing is 403 and changes no state.
What the dashboard reads from disk
For GET views — including the wizard pages and the per-venue status block — the server reads local state, including nonsecret disk metadata under $KRELLBOT_HOME and the detected keychain backend type. The historical status file is written after a credential POST when possible:
- Armed packs —
config.json, with mode, cap, version, pending version, resting stop, and owned qty. - Paper state —
run/paper-<venue>.json, with balances and resting stops. - Journal tail —
journal/*.jsonl, the most-recent 20 records. - License cache —
catalog/license-cache.json, ormissing. - Receipts — file listing of
receipts/. - Per-venue key status —
keys-onboarding-status.json: nonsecret metadata only. No key or secret bytes.
The GET status view does not contact Kraken, Coinbase, or krellbot.dev. Explicit docs exit links can redirect your browser to krellbot.dev/docs or to the public source repository; those navigation links do not carry exchange credentials. The status block is HISTORICAL — derived solely from the durable timestamp written by the last credential POST. Current key presence is not checked, current connectivity is not implied, current keyring contents are not read. CLI-side rotation cannot be detected by the page.
Credential-source precedence when the engine later loads a key is a complete KRELLBOT_<VEN>_KEY and KRELLBOT_<VEN>_SECRET environment pair, then the keyring, then an explicitly configured KRELLBOT_<VEN>_KEYFILE if no keyring pair exists. A partial environment pair is an error. The historical browser status does not prove which credential a later trade will use.
The wizard Exchange step is the single credential-bearing path
When the dashboard is opened by krellbot ui on a fresh install, the four-step wizard renders Welcome → Security → Exchange → Next. Of those, the Exchange step is the only place a credentialed form lives:
- Welcome.
- Security. Read-only local trust posture.
- Exchange. Kraken or Coinbase. One credential-bearing POST.
- Next. Enter the dashboard.
The browser form action is keys/add on the loopback dashboard — the browser POST stays on the machine. The Exchange handler sends an authenticated HTTPS request to the selected Kraken or Coinbase venue to verify trade-only permission, and on success stores the pair in the native OS keychain via keys_onboarding.probe_and_store.
The venue probe is conditional on the operator submitting the Exchange form. krellbot.dev never receives the exchange key or secret via this form. The credential is never echoed back or written to a receipt or journal; the form has autocomplete="off" on every secret field.
The four-step wizard ships with the frozen binary. On a host whose chip / OS combination is not in the manifest, the install endpoint returns 503 and the wizard is not yet reachable — use the CLI path shown in Getting started until the manifest entry for that combination is published. The dashboard as a server is reachable from the installed engine; the four-step wizard UI is the part that requires the matching manifest entry on disk.
What the GET status page does NOT do
- The GET status page does not probe the venue. There is no live connectivity check on every load — the GET never makes a network call to Kraken, Coinbase, or krellbot.dev.
- The GET status page does not read the API key. The status module never calls
keyring.get_password(..., 'key')for a presence test; status derives solely from the durable, nonsecret status file. - The GET status page does not infer current connectivity from the historical timestamp. "Last stored at <ts>" is HISTORY, not "currently connected".
- CLI-side key removal or rotation is not detectable from the GET status page; the operator can run
krellbot doctorfor a current-state readout.
Actions
- Arm paper — submits
pack_path,venue,paper_balance, andmode=paper. Live arm from the page is403; the typed confirmation for live is a CLI-only path. - Disarm — submits
venue+pair; removes the armed record. - Stop all — disarms every armed pack and, for each paper pack that still owns quantity, sells that qty at the last fill (or the resting stop) minus the paper fee and slippage. It does not sell coins the pack does not own, and it does not invent a price.
GET /is 403. The gate token is only in the URL the CLI prints. - Adopt pending — moves
pending_versionintopack_version. Refused while the pack still owns quantity.
Those actions submit to relative URLs (arm, disarm, stop_all, adopt) and the server reads the form body itself. The Exchange POST's outbound venue probe is described above. There is no third-party CDN or Google font.
Static assets
src/krellbot/ui/static/ contains only relative references: index.html loads static/style.css and static/app.js. The HTML, CSS, and JS files contain no http://, no https://, and no protocol-relative // URLs. The repository enforces this with rg -n "https?://" src/krellbot/ui/static, which must come back empty.
Tests
tests/test_ui_server.py covers the seven contract points in one shot: foreign host header rejection, CSRF enforcement, loopback bind, no external URLs in static, stop-all disarming, live-arm refusal, and adopt-while-long refusal. Every test starts a DashboardServer on a thread with port=0, reads the bound port back, talks to it over http.client, and tears the server down in finally. No sleep-to-poll.
Threat model
- The bind address is fixed at
127.0.0.1. The OS will not deliver a packet to it from another host. - The gate token is 32 bytes from
secrets.token_hex(32). Brute-forcing the URL is2**256of work, andhmac.compare_digestmakes timing oracles useless. - The
Hostheader check rejects requests that name a different host, even with the right port, so a DNS rebinding attacker cannot trick a browser into sending a request to a hostile origin and reading the response. - The CSRF check on POSTs stops a malicious site from submitting forms to the dashboard when the user's browser has a session cookie, because the malicious site cannot set the matching
krellbot_csrfcookie on the loopback origin. - Live arm from the page is forbidden by construction. The CLI's typed
LIVEconfirmation stays the only path to a live-armed pack.