Turn the web interface into an admin dashboard, and let it run in the background - #15
Merged
Merged
Conversation
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>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, written0600inside a0700directory 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--serverto redirect one command. Access codes are read from stdin so they stay out of shell history.OUTLINE_API_URLstill 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
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
uirather than a half-working guess.The token moves into
~/.config/shadowtools/token(0600) so the URL survives a restart and can be bookmarked.uireads the same file, so it does not matter which way the dashboard was started.service url --rotatekills every old link.A service definition is an ordinary file that other tooling reads and backup software copies, so
OUTLINE_API_URLandOUTLINE_CERT_SHA256are deliberately not carried into one. That makes a server configured only that way invisible to the background dashboard, soinstallsays so when it sees them set.Four defects found while verifying
Each was found by running the thing, not by reading it.
redactApiUrlshowed 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.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.timeoutdoes. Now 15s,OUTLINE_TIMEOUT_MSto override.service restartfailed withBootstrap failed: 5: Input/output error.launchctl bootoutreturns when it has asked for an unload, not when the job is gone. Restart now useskickstart -k, andstopwaits for the unload to finish — which also fixes installing over a running service.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
plutiland 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
launchctland 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