Skip to content

Add a local web interface - #8

Merged
rahmanow merged 1 commit into
masterfrom
claude/repo-setup-features-sv89xo
Aug 28, 2026
Merged

Add a local web interface#8
rahmanow merged 1 commit into
masterfrom
claude/repo-setup-features-sv89xo

Conversation

@rahmanow

Copy link
Copy Markdown
Owner

A new ui command serves a single-page interface covering everything the CLI does. This was the "Web App" item on outline-br's old roadmap.

node shadowboxKey.js ui
Web interface running. Open this URL:

  http://127.0.0.1:8787/?t=979ea2e12d7c5ba8e1d38631b2effd32583bca3b035775f3

It listens on localhost only, and the token in the URL authorises it.
Press Ctrl+C to stop.

The page lists keys with usage, limit and access URL, and handles add, rename, delete, per-key and server-wide caps, and QR codes. It follows the system light/dark theme.

No new dependencies. The page is inlined — no build step, no bundler, no CDN. The QR reuses qrcode-terminal's block output, which renders as a genuinely scannable code in the browser, so nothing new was needed for it either.

Security

The Management API URL is full administrative control of the server, so the interface is deliberately narrow. It never reaches the browser — the local server holds the credential and proxies each call. Three guards back that up:

  • Loopback only. Binds 127.0.0.1, so nothing else on the network can reach it.
  • Token-gated. A random token minted at each start, carried in the printed URL and required as a header on every API call. Another page in the same browser cannot drive it, and requiring a custom header means a cross-origin attempt hits a CORS preflight that is never answered. Compared in constant time.
  • Host-checked. Requests whose Host is not the loopback address are refused — this is what stops DNS rebinding from turning an attacker's domain into a route to 127.0.0.1.

Two bugs caught while testing

Both found before shipping rather than after, and both now covered by tests:

The page's own fetch calls were blocked by its own CSP. connect-src falls back to default-src, which was 'none', so the table stayed on "Loading…" forever. Only visible by driving the real page in a browser — every curl test passed. A test now asserts the directive is present.

start() built the request handler before listen() assigned a port. The Host check therefore compared against the requested port. With --port 0, where the kernel picks the port, every request would have been refused with 403. The handler is now built from the bound port.

Testing

62 tests, all passing (23 new). The server tests drive the handler over a real socket with a stand-in client, so they need no Outline server and no certificate — fast, and no openssl gate.

Beyond the unit tests I drove the actual page in headless Chromium: loading, adding a key through the dialog, opening the QR modal, setting a limit, and both colour schemes, with zero console errors. Contrast was measured rather than eyeballed — every text/background pair passes WCAG AA in both themes (link 5.17 light / 6.49 dark, muted URL 4.83 / 6.40). Also verified --port 0, port-in-use, an invalid port, and clean SIGTERM shutdown.

Also

  • getServerInfo() added to the client, so the interface can show the server-wide default cap.
  • README gains a Web interface section documenting the security model, plus command/option table rows and two troubleshooting entries.

Generated by Claude Code

A new 'ui' command serves a single-page interface over the Management
API, covering everything the CLI does: listing keys with usage and
limits, adding, renaming, deleting, per-key and server-wide caps, and QR
codes. It follows the system light or dark theme and adds no
dependencies — the page is inlined, and the QR reuses qrcode-terminal's
block output, which renders as a scannable code in the browser.

The Management API URL is full admin control of the server, so the
interface is deliberately narrow. It never reaches the browser: the local
server holds it and proxies. Three guards back that up — it binds
loopback only; every API call must carry a token minted at startup and
handed over in the printed URL, which also forces a CORS preflight on any
cross-origin attempt; and requests whose Host header is not the loopback
address are refused, which is what stops DNS rebinding.

Two bugs found while testing rather than after shipping:

- The page's own fetch calls were blocked by its Content-Security-Policy.
  connect-src falls back to default-src, which was 'none'. Caught by
  driving the real page in a browser; a test now asserts the directive.
- start() built the request handler before listen() assigned a port, so
  the Host check compared against the requested port. With --port 0 the
  kernel picks one and every request would have been refused. The handler
  is now built from the bound port.

Adds getServerInfo() to the client to read the server-wide limit, and 23
tests driving the handler over a real socket with a stand-in client, so
they need no Outline server or certificate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJR2DDBimsijgYgZS3bUS8
@rahmanow
rahmanow merged commit 4bd9d0d into master Aug 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants