Skip to content

Turn the web interface into an admin dashboard, and let it run in the background - #15

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

Turn the web interface into an admin dashboard, and let it run in the background#15
rahmanow merged 2 commits into
masterfrom
claude/repo-setup-features-sv89xo

Conversation

@rahmanow

Copy link
Copy Markdown
Owner

The web interface managed keys on the one server the environment named, for as long as you kept a terminal open. This makes it a dashboard that manages the servers too, and that can run in the background with a URL you can bookmark.

Two commits, readable in order.

Turn the web interface into a multi-server admin dashboard

Paste a server's access code from Outline Manager and it is saved for next time, so one panel covers every server you run rather than one shell per server.

Four sections, because they are different jobs: Overview for the shape of things, Access keys for the day-to-day, Servers for setup and when something has broken, Settings for what is stored where. The section lives in the URL hash, so a reload lands where you were. An unreachable server no longer blanks the page — Overview and Access keys say so and point at Servers, while Servers and Settings keep working, since that is where you go to fix it.

Saved servers live in ~/.config/shadowtools/config.json, written 0600 inside a 0700 directory and replaced by rename so an interrupted write cannot truncate a file full of credentials. The CLI reads the same file: servers add / use / remove, and --server to redirect one command. Access codes are read from stdin so they stay out of shell history. OUTLINE_API_URL still wins and appears in the panel as a read-only environment entry, so existing setups behave exactly as before.

The credential boundary is the reason for lib/registry.js. The old interface guaranteed the Management API URL never reached the browser; managing servers from the page would have quietly ended that. So the server list carries redacted previews, and the full access code is served by one endpoint that exists to reveal it, only when someone clicks the button asking for it.

QR codes in the dashboard are vector rather than block characters, reusing the encoder already vendored inside qrcode-terminal — no new dependency, guarded require, old output as fallback. Only a path built from the module matrix crosses the wire, so no part of an access URL reaches the markup.

Run the dashboard as a background service

shadowtools service install

Registers it with the service manager the platform already has — launchd on macOS, systemd's user instance on Linux — so it starts at login, comes back if it exits, and answers on the same URL every time. Alongside it: status, start, stop, restart, logs, url, uninstall.

Per-user agents only. Nothing runs as root, nothing installs system-wide, and no step asks for a password, which is the right privilege level for something holding one user's Outline credentials. Windows gets a refusal that points at ui rather than a half-working guess.

The token moves into ~/.config/shadowtools/token (0600) so the URL survives a restart and can be bookmarked. ui reads the same file, so it does not matter which way the dashboard was started. service url --rotate kills every old link.

A service definition is an ordinary file that other tooling reads and backup software copies, so OUTLINE_API_URL and OUTLINE_CERT_SHA256 are deliberately not carried into one. That makes a server configured only that way invisible to the background dashboard, so install says so when it sees them set.

Four defects found while verifying

Each was found by running the thing, not by reading it.

  1. redactApiUrl showed short secrets in full. The truncation was conditional on the secret being longer than the cap, so a shorter one passed through intact. Now keeps at most half. A test asserts the secret appears nowhere in the server list.

  2. No timeout on Management API requests. Adding a server at an address that drops packets — a wrong IP in a pasted code, a firewall — froze the dashboard for the OS TCP timeout, well over a minute. req.setTimeout() does not help, as it only arms once the socket connects; options.timeout does. Now 15s, OUTLINE_TIMEOUT_MS to override.

  3. service restart failed with Bootstrap failed: 5: Input/output error. launchctl bootout returns when it has asked for an unload, not when the job is gone. Restart now uses kickstart -k, and stop waits for the unload to finish — which also fixes installing over a running service.

  4. The service log contained the token. launchd creates a log file world-readable, and the dashboard prints its URL at startup, so every local user could read the token and drive the panel. The URL now prints only when stdout is a terminal; the log and its directory are created 0600/0700. A test runs the real CLI with stdout piped and asserts no token appears.

Testing

131 tests, up from 62. New coverage for the config store, the server registry, the dashboard's HTTP routes and guards driven over a real socket, the QR path (replayed into a grid and compared module for module against the encoder), and the service definitions — the plist is checked with plutil and round-tripped.

Beyond the suite, the dashboard was driven in a browser through every section and dialog, and the service was installed on a real machine, restarted repeatedly, reinstalled onto a different port, then uninstalled with launchctl and the filesystem confirming nothing was left behind.

Not included

Installing Shadowbox over SSH. The Servers section marks where it lands, and the registry is ready to take the access code an installer prints.

🤖 Generated with Claude Code

rahmanow and others added 2 commits August 28, 2026 15:08
The panel managed keys on the one server the environment named. It now
manages the servers too: paste the access code from Outline Manager and
it is saved for next time, so one dashboard covers every server you run
rather than one shell per server.

Four sections, because they are different jobs: Overview for the shape
of things, Access keys for the day-to-day, Servers for setup and when
something has broken, Settings for what is stored where. The section
lives in the URL hash so a reload lands where you were. An unreachable
server no longer blanks the page — Overview and Access keys say so and
point at Servers, while Servers and Settings keep working, since that
is where you go to fix it.

Saved servers live in ~/.config/shadowtools/config.json, written 0600
inside a 0700 directory and replaced by rename so an interrupted write
cannot truncate a file full of credentials. The CLI reads the same file:
servers add/use/remove, and --server to redirect one command. Access
codes are read from stdin so they stay out of shell history.
OUTLINE_API_URL still wins and appears in the panel as a read-only
'environment' entry, so existing setups behave exactly as before.

The credential boundary is the reason for the shape of lib/registry.js.
The old interface guaranteed the Management API URL never reached the
browser; managing servers from the page would have quietly ended that.
So the server list carries redacted previews, and the full access code
is served by one endpoint that exists to reveal it, only when someone
clicks the button asking for it.

Two defects found while verifying this, both fixed here:

  - redactApiUrl showed a secret of six characters or fewer in full,
    since the truncation was conditional on being longer than the cap.
    It now keeps at most half, so the string is never usable. A test
    asserts the secret does not appear anywhere in the server list.

  - Nothing bounded a Management API request. Adding a server at an
    address that drops packets — a wrong IP in a pasted code, a
    firewall — froze the dashboard for the operating system's TCP
    timeout with nothing on screen. req.setTimeout() does not help,
    as it only arms once the socket is connected; options.timeout
    does. Now 15s, OUTLINE_TIMEOUT_MS to override, and that same
    bootstrap fails in under a second naming the host.

QR codes in the dashboard are vector rather than block characters,
which scan off a screen and survive a screenshot. No new dependency:
qrcode-terminal already vendors an encoder, and the deep require is
guarded so a future version that moves it falls back to what the page
rendered before. Only a path built from the module matrix crosses the
wire, so no part of an access URL reaches the markup. A test replays
that path back into a grid and compares it module for module.

Installing Shadowbox over SSH is not here yet; the Servers section
marks where it lands, and the registry is ready to take the access code
an installer prints.

115 tests, up from 62.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ui` serves the panel for as long as you keep a terminal open, which is
the wrong shape for something you want to leave running and bookmark.
`service install` registers it with the service manager the platform
already has — launchd on macOS, systemd's user instance on Linux — so
it starts at login, comes back if it exits, and answers on the same URL
every time. Alongside it: status, start, stop, restart, logs, url,
uninstall.

Per-user agents only. Nothing runs as root, nothing installs
system-wide, and no step asks for a password, which is the right
privilege level for something holding one user's Outline credentials.
Windows gets a refusal that points at `ui`, rather than a half-working
guess.

The token moves out of the process and into
~/.config/shadowtools/token, mode 0600, so the URL survives a restart
and can be bookmarked. `ui` reads the same file, so it does not matter
which way the dashboard was started. Minting per run was the safer
default when the URL was read off a terminal and thrown away; it is the
wrong one once something is meant to keep serving. The file shares a
directory, a mode and a threat model with config.json, and anyone who
can read the token can read the Management API URLs beside it and skip
the dashboard entirely. `service url --rotate` kills every old link.

Two problems found by installing it and watching what happened:

  - `service restart` failed with 'Bootstrap failed: 5: Input/output
    error'. launchctl bootout returns when it has asked for an unload,
    not when the job is gone, so the bootstrap straight afterwards hit
    a job still registered. Restart now uses kickstart -k, which
    restarts a loaded job in one step with no window to race, and stop
    waits for the unload to actually finish — which also fixes
    installing over a service that is already running.

  - The service log had the token in it. launchd creates a log file
    world-readable, and the dashboard prints its URL at startup, so
    every local user could read the token and drive the panel. The URL
    is now printed only when stdout is a terminal; the log records that
    it is listening and nothing else. The log and its directory are
    created 0600/0700 rather than left at launchd's default. A test
    runs the real CLI with stdout piped and asserts no token appears.

A service definition is an ordinary file that other tooling reads and
backup software copies, so OUTLINE_API_URL and OUTLINE_CERT_SHA256 are
deliberately not carried into one — only locations and tunables are.
That means a server configured only through those variables is invisible
to the background dashboard, so install says so when it sees them set.

Verified end to end on this machine: installed, restarted repeatedly,
reinstalled onto a different port, driven in a browser, then uninstalled
with launchctl and the filesystem confirming nothing was left behind.

131 tests, up from 115.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rahmanow
rahmanow merged commit 2b23c56 into master Aug 28, 2026
4 checks passed
@rahmanow rahmanow mentioned this pull request Aug 28, 2026
rahmanow added a commit that referenced this pull request Aug 28, 2026
Version bump and CHANGELOG for 4.1.0. The code shipping here landed in #15.
@rahmanow
rahmanow deleted the claude/repo-setup-features-sv89xo branch August 28, 2026 19:50
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.

1 participant