From 3e1f67e75bebb9a4b096a39badd7ab45a26bc449 Mon Sep 17 00:00:00 2001 From: Azat Date: Fri, 28 Aug 2026 15:01:31 -0400 Subject: [PATCH 1/2] Turn the web interface into a multi-server admin dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 47 ++ README.md | 146 ++++-- lib/config.js | 231 +++++++++ lib/outline.js | 29 +- lib/qr.js | 74 +++ lib/registry.js | 236 +++++++++ lib/server.js | 209 ++++++-- lib/web.js | 1084 ++++++++++++++++++++++++++++++++--------- package-lock.json | 7 +- package.json | 4 +- shadowtools.js | 294 ++++++++--- test/api.test.js | 57 +++ test/config.test.js | 139 ++++++ test/qr.test.js | 71 +++ test/registry.test.js | 179 +++++++ test/server.test.js | 194 +++++++- 16 files changed, 2633 insertions(+), 368 deletions(-) create mode 100644 lib/config.js create mode 100644 lib/qr.js create mode 100644 lib/registry.js create mode 100644 test/config.test.js create mode 100644 test/qr.test.js create mode 100644 test/registry.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 173a08e..3d92d94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,53 @@ Notable changes to shadowtools. The format follows ## [Unreleased] +### Added + +- **The web interface is now a multi-server admin dashboard.** It manages the + servers as well as the keys: paste a server's access code from Outline + Manager and it is saved for next time, so one panel covers every server you + run. Four sections — Overview, Access keys, Servers, Settings — with the + section in the URL hash so a reload lands where you were. +- **Saved servers**, 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. `SHADOWTOOLS_CONFIG` moves it. +- **`servers` commands** — `servers`, `servers add`, `servers use`, + `servers remove` — and `--server` to point one command at a server without + changing the active one. Access codes are read from stdin, so they stay out + of shell history. +- **`OUTLINE_TIMEOUT_MS`**, for the new Management API request timeout. + +### Changed + +- `OUTLINE_API_URL` still takes precedence and now appears in the dashboard as + a read-only *environment* entry, so existing setups behave exactly as before. +- The dashboard's QR codes are vector rather than block characters, so they + scan off a screen and survive a screenshot. No new dependency: the encoder + already vendored inside `qrcode-terminal` is reused, behind a guarded + require that falls back to the previous block output. +- An unreachable server no longer blanks the page. Overview and Access keys + report it and point at Servers; Servers and Settings keep working, since + that is where the problem gets fixed. + +### Fixed + +- **A short access-code secret was displayed in full.** The redaction that + shortens a Management API URL for display only truncated secrets longer than + its cap, so a shorter one passed through intact. It now keeps at most half. +- **A Management API request had no timeout.** An address that drops packets + rather than refusing the connection — a wrong IP in a pasted access code, a + firewall — hung for the operating system's TCP timeout, well over a minute, + which in the dashboard looked like a frozen page. Requests now give up after + 15 seconds and say which host did not answer. + +### Security + +- Adding servers to the dashboard could have ended the guarantee that a + Management API URL never reaches the browser. It does not: the server list + the page receives carries redacted URLs, and the full access code is served + by a single endpoint that exists to reveal it, only when someone clicks the + button that asks for it. + ## [4.0.1] — 2026-08-28 ### Changed diff --git a/README.md b/README.md index a6387e5..c57433b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ [![CI](https://github.com/rahmanow/shadowtools/actions/workflows/ci.yml/badge.svg)](https://github.com/rahmanow/shadowtools/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/shadowtools.svg)](https://www.npmjs.com/package/shadowtools) -Manage access keys on your [Outline VPN](https://getoutline.org/) (Shadowbox) server, from the terminal, a local web interface, or your own code. It lists, creates, renames and deletes keys, sets per-key data limits, reports how much data each key has used, and prints scannable QR codes so people can onboard with the Outline app instead of copy-pasting `ss://` strings. +Manage access keys on your [Outline VPN](https://getoutline.org/) (Shadowbox) servers, from a local admin dashboard, the terminal, or your own code. It lists, creates, renames and deletes keys, sets per-key data limits, reports how much data each key has used, and shows scannable QR codes so people can onboard with the Outline app instead of copy-pasting `ss://` strings. + +The dashboard manages the servers too: paste the 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. It can also rewrite every access URL to use your own domain in place of the server's raw IP address — handy when you have pointed a domain at your Outline server, or when your provider's IP has been blocked and you have restored the server elsewhere. @@ -12,8 +14,8 @@ This project supersedes [outline-br](https://github.com/rahmanow/outline-br), wh ## Prerequisites - [Node.js](https://nodejs.org/) 14 or newer (with npm) -- An Outline server set up via [Outline Manager](https://getoutline.org/get-started/) -- Your server's **Management API URL** — in Outline Manager open your server, go to **Settings**, and copy the *Management API URL* (it looks like `https://1.2.3.4:16942/AbCdEf123...`) +- One or more Outline servers set up via [Outline Manager](https://getoutline.org/get-started/) +- Each server's **access code** — in Outline Manager open the server, go to **Settings**, and copy the line under *Management API URL* ## Installation @@ -25,20 +27,40 @@ npm install ## Configuration -Configure the tool either with environment variables (recommended — keeps secrets out of the code) or by editing the constants at the top of `shadowtools.js`. - -| Setting | Environment variable | Description | -| --- | --- | --- | -| Management API URL | `OUTLINE_API_URL` | The Management API URL copied from Outline Manager. **Required.** | -| Certificate fingerprint | `OUTLINE_CERT_SHA256` | The server's `certSha256`. Optional but **recommended** — see [certificate pinning](#certificate-pinning). | -| Custom domain | `OUTLINE_DOMAIN` | A domain that points at your Outline server. Optional — leave empty to keep the raw IP in the access URLs. | - -Outline Manager gives you the first two together. Under **Settings → Management API URL** it shows a line like: +Everything starts from your server's **access code**. In Outline Manager, open your server and go to **Settings → Management API URL**; it shows a line like: ```json {"apiUrl":"https://1.2.3.4:16942/AbCdEf123","certSha256":"E3823F9BB490D354...52F5A584"} ``` +> ⚠️ That line grants full administrative control of your Outline server. Treat it like a password: don't commit it to version control or share it. + +There are two ways to give it to shadowtools, and they work together. + +### Add it in the dashboard (easiest, and handles several servers) + +```bash +node shadowtools.js ui +``` + +Open the printed URL, go to **Servers → Add server**, and paste the access code. It is saved to a config file readable only by you, so the CLI picks it up too and you do not have to paste it again. Repeat for each server you run, and switch between them from the header. + +You can do the same from the terminal — the access code is read from stdin so it stays out of your shell history: + +```bash +pbpaste | node shadowtools.js servers add Frankfurt +``` + +### Or set environment variables (one server, nothing on disk) + +| Setting | Environment variable | Description | +| --- | --- | --- | +| Management API URL | `OUTLINE_API_URL` | The `apiUrl` from the access code. | +| Certificate fingerprint | `OUTLINE_CERT_SHA256` | The `certSha256` from the same line. Optional but **recommended** — see [certificate pinning](#certificate-pinning). | +| Custom domain | `OUTLINE_DOMAIN` | A domain that points at your Outline server. Optional — leave empty to keep the raw IP in the access URLs. | +| Config file location | `SHADOWTOOLS_CONFIG` | Override where saved servers are stored. Optional. | +| Request timeout | `OUTLINE_TIMEOUT_MS` | How long to wait for a Management API response, in milliseconds. Optional, default 15000. | + A convenient way to keep these out of your shell history is a `.env` file (already git-ignored) that you source before running: ```bash @@ -52,7 +74,13 @@ export OUTLINE_DOMAIN="vpn.example.com" source .env ``` -> ⚠️ The Management API URL grants full administrative control of your Outline server. Treat it like a password: don't commit it to version control or share it. +A server configured this way appears in the dashboard alongside your saved ones, marked **environment**. It is never written to the config file, and it cannot be edited or removed from the panel — change the variables instead. You can still make it the active server with one click. + +### Where saved servers live + +`$XDG_CONFIG_HOME/shadowtools/config.json`, or `~/.config/shadowtools/config.json` when that is unset. The file is written mode `0600` inside a `0700` directory, because it holds Management API URLs: anyone who can read it can administer every server listed in it. Point `SHADOWTOOLS_CONFIG` somewhere else if you prefer. + +The editing constants at the top of `shadowtools.js` still work, if you would rather not use either mechanism. ## Commands @@ -70,17 +98,24 @@ node shadowtools.js [options] | `limit server ` | Set or clear the server-wide default data limit | | `usage` | Show how much data each key has transferred | | `qr ` | Print a key's access URL as a scannable QR code | -| `ui` | Open a local web interface covering all of the above | +| `servers` | List the Outline servers this machine knows about | +| `servers add ` | Save a server, reading its access code from stdin | +| `servers use ` | Choose the server other commands act on | +| `servers remove ` | Forget a saved server (the server itself is untouched) | +| `ui` | Open the admin dashboard, covering all of the above | -`` may be either a key id or a key name. Running with no command at all is the same as `list`, so the original behaviour still works. +`` may be either a key id or a key name, and `` either a server id or a server name. Running with no command at all is the same as `list`, so the original behaviour still works. + +Key commands act on whichever server is active. Use `servers use` to change that, or `--server` to redirect a single command without changing it. | Option | Applies to | Effect | | --- | --- | --- | | `--qr` | `list`, `add` | Also print a QR code for each key | -| `--json` | `list`, `usage` | Output JSON instead of a table | -| `--csv` | `list`, `usage` | Output CSV instead of a table | +| `--json` | `list`, `usage`, `servers` | Output JSON instead of a table | +| `--csv` | `list`, `usage`, `servers` | Output CSV instead of a table | | `--limit ` | `add` | Give the new key a data limit straight away | -| `--port ` | `ui` | Port for the web interface (default 8787) | +| `--server ` | key commands | Act on this server instead of the active one | +| `--port ` | `ui` | Port for the dashboard (default 8787) | | `-h`, `--help` | — | Show usage | Sizes accept a unit suffix: `10GB`, `500MB`, `2TB`, or a plain byte count. @@ -136,7 +171,20 @@ Export usage for a spreadsheet: node shadowtools.js usage --csv > usage.csv ``` -## Web interface +Work across servers: + +```console +$ node shadowtools.js servers + ID NAME HOST SOURCE CERT PINNED +- ------------ --------- ------------ ------ ----------- +* 7009f5d5ff9d Frankfurt 198.51.100.7 saved yes + d1cf000628fc Singapore 203.0.113.4 saved no + +$ node shadowtools.js list --server Singapore +$ node shadowtools.js servers use Singapore +``` + +## Admin dashboard If you would rather click than type: @@ -147,7 +195,7 @@ node shadowtools.js ui It prints a URL to open: ``` -Web interface running. Open this URL: +Dashboard running. Open this URL: http://127.0.0.1:8787/?t=979ea2e12d7c5ba8e1d38631b2effd32583bca3b035775f3 @@ -155,19 +203,42 @@ It listens on localhost only, and the token in the URL authorises it. Press Ctrl+C to stop. ``` -The page lists every key with its usage, limit and access URL, and lets you add, -rename, delete, cap and show a QR code for any of them, plus set the server-wide -default cap. It follows your system light or dark theme. Use `--port` to move it -off 8787. +You can run it before configuring anything: with no servers yet, it opens on the +Servers section so you can paste your first access code. Use `--port` to move it +off 8787. It follows your system light or dark theme. + +Four sections, switched from the sidebar: + +| Section | What it covers | +| --- | --- | +| **Overview** | Key count, total transferred, the default cap, and which keys have hit their limit | +| **Access keys** | Every key with its usage, limit and access URL — add, rename, cap, delete, copy, or show a QR code | +| **Servers** | Add, edit, test, switch between and remove servers; reveal a server's access code to copy elsewhere | +| **Settings** | The active server's domain and default data cap, and where saved servers are stored | + +The server picker in the header switches everything at once, with a dot showing +whether that server is answering. When one is unreachable, Overview and Access +keys say so and point you at Servers rather than showing a blank page — and +Servers and Settings keep working, since that is where you go to fix it. + +### Installing Shadowbox on a new server + +Not yet — the Servers section marks where it lands. Today, provision a server +with Outline Manager and paste its access code into the dashboard. The plan is +to connect to a fresh host over SSH, run the Outline installer on it, and have +the resulting access code register itself, with no round trip through Outline +Manager. ### How it is secured -The Management API URL is full administrative control of your Outline server, so -the interface is deliberately narrow: +A Management API URL is full administrative control of an Outline server, so the +dashboard is deliberately narrow: -- **It never leaves the process.** The browser talks only to this local server, - which holds the credential and proxies each call. Nothing sensitive is sent to - the page. +- **Credentials never leave the process.** The browser talks only to this local + server, which holds them and proxies each call. The server list the page + receives carries redacted URLs — enough to tell two servers apart, not enough + to use. The full access code is served by one endpoint that exists for that + purpose, and only when you click the button that asks for it. - **Loopback only.** It binds `127.0.0.1`, so nothing else on your network can reach it — not a shared-hosting concern, a deliberate limit. - **Token-gated.** A random token is minted at each start and carried in the @@ -256,10 +327,13 @@ New code should prefer `listKeys()`, which returns structured data and leaves pr index.js Programmatic API: listKeys, getUsage, getKeys shadowtools.js CLI entry point: argument parsing and commands lib/outline.js Outline Management API client +lib/config.js Saved servers: access-code parsing and the on-disk store +lib/registry.js The servers this install knows about, and their clients lib/format.js Byte formatting, size parsing, table/CSV output lib/errors.js UserError, for messages shown without a stack trace -lib/server.js Local web interface: HTTP routes and their guards -lib/web.js The interface's page, inlined so it needs no assets +lib/server.js Dashboard: HTTP routes and their guards +lib/web.js The dashboard page, inlined so it needs no assets +lib/qr.js Vector QR codes for the dashboard test/ Tests, run with the built-in Node test runner ``` @@ -271,7 +345,7 @@ Run the test suite: npm test ``` -The tests cover the pure logic — size parsing and formatting, access-URL rewriting, table and CSV rendering, argument parsing and key lookup — and need Node 18 or newer for the built-in test runner, even though the tool itself runs on Node 14+. +The tests cover the pure logic — size parsing and formatting, access-URL rewriting, table and CSV rendering, argument parsing and key lookup — along with the config store, the server registry, the dashboard's HTTP routes and its guards, driven over a real socket against a stand-in Outline server. They need Node 18 or newer for the built-in test runner, even though the tool itself runs on Node 14+. Every push and pull request runs the suite on Node 18, 20 and 22 via GitHub Actions, along with a syntax check and an audit of production dependencies. See [`.github/workflows/ci.yml`](.github/workflows/ci.yml). @@ -339,13 +413,17 @@ The HTTP calls use the built-in `node:https` module because the global `fetch()` ## Troubleshooting -- **`Please configure your Management API URL first`** — set `OUTLINE_API_URL`, or replace the placeholder in `shadowtools.js`. +- **`No Outline server is configured yet`** — add one in the dashboard (`node shadowtools.js ui`), pipe an access code into `servers add`, or set `OUTLINE_API_URL`. +- **`That server is already saved as ...`** — the same Management API URL is already in your config; edit that entry instead of adding a second one. +- **`comes from OUTLINE_API_URL in your environment`** — that server is defined by environment variables, so the dashboard will not rewrite it. Change the variables, or add it as a saved server. +- **`... is not valid JSON, so it was left untouched`** — the config file was hand-edited into an invalid state. Fix or delete it; nothing is overwritten until it parses. - **`Could not reach the Outline server`** — check that the Management API URL is correct and that its port (usually `16942`) is reachable from your machine. +- **`did not respond within 15 seconds`** — the address accepted nothing and refused nothing, which usually means a wrong IP or a firewall dropping the port. Raise `OUTLINE_TIMEOUT_MS` if your link is genuinely that slow. - **`Cannot find module 'qrcode-terminal'`** — run `npm install` in the project directory first. - **`The server presented an unexpected TLS certificate`** — either `OUTLINE_CERT_SHA256` is stale (recopy `certSha256` from Outline Manager after rebuilding or migrating the server), or something other than your Outline server answered. See [certificate pinning](#certificate-pinning). - **`is not a SHA-256 certificate fingerprint`** — `OUTLINE_CERT_SHA256` must be 64 hex characters, with or without colons. - **`Port 8787 is already in use`** — something else has the port; pass `--port 9000` (or any free port). -- **The web interface says the token is invalid** — it is regenerated on every start, so reopen the URL currently printed in your terminal. +- **The dashboard says the token is invalid** — it is regenerated on every start, so reopen the URL currently printed in your terminal. - **`Management API responded with 404`** — your Outline server may be running an older release that lacks data-limit or metrics endpoints. Upgrade the server, or stick to `list`, `add`, `remove` and `rename`. - **Keys print with the IP instead of your domain** — make sure `OUTLINE_DOMAIN` (or the `domain` constant) is set and non-empty. diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..8ccc9ef --- /dev/null +++ b/lib/config.js @@ -0,0 +1,231 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); + +const { UserError } = require('./errors'); + +/** + * Where the dashboard keeps the servers it manages. + * + * Until now the Management API URL only ever came from the environment, which + * is fine for one server driven from one shell. The dashboard manages several, + * and adding one there has to outlive the process, so the credential lands on + * disk. That is a real widening of the blast radius, so the file is written + * 0600 inside a 0700 directory and never leaves this machine — the same + * treatment an SSH private key gets, for the same reason: whoever reads it has + * full administrative control of every server listed in it. + * + * Environment variables still win over anything stored here, so an existing + * setup keeps behaving exactly as it did and CI never reads a stray file. + */ + +const VERSION = 1; + +/** + * The id of the server configured through OUTLINE_API_URL. It is reserved: the + * environment entry is never written to the file, but it can be the active + * choice, so the id has to survive a load/save round trip. + */ +const ENV_SERVER_ID = 'env'; + +/** Resolves the config file path, honouring SHADOWTOOLS_CONFIG and XDG_CONFIG_HOME. */ +function configPath() { + if (process.env.SHADOWTOOLS_CONFIG) return process.env.SHADOWTOOLS_CONFIG; + const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + return path.join(base, 'shadowtools', 'config.json'); +} + +function emptyConfig() { + return { version: VERSION, activeServerId: null, servers: [] }; +} + +function newId() { + return crypto.randomBytes(6).toString('hex'); +} + +/** + * Pulls an apiUrl and certSha256 out of whatever the user pasted. + * + * Outline Manager shows the pair as one JSON blob under Settings, and that blob + * is what people copy, so accept it verbatim — including when a mail client has + * wrapped it across lines. A bare Management API URL is accepted too, since + * that is the other thing that gets passed around, and older docs only mention + * the URL. + */ +function parseAccessCode(input) { + const text = String(input == null ? '' : input).trim(); + if (!text) throw new UserError('Paste the access code from Outline Manager > Settings.'); + + let apiUrl = text; + let certSha256 = ''; + + // The JSON form, possibly with the surrounding prose people copy along with it. + const braces = text.indexOf('{'); + if (braces !== -1) { + const slice = text.slice(braces, text.lastIndexOf('}') + 1); + + // Retry with whitespace removed if the first parse fails. A mail client + // or chat window that wraps the line puts a raw newline inside a string, + // which JSON rejects outright — and neither field here can legitimately + // contain whitespace, so collapsing it can only recover a valid paste. + let parsed; + for (const candidate of [slice, slice.replace(/\s+/g, '')]) { + try { + parsed = JSON.parse(candidate); + break; + } catch (err) { /* try the next form */ } + } + if (!parsed || typeof parsed !== 'object') { + throw new UserError( + 'That looks like the JSON access code but could not be parsed. ' + + 'Copy the whole line from Outline Manager > Settings, braces included.' + ); + } + + apiUrl = String(parsed.apiUrl || '').trim(); + certSha256 = String(parsed.certSha256 || '').trim(); + } + + // Whitespace inside a copied URL is always a wrapped line, never meaningful. + apiUrl = apiUrl.replace(/\s+/g, ''); + + let url; + try { + url = new URL(apiUrl); + } catch (err) { + throw new UserError(`"${apiUrl}" is not a Management API URL. It should look like https://1.2.3.4:16942/AbCdEf123.`); + } + + if (url.protocol !== 'https:') { + throw new UserError('A Management API URL must use https.'); + } + // The path component is the admin secret; without it the URL is useless. + if (url.pathname.replace(/\/+$/, '') === '') { + throw new UserError( + 'That Management API URL has no secret path. Copy the whole URL from ' + + 'Outline Manager > Settings, including the part after the port.' + ); + } + + return { apiUrl: apiUrl.replace(/\/+$/, ''), certSha256 }; +} + +/** + * Shortens a Management API URL for display, keeping enough of the secret to + * tell two servers apart without putting the whole credential on screen. + * + * Never more than half the secret, so a short one is not simply printed in + * full: the point is that this string can appear anywhere — a dashboard, a + * screenshot, a support thread — without being usable. + */ +function redactApiUrl(apiUrl) { + try { + const url = new URL(apiUrl); + const secret = url.pathname.replace(/^\/+/, ''); + const keep = Math.min(6, Math.floor(secret.length / 2)); + return `${url.protocol}//${url.host}/${secret.slice(0, keep)}…`; + } catch (err) { + return ''; + } +} + +/** Fills in defaults and drops anything unrecognised from a file we did not write. */ +function normalizeConfig(raw) { + const config = emptyConfig(); + if (!raw || typeof raw !== 'object') return config; + + const servers = Array.isArray(raw.servers) ? raw.servers : []; + config.servers = servers + .filter(server => server && typeof server.apiUrl === 'string' && server.apiUrl) + .map(server => ({ + id: String(server.id || newId()), + name: String(server.name || '').trim(), + apiUrl: String(server.apiUrl).replace(/\/+$/, ''), + certSha256: String(server.certSha256 || ''), + domain: String(server.domain || '').trim(), + createdAt: String(server.createdAt || ''), + })); + + const active = raw.activeServerId ? String(raw.activeServerId) : null; + const known = active === ENV_SERVER_ID || config.servers.some(s => s.id === active); + config.activeServerId = known ? active : null; + return config; +} + +/** + * A config store backed by a file. + * + * Reads are not cached: the CLI and a running dashboard can both be pointed at + * the same file, and a stale in-memory copy would silently undo the other's + * edits on the next write. + */ +function createStore(file = configPath()) { + return { + path: file, + + load() { + let text; + try { + text = fs.readFileSync(file, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') return emptyConfig(); + throw new UserError(`Could not read ${file}: ${err.message}`); + } + + try { + return normalizeConfig(JSON.parse(text)); + } catch (err) { + throw new UserError( + `${file} is not valid JSON, so it was left untouched. ` + + 'Fix or delete it, then try again.' + ); + } + }, + + save(config) { + const dir = path.dirname(file); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + + // Write beside the target and rename, so an interrupted write cannot + // truncate a file full of credentials into an unrecoverable state. + const temp = path.join(dir, `.config.${process.pid}.${Date.now()}.tmp`); + fs.writeFileSync(temp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); + try { + fs.renameSync(temp, file); + } catch (err) { + try { fs.unlinkSync(temp); } catch (cleanupErr) { /* best effort */ } + throw new UserError(`Could not write ${file}: ${err.message}`); + } + // rename keeps the temp file's mode, but an existing file keeps its + // own, so re-assert it for a config written before this ran. + try { fs.chmodSync(file, 0o600); } catch (err) { /* not all filesystems */ } + return config; + }, + }; +} + +/** An in-memory store with the same shape, for tests and for --no-config runs. */ +function createMemoryStore(initial) { + let config = normalizeConfig(initial); + return { + path: null, + load() { return JSON.parse(JSON.stringify(config)); }, + save(next) { config = normalizeConfig(next); return this.load(); }, + }; +} + +module.exports = { + VERSION, + ENV_SERVER_ID, + configPath, + createStore, + createMemoryStore, + emptyConfig, + newId, + normalizeConfig, + parseAccessCode, + redactApiUrl, +}; diff --git a/lib/outline.js b/lib/outline.js index 65fd3c3..e24edb8 100644 --- a/lib/outline.js +++ b/lib/outline.js @@ -19,6 +19,19 @@ const { UserError } = require('./errors'); // Forcing a full handshake each time costs a few milliseconds per request. const agent = new https.Agent({ rejectUnauthorized: false, maxCachedSessions: 0 }); +// A host that drops packets rather than refusing the connection — a wrong IP in +// a pasted access code, a firewall, a server that has moved — would otherwise +// hang for the operating system's TCP timeout, well over a minute, with nothing +// on screen. The Management API answers in milliseconds when it answers at all, +// so anything approaching this is already a failure worth reporting. +const DEFAULT_REQUEST_TIMEOUT_MS = 15000; + +/** The timeout to use, read per request so a caller can change it at runtime. */ +function requestTimeout() { + const given = Number(process.env.OUTLINE_TIMEOUT_MS); + return Number.isFinite(given) && given > 0 ? given : DEFAULT_REQUEST_TIMEOUT_MS; +} + /** * Normalises a SHA-256 certificate fingerprint to bare lowercase hex, accepting * the colon-separated form openssl prints as well as the plain form Outline @@ -70,7 +83,11 @@ function pinCertificate(req, socket, expected) { /** Sends one request and resolves with the status line and raw body text. */ function send(url, method, body, fingerprint) { return new Promise((resolve, reject) => { - const options = { method, agent }; + // timeout must go in the options rather than on the returned request: + // req.setTimeout() only arms once the socket is connected, which is + // exactly the case a black-holed address never reaches. + const timeout = requestTimeout(); + const options = { method, agent, timeout }; if (body !== undefined) { options.headers = { 'Content-Type': 'application/json', @@ -94,6 +111,14 @@ function send(url, method, body, fingerprint) { req.on('socket', socket => pinCertificate(req, socket, fingerprint)); } + req.on('timeout', () => { + req.destroy(new UserError( + `The server at ${new URL(url).host} did not respond within ` + + `${timeout / 1000} seconds. Check that the address in the Management API ` + + 'URL is right and that its port is reachable from here.' + )); + }); + req.on('error', reject); if (body !== undefined) req.write(body); req.end(); @@ -186,4 +211,4 @@ class OutlineClient { } } -module.exports = { OutlineClient, normalizeFingerprint }; +module.exports = { OutlineClient, normalizeFingerprint, requestTimeout, DEFAULT_REQUEST_TIMEOUT_MS }; diff --git a/lib/qr.js b/lib/qr.js new file mode 100644 index 0000000..63263a2 --- /dev/null +++ b/lib/qr.js @@ -0,0 +1,74 @@ +'use strict'; + +/** + * QR codes for the dashboard. + * + * The CLI prints block-character codes, which scan fine in a terminal but look + * like 9px mush in a browser. The dashboard is where people actually hand a key + * to someone, so it gets a vector code instead: crisp at any size, legible on a + * projector, and clean in a screenshot. + * + * Rather than take on a second QR dependency, this reuses the encoder already + * vendored inside qrcode-terminal. That is an internal path, so the require is + * guarded — if a future version moves it, the dashboard quietly falls back to + * the block-character code, which is exactly what it rendered before. + * + * What crosses the wire is a path string built solely from the module matrix. + * No part of the access URL appears in the markup, so nothing here can be + * turned into an injection point by a hostile key name or host. + */ + +let encoder = null; +try { + // eslint-disable-next-line global-require + const QRCode = require('qrcode-terminal/vendor/QRCode'); + const levels = require('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel'); + encoder = { QRCode, levels }; +} catch (err) { + encoder = null; +} + +const MARGIN = 4; // The quiet zone the spec requires, in modules. + +/** + * Encodes text as an SVG path over a unit-per-module grid. + * + * Level M is a deliberate step up from the CLI's L: an access URL scanned off a + * screen at an angle has less margin for error than one scanned off a terminal. + * + * @returns {{path: string, size: number}|null} null when the encoder is absent. + */ +function toSvgPath(text) { + if (!encoder || !text) return null; + + let qr; + try { + qr = new encoder.QRCode(-1, encoder.levels.M); + qr.addData(String(text)); + qr.make(); + } catch (err) { + // Text too long for any version, or an encoder we no longer understand. + return null; + } + + const count = qr.getModuleCount(); + const parts = []; + + // Merge each row's dark modules into runs, so the path is a few hundred + // commands rather than one per module. + for (let row = 0; row < count; row++) { + let start = -1; + for (let col = 0; col <= count; col++) { + const dark = col < count && qr.isDark(row, col); + if (dark && start === -1) start = col; + if (!dark && start !== -1) { + parts.push(`M${start + MARGIN} ${row + MARGIN}h${col - start}v1h-${col - start}z`); + start = -1; + } + } + } + + return { path: parts.join(''), size: count + MARGIN * 2 }; +} + +module.exports = { toSvgPath, MARGIN }; diff --git a/lib/registry.js b/lib/registry.js new file mode 100644 index 0000000..96eeb91 --- /dev/null +++ b/lib/registry.js @@ -0,0 +1,236 @@ +'use strict'; + +const { OutlineClient } = require('./outline'); +const { UserError } = require('./errors'); +const { + ENV_SERVER_ID, + createMemoryStore, + newId, + parseAccessCode, + redactApiUrl, +} = require('./config'); + +/** + * The set of Outline servers this installation knows about, and the Management + * API clients that talk to them. + * + * Two sources feed it. The environment contributes at most one server, which is + * how shadowtools has always been configured and which stays read-only here — + * the dashboard will not silently rewrite someone's shell setup. The config + * file contributes the rest, and those are the ones the dashboard creates and + * edits. Everything downstream (the HTTP handler, the CLI) asks the registry + * for a client rather than constructing one, so there is a single place that + * decides which server an operation lands on. + * + * When direct server access arrives, provisioning a box ends by handing the + * registry the access code the installer prints, and the new server joins the + * same list as anything added by hand. + */ + +/** Everything about a server except the credential itself. */ +function describe(server, { active, source }) { + return { + id: server.id, + name: server.name || '', + host: hostOf(server), + domain: server.domain || '', + apiUrlPreview: redactApiUrl(server.apiUrl), + certPinned: Boolean(server.certSha256), + createdAt: server.createdAt || '', + source, + active, + // Servers from the environment are edited by editing the environment. + editable: source !== 'env', + }; +} + +function hostOf(server) { + try { + return new URL(server.apiUrl).hostname; + } catch (err) { + return ''; + } +} + +function createRegistry({ store, env = null, clientFactory } = {}) { + if (!store) store = createMemoryStore(); + + // Clients hold a TLS agent and a validated fingerprint, so build one per + // server and reuse it rather than re-parsing the credential per request. + const clients = new Map(); + const makeClient = clientFactory || (server => new OutlineClient(server.apiUrl, server.certSha256)); + + const envServer = env && env.apiUrl + ? { + id: ENV_SERVER_ID, + name: env.name || 'Environment', + apiUrl: env.apiUrl, + certSha256: env.certSha256 || '', + domain: env.domain || '', + createdAt: '', + } + : null; + + /** Every server, environment first, in the order the dashboard shows them. */ + function all(config) { + const stored = config.servers.map(server => ({ ...server, source: 'file' })); + return envServer ? [{ ...envServer, source: 'env' }, ...stored] : stored; + } + + function activeIdFor(config) { + const servers = all(config); + if (!servers.length) return null; + const chosen = servers.find(server => server.id === config.activeServerId); + return chosen ? chosen.id : servers[0].id; + } + + function find(config, id) { + const server = all(config).find(candidate => candidate.id === id); + if (!server) throw new UserError(`No configured server with id "${id}".`); + return server; + } + + /** + * The index of a stored server, or a refusal explaining why there isn't one: + * either the id names the environment entry, which this cannot rewrite, or + * it names nothing at all. + */ + function storedIndex(config, id) { + const index = config.servers.findIndex(server => server.id === id); + if (index !== -1) return index; + + // Raises "no such server" for an unknown id. + const server = find(config, id); + throw new UserError( + `"${server.name || server.id}" comes from OUTLINE_API_URL in your environment. ` + + 'Change it there, or add it as a saved server instead.' + ); + } + + return { + get storePath() { return store.path; }, + get hasEnvServer() { return Boolean(envServer); }, + + list() { + const config = store.load(); + const active = activeIdFor(config); + return all(config).map(server => + describe(server, { active: server.id === active, source: server.source })); + }, + + /** The server operations run against, or null when nothing is configured. */ + active() { + const config = store.load(); + const id = activeIdFor(config); + return id ? find(config, id) : null; + }, + + /** The active server's client, with a message that says how to fix "none". */ + activeClient() { + const server = this.active(); + if (!server) { + throw new UserError( + 'No Outline server is configured yet. Add one with its access code from ' + + 'Outline Manager > Settings, or set OUTLINE_API_URL.' + ); + } + return { server, client: this.clientFor(server) }; + }, + + /** One server by id, with its client — for acting on a server that is not active. */ + clientById(id) { + const server = find(store.load(), id); + return { server, client: this.clientFor(server) }; + }, + + clientFor(server) { + // Key on the credential, so editing a server's access code retires + // the client that still points at the old one. + const cacheKey = `${server.id}:${server.apiUrl}:${server.certSha256 || ''}`; + if (!clients.has(cacheKey)) clients.set(cacheKey, makeClient(server)); + return clients.get(cacheKey); + }, + + add({ name, accessCode, domain }) { + const { apiUrl, certSha256 } = parseAccessCode(accessCode); + const config = store.load(); + + // Adding the same server twice is a paste slip, not an intention. + const clash = config.servers.find(server => server.apiUrl === apiUrl); + if (clash) { + throw new UserError(`That server is already saved as "${clash.name || clash.id}".`); + } + + const server = { + id: newId(), + name: String(name || '').trim(), + apiUrl, + certSha256, + domain: String(domain || '').trim(), + createdAt: new Date().toISOString(), + }; + + // Validate the fingerprint now, so a typo surfaces on the form that + // introduced it rather than on the first request minutes later. + makeClient(server); + + config.servers.push(server); + config.activeServerId = server.id; + store.save(config); + return server.id; + }, + + update(id, { name, domain, accessCode }) { + const config = store.load(); + const index = storedIndex(config, id); + const server = { ...config.servers[index] }; + if (name !== undefined) server.name = String(name || '').trim(); + if (domain !== undefined) server.domain = String(domain || '').trim(); + if (accessCode) { + const parsed = parseAccessCode(accessCode); + server.apiUrl = parsed.apiUrl; + server.certSha256 = parsed.certSha256; + } + + makeClient(server); + config.servers[index] = server; + store.save(config); + return server.id; + }, + + remove(id) { + const config = store.load(); + const index = storedIndex(config, id); + config.servers.splice(index, 1); + if (config.activeServerId === id) config.activeServerId = null; + store.save(config); + }, + + activate(id) { + const config = store.load(); + find(config, id); + config.activeServerId = id; + store.save(config); + }, + + /** + * The full access code for one server. + * + * Everything else in this module deliberately hands out redacted URLs: + * the credential lives in this process and the browser has no use for + * it. Reading it back is a distinct, user-initiated act — copying a + * server into Outline Manager or onto another machine — so it gets its + * own call rather than riding along in the server list. + */ + accessCode(id) { + const server = find(store.load(), id); + return { + apiUrl: server.apiUrl, + certSha256: server.certSha256 || '', + json: JSON.stringify({ apiUrl: server.apiUrl, certSha256: server.certSha256 || '' }), + }; + }, + }; +} + +module.exports = { createRegistry, hostOf }; diff --git a/lib/server.js b/lib/server.js index c8964b5..5bd80f9 100644 --- a/lib/server.js +++ b/lib/server.js @@ -4,18 +4,20 @@ const http = require('http'); const crypto = require('crypto'); const qrcode = require('qrcode-terminal'); -const { OutlineClient } = require('./outline'); const { rewriteAccessUrl, parseBytes } = require('./format'); const { UserError } = require('./errors'); +const { createRegistry, hostOf } = require('./registry'); +const { createStore, createMemoryStore } = require('./config'); +const { toSvgPath } = require('./qr'); const { page } = require('./web'); /** - * A local web UI over the Management API. + * A local admin dashboard over the Management API. * - * Security shape, which matters more here than in most little servers: the - * Management API URL is full administrative control of the Outline server, and + * Security shape, which matters more here than in most little servers: a + * Management API URL is full administrative control of an Outline server, and * it never leaves this process — the browser talks only to this server, which - * holds the credential and proxies. Three things guard that: + * holds the credentials and proxies. Four things guard that: * * - it listens on the loopback interface only, so nothing off-machine can * reach it; @@ -24,7 +26,10 @@ const { page } = require('./web'); * it, and requiring a custom header means cross-origin attempts hit a CORS * preflight that is never answered; * - the Host header must name the loopback address, which is what stops DNS - * rebinding from turning an attacker's domain into a route to 127.0.0.1. + * rebinding from turning an attacker's domain into a route to 127.0.0.1; + * - the dashboard is told which servers exist, but not their access codes. + * Those are sent only to the one endpoint that exists to reveal them, and + * only when someone clicks the button that asks for it. */ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']); @@ -80,41 +85,105 @@ function renderQr(text) { }); } +/** + * Wraps an already-built client as a one-server registry. + * + * This is the seam the CLI's `ui` command and the tests come through when they + * have a client in hand and no interest in the config file. + */ +function registryForClient(client, domain) { + const apiUrl = client.baseUrl || `https://${client.hostname}/`; + return createRegistry({ + store: createMemoryStore(), + env: { apiUrl, domain, name: 'Environment' }, + clientFactory: () => client, + }); +} + /** * Builds the request handler. Exported separately from start() so tests can * drive it without binding a port. */ -function createHandler({ client, domain, token, port }) { - const host = () => domain || client.hostname; +function createHandler({ registry, client, domain, token, port }) { + if (!registry) { + if (!client) throw new Error('createHandler needs either a registry or a client.'); + registry = registryForClient(client, domain); + } + + const hostFor = server => server.domain || hostOf(server); + /** + * Everything the dashboard renders, in one response. + * + * A server that cannot be reached must not blank the page: the list of + * servers is exactly what you need to fix it, so a failure is reported + * alongside that list rather than as a failed request. + */ async function state() { - const [keys, transferred, server] = await Promise.all([ - client.listKeys(), - client.getTransferMetrics(), - client.getServerInfo().catch(() => null), - ]); + const servers = registry.list(); + const base = { + servers, + configPath: registry.storePath, + activeServerId: null, + host: '', + serverName: null, + serverLimitBytes: null, + keys: [], + reachable: false, + unreachableReason: null, + }; + + let active; + let client; + try { + ({ server: active, client } = registry.activeClient()); + } catch (err) { + // No server configured at all; the dashboard shows its empty state. + return base; + } + + base.activeServerId = active.id; + base.host = hostFor(active); + + let keys; + let transferred; + let info; + try { + [keys, transferred, info] = await Promise.all([ + client.listKeys(), + client.getTransferMetrics(), + client.getServerInfo().catch(() => null), + ]); + } catch (err) { + base.unreachableReason = err instanceof UserError ? err.message : 'Could not reach this server.'; + return base; + } return { - host: host(), - serverName: server && server.name ? server.name : null, - serverLimitBytes: server && server.accessKeyDataLimit - ? server.accessKeyDataLimit.bytes - : null, + ...base, + reachable: true, + serverName: info && info.name ? info.name : null, + serverLimitBytes: info && info.accessKeyDataLimit ? info.accessKeyDataLimit.bytes : null, keys: keys.map(key => ({ id: key.id, name: key.name || '', port: key.port, dataLimitBytes: key.dataLimit ? key.dataLimit.bytes : null, bytes: transferred[key.id] || 0, - accessUrl: rewriteAccessUrl(key.accessUrl, client.hostname, host()), + accessUrl: rewriteAccessUrl(key.accessUrl, client.hostname, hostFor(active)), })), }; } + /** The active server's client, for the routes that act on access keys. */ + const withActive = () => registry.activeClient(); + const routes = [ ['GET', /^\/api\/state$/, () => state()], + // Access keys, always on the active server. ['POST', /^\/api\/keys$/, async (m, body) => { + const { client } = withActive(); const key = await client.createKey((body.name || '').trim()); const limit = toBytes(body.limitBytes); if (limit !== null && key) await client.setKeyDataLimit(key.id, limit); @@ -122,6 +191,7 @@ function createHandler({ client, domain, token, port }) { }], ['DELETE', /^\/api\/keys\/([^/]+)$/, async m => { + const { client } = withActive(); await client.removeKey(decodeURIComponent(m[1])); return state(); }], @@ -129,6 +199,7 @@ function createHandler({ client, domain, token, port }) { ['PUT', /^\/api\/keys\/([^/]+)\/name$/, async (m, body) => { const name = (body.name || '').trim(); if (!name) throw new UserError('A key name cannot be empty.'); + const { client } = withActive(); await client.renameKey(decodeURIComponent(m[1]), name); return state(); }], @@ -136,6 +207,7 @@ function createHandler({ client, domain, token, port }) { ['PUT', /^\/api\/keys\/([^/]+)\/limit$/, async (m, body) => { const id = decodeURIComponent(m[1]); const bytes = toBytes(body.bytes); + const { client } = withActive(); if (bytes === null) await client.clearKeyDataLimit(id); else await client.setKeyDataLimit(id, bytes); return state(); @@ -143,6 +215,7 @@ function createHandler({ client, domain, token, port }) { ['PUT', /^\/api\/server\/limit$/, async (m, body) => { const bytes = toBytes(body.bytes); + const { client } = withActive(); if (bytes === null) await client.clearServerDataLimit(); else await client.setServerDataLimit(bytes); return state(); @@ -150,11 +223,60 @@ function createHandler({ client, domain, token, port }) { ['GET', /^\/api\/keys\/([^/]+)\/qr$/, async m => { const id = decodeURIComponent(m[1]); + const { server, client } = withActive(); const keys = await client.listKeys(); const key = keys.find(k => String(k.id) === id); if (!key) throw new UserError(`No key with id "${id}".`); - const url = rewriteAccessUrl(key.accessUrl, client.hostname, host()); - return { qr: await renderQr(url), accessUrl: url, name: key.name || '' }; + const url = rewriteAccessUrl(key.accessUrl, client.hostname, hostFor(server)); + const svg = toSvgPath(url); + return { + qr: await renderQr(url), + svgPath: svg ? svg.path : null, + svgSize: svg ? svg.size : 0, + accessUrl: url, + name: key.name || '', + }; + }], + + // The servers themselves. + ['POST', /^\/api\/servers$/, async (m, body) => { + registry.add({ name: body.name, accessCode: body.accessCode, domain: body.domain }); + return state(); + }], + + ['PUT', /^\/api\/servers\/([^/]+)$/, async (m, body) => { + registry.update(decodeURIComponent(m[1]), { + name: body.name, + domain: body.domain, + accessCode: body.accessCode, + }); + return state(); + }], + + ['DELETE', /^\/api\/servers\/([^/]+)$/, async m => { + registry.remove(decodeURIComponent(m[1])); + return state(); + }], + + ['POST', /^\/api\/servers\/([^/]+)\/activate$/, async m => { + registry.activate(decodeURIComponent(m[1])); + return state(); + }], + + // Deliberately its own endpoint; see registry.accessCode(). + ['GET', /^\/api\/servers\/([^/]+)\/access-code$/, async m => + registry.accessCode(decodeURIComponent(m[1]))], + + // Checks one server without switching to it, so a credential can be + // fixed and verified before it becomes the one everything else uses. + ['POST', /^\/api\/servers\/([^/]+)\/test$/, async m => { + const { client } = registry.clientById(decodeURIComponent(m[1])); + const info = await client.getServerInfo(); + return { + ok: true, + name: (info && info.name) || null, + version: (info && info.version) || null, + }; }], ]; @@ -198,14 +320,21 @@ function createHandler({ client, domain, token, port }) { return send(401, { error: 'Missing or invalid token. Reopen the URL printed in the terminal.' }); } - for (const [method, pattern, run] of routes) { - const match = pattern.exec(path); - if (!match) continue; - if (req.method !== method) return send(405, { error: 'Method not allowed.' }); + // Collect every route for this path first, so a path that exists under a + // different method answers 405 rather than falling through to 404. + const matched = routes + .map(([method, pattern, run]) => ({ method, run, match: pattern.exec(path) })) + .filter(route => route.match); + + if (matched.length) { + const route = matched.find(candidate => candidate.method === req.method); + if (!route) return send(405, { error: 'Method not allowed.' }); try { - const body = method === 'GET' || method === 'DELETE' ? {} : await readJsonBody(req); - return send(200, await run(match, body)); + const body = route.method === 'GET' || route.method === 'DELETE' + ? {} + : await readJsonBody(req); + return send(200, await route.run(route.match, body)); } catch (err) { const known = err instanceof UserError; if (!known) console.error(err); @@ -217,9 +346,25 @@ function createHandler({ client, domain, token, port }) { }; } -/** Starts the UI and resolves with { server, url, port }. */ -async function start({ client, managementApiUrl, certSha256, domain, port = 8787 }) { - if (!client) client = new OutlineClient(managementApiUrl, certSha256); +/** + * Starts the dashboard and resolves with { server, url, port, token }. + * + * Passing a client keeps the old single-server behaviour; passing nothing lets + * the registry read the config file, which is what makes it possible to open + * the dashboard with no configuration at all and add the first server there. + */ +async function start({ client, managementApiUrl, certSha256, domain, port = 8787, store, registry } = {}) { + if (!registry) { + if (client) { + registry = registryForClient(client, domain); + } else { + registry = createRegistry({ + store: store || createStore(), + env: managementApiUrl ? { apiUrl: managementApiUrl, certSha256, domain } : null, + }); + } + } + const token = crypto.randomBytes(24).toString('hex'); // Built after listen(), because the Host check compares against the port we @@ -241,8 +386,8 @@ async function start({ client, managementApiUrl, certSha256, domain, port = 8787 }); const actualPort = server.address().port; - handler = createHandler({ client, domain, token, port: actualPort }); + handler = createHandler({ registry, token, port: actualPort }); return { server, port: actualPort, url: `http://127.0.0.1:${actualPort}/?t=${token}`, token }; } -module.exports = { start, createHandler, hostIsLoopback, toBytes }; +module.exports = { start, createHandler, hostIsLoopback, toBytes, registryForClient }; diff --git a/lib/web.js b/lib/web.js index 537c253..987bfb3 100644 --- a/lib/web.js +++ b/lib/web.js @@ -1,127 +1,251 @@ 'use strict'; /** - * The whole UI as one self-contained document — no build step, no bundler, no - * CDN. The server sends a Content-Security-Policy that forbids loading anything - * external, so everything the page needs has to live here. + * The whole dashboard as one self-contained document — no build step, no + * bundler, no CDN. The server sends a Content-Security-Policy that forbids + * loading anything external, so everything the page needs lives here. + * + * It is split into sections rather than one long page because the panel now + * manages servers as well as keys, and those are different jobs: you open + * Servers when something is being set up or has gone wrong, and Keys the rest + * of the time. Sections live in the URL hash so a reload lands where you were. */ const CSS = ` :root { color-scheme: light dark; - --bg: #f6f7f9; + --bg: #f4f5f7; --panel: #ffffff; - --line: #e2e5ea; - --ink: #1a1d21; - --muted: #6b7280; + --raised: #fafbfc; + --line: #e3e6ea; + --ink: #14171a; + --muted: #687180; --accent: #2563eb; + --accent-soft: #eef3ff; --danger: #dc2626; --ok: #059669; --warn: #d97706; --radius: 10px; + --sidebar: 208px; } @media (prefers-color-scheme: dark) { :root { - --bg: #14161a; --panel: #1c1f25; --line: #2b3038; --ink: #e8eaed; - --muted: #9aa2ad; --accent: #60a5fa; --danger: #f87171; --ok: #34d399; --warn: #fbbf24; + --bg: #101216; --panel: #191c21; --raised: #1f2329; --line: #2a2f37; --ink: #e9ebee; + --muted: #98a1ad; --accent: #6ea8fe; --accent-soft: #1c2536; --danger: #f87171; + --ok: #34d399; --warn: #fbbf24; } } * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; } -header { - display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; - padding: 20px 24px; border-bottom: 1px solid var(--line); background: var(--panel); +.app { display: grid; grid-template-columns: var(--sidebar) 1fr; min-height: 100vh; } + +/* Sidebar */ +aside { + background: var(--panel); border-right: 1px solid var(--line); + display: flex; flex-direction: column; gap: 4px; padding: 18px 12px; } -h1 { font-size: 17px; margin: 0; font-weight: 620; letter-spacing: -0.01em; } -.sub { color: var(--muted); font-size: 13px; } -main { max-width: 1100px; margin: 0 auto; padding: 24px; } -.bar { - display: flex; gap: 10px; flex-wrap: wrap; align-items: center; - margin-bottom: 18px; +.brand { padding: 0 10px 14px; } +.brand b { display: block; font-size: 15px; letter-spacing: -0.01em; } +.brand span { font-size: 12px; color: var(--muted); } +nav { display: flex; flex-direction: column; gap: 2px; } +nav a { + display: flex; align-items: center; gap: 9px; padding: 7px 10px; border-radius: 7px; + color: var(--ink); text-decoration: none; font-size: 14px; } -.bar .spacer { flex: 1; } -button, input, select { +nav a:hover { background: var(--raised); } +nav a[aria-current="page"] { background: var(--accent-soft); color: var(--accent); font-weight: 560; } +nav a .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); opacity: 0.5; flex: none; } +nav a[aria-current="page"] .dot { background: var(--accent); opacity: 1; } +nav a .count { margin-left: auto; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; } +aside .foot { margin-top: auto; padding: 12px 10px 0; font-size: 11px; color: var(--muted); line-height: 1.45; } + +/* Top bar */ +main { min-width: 0; display: flex; flex-direction: column; } +.top { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding: 14px 24px; border-bottom: 1px solid var(--line); background: var(--panel); +} +.top h1 { font-size: 16px; margin: 0; font-weight: 600; letter-spacing: -0.01em; } +.top .spacer { flex: 1; } +.crumb { font-size: 13px; color: var(--muted); } +.content { padding: 22px 24px 48px; max-width: 1080px; width: 100%; } + +/* Controls */ +button, input, select, textarea { font: inherit; color: inherit; border: 1px solid var(--line); background: var(--panel); border-radius: 8px; padding: 7px 11px; } +textarea { resize: vertical; min-height: 74px; width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } button { cursor: pointer; } button:hover:not(:disabled) { border-color: var(--accent); } button:disabled { opacity: 0.5; cursor: default; } button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 550; } +button.primary:hover:not(:disabled) { filter: brightness(1.06); } button.danger { color: var(--danger); } button.link { - border: none; background: none; padding: 2px 4px; color: var(--accent); - text-decoration: underline; text-underline-offset: 2px; + border: none; background: none; padding: 2px 5px; color: var(--accent); + text-decoration: underline; text-underline-offset: 2px; font-size: 13px; } +button.link.danger { color: var(--danger); } +button.small { padding: 4px 9px; font-size: 13px; } input { min-width: 0; } -input:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +input:focus-visible, button:focus-visible, textarea:focus-visible, select:focus-visible { + outline: 2px solid var(--accent); outline-offset: 1px; +} +.bar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; } +.bar .spacer { flex: 1; } +.search { width: 200px; } + +/* Server switcher */ +.switcher { display: flex; align-items: center; gap: 8px; } +.switcher select { max-width: 260px; padding: 6px 10px; } +.switcher .state { font-size: 12px; display: inline-flex; align-items: center; gap: 5px; color: var(--muted); } +.led { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; } +.led.up { background: var(--ok); } +.led.down { background: var(--danger); } +/* Panels and tables */ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; } +.panel + .panel { margin-top: 18px; } +.panel > h2 { + margin: 0; padding: 13px 16px; font-size: 13px; font-weight: 600; letter-spacing: 0.01em; + border-bottom: 1px solid var(--line); background: var(--raised); +} +.panel > h2 .sub { font-weight: 400; color: var(--muted); margin-left: 8px; font-size: 12px; } +.panel .body { padding: 16px; } .scroll { overflow-x: auto; } table { border-collapse: collapse; width: 100%; font-size: 14px; } th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; } -th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); font-weight: 600; } +th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600; } tbody tr:last-child td { border-bottom: none; } +tbody tr:hover { background: var(--raised); } td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } td.actions { text-align: right; white-space: nowrap; } -.url { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; - color: var(--muted); max-width: 320px; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; display: inline-block; vertical-align: bottom; +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); } +.url { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: bottom; } .name { font-weight: 550; } .name.unnamed { color: var(--muted); font-weight: 400; font-style: italic; } -.meter { width: 110px; height: 5px; background: var(--line); border-radius: 3px; overflow: hidden; margin: 4px 0 0 auto; } +.meter { width: 104px; height: 5px; background: var(--line); border-radius: 3px; overflow: hidden; margin: 5px 0 0 auto; } .meter i { display: block; height: 100%; background: var(--ok); } .meter i.warn { background: var(--warn); } .meter i.over { background: var(--danger); } .pill { - display: inline-block; padding: 1px 7px; border-radius: 999px; + display: inline-block; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line); font-size: 12px; color: var(--muted); } -.empty, .loading { padding: 36px; text-align: center; color: var(--muted); } -.err { - margin-bottom: 16px; padding: 10px 14px; border-radius: 8px; - border: 1px solid var(--danger); color: var(--danger); - background: color-mix(in srgb, var(--danger) 8%, transparent); - white-space: pre-wrap; +.pill.on { border-color: var(--ok); color: var(--ok); } +.pill.soft { background: var(--accent-soft); border-color: transparent; color: var(--accent); } +.empty { padding: 34px 20px; text-align: center; color: var(--muted); } +.empty p { margin: 0 0 14px; } + +/* Stats */ +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 12px; margin-bottom: 18px; } +.stat { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; } +.stat .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600; } +.stat .value { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; margin-top: 5px; font-variant-numeric: tabular-nums; } +.stat .note { font-size: 12px; color: var(--muted); margin-top: 3px; } + +/* Notices */ +.notice { + margin-bottom: 16px; padding: 11px 14px; border-radius: 9px; + border: 1px solid var(--line); background: var(--panel); font-size: 14px; + display: flex; gap: 12px; align-items: flex-start; white-space: pre-wrap; } +/* An explicit display beats the user agent's [hidden] rule, so restore it. */ +.notice[hidden] { display: none; } +.notice .grow { flex: 1; min-width: 0; } +.notice.err { border-color: var(--danger); color: var(--danger); } +.notice.warn { border-color: var(--warn); color: var(--warn); } +.notice strong { display: block; margin-bottom: 2px; } + +/* Server cards */ +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; } +.card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); padding: 15px 16px; } +.card.active { border-color: var(--accent); } +.card h3 { margin: 0 0 3px; font-size: 14px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.card dl { margin: 10px 0 0; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 12.5px; } +.card > dl:first-child { margin-top: 0; } +.card dt { color: var(--muted); } +.card dd { margin: 0; overflow-wrap: anywhere; } +.card .row { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 13px; padding-top: 11px; border-top: 1px solid var(--line); } +.test-result { margin-top: 8px; font-size: 12.5px; color: var(--muted); overflow-wrap: anywhere; } +.test-result.ok { color: var(--ok); } +.test-result.bad { color: var(--danger); } +.card.future { border-style: dashed; color: var(--muted); } +.card.future h3 { color: var(--ink); } +.card.future p { margin: 8px 0 0; font-size: 13px; line-height: 1.5; } + +/* Dialogs */ dialog { border: 1px solid var(--line); border-radius: var(--radius); padding: 0; - background: var(--panel); color: var(--ink); max-width: 92vw; + background: var(--panel); color: var(--ink); max-width: 94vw; width: 420px; } -dialog::backdrop { background: rgba(0,0,0,0.45); } -.dlg { padding: 20px; min-width: 300px; } -.dlg h2 { margin: 0 0 14px; font-size: 15px; } -.dlg label { display: block; font-size: 13px; color: var(--muted); margin: 10px 0 4px; } +dialog.wide { width: 470px; } +dialog::backdrop { background: rgba(0,0,0,0.5); } +.dlg { padding: 20px; } +.dlg h2 { margin: 0 0 4px; font-size: 15px; } +.dlg .lede { margin: 0 0 14px; font-size: 13px; color: var(--muted); } +.dlg label { display: block; font-size: 13px; color: var(--muted); margin: 12px 0 4px; } .dlg input { width: 100%; } -.dlg .row { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; } +.dlg .row { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; } +.dlg .row .spacer { flex: 1; } .hint { font-size: 12px; color: var(--muted); margin-top: 6px; } +.dlg .err { color: var(--danger); font-size: 13px; margin-top: 12px; white-space: pre-wrap; } + +/* QR */ +.qr-wrap { display: flex; flex-direction: column; align-items: center; gap: 14px; } +.qr-svg { width: 232px; height: 232px; background: #fff; border-radius: 8px; padding: 6px; } pre.qr { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - line-height: 1; font-size: 9px; letter-spacing: 0; margin: 0; text-align: center; + line-height: 1; font-size: 9px; margin: 0; text-align: center; background: #fff; color: #000; padding: 12px; border-radius: 8px; overflow: auto; } .qr-url { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; - word-break: break-all; color: var(--muted); margin-top: 12px; max-width: 380px; + word-break: break-all; color: var(--muted); width: 100%; +} + +@media (max-width: 720px) { + .app { grid-template-columns: 1fr; } + aside { flex-direction: row; align-items: center; gap: 10px; padding: 10px 14px; overflow-x: auto; border-right: none; border-bottom: 1px solid var(--line); } + .brand { padding: 0 8px 0 0; } + .brand span, aside .foot { display: none; } + nav { flex-direction: row; } + nav a .count { display: none; } + .content, .top { padding-left: 14px; padding-right: 14px; } } `; const JS = ` 'use strict'; + const token = new URLSearchParams(location.search).get('t') || ''; -let state = { keys: [], host: '', serverLimitBytes: null, serverName: null }; +const SECTIONS = [ + { id: 'overview', label: 'Overview' }, + { id: 'keys', label: 'Access keys' }, + { id: 'servers', label: 'Servers' }, + { id: 'settings', label: 'Settings' }, +]; + +let state = { + servers: [], keys: [], host: '', serverName: null, serverLimitBytes: null, + activeServerId: null, configPath: null, reachable: false, unreachableReason: null, +}; +let section = 'overview'; +let filter = ''; const $ = sel => document.querySelector(sel); const el = (tag, props, ...kids) => { - const n = Object.assign(document.createElement(tag), props || {}); - for (const k of kids.flat()) if (k != null) n.append(k); - return n; + const node = Object.assign(document.createElement(tag), props || {}); + for (const kid of kids.flat()) if (kid != null && kid !== false) node.append(kid); + return node; }; function formatBytes(bytes) { @@ -133,6 +257,11 @@ function formatBytes(bytes) { return (v >= 10 ? v.toFixed(0) : v.toFixed(1)) + ' ' + units[i]; } +/** A size string the limit dialogs can round-trip, e.g. 10.5 GB -> "10.5GB". */ +function sizeInput(bytes) { + return bytes ? formatBytes(bytes).replace(' ', '') : ''; +} + async function api(method, path, body) { const res = await fetch(path, { method, @@ -146,187 +275,719 @@ async function api(method, path, body) { function showError(message) { const box = $('#error'); - box.textContent = message || ''; box.hidden = !message; + if (message) box.replaceChildren(el('div', { className: 'grow', textContent: message })); + else box.replaceChildren(); } -/** Runs an action, shows any failure, and refreshes from whatever it returns. */ -async function act(fn) { - showError(''); +/** + * Runs an action and re-renders from whatever state it returns. + * + * Failures propagate, so a caller that has somewhere better to put the message + * — an open dialog — can. Most callers want act() instead. + */ +async function run(fn) { document.body.style.cursor = 'progress'; try { const next = await fn(); - if (next && next.keys) { state = next; render(); } - } catch (err) { - showError(err.message); + if (next && Array.isArray(next.servers)) { state = next; render(); } + return next; } finally { document.body.style.cursor = ''; } } +/** run(), with any failure reported in the banner above the current section. */ +async function act(fn) { + showError(''); + try { + return await run(fn); + } catch (err) { + showError(err.message); + // Re-render so the shell still matches the state we did manage to load. + render(); + } +} + +/* ---------- generic dialog ---------- */ + +/** + * Opens a form dialog and resolves with the field values, or null if cancelled. + * + * When a submit handler is given it runs while the dialog is still open, and a + * failure is shown inside it. That matters most for the access-code form: the + * server rejects a bad paste, and closing the dialog first would throw away + * everything else the user had typed alongside it. + */ +function openForm({ title, lede, fields, okText = 'Save', danger = false, wide = false, dismissOnly = false, submit: onSubmit }) { + return new Promise(resolve => { + const dlg = el('dialog', { className: wide ? 'wide' : '' }); + const errBox = el('div', { className: 'err', hidden: true }); + const inputs = new Map(); + + const body = el('div', { className: 'dlg' }, + el('h2', { textContent: title }), + lede ? el('p', { className: 'lede', textContent: lede }) : null, + fields.map(field => { + const control = field.multiline + ? el('textarea', { value: field.value || '', placeholder: field.placeholder || '', spellcheck: false }) + : el('input', { + value: field.value || '', placeholder: field.placeholder || '', + autocomplete: 'off', spellcheck: false, type: field.type || 'text', + }); + inputs.set(field.name, control); + return el('div', {}, + el('label', { textContent: field.label }), + control, + field.hint ? el('div', { className: 'hint', textContent: field.hint }) : null); + }), + errBox); + + // A dialog that only shows something has nothing to cancel out of. + const cancel = el('button', { type: 'button', textContent: 'Cancel', hidden: dismissOnly }); + const submit = el('button', { + type: 'submit', className: danger ? 'danger' : 'primary', textContent: okText, + }); + body.append(el('div', { className: 'row' }, cancel, submit)); + + const form = el('form', {}, body); + dlg.append(form); + document.body.append(dlg); + + const close = result => { dlg.close(); dlg.remove(); resolve(result); }; + cancel.onclick = () => close(null); + dlg.addEventListener('cancel', ev => { ev.preventDefault(); close(null); }); + + form.addEventListener('submit', async ev => { + ev.preventDefault(); + const values = {}; + for (const [name, control] of inputs) values[name] = control.value.trim(); + + if (!onSubmit) return close(values); + + errBox.hidden = true; + submit.disabled = true; + const busy = submit.textContent; + submit.textContent = 'Working…'; + try { + await onSubmit(values); + close(values); + } catch (err) { + errBox.textContent = err.message; + errBox.hidden = false; + submit.disabled = false; + submit.textContent = busy; + } + }); + + dlg.showModal(); + const first = inputs.values().next().value; + if (first) { first.focus(); first.select && first.select(); } + }); +} + +/* ---------- shared bits ---------- */ + function meter(used, limit) { if (!limit) return null; const pct = Math.min(100, (used / limit) * 100); - const cls = pct >= 100 ? 'over' : pct >= 80 ? 'warn' : ''; - const bar = el('i', { className: cls }); + const bar = el('i', { className: pct >= 100 ? 'over' : pct >= 80 ? 'warn' : '' }); bar.style.width = pct + '%'; return el('div', { className: 'meter' }, bar); } -function render() { - $('#host').textContent = state.host || ''; - $('#server-limit').textContent = state.serverLimitBytes - ? 'Default cap ' + formatBytes(state.serverLimitBytes) - : 'No default cap'; +function activeServer() { + return state.servers.find(server => server.id === state.activeServerId) || null; +} - const total = state.keys.reduce((sum, k) => sum + (k.bytes || 0), 0); - $('#summary').textContent = state.keys.length + (state.keys.length === 1 ? ' key' : ' keys') - + ' · ' + formatBytes(total) + ' transferred'; +async function copy(text, button) { + try { + await navigator.clipboard.writeText(text); + if (button) { + const original = button.textContent; + button.textContent = 'copied'; + setTimeout(() => { button.textContent = original; }, 1200); + } + } catch (err) { + // Clipboard access can be refused; fall back to a selection to copy by hand. + await openForm({ + title: 'Copy this', lede: 'Your browser refused clipboard access.', + fields: [{ name: 'value', label: 'Value', value: text, multiline: true }], + okText: 'Done', dismissOnly: true, + }); + } +} - const body = $('#rows'); - body.replaceChildren(); +/* ---------- access keys ---------- */ - if (!state.keys.length) { - $('#table-wrap').replaceChildren(el('div', { className: 'empty' }, 'No access keys yet. Add one to get started.')); - return; - } - if (!document.querySelector('#rows')) return; - - for (const key of state.keys) { - const nameCell = el('td', {}, - el('div', { className: 'name' + (key.name ? '' : ' unnamed'), textContent: key.name || 'Unnamed' })); - - const usageCell = el('td', { className: 'num' }, - el('div', { textContent: formatBytes(key.bytes) }), - meter(key.bytes, key.dataLimitBytes)); - - const limitCell = el('td', { className: 'num' }, - key.dataLimitBytes - ? el('span', { className: 'pill', textContent: formatBytes(key.dataLimitBytes) }) - : el('span', { className: 'pill', textContent: '—' })); - - const urlCell = el('td', {}, - el('span', { className: 'url', textContent: key.accessUrl, title: key.accessUrl }), - ' ', - el('button', { className: 'link', textContent: 'copy', onclick: () => copy(key.accessUrl) })); - - const actions = el('td', { className: 'actions' }, - el('button', { className: 'link', textContent: 'QR', onclick: () => showQr(key.id) }), - el('button', { className: 'link', textContent: 'rename', onclick: () => rename(key) }), - el('button', { className: 'link', textContent: 'limit', onclick: () => setLimit(key) }), - el('button', { className: 'link danger', textContent: 'delete', onclick: () => remove(key) })); - - body.append(el('tr', {}, - el('td', { className: 'num', textContent: key.id }), - nameCell, usageCell, limitCell, urlCell, actions)); - } +function addKey() { + return openForm({ + title: 'New access key', + lede: 'Creates a key on ' + (activeServer() ? activeServer().name || activeServer().host : 'this server') + '.', + fields: [ + { name: 'name', label: 'Name', placeholder: 'e.g. Alice' }, + { name: 'limit', label: 'Data limit', placeholder: 'no limit', hint: 'e.g. 50GB. Leave empty for no limit.' }, + ], + okText: 'Create', + }).then(values => { + if (!values) return; + return act(() => api('POST', '/api/keys', { name: values.name, limitBytes: values.limit || null })); + }); } -async function copy(text) { +function renameKey(key) { + return openForm({ + title: 'Rename key', + fields: [{ name: 'name', label: 'Name', value: key.name }], + }).then(values => { + if (!values || values.name === key.name) return; + return act(() => api('PUT', '/api/keys/' + encodeURIComponent(key.id) + '/name', { name: values.name })); + }); +} + +function limitKey(key) { + return openForm({ + title: 'Data limit for ' + (key.name || 'key ' + key.id), + fields: [{ + name: 'limit', label: 'Limit', value: sizeInput(key.dataLimitBytes), + placeholder: 'no limit', hint: 'e.g. 50GB, 500MB. Leave empty to remove the limit.', + }], + }).then(values => { + if (!values) return; + return act(() => api('PUT', '/api/keys/' + encodeURIComponent(key.id) + '/limit', { bytes: values.limit || null })); + }); +} + +function removeKey(key) { + const label = key.name || 'key ' + key.id; + if (!confirm('Delete ' + label + '? Anyone using this key loses access immediately.')) return; + return act(() => api('DELETE', '/api/keys/' + encodeURIComponent(key.id))); +} + +async function showQr(key) { + let data; try { - await navigator.clipboard.writeText(text); + data = await api('GET', '/api/keys/' + encodeURIComponent(key.id) + '/qr'); } catch (err) { - // Clipboard access can be refused; fall back to a selection the user can copy. - const box = $('#copy-fallback'); - box.value = text; box.hidden = false; box.select(); + return showError(err.message); } + + const dlg = el('dialog', { className: 'wide' }); + let code; + if (data.svgPath) { + // Built node by node rather than as markup: nothing derived from the access + // URL ends up in an attribute this way. + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', '0 0 ' + data.svgSize + ' ' + data.svgSize); + svg.setAttribute('shape-rendering', 'crispEdges'); + svg.setAttribute('class', 'qr-svg'); + const bg = document.createElementNS(ns, 'rect'); + bg.setAttribute('width', String(data.svgSize)); + bg.setAttribute('height', String(data.svgSize)); + bg.setAttribute('fill', '#fff'); + const path = document.createElementNS(ns, 'path'); + path.setAttribute('d', data.svgPath); + path.setAttribute('fill', '#000'); + svg.append(bg, path); + code = svg; + } else { + code = el('pre', { className: 'qr', textContent: data.qr }); + } + + const close = el('button', { textContent: 'Close' }); + const copyBtn = el('button', { textContent: 'Copy URL' }); + dlg.append(el('div', { className: 'dlg' }, + el('h2', { textContent: data.name || 'Access key' }), + el('p', { className: 'lede', textContent: 'Scan this in the Outline app to connect.' }), + el('div', { className: 'qr-wrap' }, code, el('div', { className: 'qr-url', textContent: data.accessUrl })), + el('div', { className: 'row' }, copyBtn, close))); + + copyBtn.onclick = () => copy(data.accessUrl, copyBtn); + const dismiss = () => { dlg.close(); dlg.remove(); }; + close.onclick = dismiss; + dlg.addEventListener('cancel', dismiss); + document.body.append(dlg); + dlg.showModal(); } -function ask({ title, label, value = '', hint = '', okText = 'Save', placeholder = '' }) { - return new Promise(resolve => { - const dlg = $('#prompt'); - $('#prompt-title').textContent = title; - $('#prompt-label').textContent = label; - $('#prompt-hint').textContent = hint; - $('#prompt-ok').textContent = okText; - const input = $('#prompt-input'); - input.value = value; - input.placeholder = placeholder; - - const done = result => { - dlg.close(); - form.removeEventListener('submit', onSubmit); - dlg.removeEventListener('cancel', onCancel); - resolve(result); - }; - const form = $('#prompt-form'); - const onSubmit = ev => { ev.preventDefault(); done(input.value); }; - const onCancel = () => done(null); - - form.addEventListener('submit', onSubmit); - dlg.addEventListener('cancel', onCancel); - dlg.showModal(); - input.focus(); - input.select(); +function keysTable() { + const needle = filter.trim().toLowerCase(); + const keys = needle + ? state.keys.filter(key => (key.name || '').toLowerCase().includes(needle) || String(key.id) === needle) + : state.keys; + + if (!state.keys.length) { + const add = el('button', { className: 'primary', textContent: 'Add the first key', onclick: addKey }); + return el('div', { className: 'panel' }, + el('div', { className: 'empty' }, el('p', { textContent: 'This server has no access keys yet.' }), add)); + } + if (!keys.length) { + return el('div', { className: 'panel' }, + el('div', { className: 'empty' }, el('p', { textContent: 'No key matches "' + filter + '".' }))); + } + + const rows = keys.map(key => { + const copyBtn = el('button', { className: 'link', textContent: 'copy' }); + copyBtn.onclick = () => copy(key.accessUrl, copyBtn); + + return el('tr', {}, + el('td', { className: 'num mono', textContent: key.id }), + el('td', {}, el('div', { + className: 'name' + (key.name ? '' : ' unnamed'), + textContent: key.name || 'Unnamed', + })), + el('td', { className: 'num' }, + el('div', { textContent: formatBytes(key.bytes) }), + meter(key.bytes, key.dataLimitBytes)), + el('td', { className: 'num' }, + el('span', { className: 'pill', textContent: key.dataLimitBytes ? formatBytes(key.dataLimitBytes) : '—' })), + el('td', {}, + el('span', { className: 'mono url', textContent: key.accessUrl, title: key.accessUrl }), ' ', copyBtn), + el('td', { className: 'actions' }, + el('button', { className: 'link', textContent: 'QR', onclick: () => showQr(key) }), + el('button', { className: 'link', textContent: 'rename', onclick: () => renameKey(key) }), + el('button', { className: 'link', textContent: 'limit', onclick: () => limitKey(key) }), + el('button', { className: 'link danger', textContent: 'delete', onclick: () => removeKey(key) }))); }); + + return el('div', { className: 'panel scroll' }, + el('table', {}, + el('thead', {}, el('tr', {}, + el('th', { textContent: 'ID' }), + el('th', { textContent: 'Name' }), + el('th', { textContent: 'Used', style: 'text-align:right' }), + el('th', { textContent: 'Limit', style: 'text-align:right' }), + el('th', { textContent: 'Access URL' }), + el('th', {}))), + el('tbody', {}, rows))); } -async function addKey() { - const name = await ask({ - title: 'New access key', label: 'Name', okText: 'Create', placeholder: 'e.g. Alice', +function renderKeys() { + const search = el('input', { + className: 'search', type: 'search', placeholder: 'Filter by name or id', value: filter, }); - if (name === null) return; - act(() => api('POST', '/api/keys', { name })); + search.oninput = () => { + filter = search.value; + const wrap = $('#keys-table'); + if (wrap) wrap.replaceWith(Object.assign(keysTable(), { id: 'keys-table' })); + }; + + const table = keysTable(); + table.id = 'keys-table'; + + return [ + el('div', { className: 'bar' }, + el('button', { className: 'primary', textContent: 'Add key', onclick: addKey }), + search, + el('span', { className: 'spacer' }), + el('span', { className: 'crumb', textContent: state.keys.length + (state.keys.length === 1 ? ' key' : ' keys') })), + table, + ]; } -async function rename(key) { - const name = await ask({ title: 'Rename key', label: 'Name', value: key.name }); - if (name === null || name === key.name) return; - act(() => api('PUT', '/api/keys/' + encodeURIComponent(key.id) + '/name', { name })); +/* ---------- overview ---------- */ + +function stat(label, value, note) { + return el('div', { className: 'stat' }, + el('div', { className: 'label', textContent: label }), + el('div', { className: 'value', textContent: value }), + note ? el('div', { className: 'note', textContent: note }) : null); } -async function setLimit(key) { - const value = await ask({ - title: 'Data limit for ' + (key.name || 'key ' + key.id), - label: 'Limit', - value: key.dataLimitBytes ? formatBytes(key.dataLimitBytes).replace(' ', '') : '', - hint: 'e.g. 50GB, 500MB. Leave empty to remove the limit.', - placeholder: 'no limit', +function renderOverview() { + const total = state.keys.reduce((sum, key) => sum + (key.bytes || 0), 0); + const capped = state.keys.filter(key => key.dataLimitBytes).length; + const overLimit = state.keys.filter(key => key.dataLimitBytes && key.bytes >= key.dataLimitBytes); + const server = activeServer(); + + const busiest = state.keys.slice().sort((a, b) => b.bytes - a.bytes).slice(0, 5); + + const nodes = [ + el('div', { className: 'stats' }, + stat('Access keys', String(state.keys.length), capped ? capped + ' with a limit' : 'none limited'), + stat('Transferred', formatBytes(total), 'across all keys'), + stat('Default cap', state.serverLimitBytes ? formatBytes(state.serverLimitBytes) : 'None', + 'applies to keys without their own'), + stat('Servers', String(state.servers.length), server ? 'active: ' + (server.name || server.host) : 'none configured')), + ]; + + if (overLimit.length) { + nodes.push(el('div', { className: 'notice warn' }, el('div', { className: 'grow' }, + el('strong', { textContent: overLimit.length + (overLimit.length === 1 ? ' key has' : ' keys have') + ' reached its data limit' }), + overLimit.map(key => key.name || 'key ' + key.id).join(', ')))); + } + + nodes.push(el('div', { className: 'panel' }, + el('h2', {}, 'Busiest keys', el('span', { className: 'sub', textContent: 'by data transferred' })), + busiest.length + ? el('div', { className: 'scroll' }, el('table', {}, + el('thead', {}, el('tr', {}, + el('th', { textContent: 'Name' }), + el('th', { textContent: 'Used', style: 'text-align:right' }), + el('th', { textContent: 'Of limit', style: 'text-align:right' }))), + el('tbody', {}, busiest.map(key => el('tr', {}, + el('td', {}, el('span', { + className: 'name' + (key.name ? '' : ' unnamed'), + textContent: key.name || 'Unnamed', + })), + el('td', { className: 'num' }, el('div', { textContent: formatBytes(key.bytes) }), + meter(key.bytes, key.dataLimitBytes)), + el('td', { + className: 'num', + textContent: key.dataLimitBytes ? Math.round((key.bytes / key.dataLimitBytes) * 100) + '%' : '—', + })))))) + : el('div', { className: 'empty', textContent: 'No keys on this server yet.' }))); + + return nodes; +} + +/* ---------- servers ---------- */ + +function addServer() { + return openForm({ + title: 'Add an Outline server', + lede: 'Open Outline Manager, choose your server, and copy the line under Settings > Management API URL.', + wide: true, + fields: [ + { name: 'name', label: 'Name', placeholder: 'e.g. Frankfurt' }, + { + name: 'accessCode', label: 'Access code', multiline: true, + placeholder: '{"apiUrl":"https://1.2.3.4:16942/AbCdEf123","certSha256":"E38…"}', + hint: 'The whole JSON line, or just the Management API URL.', + }, + { name: 'domain', label: 'Custom domain (optional)', placeholder: 'vpn.example.com', + hint: 'Used in access URLs in place of the server IP.' }, + ], + okText: 'Add server', + submit: values => run(() => api('POST', '/api/servers', values)), }); - if (value === null) return; - act(() => api('PUT', '/api/keys/' + encodeURIComponent(key.id) + '/limit', { bytes: value.trim() || null })); } -async function setServerLimit() { - const value = await ask({ - title: 'Server-wide default limit', - label: 'Limit', - value: state.serverLimitBytes ? formatBytes(state.serverLimitBytes).replace(' ', '') : '', - hint: 'Applies to keys without their own limit. Leave empty to remove.', - placeholder: 'no limit', +function editServer(server) { + return openForm({ + title: 'Edit ' + (server.name || server.host), + wide: true, + fields: [ + { name: 'name', label: 'Name', value: server.name }, + { name: 'domain', label: 'Custom domain', value: server.domain, placeholder: 'vpn.example.com', + hint: 'Leave empty to use the server IP in access URLs.' }, + { + name: 'accessCode', label: 'Replace access code (optional)', multiline: true, + placeholder: server.apiUrlPreview, + hint: 'Leave empty to keep the current one. Paste a new code if the server moved or was rebuilt.', + }, + ], + submit: values => run(() => api('PUT', '/api/servers/' + encodeURIComponent(server.id), values)), }); - if (value === null) return; - act(() => api('PUT', '/api/server/limit', { bytes: value.trim() || null })); } -async function remove(key) { - const label = key.name || 'key ' + key.id; - if (!confirm('Delete ' + label + '? Anyone using this key loses access immediately.')) return; - act(() => api('DELETE', '/api/keys/' + encodeURIComponent(key.id))); +async function revealAccessCode(server) { + let data; + try { + data = await api('GET', '/api/servers/' + encodeURIComponent(server.id) + '/access-code'); + } catch (err) { + return showError(err.message); + } + await openForm({ + title: 'Access code for ' + (server.name || server.host), + lede: 'This grants full administrative control of the server. Treat it like a password.', + wide: true, + fields: [{ name: 'code', label: 'Paste into Outline Manager or another machine', value: data.json, multiline: true }], + okText: 'Done', dismissOnly: true, + }); } -async function showQr(id) { +/** + * Checks one server and reports the outcome under its buttons. + * + * The result deliberately does not go in the button's own label: it is longer + * than "test", and growing a button reflows the row while the pointer is still + * over it, which is how you end up clicking "remove" by accident. + */ +async function testServer(server, button, result) { + button.disabled = true; + result.className = 'test-result'; + result.textContent = 'Testing…'; showError(''); + try { - const data = await api('GET', '/api/keys/' + encodeURIComponent(id) + '/qr'); - $('#qr-title').textContent = data.name || 'Access key'; - $('#qr-code').textContent = data.qr; - $('#qr-url').textContent = data.accessUrl; - $('#qr').showModal(); + const info = await api('POST', '/api/servers/' + encodeURIComponent(server.id) + '/test'); + result.className = 'test-result ok'; + result.textContent = info.name ? 'Reached "' + info.name + '"' : 'Reachable'; } catch (err) { - showError(err.message); + result.className = 'test-result bad'; + result.textContent = err.message; + } finally { + button.disabled = false; } } +function removeServer(server) { + const label = server.name || server.host; + if (!confirm( + 'Remove ' + label + ' from this dashboard?\\n\\n' + + 'The server itself and its keys are untouched — only the saved access code goes away.' + )) return; + return act(() => api('DELETE', '/api/servers/' + encodeURIComponent(server.id))); +} + +function serverCard(server) { + const card = el('div', { className: 'card' + (server.active ? ' active' : '') }); + + card.append(el('h3', {}, + server.name || server.host || 'Unnamed server', + server.active ? el('span', { className: 'pill on', textContent: 'active' }) : null, + server.source === 'env' ? el('span', { className: 'pill', textContent: 'environment' }) : null)); + + const dl = el('dl', {}, + el('dt', { textContent: 'Host' }), el('dd', { className: 'mono', textContent: server.host || '—' }), + el('dt', { textContent: 'Domain' }), el('dd', { textContent: server.domain || 'none' }), + el('dt', { textContent: 'API' }), el('dd', { className: 'mono', textContent: server.apiUrlPreview || '—' }), + el('dt', { textContent: 'Cert' }), + el('dd', { textContent: server.certPinned ? 'pinned' : 'not pinned' })); + card.append(dl); + + const testResult = el('div', { className: 'test-result', hidden: true }); + const testBtn = el('button', { className: 'link', textContent: 'test' }); + testBtn.onclick = () => { testResult.hidden = false; testServer(server, testBtn, testResult); }; + + const row = el('div', { className: 'row' }, + server.active + ? null + : el('button', { + className: 'link', textContent: 'make active', + onclick: () => act(() => api('POST', '/api/servers/' + encodeURIComponent(server.id) + '/activate')), + }), + testBtn, + el('button', { className: 'link', textContent: 'access code', onclick: () => revealAccessCode(server) }), + server.editable ? el('button', { className: 'link', textContent: 'edit', onclick: () => editServer(server) }) : null, + server.editable + ? el('button', { className: 'link danger', textContent: 'remove', onclick: () => removeServer(server) }) + : null); + card.append(row, testResult); + return card; +} + +/** + * The card that stands in for direct server access. + * + * It is deliberately inert rather than a disabled button: there is nothing to + * click yet, and saying so plainly is more useful than a control that looks + * broken. When provisioning lands it takes this slot, and everything it creates + * joins the same list as the servers above. + */ +function provisionCard() { + return el('div', { className: 'card future' }, + el('h3', {}, 'Install Shadowbox on a new server', el('span', { className: 'pill soft', textContent: 'coming next' })), + el('p', { textContent: + 'Connect to a fresh host over SSH, run the Outline installer on it, and have the ' + + 'resulting access code land here automatically — no round trip through Outline Manager.' })); +} + +function renderServers() { + const nodes = [ + el('div', { className: 'bar' }, + el('button', { className: 'primary', textContent: 'Add server', onclick: addServer }), + el('span', { className: 'spacer' }), + el('span', { + className: 'crumb', + textContent: state.servers.length + (state.servers.length === 1 ? ' server' : ' servers'), + })), + ]; + + if (!state.servers.length) { + nodes.push(el('div', { className: 'panel' }, el('div', { className: 'empty' }, + el('p', { textContent: 'No Outline servers yet. Add one with its access code from Outline Manager.' }), + el('button', { className: 'primary', textContent: 'Add your first server', onclick: addServer })))); + } else { + nodes.push(el('div', { className: 'cards' }, state.servers.map(serverCard))); + } + + nodes.push(el('div', { className: 'panel' }, + el('h2', {}, 'Provisioning'), + el('div', { className: 'body' }, el('div', { className: 'cards' }, provisionCard())))); + + return nodes; +} + +/* ---------- settings ---------- */ + +function setServerLimit() { + return openForm({ + title: 'Server-wide default limit', + lede: 'Applies to every key that has no limit of its own.', + fields: [{ + name: 'limit', label: 'Limit', value: sizeInput(state.serverLimitBytes), + placeholder: 'no limit', hint: 'e.g. 100GB. Leave empty to remove the default cap.', + }], + }).then(values => { + if (!values) return; + return act(() => api('PUT', '/api/server/limit', { bytes: values.limit || null })); + }); +} + +function renderSettings() { + const server = activeServer(); + const nodes = []; + + nodes.push(el('div', { className: 'panel' }, + el('h2', {}, 'Active server', + el('span', { className: 'sub', textContent: server ? (server.name || server.host) : 'none' })), + el('div', { className: 'body' }, + server + ? el('div', {}, + state.serverName + ? el('div', { className: 'bar' }, + el('span', { className: 'crumb', textContent: 'Reports its name as "' + state.serverName + '"' })) + : null, + el('div', { className: 'bar' }, + el('span', { className: 'crumb', textContent: 'Access URLs use ' + (state.host || '—') }), + el('span', { className: 'spacer' }), + server.editable + ? el('button', { className: 'small', textContent: 'Change domain', onclick: () => editServer(server) }) + : el('span', { className: 'crumb', textContent: 'Set OUTLINE_DOMAIN to change this' })), + el('div', { className: 'bar' }, + el('span', { + className: 'crumb', + textContent: state.reachable + ? 'Default data cap: ' + + (state.serverLimitBytes ? formatBytes(state.serverLimitBytes) : 'none') + : 'Default data cap: unknown while the server is unreachable', + }), + el('span', { className: 'spacer' }), + el('button', { + className: 'small', textContent: 'Set default cap', + disabled: !state.reachable, onclick: setServerLimit, + }))) + : el('div', { className: 'crumb', textContent: 'Add a server to configure it.' })))); + + nodes.push(el('div', { className: 'panel' }, + el('h2', {}, 'Where things are stored'), + el('div', { className: 'body' }, + el('div', { className: 'card' }, + el('dl', {}, + el('dt', { textContent: 'Saved servers' }), + el('dd', { className: 'mono', textContent: state.configPath || 'not saved to disk this session' }), + el('dt', { textContent: 'Interface' }), + el('dd', { textContent: 'localhost only, authorised by the token in this URL' }))), + el('p', { className: 'hint', textContent: + 'The config file holds Management API URLs, which grant full administrative control of ' + + 'your servers. It is written readable only by you. Anyone who can read it can administer ' + + 'every server listed above.' })))); + + return nodes; +} + +/* ---------- shell ---------- */ + +function renderNav() { + const nav = $('#nav'); + nav.replaceChildren(); + for (const item of SECTIONS) { + const link = el('a', { href: '#' + item.id, textContent: '' }, + el('span', { className: 'dot' }), + el('span', { textContent: item.label })); + if (item.id === section) link.setAttribute('aria-current', 'page'); + if (item.id === 'keys' && state.keys.length) { + link.append(el('span', { className: 'count', textContent: String(state.keys.length) })); + } + if (item.id === 'servers' && state.servers.length) { + link.append(el('span', { className: 'count', textContent: String(state.servers.length) })); + } + nav.append(link); + } +} + +function renderSwitcher() { + const wrap = $('#switcher'); + wrap.replaceChildren(); + if (!state.servers.length) return; + + const select = el('select', { title: 'Active server' }); + for (const server of state.servers) { + const option = el('option', { value: server.id, textContent: server.name || server.host }); + if (server.id === state.activeServerId) option.selected = true; + select.append(option); + } + select.onchange = () => + act(() => api('POST', '/api/servers/' + encodeURIComponent(select.value) + '/activate')); + + const led = el('span', { className: 'led ' + (state.reachable ? 'up' : 'down') }); + wrap.append(select, el('span', { className: 'state' }, led, + el('span', { textContent: state.reachable ? 'reachable' : 'unreachable' }))); +} + +/** The banner shown when the active server exists but cannot be talked to. */ +function unreachableNotice() { + const goToServers = el('button', { className: 'small', textContent: 'Open Servers' }); + goToServers.onclick = () => { location.hash = '#servers'; }; + + return el('div', { className: 'notice err' }, + el('div', { className: 'grow' }, + el('strong', { textContent: 'Cannot reach this server' }), + state.unreachableReason || 'The Management API did not respond.'), + goToServers); +} + +/** Shown before any server exists, in place of every section but Servers. */ +function noServerNotice() { + return el('div', { className: 'panel' }, el('div', { className: 'empty' }, + el('p', { textContent: 'No Outline server is configured yet.' }), + el('button', { className: 'primary', textContent: 'Add a server', onclick: addServer }))); +} + +function render() { + renderNav(); + renderSwitcher(); + + const server = activeServer(); + $('#title').textContent = SECTIONS.find(item => item.id === section).label; + $('#crumb').textContent = server ? (state.host || server.host) : ''; + + const view = $('#view'); + view.replaceChildren(); + + // Overview and Access keys are made entirely of server data, so an + // unreachable server leaves them nothing to show. Servers and Settings are + // mostly local, and are exactly where you go to fix the problem, so they + // render under the banner rather than behind it. + const needsServer = section === 'overview' || section === 'keys'; + const down = Boolean(state.activeServerId) && !state.reachable; + + if (down) view.append(unreachableNotice()); + + if (!state.servers.length && section !== 'servers') { + view.append(noServerNotice()); + return; + } + if (down && needsServer) return; + + const sections = { + overview: renderOverview, + keys: renderKeys, + servers: renderServers, + settings: renderSettings, + }; + for (const node of [sections[section]()].flat()) if (node) view.append(node); +} + +function onHashChange() { + const wanted = (location.hash || '').replace('#', ''); + section = SECTIONS.some(item => item.id === wanted) ? wanted : 'overview'; + render(); +} + function init() { if (!token) { showError('No access token in the URL. Open the link printed in your terminal.'); } - $('#add').onclick = addKey; $('#refresh').onclick = () => act(() => api('GET', '/api/state')); - $('#server-limit-btn').onclick = setServerLimit; - $('#prompt-cancel').onclick = () => $('#prompt').close(); - $('#qr-close').onclick = () => $('#qr').close(); + window.addEventListener('hashchange', onHashChange); + + const wanted = (location.hash || '').replace('#', ''); + section = SECTIONS.some(item => item.id === wanted) ? wanted : 'overview'; + act(() => api('GET', '/api/state')); } @@ -338,63 +999,32 @@ const HTML = ` -Outline access keys +shadowtools -
-

Outline access keys

- - -
- -
- - -
- - - - - -
- -
- - - - - - - - -
IDNameUsedLimitAccess URL
Loading…
-
- - -
- - -
-

- - -
-
- - +
+ + +
+
+

Overview

+ + + + +
+ +
+ +
- -
- - -
-

-

-    
-
-
-
+ + diff --git a/package-lock.json b/package-lock.json index 0a4fd01..0fd7472 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { - "name": "shadowbox-keys", + "name": "shadowtools", "version": "4.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "shadowbox-keys", + "name": "shadowtools", "version": "4.0.1", + "license": "MIT", "dependencies": { "qrcode-terminal": "^0.12.0" }, "bin": { - "shadowbox-keys": "shadowboxKey.js" + "shadowtools": "shadowtools.js" }, "engines": { "node": ">=14" diff --git a/package.json b/package.json index 4317539..9905b68 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "shadowtools", "version": "4.0.1", - "description": "Manage Outline VPN (Shadowbox) access keys from the command line or from code: list, create, rename, delete, set data limits, report usage and print QR codes", + "description": "Manage Outline VPN (Shadowbox) servers and access keys from a local admin dashboard, the command line, or your own code: list, create, rename, delete, set data limits, report usage and show QR codes", "license": "MIT", "main": "index.js", "bin": { @@ -25,6 +25,8 @@ "shadowsocks", "access-keys", "cli", + "dashboard", + "admin-panel", "qrcode", "outline-br" ], diff --git a/shadowtools.js b/shadowtools.js index 0abeb31..861967e 100755 --- a/shadowtools.js +++ b/shadowtools.js @@ -4,17 +4,22 @@ // BEGIN Variables to change // Prefer setting these via environment variables (OUTLINE_API_URL, OUTLINE_DOMAIN) // so the Management API URL never ends up committed to version control. +// +// These are no longer the only way to configure a server: the dashboard saves +// servers to a config file, and the CLI reads the same file. An environment +// variable still wins, so an existing setup keeps behaving as it always has. const managementApiUrl = process.env.OUTLINE_API_URL || 'https://xx.xx.xx.xxx:16942/xxxxxxxxxxxxxxxxxxxxxx'; // Management API URL from Outline Manager > Settings const domain = process.env.OUTLINE_DOMAIN || ''; // set your custom domain if you have one, e.g. 'vpn.example.com' const certSha256 = process.env.OUTLINE_CERT_SHA256 || ''; // recommended: certSha256 from Outline Manager, to pin the server's certificate // END Variables to change const qrcode = require('qrcode-terminal'); -const { OutlineClient } = require('./lib/outline'); const { UserError } = require('./lib/errors'); +const { createStore } = require('./lib/config'); +const { createRegistry } = require('./lib/registry'); const { formatBytes, parseBytes, rewriteAccessUrl, printTable, printCsv } = require('./lib/format'); -const USAGE = `shadowtools - manage access keys on your Outline VPN server +const USAGE = `shadowtools - manage access keys on your Outline VPN servers Usage: node shadowtools.js [options] @@ -27,32 +32,44 @@ Commands: the key to set the server-wide default limit) usage Show data transferred per key qr Print an access key as a scannable QR code - ui Open a local web interface for all of the above + servers List the Outline servers this machine knows about + servers add Save a server, reading its access code from stdin + servers use Choose the server other commands act on + servers remove Forget a saved server (the server itself is untouched) + ui Open the admin dashboard in your browser may be either a key id or a key name. + may be either a server id or a server name. Options: --qr Also print a QR code for each key (list, add) - --json Output JSON instead of a table (list, usage) - --csv Output CSV instead of a table (list, usage) + --json Output JSON instead of a table (list, usage, servers) + --csv Output CSV instead of a table (list, usage, servers) --limit Data limit for a newly created key (add) - --port Port for the web interface (ui, default 8787) + --server Act on this server instead of the active one + --port Port for the dashboard (ui, default 8787) -h, --help Show this help Sizes accept a unit suffix, e.g. 10GB, 500MB, 2TB. -Configuration (environment variables): +Configuration: + Servers added in the dashboard are saved to a config file readable only by + you. These environment variables define one more server, which takes + precedence and is never written to that file: + OUTLINE_API_URL Management API URL from Outline Manager > Settings OUTLINE_DOMAIN Optional custom domain to use in place of the server IP OUTLINE_CERT_SHA256 Recommended. The server's certSha256, from the same place in Outline Manager. When set, the tool refuses to talk to any server presenting a different certificate. + SHADOWTOOLS_CONFIG Override where saved servers are stored. Examples: node shadowtools.js list --qr node shadowtools.js add Alice --limit 50GB node shadowtools.js limit Alice 10GB node shadowtools.js usage --csv + node shadowtools.js servers use Frankfurt node shadowtools.js ui --port 9000 `; @@ -68,6 +85,8 @@ function parseArgs(argv) { flags.limit = argv[++i]; } else if (arg === '--port') { flags.port = argv[++i]; + } else if (arg === '--server') { + flags.server = argv[++i]; } else if (arg.startsWith('--')) { flags[arg.slice(2)] = true; } else { @@ -97,6 +116,36 @@ function findKey(keys, needle) { return found[0]; } +/** The same id-then-name resolution, over the configured servers. */ +function findServer(servers, needle) { + const byId = servers.find(server => server.id === needle); + if (byId) return byId; + + const exact = servers.filter(server => server.name === needle); + const found = exact.length + ? exact + : servers.filter(server => (server.name || '').toLowerCase() === needle.toLowerCase()); + + if (found.length === 0) { + throw new UserError(`No server matches "${needle}". Run "servers" to see the configured servers.`); + } + if (found.length > 1) { + throw new UserError(`"${needle}" matches more than one server. Use the server id instead.`); + } + return found[0]; +} + +/** Reads an access code piped or typed into stdin, so it stays out of shell history. */ +function readStdin() { + return new Promise((resolve, reject) => { + let text = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => (text += chunk)); + process.stdin.on('end', () => resolve(text)); + process.stdin.on('error', reject); + }); +} + function printQr(label, accessUrl) { console.log(`\n${label}:`); qrcode.generate(accessUrl, { small: true }); @@ -110,13 +159,13 @@ function output(columns, rows, flags) { } const commands = { - async list(client, host, args, flags) { - const keys = await client.listKeys(); + async list(ctx, args, flags) { + const keys = await ctx.client.listKeys(); const rows = keys.map(key => ({ id: key.id, name: key.name || '(unnamed)', limit: key.dataLimit ? formatBytes(key.dataLimit.bytes) : '-', - accessUrl: rewriteAccessUrl(key.accessUrl, client.hostname, host), + accessUrl: rewriteAccessUrl(key.accessUrl, ctx.client.hostname, ctx.host), })); output( @@ -135,40 +184,40 @@ const commands = { } }, - async add(client, host, args, flags) { + async add(ctx, args, flags) { const name = args.join(' ').trim(); if (!name) throw new UserError('Please give the new key a name, e.g. add Alice'); - const key = await client.createKey(name); + const key = await ctx.client.createKey(name); if (flags.limit) { - await client.setKeyDataLimit(key.id, parseBytes(flags.limit)); + await ctx.client.setKeyDataLimit(key.id, parseBytes(flags.limit)); } - const accessUrl = rewriteAccessUrl(key.accessUrl, client.hostname, host); + const accessUrl = rewriteAccessUrl(key.accessUrl, ctx.client.hostname, ctx.host); console.log(`Created key "${name}" (id ${key.id})`); if (flags.limit) console.log(`Data limit: ${formatBytes(parseBytes(flags.limit))}`); console.log(accessUrl); if (flags.qr) printQr(name, accessUrl); }, - async remove(client, host, args) { + async remove(ctx, args) { if (!args[0]) throw new UserError('Please say which key to remove, e.g. remove Alice'); - const key = findKey(await client.listKeys(), args[0]); - await client.removeKey(key.id); + const key = findKey(await ctx.client.listKeys(), args[0]); + await ctx.client.removeKey(key.id); console.log(`Removed key "${key.name || key.id}" (id ${key.id})`); }, - async rename(client, host, args) { + async rename(ctx, args) { const newName = args.slice(1).join(' ').trim(); if (!args[0] || !newName) { throw new UserError('Please give both a key and a new name, e.g. rename Alice Bob'); } - const key = findKey(await client.listKeys(), args[0]); - await client.renameKey(key.id, newName); + const key = findKey(await ctx.client.listKeys(), args[0]); + await ctx.client.renameKey(key.id, newName); console.log(`Renamed key ${key.id} from "${key.name || '(unnamed)'}" to "${newName}"`); }, - async limit(client, host, args) { + async limit(ctx, args) { const [target, size] = args; if (!target || !size) { throw new UserError('Please give a key and a size, e.g. limit Alice 10GB (or limit Alice none)'); @@ -177,31 +226,31 @@ const commands = { if (target === 'server') { if (clearing) { - await client.clearServerDataLimit(); + await ctx.client.clearServerDataLimit(); console.log('Cleared the server-wide default data limit.'); } else { const bytes = parseBytes(size); - await client.setServerDataLimit(bytes); + await ctx.client.setServerDataLimit(bytes); console.log(`Server-wide default data limit set to ${formatBytes(bytes)}.`); } return; } - const key = findKey(await client.listKeys(), target); + const key = findKey(await ctx.client.listKeys(), target); if (clearing) { - await client.clearKeyDataLimit(key.id); + await ctx.client.clearKeyDataLimit(key.id); console.log(`Cleared the data limit on "${key.name || key.id}".`); } else { const bytes = parseBytes(size); - await client.setKeyDataLimit(key.id, bytes); + await ctx.client.setKeyDataLimit(key.id, bytes); console.log(`Data limit on "${key.name || key.id}" set to ${formatBytes(bytes)}.`); } }, - async usage(client, host, args, flags) { + async usage(ctx, args, flags) { const [keys, transferred] = await Promise.all([ - client.listKeys(), - client.getTransferMetrics(), + ctx.client.listKeys(), + ctx.client.getTransferMetrics(), ]); const rows = keys @@ -237,45 +286,155 @@ const commands = { } }, - async ui(client, host, args, flags) { - const { start } = require('./lib/server'); + async qr(ctx, args) { + if (!args[0]) throw new UserError('Please say which key to show, e.g. qr Alice'); + const key = findKey(await ctx.client.listKeys(), args[0]); + printQr(key.name || String(key.id), rewriteAccessUrl(key.accessUrl, ctx.client.hostname, ctx.host)); + }, +}; - const port = flags.port === undefined ? 8787 : Number(flags.port); - if (!Number.isInteger(port) || port < 0 || port > 65535) { - throw new UserError(`"${flags.port}" is not a valid port.`); +/** + * Commands that manage the servers themselves rather than the keys on one, so + * they take the registry and never need a reachable Outline server. + */ +const serverCommands = { + async list(registry, args, flags) { + const servers = registry.list(); + + // JSON is for scripts, so give them the structured records rather than + // the table's display strings — booleans, not asterisks. + if (flags.json) { + console.log(JSON.stringify(servers, null, 2)); + return; } - let started; - try { - started = await start({ client, domain: host, port }); - } catch (err) { - if (err.code === 'EADDRINUSE') { - throw new UserError(`Port ${port} is already in use. Choose another with --port.`); - } - throw err; + if (!servers.length) { + console.log('No Outline servers configured yet.'); + console.log('Add one with "servers add ", or open the dashboard with "ui".'); + return; } - console.log('Web interface running. Open this URL:'); - console.log(`\n ${started.url}\n`); - console.log('It listens on localhost only, and the token in the URL authorises it.'); - console.log('Press Ctrl+C to stop.'); - - // Resolve only when the server closes, so the CLI stays alive serving it. - await new Promise(resolve => { - const stop = () => started.server.close(resolve); - process.once('SIGINT', stop); - process.once('SIGTERM', stop); - started.server.once('close', resolve); - }); + const rows = servers.map(server => ({ + active: server.active ? '*' : '', + id: server.id, + name: server.name || '(unnamed)', + host: server.domain || server.host, + source: server.source === 'env' ? 'environment' : 'saved', + pinned: server.certPinned ? 'yes' : 'no', + })); + + output( + [ + { key: 'active', header: '' }, + { key: 'id', header: 'ID' }, + { key: 'name', header: 'NAME' }, + { key: 'host', header: 'HOST' }, + { key: 'source', header: 'SOURCE' }, + { key: 'pinned', header: 'CERT PINNED' }, + ], + rows, + flags + ); + + if (!flags.json && !flags.csv && registry.storePath) { + console.log(`\nSaved servers live in ${registry.storePath}`); + } }, - async qr(client, host, args) { - if (!args[0]) throw new UserError('Please say which key to show, e.g. qr Alice'); - const key = findKey(await client.listKeys(), args[0]); - printQr(key.name || String(key.id), rewriteAccessUrl(key.accessUrl, client.hostname, host)); + async add(registry, args) { + const name = args.join(' ').trim(); + if (process.stdin.isTTY) { + console.error('Paste the access code from Outline Manager > Settings, then press Ctrl+D:'); + } + const accessCode = await readStdin(); + + const id = registry.add({ name, accessCode }); + console.log(`Saved server "${name || id}" (id ${id}) and made it active.`); + if (registry.storePath) console.log(`Stored in ${registry.storePath}`); + }, + + async use(registry, args) { + if (!args[0]) throw new UserError('Please say which server to use, e.g. servers use Frankfurt'); + const server = findServer(registry.list(), args[0]); + registry.activate(server.id); + console.log(`Now acting on "${server.name || server.host}" (id ${server.id}).`); + }, + + async remove(registry, args) { + if (!args[0]) throw new UserError('Please say which server to remove, e.g. servers remove Frankfurt'); + const server = findServer(registry.list(), args[0]); + registry.remove(server.id); + console.log(`Forgot "${server.name || server.host}". The server itself is untouched.`); }, }; +/** The whole `servers` command family, dispatched on its first argument. */ +async function serversCommand(registry, args, flags) { + const [sub, ...rest] = args; + const run = serverCommands[sub || 'list']; + if (!run) { + throw new UserError(`Unknown servers command "${sub}". Try: servers, servers add, servers use, servers remove.`); + } + await run(registry, rest, flags); +} + +async function uiCommand(registry, args, flags) { + const { start } = require('./lib/server'); + + const port = flags.port === undefined ? 8787 : Number(flags.port); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new UserError(`"${flags.port}" is not a valid port.`); + } + + let started; + try { + started = await start({ registry, port }); + } catch (err) { + if (err.code === 'EADDRINUSE') { + throw new UserError(`Port ${port} is already in use. Choose another with --port.`); + } + throw err; + } + + console.log('Dashboard running. Open this URL:'); + console.log(`\n ${started.url}\n`); + console.log('It listens on localhost only, and the token in the URL authorises it.'); + if (!registry.list().length) { + console.log('No servers configured yet — add your first one from the Servers section.'); + } + console.log('Press Ctrl+C to stop.'); + + // Resolve only when the server closes, so the CLI stays alive serving it. + await new Promise(resolve => { + const stop = () => started.server.close(resolve); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + started.server.once('close', resolve); + }); +} + +/** Builds the registry from the environment and the saved config file. */ +function buildRegistry() { + // The placeholder means "not configured", not "a server at xx.xx.xx.xxx". + const configured = managementApiUrl && !managementApiUrl.includes('xx.xx.xx.xxx'); + return createRegistry({ + store: createStore(), + env: configured ? { apiUrl: managementApiUrl, certSha256, domain } : null, + }); +} + +/** Picks the server a key command acts on: --server if given, else the active one. */ +function contextFor(registry, flags) { + if (flags.server) { + const chosen = findServer(registry.list(), flags.server); + const { server, client } = registry.clientById(chosen.id); + return { registry, server, client, host: server.domain || client.hostname }; + } + + const { server, client } = registry.activeClient(); + return { registry, server, client, host: server.domain || client.hostname }; +} + async function main() { const { positional, flags } = parseArgs(process.argv.slice(2)); const [commandName, ...args] = positional; @@ -285,21 +444,20 @@ async function main() { return; } + const registry = buildRegistry(); + + // These two manage or serve the configuration itself, so they must work + // before any Outline server exists — that is how the first one gets added. + if (commandName === 'servers') return serversCommand(registry, args, flags); + if (commandName === 'ui') return uiCommand(registry, args, flags); + // Listing is the historical default, so bare `node shadowtools.js` still works. const command = commands[commandName || 'list']; if (!command) { throw new UserError(`Unknown command "${commandName}". Run with --help to see what is available.`); } - if (managementApiUrl.includes('xx.xx.xx.xxx')) { - throw new UserError( - 'Please configure your Management API URL first.\n' + - 'Set the OUTLINE_API_URL environment variable, or edit the managementApiUrl constant in shadowtools.js.' - ); - } - - const client = new OutlineClient(managementApiUrl, certSha256); - await command(client, domain || client.hostname, args, flags); + await command(contextFor(registry, flags), args, flags); } // Only run when invoked directly, so the tests can import the helpers below. @@ -310,4 +468,4 @@ if (require.main === module) { }); } -module.exports = { parseArgs, findKey }; +module.exports = { parseArgs, findKey, findServer }; diff --git a/test/api.test.js b/test/api.test.js index 9228ef6..808afe2 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -160,3 +160,60 @@ test('programmatic API', { skip: hasOpenssl ? false : 'openssl is not available' fs.rmSync(credentials.dir, { recursive: true, force: true }); } }); + +test('a server that accepts the connection but never answers is given up on', async t => { + // Without a timeout this hangs for the operating system's TCP timeout — + // well over a minute — which in the dashboard looks like a frozen page. + const net = require('node:net'); + const { OutlineClient, DEFAULT_REQUEST_TIMEOUT_MS } = require('../lib/outline'); + const { UserError } = require('../lib/errors'); + + assert.strictEqual(DEFAULT_REQUEST_TIMEOUT_MS, 15000); + + const silent = net.createServer(() => { /* accept, then say nothing */ }); + await new Promise(resolve => silent.listen(0, '127.0.0.1', resolve)); + const port = silent.address().port; + + const previous = process.env.OUTLINE_TIMEOUT_MS; + process.env.OUTLINE_TIMEOUT_MS = '200'; + t.after(() => { + if (previous === undefined) delete process.env.OUTLINE_TIMEOUT_MS; + else process.env.OUTLINE_TIMEOUT_MS = previous; + silent.close(); + }); + + const started = Date.now(); + await assert.rejects( + () => new OutlineClient(`https://127.0.0.1:${port}/secret`).listKeys(), + err => { + assert.ok(err instanceof UserError, 'a hung server is a user-facing problem, not a bug'); + assert.match(err.message, /did not respond within/); + return true; + } + ); + + // Generous headroom for a slow machine; the point is that it is not minutes. + assert.ok(Date.now() - started < 5000, 'should give up promptly'); +}); + +test('OUTLINE_TIMEOUT_MS is honoured, and nonsense falls back to the default', async t => { + const { requestTimeout, DEFAULT_REQUEST_TIMEOUT_MS } = require('../lib/outline'); + + const previous = process.env.OUTLINE_TIMEOUT_MS; + t.after(() => { + if (previous === undefined) delete process.env.OUTLINE_TIMEOUT_MS; + else process.env.OUTLINE_TIMEOUT_MS = previous; + }); + + delete process.env.OUTLINE_TIMEOUT_MS; + assert.strictEqual(requestTimeout(), DEFAULT_REQUEST_TIMEOUT_MS); + + process.env.OUTLINE_TIMEOUT_MS = '2500'; + assert.strictEqual(requestTimeout(), 2500); + + // A bad value must not become "no timeout" or a timeout of zero. + for (const bad of ['', 'soon', '0', '-1', 'NaN']) { + process.env.OUTLINE_TIMEOUT_MS = bad; + assert.strictEqual(requestTimeout(), DEFAULT_REQUEST_TIMEOUT_MS, `for "${bad}"`); + } +}); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..64349fd --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,139 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + createStore, + normalizeConfig, + parseAccessCode, + redactApiUrl, + ENV_SERVER_ID, +} = require('../lib/config'); +const { UserError } = require('../lib/errors'); + +const API_URL = 'https://1.2.3.4:16942/AbCdEf123'; +const FINGERPRINT = 'E3823F9BB490D35487EE013EC7D23A3662F76B95EB01A40F4851905152F5A584'; + +/** A scratch config path that is cleaned up with the test. */ +function tempConfig(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shadowtools-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return path.join(dir, 'nested', 'config.json'); +} + +test('parseAccessCode reads the JSON blob Outline Manager shows', () => { + const code = `{"apiUrl":"${API_URL}","certSha256":"${FINGERPRINT}"}`; + assert.deepStrictEqual(parseAccessCode(code), { apiUrl: API_URL, certSha256: FINGERPRINT }); +}); + +test('parseAccessCode ignores prose pasted around the blob', () => { + const code = `Here it is: {"apiUrl":"${API_URL}","certSha256":"${FINGERPRINT}"} — enjoy`; + assert.strictEqual(parseAccessCode(code).apiUrl, API_URL); +}); + +test('parseAccessCode survives a URL wrapped across lines by a mail client', () => { + const wrapped = `{"apiUrl":"https://1.2.3.4:16942/\n AbCdEf123","certSha256":"${FINGERPRINT}"}`; + assert.strictEqual(parseAccessCode(wrapped).apiUrl, API_URL); +}); + +test('parseAccessCode accepts a bare Management API URL, with no fingerprint', () => { + assert.deepStrictEqual(parseAccessCode(` ${API_URL} `), { apiUrl: API_URL, certSha256: '' }); +}); + +test('parseAccessCode drops a trailing slash so the same server is not saved twice', () => { + assert.strictEqual(parseAccessCode(API_URL + '/').apiUrl, API_URL); +}); + +test('parseAccessCode refuses a URL with no secret path', () => { + // The path is the admin secret; without it the URL cannot administer anything. + assert.throws(() => parseAccessCode('https://1.2.3.4:16942'), /no secret path/); + assert.throws(() => parseAccessCode('https://1.2.3.4:16942/'), /no secret path/); +}); + +test('parseAccessCode refuses plaintext http and unparseable input', () => { + assert.throws(() => parseAccessCode('http://1.2.3.4:16942/AbC'), /must use https/); + assert.throws(() => parseAccessCode('not a url'), UserError); + assert.throws(() => parseAccessCode(''), UserError); + assert.throws(() => parseAccessCode('{"apiUrl": broken}'), /could not be parsed/); +}); + +test('redactApiUrl keeps the host and only a stub of the secret', () => { + assert.strictEqual(redactApiUrl(API_URL), 'https://1.2.3.4:16942/AbCd…'); + assert.strictEqual(redactApiUrl('nonsense'), ''); +}); + +test('redactApiUrl never shows a whole secret, however short it is', () => { + // A short secret must not slip through simply by fitting under the cap. + for (const secret of ['a', 'ab', 'abcd', 'abcdef', 'abcdefghij']) { + const shown = redactApiUrl(`https://1.2.3.4:16942/${secret}`); + assert.ok(!shown.includes(secret), `"${secret}" survived redaction as ${shown}`); + } +}); + +test('a store round-trips a config and creates its directory', async t => { + const file = tempConfig(t); + const store = createStore(file); + + assert.deepStrictEqual(store.load(), { version: 1, activeServerId: null, servers: [] }); + + store.save({ + version: 1, + activeServerId: 'abc', + servers: [{ id: 'abc', name: 'Frankfurt', apiUrl: API_URL, certSha256: FINGERPRINT, domain: 'vpn.example.com' }], + }); + + const loaded = store.load(); + assert.strictEqual(loaded.activeServerId, 'abc'); + assert.strictEqual(loaded.servers.length, 1); + assert.strictEqual(loaded.servers[0].domain, 'vpn.example.com'); +}); + +test('a saved config is readable only by its owner', async t => { + // It holds Management API URLs, so the file mode is the last line of defence + // on a shared machine. + const file = tempConfig(t); + createStore(file).save({ version: 1, activeServerId: null, servers: [] }); + + assert.strictEqual(fs.statSync(file).mode & 0o777, 0o600); + assert.strictEqual(fs.statSync(path.dirname(file)).mode & 0o777, 0o700); +}); + +test('saving twice leaves no temporary files behind', async t => { + const file = tempConfig(t); + const store = createStore(file); + store.save({ version: 1, activeServerId: null, servers: [] }); + store.save({ version: 1, activeServerId: null, servers: [] }); + + assert.deepStrictEqual(fs.readdirSync(path.dirname(file)), ['config.json']); +}); + +test('a corrupt config is reported rather than silently replaced', async t => { + const file = tempConfig(t); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '{ this is not json'); + + assert.throws(() => createStore(file).load(), /not valid JSON/); + // The unreadable file must still be there to rescue by hand. + assert.ok(fs.existsSync(file)); +}); + +test('normalizeConfig drops entries with no credential and unknown active ids', () => { + const config = normalizeConfig({ + activeServerId: 'gone', + servers: [{ id: 'a', apiUrl: API_URL }, { name: 'no url' }, null], + }); + + assert.strictEqual(config.servers.length, 1); + assert.strictEqual(config.activeServerId, null); +}); + +test('normalizeConfig keeps the environment server as a valid active choice', () => { + // It is never written to the file, so it would otherwise be pruned on load + // and the dashboard would forget which server you picked. + const config = normalizeConfig({ activeServerId: ENV_SERVER_ID, servers: [] }); + assert.strictEqual(config.activeServerId, ENV_SERVER_ID); +}); diff --git a/test/qr.test.js b/test/qr.test.js new file mode 100644 index 0000000..a7a9bae --- /dev/null +++ b/test/qr.test.js @@ -0,0 +1,71 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); + +const { toSvgPath, MARGIN } = require('../lib/qr'); + +const ACCESS_URL = 'ss://YWVzOnBhc3N3b3Jk@vpn.example.com:443/?outline=1'; + +/** Replays the path commands back into a grid of dark modules. */ +function decodePath(path, size) { + const grid = Array.from({ length: size }, () => new Array(size).fill(false)); + for (const run of path.matchAll(/M(\d+) (\d+)h(\d+)v1h-(\d+)z/g)) { + const [x, y, width, back] = [Number(run[1]), Number(run[2]), Number(run[3]), Number(run[4])]; + assert.strictEqual(width, back, 'each run must close on itself'); + for (let i = 0; i < width; i++) grid[y][x + i] = true; + } + return grid; +} + +test('the SVG path reproduces the encoder\'s module matrix exactly', () => { + // A QR code that is off by one module is a QR code that does not scan, and + // nothing else in this project would notice. + const QRCode = require('qrcode-terminal/vendor/QRCode'); + const levels = require('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel'); + + const { path, size } = toSvgPath(ACCESS_URL); + const grid = decodePath(path, size); + + const qr = new QRCode(-1, levels.M); + qr.addData(ACCESS_URL); + qr.make(); + const count = qr.getModuleCount(); + + assert.strictEqual(size, count + MARGIN * 2); + for (let row = 0; row < size; row++) { + for (let col = 0; col < size; col++) { + const inside = row >= MARGIN && col >= MARGIN && row < MARGIN + count && col < MARGIN + count; + const expected = inside ? qr.isDark(row - MARGIN, col - MARGIN) : false; + assert.strictEqual(grid[row][col], expected, `module ${row},${col}`); + } + } +}); + +test('the quiet zone is left blank on every side', () => { + // Scanners need it; a code drawn flush to the edge reads unreliably. + const { path, size } = toSvgPath(ACCESS_URL); + const grid = decodePath(path, size); + + for (let i = 0; i < size; i++) { + for (let m = 0; m < MARGIN; m++) { + assert.strictEqual(grid[m][i], false, `top row ${m}`); + assert.strictEqual(grid[size - 1 - m][i], false, `bottom row ${m}`); + assert.strictEqual(grid[i][m], false, `left column ${m}`); + assert.strictEqual(grid[i][size - 1 - m], false, `right column ${m}`); + } + } +}); + +test('the path carries no trace of the text it encodes', () => { + // It is set as an attribute on an SVG element, so it must be pure geometry. + const { path } = toSvgPath(ACCESS_URL); + assert.match(path, /^[Mhvz0-9 -]+$/); +}); + +test('toSvgPath returns null rather than throwing on input it cannot encode', () => { + assert.strictEqual(toSvgPath(''), null); + assert.strictEqual(toSvgPath(null), null); + // Far past the capacity of the largest QR version. + assert.strictEqual(toSvgPath('x'.repeat(10000)), null); +}); diff --git a/test/registry.test.js b/test/registry.test.js new file mode 100644 index 0000000..fcefb30 --- /dev/null +++ b/test/registry.test.js @@ -0,0 +1,179 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); + +const { createRegistry } = require('../lib/registry'); +const { createMemoryStore } = require('../lib/config'); +const { UserError } = require('../lib/errors'); + +const API_A = 'https://1.2.3.4:16942/AaAaAa'; +const API_B = 'https://5.6.7.8:16942/BbBbBb'; +const FINGERPRINT = 'e3823f9bb490d35487ee013ec7d23a3662f76b95eb01a40f4851905152f5a584'; + +/** A registry whose clients are stubs, so nothing here touches the network. */ +function registry({ env = null, servers = [], activeServerId = null } = {}) { + return createRegistry({ + store: createMemoryStore({ version: 1, activeServerId, servers }), + env, + clientFactory: server => ({ hostname: new URL(server.apiUrl).hostname, apiUrl: server.apiUrl }), + }); +} + +test('a registry with nothing configured lists nothing and says how to fix it', () => { + const reg = registry(); + assert.deepStrictEqual(reg.list(), []); + assert.strictEqual(reg.active(), null); + assert.throws(() => reg.activeClient(), /No Outline server is configured/); +}); + +test('adding a server saves it, makes it active, and reports it', () => { + const reg = registry(); + const id = reg.add({ name: 'Frankfurt', accessCode: API_A, domain: 'vpn.example.com' }); + + const [server] = reg.list(); + assert.strictEqual(server.id, id); + assert.strictEqual(server.name, 'Frankfurt'); + assert.strictEqual(server.host, '1.2.3.4'); + assert.strictEqual(server.domain, 'vpn.example.com'); + assert.strictEqual(server.active, true); + assert.strictEqual(server.source, 'file'); + assert.strictEqual(server.editable, true); +}); + +test('a listed server carries a redacted URL, never the credential', () => { + // The browser has no use for the secret, so it must not ride along in state. + const reg = registry(); + reg.add({ name: 'Frankfurt', accessCode: API_A }); + + const [server] = reg.list(); + assert.ok(!JSON.stringify(server).includes('AaAaAa'), 'the secret leaked into the server list'); + assert.match(server.apiUrlPreview, /^https:\/\/1\.2\.3\.4:16942\//); +}); + +test('the access code is available on its own, for the one place that needs it', () => { + const reg = registry(); + const id = reg.add({ name: 'Frankfurt', accessCode: `{"apiUrl":"${API_A}","certSha256":"${FINGERPRINT}"}` }); + + const code = reg.accessCode(id); + assert.strictEqual(code.apiUrl, API_A); + assert.strictEqual(code.certSha256, FINGERPRINT); + assert.deepStrictEqual(JSON.parse(code.json), { apiUrl: API_A, certSha256: FINGERPRINT }); +}); + +test('adding the same server twice is refused as the paste slip it is', () => { + const reg = registry(); + reg.add({ name: 'Frankfurt', accessCode: API_A }); + assert.throws(() => reg.add({ name: 'Again', accessCode: API_A }), /already saved as "Frankfurt"/); +}); + +test('a bad access code is refused before anything is stored', () => { + const reg = registry(); + assert.throws(() => reg.add({ name: 'Bad', accessCode: 'nonsense' }), UserError); + assert.deepStrictEqual(reg.list(), []); +}); + +test('a bad fingerprint is caught when the server is added, not on first use', () => { + // clientFactory here is the real one, so construction validates the digest. + const reg = createRegistry({ store: createMemoryStore() }); + assert.throws( + () => reg.add({ name: 'Bad', accessCode: `{"apiUrl":"${API_A}","certSha256":"nope"}` }), + /not a SHA-256 certificate fingerprint/ + ); + assert.deepStrictEqual(reg.list(), []); +}); + +test('updating changes name, domain and the access code itself', () => { + const reg = registry(); + const id = reg.add({ name: 'Old', accessCode: API_A }); + + reg.update(id, { name: 'New', domain: 'vpn.example.com', accessCode: API_B }); + + const [server] = reg.list(); + assert.strictEqual(server.name, 'New'); + assert.strictEqual(server.domain, 'vpn.example.com'); + assert.strictEqual(server.host, '5.6.7.8'); + assert.strictEqual(reg.accessCode(id).apiUrl, API_B); +}); + +test('updating without an access code keeps the existing one', () => { + const reg = registry(); + const id = reg.add({ name: 'Old', accessCode: API_A }); + reg.update(id, { name: 'Renamed', accessCode: '' }); + assert.strictEqual(reg.accessCode(id).apiUrl, API_A); +}); + +test('replacing the access code retires the client built from the old one', () => { + // A rebuilt server keeps its name but gets a new secret; a cached client + // would keep talking to the address that no longer works. + const reg = registry(); + const id = reg.add({ name: 'Frankfurt', accessCode: API_A }); + const before = reg.clientById(id).client; + + reg.update(id, { accessCode: API_B }); + const after = reg.clientById(id).client; + + assert.notStrictEqual(before, after); + assert.strictEqual(after.hostname, '5.6.7.8'); +}); + +test('removing a server clears it as the active choice', () => { + const reg = registry(); + const first = reg.add({ name: 'One', accessCode: API_A }); + const second = reg.add({ name: 'Two', accessCode: API_B }); + assert.strictEqual(reg.active().id, second); + + reg.remove(second); + assert.strictEqual(reg.list().length, 1); + assert.strictEqual(reg.active().id, first); +}); + +test('an unknown server id is refused everywhere it can be given', () => { + const reg = registry(); + for (const run of [ + () => reg.remove('nope'), + () => reg.update('nope', { name: 'x' }), + () => reg.activate('nope'), + () => reg.accessCode('nope'), + () => reg.clientById('nope'), + ]) { + assert.throws(run, /No configured server with id "nope"/); + } +}); + +test('the environment server is listed first and is always available', () => { + const reg = registry({ env: { apiUrl: API_A, domain: 'vpn.example.com' }, servers: [] }); + const [server] = reg.list(); + + assert.strictEqual(server.id, 'env'); + assert.strictEqual(server.source, 'env'); + assert.strictEqual(server.active, true); + assert.strictEqual(server.domain, 'vpn.example.com'); +}); + +test('the environment server cannot be edited or removed from the dashboard', () => { + const reg = registry({ env: { apiUrl: API_A } }); + assert.strictEqual(reg.list()[0].editable, false); + + for (const run of [() => reg.update('env', { name: 'x' }), () => reg.remove('env')]) { + assert.throws(run, /comes from OUTLINE_API_URL in your environment/); + } +}); + +test('a stored choice of active server outranks the environment', () => { + // Picking a server in the dashboard has to stick, even though the + // environment entry is the one listed first. + const reg = registry({ env: { apiUrl: API_A } }); + const saved = reg.add({ name: 'Saved', accessCode: API_B }); + + assert.strictEqual(reg.active().id, saved); + + reg.activate('env'); + assert.strictEqual(reg.active().id, 'env'); + assert.strictEqual(reg.list().find(server => server.id === saved).active, false); +}); + +test('an active id naming a server that is gone falls back rather than failing', () => { + const reg = registry({ servers: [{ id: 'a', name: 'One', apiUrl: API_A }], activeServerId: 'vanished' }); + assert.strictEqual(reg.active().id, 'a'); +}); diff --git a/test/server.test.js b/test/server.test.js index 72914d1..f4f5ebc 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -5,6 +5,8 @@ const assert = require('node:assert'); const http = require('node:http'); const { createHandler, hostIsLoopback, toBytes } = require('../lib/server'); +const { createRegistry } = require('../lib/registry'); +const { createMemoryStore } = require('../lib/config'); const { UserError } = require('../lib/errors'); const GB = 1024 ** 3; @@ -129,7 +131,7 @@ test('web interface', async t => { const res = await call('GET', '/', { token: null }); assert.strictEqual(res.status, 200); assert.match(res.headers['content-type'], /text\/html/); - assert.match(res.text, /Outline access keys<\/title>/); + assert.match(res.text, /<title>shadowtools<\/title>/); }); await t.test('the page policy allows its own fetch calls', async () => { @@ -263,3 +265,193 @@ test('the interface falls back to the server hostname when no domain is set', as server.close(); } }); + +/* -------------------------------------------------------------------------- + * Server management, which is what turned the key list into a dashboard. + * ------------------------------------------------------------------------ */ + +const API_A = 'https://1.2.3.4:16942/AaAaAaAaAa'; +const API_B = 'https://5.6.7.8:16942/BbBbBbBbBb'; + +/** Starts the handler over a registry whose clients are the fake above. */ +async function startDashboard({ env = null, clients = {} } = {}) { + const registry = createRegistry({ + store: createMemoryStore(), + env, + clientFactory: server => { + const host = new URL(server.apiUrl).hostname; + if (clients[host]) return clients[host]; + // A server nobody stubbed stands in for one that cannot be reached. + const fail = () => Promise.reject(new UserError('Could not reach the Outline server: timeout')); + return { + hostname: host, + listKeys: fail, getTransferMetrics: fail, getServerInfo: fail, + }; + }, + }); + + let handler; + const server = http.createServer((req, res) => { + handler(req, res).catch(() => { res.writeHead(500); res.end(); }); + }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + const port = server.address().port; + handler = createHandler({ registry, token: TOKEN, port }); + return { server, port, registry }; +} + +test('the dashboard manages servers', async t => { + const client = fakeClient(); + const { server, port } = await startDashboard({ clients: { '1.2.3.4': client } }); + const call = (method, path, opts) => request(port, method, path, opts); + let addedId; + + try { + await t.test('state before any server is configured is empty, not an error', async () => { + const res = await call('GET', '/api/state'); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual(res.json.servers, []); + assert.strictEqual(res.json.activeServerId, null); + assert.strictEqual(res.json.reachable, false); + assert.deepStrictEqual(res.json.keys, []); + }); + + await t.test('a server added by access code becomes active immediately', async () => { + const res = await call('POST', '/api/servers', { + body: { name: 'Frankfurt', accessCode: API_A, domain: 'vpn.example.com' }, + }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.json.servers.length, 1); + + const [added] = res.json.servers; + addedId = added.id; + assert.strictEqual(added.active, true); + assert.strictEqual(res.json.reachable, true); + assert.strictEqual(res.json.host, 'vpn.example.com'); + assert.strictEqual(res.json.keys.length, 2); + }); + + await t.test('state never carries a server credential to the browser', async () => { + const res = await call('GET', '/api/state'); + assert.ok(!res.text.includes('AaAaAaAaAa'), 'the access code leaked into state'); + }); + + await t.test('the access code is served only by the endpoint that exists for it', async () => { + const res = await call('GET', '/api/servers/' + addedId + '/access-code'); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.json.apiUrl, API_A); + }); + + await t.test('a rejected access code is a clear 400 and changes nothing', async () => { + const res = await call('POST', '/api/servers', { body: { name: 'Bad', accessCode: 'nonsense' } }); + assert.strictEqual(res.status, 400); + assert.match(res.json.error, /not a Management API URL/); + assert.strictEqual((await call('GET', '/api/state')).json.servers.length, 1); + }); + + await t.test('editing a server updates the host used in access URLs', async () => { + const res = await call('PUT', '/api/servers/' + addedId, { body: { domain: '' } }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.json.host, '1.2.3.4'); + assert.strictEqual(res.json.keys[0].accessUrl, 'ss://a@1.2.3.4:443/?outline=1'); + }); + + await t.test('an unreachable server reports why instead of blanking the page', async () => { + // Nothing stubs 5.6.7.8, so every Management API call fails. + const res = await call('POST', '/api/servers', { body: { name: 'Broken', accessCode: API_B } }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.json.reachable, false); + assert.match(res.json.unreachableReason, /Could not reach the Outline server/); + assert.strictEqual(res.json.servers.length, 2, 'the servers list must survive the failure'); + }); + + await t.test('switching back to a working server restores the keys', async () => { + const res = await call('POST', '/api/servers/' + addedId + '/activate'); + assert.strictEqual(res.json.activeServerId, addedId); + assert.strictEqual(res.json.reachable, true); + assert.strictEqual(res.json.keys.length, 2); + }); + + await t.test('a key operation lands on the active server', async () => { + const before = client.calls.length; + await call('POST', '/api/keys', { body: { name: 'Dave' } }); + assert.deepStrictEqual(client.calls.at(-1), ['create', 'Dave']); + assert.ok(client.calls.length > before); + }); + + await t.test('testing a server checks it without switching to it', async () => { + const servers = (await call('GET', '/api/state')).json.servers; + const broken = servers.find(entry => entry.name === 'Broken'); + + const res = await call('POST', '/api/servers/' + broken.id + '/test'); + assert.strictEqual(res.status, 400); + assert.match(res.json.error, /Could not reach/); + + const ok = await call('POST', '/api/servers/' + addedId + '/test'); + assert.strictEqual(ok.status, 200); + assert.strictEqual(ok.json.name, 'Test'); + assert.strictEqual((await call('GET', '/api/state')).json.activeServerId, addedId); + }); + + await t.test('removing a server leaves the remaining one active', async () => { + const servers = (await call('GET', '/api/state')).json.servers; + const broken = servers.find(entry => entry.name === 'Broken'); + + const res = await call('DELETE', '/api/servers/' + broken.id); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.json.servers.length, 1); + assert.strictEqual(res.json.activeServerId, addedId); + }); + + await t.test('an unknown server id is a 400, not a crash', async () => { + assert.strictEqual((await call('DELETE', '/api/servers/nope')).status, 400); + assert.strictEqual((await call('POST', '/api/servers/nope/activate')).status, 400); + assert.strictEqual((await call('GET', '/api/servers/nope/access-code')).status, 400); + }); + + await t.test('server routes still need the token and a loopback Host', async () => { + assert.strictEqual((await call('GET', '/api/state', { token: null })).status, 401); + assert.strictEqual( + (await call('POST', '/api/servers', { host: 'evil.example.com', body: {} })).status, 403); + }); + } finally { + server.close(); + } +}); + +test('a server from the environment is offered but cannot be rewritten', async () => { + const client = fakeClient(); + const { server, port } = await startDashboard({ + env: { apiUrl: API_A, domain: 'vpn.example.com' }, + clients: { '1.2.3.4': client }, + }); + + try { + const res = await request(port, 'GET', '/api/state'); + assert.strictEqual(res.json.servers.length, 1); + assert.strictEqual(res.json.servers[0].source, 'env'); + assert.strictEqual(res.json.servers[0].editable, false); + assert.strictEqual(res.json.host, 'vpn.example.com'); + + const edit = await request(port, 'PUT', '/api/servers/env', { body: { name: 'Renamed' } }); + assert.strictEqual(edit.status, 400); + assert.match(edit.json.error, /environment/); + } finally { + server.close(); + } +}); + +test('the QR endpoint returns a vector code as well as the terminal one', async () => { + const client = fakeClient(); + const { server, port } = await startServer(client, 'vpn.example.com'); + try { + const res = await request(port, 'GET', '/api/keys/0/qr'); + assert.strictEqual(res.status, 200); + assert.ok(res.json.svgSize > 0, 'expected a module count'); + assert.match(res.json.svgPath, /^M\d+ \d+h/); + // Nothing about the access URL may appear in the markup the page builds. + assert.ok(!res.json.svgPath.includes('vpn.example.com')); + } finally { + server.close(); + } +}); From bd3e84bcf1fd8fe656ea3303c12ba7b214c6bc38 Mon Sep 17 00:00:00 2001 From: Azat <azat@rahmanow.com> Date: Fri, 28 Aug 2026 15:36:40 -0400 Subject: [PATCH 2/2] Run the dashboard as a background service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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> --- CHANGELOG.md | 22 +++ README.md | 79 +++++++- lib/config.js | 56 ++++++ lib/server.js | 9 +- lib/service.js | 450 +++++++++++++++++++++++++++++++++++++++++++ shadowtools.js | 218 +++++++++++++++++++-- test/config.test.js | 70 +++++++ test/service.test.js | 199 +++++++++++++++++++ 8 files changed, 1080 insertions(+), 23 deletions(-) create mode 100644 lib/service.js create mode 100644 test/service.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d92d94..ec67965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ Notable changes to shadowtools. The format follows changing the active one. Access codes are read from stdin, so they stay out of shell history. - **`OUTLINE_TIMEOUT_MS`**, for the new Management API request timeout. +- **The dashboard can run as a background service** — `service install` and + friends. It registers with the service manager the platform already has, + launchd on macOS and systemd's user instance on Linux, so it starts at login, + restarts if it exits, and answers on the same URL every time. Per-user + agents only: nothing runs as root, nothing installs system-wide, and no step + asks for a password. ### Changed @@ -33,6 +39,11 @@ Notable changes to shadowtools. The format follows - An unreachable server no longer blanks the page. Overview and Access keys report it and point at Servers; Servers and Settings keep working, since that is where the problem gets fixed. +- **The dashboard URL is now stable.** The token is stored in + `~/.config/shadowtools/token` (mode `0600`) rather than minted per run, so a + bookmark survives a restart. `ui` and the background service read the same + file, so both hand out the same URL. `service url --rotate` mints a new one + and invalidates every link given out before it. ### Fixed @@ -47,6 +58,17 @@ Notable changes to shadowtools. The format follows ### Security +- **The service never writes a credential into its own definition.** A launchd + plist and a systemd unit are ordinary files 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. + `service install` says so when it sees them set, since a server configured + only that way will not appear in the background dashboard. +- **The token is kept out of the service log.** launchd creates a log file + world-readable, and the dashboard prints its URL at startup, which would have + handed the token to any other user on the machine. The URL is now printed + only when stdout is a terminal; the log records that it is listening and + nothing more, and the log and its directory are created `0600`/`0700`. - Adding servers to the dashboard could have ended the guarantee that a Management API URL never reaches the browser. It does not: the server list the page receives carries redacted URLs, and the full access code is served diff --git a/README.md b/README.md index c57433b..ed5004e 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,13 @@ node shadowtools.js <command> [options] | `servers add <name>` | Save a server, reading its access code from stdin | | `servers use <server>` | Choose the server other commands act on | | `servers remove <server>` | Forget a saved server (the server itself is untouched) | -| `ui` | Open the admin dashboard, covering all of the above | +| `ui` | Serve the admin dashboard until you stop it | +| `service install` | Run the dashboard in the background, from login on | +| `service status` | Whether it is running, and the URL to open | +| `service start` / `stop` / `restart` | Control the background service | +| `service logs` | Show what the background service has printed | +| `service url` | Print the dashboard URL | +| `service uninstall` | Stop it and remove the service definition | `<key>` may be either a key id or a key name, and `<server>` either a server id or a server name. Running with no command at all is the same as `list`, so the original behaviour still works. @@ -115,7 +121,10 @@ Key commands act on whichever server is active. Use `servers use` to change that | `--csv` | `list`, `usage`, `servers` | Output CSV instead of a table | | `--limit <size>` | `add` | Give the new key a data limit straight away | | `--server <s>` | key commands | Act on this server instead of the active one | -| `--port <n>` | `ui` | Port for the dashboard (default 8787) | +| `--port <n>` | `ui`, `service install` | Port for the dashboard (default 8787) | +| `--lines <n>` | `service logs` | How many log lines to show (default 50) | +| `--follow` | `service logs` | Keep printing new lines | +| `--rotate` | `service url` | Mint a new token, invalidating existing links | | `-h`, `--help` | — | Show usage | Sizes accept a unit suffix: `10GB`, `500MB`, `2TB`, or a plain byte count. @@ -221,6 +230,56 @@ whether that server is answering. When one is unreachable, Overview and Access keys say so and point you at Servers rather than showing a blank page — and Servers and Settings keep working, since that is where you go to fix it. +### Running it in the background + +`ui` serves the dashboard for as long as you keep the terminal open. To leave it +running and bookmark it: + +```bash +node shadowtools.js service install +``` + +That registers it with whatever service manager the platform already has — +**launchd** on macOS, **systemd's user instance** on Linux — so it starts at +login, comes back if it crashes, and answers on the same URL every time. + +```console +$ node shadowtools.js service status +running pid 4821 port 8787 +Dashboard: http://127.0.0.1:8787/?t=979ea2e12d7c5ba8e1d38631b2effd32583bca3b035775f3 +Definition: ~/Library/LaunchAgents/com.rahmanow.shadowtools.plist +Log: ~/Library/Logs/shadowtools/service.log +``` + +| Command | What it does | +| --- | --- | +| `service install` | Write the service definition and start it (`--port` to choose a port) | +| `service status` | Whether it is running, its pid and port, and the URL to open | +| `service start` / `stop` / `restart` | Control it | +| `service logs` | What it has printed (`--lines`, `--follow`) | +| `service url` | Print the dashboard URL (`--rotate` to invalidate existing links) | +| `service uninstall` | Stop it and remove the definition; saved servers are untouched | + +Nothing here runs as root and nothing is installed system-wide. These are +per-user agents, which is the right privilege level for something holding one +user's Outline credentials, and it means no step asks for your password. +Windows has no equivalent here — run `ui` in a terminal instead. + +Two consequences worth knowing: + +- **The URL is now stable.** The token lives in `~/.config/shadowtools/token` + (mode `0600`) instead of being minted per run, so a bookmark keeps working + across restarts. `ui` reads the same file, so both ways of starting the + dashboard hand out the same URL. If a link ends up somewhere it should not, + `service url --rotate` mints a new one and kills every old link. +- **The service cannot see your shell.** A background agent does not inherit + the environment you installed it from, and a service definition is an + ordinary file that other tooling reads — so `OUTLINE_API_URL` and + `OUTLINE_CERT_SHA256` are deliberately *not* written into it. A server + configured only through those variables will not appear in the background + dashboard; save it with `servers add` first. `install` warns you when it + sees them set. + ### Installing Shadowbox on a new server Not yet — the Servers section marks where it lands. Today, provision a server @@ -249,9 +308,14 @@ dashboard is deliberately narrow: refused, which is what stops DNS rebinding from turning an attacker's domain into a route to your machine. -The token changes every run, so old URLs stop working once you restart it. This -is a single-user local tool: do not put it behind a reverse proxy or expose the -port. +When you run `ui` yourself the URL is printed to your terminal. Under the +background service stdout is a log file, so the URL is deliberately *not* +printed there — the log says only that the dashboard is listening, and +`service url` is how you get the address. The log and its directory are created +`0600`/`0700` rather than left at the service manager's world-readable default. + +This is a single-user local tool: do not put it behind a reverse proxy or +expose the port. ## Using it from code @@ -332,6 +396,7 @@ lib/registry.js The servers this install knows about, and their clients lib/format.js Byte formatting, size parsing, table/CSV output lib/errors.js UserError, for messages shown without a stack trace lib/server.js Dashboard: HTTP routes and their guards +lib/service.js Running the dashboard as a launchd or systemd user service lib/web.js The dashboard page, inlined so it needs no assets lib/qr.js Vector QR codes for the dashboard test/ Tests, run with the built-in Node test runner @@ -422,7 +487,9 @@ The HTTP calls use the built-in `node:https` module because the global `fetch()` - **`Cannot find module 'qrcode-terminal'`** — run `npm install` in the project directory first. - **`The server presented an unexpected TLS certificate`** — either `OUTLINE_CERT_SHA256` is stale (recopy `certSha256` from Outline Manager after rebuilding or migrating the server), or something other than your Outline server answered. See [certificate pinning](#certificate-pinning). - **`is not a SHA-256 certificate fingerprint`** — `OUTLINE_CERT_SHA256` must be 64 hex characters, with or without colons. -- **`Port 8787 is already in use`** — something else has the port; pass `--port 9000` (or any free port). +- **`Port 8787 is already in use`** — often the background service is already serving it; run `shadowtools service status` for its URL. Otherwise pass `--port 9000` (or any free port). +- **`service status` says running but nothing answers** — the process started and then failed to bind, usually because something else holds the port. Check `shadowtools service logs`. +- **The background dashboard shows no servers, but the CLI does** — the server is configured through `OUTLINE_API_URL`, which a background service cannot see. Save it with `shadowtools servers add` instead. - **The dashboard says the token is invalid** — it is regenerated on every start, so reopen the URL currently printed in your terminal. - **`Management API responded with 404`** — your Outline server may be running an older release that lacks data-limit or metrics endpoints. Upgrade the server, or stick to `list`, `add`, `remove` and `rename`. - **Keys print with the IP instead of your domain** — make sure `OUTLINE_DOMAIN` (or the `domain` constant) is set and non-empty. diff --git a/lib/config.js b/lib/config.js index 8ccc9ef..d96476d 100644 --- a/lib/config.js +++ b/lib/config.js @@ -38,6 +38,58 @@ function configPath() { return path.join(base, 'shadowtools', 'config.json'); } +/** The directory holding the config file, and anything that sits beside it. */ +function configDir() { + return path.dirname(configPath()); +} + +/** + * The token that authorises the dashboard, kept beside the config file. + * + * It used to be minted per run, which is the safer default for a command you + * start in a terminal and read the URL from. A service you leave running wants + * the opposite: a URL you can bookmark and that still works tomorrow. Keeping + * it here rather than regenerating costs nothing in practice — this file and + * config.json share a directory, a mode and a threat model, and anyone who can + * read the token can read the Management API URLs next to it and skip the + * dashboard entirely. + * + * The `ui` command reads the same file, so a bookmark works whichever way the + * dashboard was started. + */ +function tokenPath() { + return path.join(configDir(), 'token'); +} + +function newToken() { + return crypto.randomBytes(24).toString('hex'); +} + +/** Reads the stored token, creating one the first time it is needed. */ +function readToken() { + const file = tokenPath(); + try { + const stored = fs.readFileSync(file, 'utf8').trim(); + // Anything unexpected is treated as absent rather than trusted: a + // truncated or hand-edited token would only fail confusingly later. + if (/^[0-9a-f]{48}$/.test(stored)) return stored; + } catch (err) { + if (err.code !== 'ENOENT') throw new UserError(`Could not read ${file}: ${err.message}`); + } + return rotateToken(); +} + +/** Writes a fresh token, invalidating every URL handed out before now. */ +function rotateToken() { + const file = tokenPath(); + const token = newToken(); + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + fs.writeFileSync(file, token + '\n', { mode: 0o600 }); + // An existing file keeps its own mode through a write, so re-assert it. + try { fs.chmodSync(file, 0o600); } catch (err) { /* not all filesystems */ } + return token; +} + function emptyConfig() { return { version: VERSION, activeServerId: null, servers: [] }; } @@ -221,6 +273,10 @@ module.exports = { VERSION, ENV_SERVER_ID, configPath, + configDir, + tokenPath, + readToken, + rotateToken, createStore, createMemoryStore, emptyConfig, diff --git a/lib/server.js b/lib/server.js index 5bd80f9..5fd9c50 100644 --- a/lib/server.js +++ b/lib/server.js @@ -352,8 +352,9 @@ function createHandler({ registry, client, domain, token, port }) { * Passing a client keeps the old single-server behaviour; passing nothing lets * the registry read the config file, which is what makes it possible to open * the dashboard with no configuration at all and add the first server there. + * Passing a token fixes the URL, so it can be bookmarked across restarts. */ -async function start({ client, managementApiUrl, certSha256, domain, port = 8787, store, registry } = {}) { +async function start({ client, managementApiUrl, certSha256, domain, port = 8787, store, registry, token } = {}) { if (!registry) { if (client) { registry = registryForClient(client, domain); @@ -365,7 +366,11 @@ async function start({ client, managementApiUrl, certSha256, domain, port = 8787 } } - const token = crypto.randomBytes(24).toString('hex'); + // A caller that wants a URL people can bookmark — the background service, + // and `ui` alongside it — passes the stored token. Anything else gets a + // fresh one per run, which is the safer default when nobody is relying on + // the URL surviving. + if (!token) token = crypto.randomBytes(24).toString('hex'); // Built after listen(), because the Host check compares against the port we // actually got — with port 0 the kernel picks one, and a handler built from diff --git a/lib/service.js b/lib/service.js new file mode 100644 index 0000000..f5a5c7a --- /dev/null +++ b/lib/service.js @@ -0,0 +1,450 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const { UserError } = require('./errors'); + +/** + * Running the dashboard as a background service on this machine. + * + * The `ui` command serves the dashboard for as long as you keep a terminal + * open, which is the wrong shape for a panel you want to leave running and + * bookmark. This installs it with whichever service manager the platform + * already has — launchd on macOS, systemd's user instance on Linux — so it + * starts at login, comes back if it crashes, and answers on the same URL every + * time. + * + * Nothing here runs as root and nothing installs system-wide: these are + * per-user agents, which is the correct privilege level for something holding + * one user's Outline credentials, and it means no step needs a password. + * + * The file-generating functions are pure so they can be tested without a + * service manager present; everything that touches launchctl or systemctl is + * kept to thin wrappers below them. + */ + +const LABEL = 'com.rahmanow.shadowtools'; +const UNIT = 'shadowtools'; +const DEFAULT_PORT = 8787; + +/** Which service manager to drive here, or null when there is none we support. */ +function platform() { + if (process.platform === 'darwin') return 'launchd'; + if (process.platform === 'linux') return 'systemd'; + return null; +} + +function requirePlatform() { + const kind = platform(); + if (!kind) { + throw new UserError( + `Running as a background service is not supported on ${process.platform}. ` + + 'Run "shadowtools ui" in a terminal instead, or start it with whatever ' + + 'service manager this system provides.' + ); + } + return kind; +} + +/** Where the service definition, the log and the runtime state live. */ +function paths(kind = platform()) { + const home = os.homedir(); + if (kind === 'launchd') { + return { + definition: path.join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`), + log: path.join(home, 'Library', 'Logs', 'shadowtools', 'service.log'), + }; + } + const base = process.env.XDG_CONFIG_HOME || path.join(home, '.config'); + return { + definition: path.join(base, 'systemd', 'user', `${UNIT}.service`), + // systemd collects stdout itself; `service logs` reads it back out. + log: null, + }; +} + +/** Escapes text for an XML text node, since a path may contain & or <. */ +function xmlEscape(text) { + return String(text) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +/** + * The environment the service runs with. + * + * Deliberately not everything in the current shell. A launchd plist and a + * systemd unit are ordinary files that other tooling reads and back-up software + * copies, so a Management API URL must never be written into one — the config + * file exists for that, with a mode that suits it. Only settings that are + * locations or tunables are carried over. + */ +function serviceEnvironment(env = process.env) { + const carried = {}; + for (const name of ['SHADOWTOOLS_CONFIG', 'XDG_CONFIG_HOME', 'OUTLINE_TIMEOUT_MS']) { + if (env[name]) carried[name] = env[name]; + } + return carried; +} + +/** The variables that configure a server directly, which must not be baked in. */ +function secretsInEnvironment(env = process.env) { + return ['OUTLINE_API_URL', 'OUTLINE_CERT_SHA256'].filter(name => env[name]); +} + +/** A launchd agent that starts at login and is restarted if it exits. */ +function launchdPlist({ node, script, port, log, environment }) { + const entries = Object.entries(environment) + .map(([key, value]) => + `\t\t<key>${xmlEscape(key)}</key>\n\t\t<string>${xmlEscape(value)}</string>`) + .join('\n'); + + const env = entries + ? `\t<key>EnvironmentVariables</key>\n\t<dict>\n${entries}\n\t</dict>\n` + : ''; + + return `<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> +\t<key>Label</key> +\t<string>${LABEL}</string> +\t<key>ProgramArguments</key> +\t<array> +\t\t<string>${xmlEscape(node)}</string> +\t\t<string>${xmlEscape(script)}</string> +\t\t<string>ui</string> +\t\t<string>--port</string> +\t\t<string>${port}</string> +\t</array> +\t<key>RunAtLoad</key> +\t<true/> +\t<key>KeepAlive</key> +\t<true/> +${env}\t<key>StandardOutPath</key> +\t<string>${xmlEscape(log)}</string> +\t<key>StandardErrorPath</key> +\t<string>${xmlEscape(log)}</string> +\t<key>ProcessType</key> +\t<string>Background</string> +</dict> +</plist> +`; +} + +/** A systemd user unit with the same lifecycle as the launchd agent above. */ +function systemdUnit({ node, script, port, environment }) { + const env = Object.entries(environment) + .map(([key, value]) => `Environment=${key}=${value}`) + .join('\n'); + + return `[Unit] +Description=shadowtools dashboard +Documentation=https://github.com/rahmanow/shadowtools +After=network.target + +[Service] +Type=simple +ExecStart=${node} ${script} ui --port ${port} +Restart=on-failure +RestartSec=5 +${env}${env ? '\n' : ''} +[Install] +WantedBy=default.target +`; +} + +/** Absolute paths to the running Node binary and this installation's CLI. */ +function programPaths() { + return { + node: process.execPath, + script: path.resolve(__dirname, '..', 'shadowtools.js'), + }; +} + +/** Runs a service-manager command, returning its output and exit status. */ +function run(command, args) { + const result = spawnSync(command, args, { encoding: 'utf8' }); + if (result.error && result.error.code === 'ENOENT') { + throw new UserError(`${command} was not found, so the service cannot be managed here.`); + } + return { + code: result.status, + stdout: (result.stdout || '').trim(), + stderr: (result.stderr || '').trim(), + }; +} + +/** The port a previously written definition was installed with, if any. */ +function installedPort(kind = platform()) { + const { definition } = paths(kind); + let text; + try { + text = fs.readFileSync(definition, 'utf8'); + } catch (err) { + return null; + } + const match = kind === 'launchd' + ? text.match(/<string>--port<\/string>\s*<string>(\d+)<\/string>/) + : text.match(/ExecStart=.*--port\s+(\d+)/); + return match ? Number(match[1]) : null; +} + +function isInstalled(kind = platform()) { + return fs.existsSync(paths(kind).definition); +} + +/** Blocks for a moment, so a poll loop can stay synchronous like its callers. */ +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** + * Whether the service manager still has the job registered. + * + * Distinct from "running": launchd keeps a job registered but stopped, and a + * job mid-unload is registered with no pid. Bootstrapping over either fails. + */ +function isLoaded(kind = platform()) { + if (kind === 'launchd') return run('launchctl', ['list', LABEL]).code === 0; + return run('systemctl', ['--user', 'is-active', UNIT]).stdout === 'active'; +} + +/** + * Waits for an unload to actually finish. + * + * launchctl bootout returns as soon as it has asked, not when the job is gone, + * so a bootstrap issued straight afterwards fails with a bare "Input/output + * error" — which is what a restart used to do. + */ +function waitUntilUnloaded(kind = platform(), timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isLoaded(kind)) return true; + sleepSync(100); + } + return false; +} + +function requireInstalled(kind) { + if (!isInstalled(kind)) { + throw new UserError('The service is not installed. Run "shadowtools service install" first.'); + } +} + +/** Writes the service definition and hands it to the service manager. */ +function install({ port = DEFAULT_PORT, env = process.env } = {}) { + const kind = requirePlatform(); + const { definition, log } = paths(kind); + const { node, script } = programPaths(); + const environment = serviceEnvironment(env); + + if (!fs.existsSync(script)) { + throw new UserError(`Could not find ${script}, so there is nothing to install.`); + } + + // Replacing an installed service means unloading the old definition first, + // or launchd keeps running the one it already has. + if (isInstalled(kind)) stop({ quiet: true }); + + fs.mkdirSync(path.dirname(definition), { recursive: true }); + if (log) { + // launchd would create this world-readable, and a log of an + // administrative interface is nobody else's business. Creating it + // first fixes the mode, since launchd appends to what it finds. + fs.mkdirSync(path.dirname(log), { recursive: true, mode: 0o700 }); + fs.closeSync(fs.openSync(log, 'a', 0o600)); + try { + fs.chmodSync(log, 0o600); + fs.chmodSync(path.dirname(log), 0o700); + } catch (err) { /* not all filesystems */ } + } + + const contents = kind === 'launchd' + ? launchdPlist({ node, script, port, log, environment }) + : systemdUnit({ node, script, port, environment }); + + // 0600 even though nothing secret goes in: this file names the paths and + // port of an administrative interface, and nothing else needs to read it. + fs.writeFileSync(definition, contents, { mode: 0o600 }); + + if (kind === 'systemd') run('systemctl', ['--user', 'daemon-reload']); + start(); + + return { kind, definition, log, port, environment, skipped: secretsInEnvironment(env) }; +} + +function start() { + const kind = requirePlatform(); + requireInstalled(kind); + const { definition } = paths(kind); + + const result = kind === 'launchd' + ? run('launchctl', ['bootstrap', `gui/${process.getuid()}`, definition]) + : run('systemctl', ['--user', 'enable', '--now', UNIT]); + + // Already loaded is the state we wanted, not a failure worth reporting. + if (result.code !== 0 && !/already (bootstrapped|loaded)/i.test(result.stderr)) { + throw new UserError(`Could not start the service: ${result.stderr || result.stdout}`); + } +} + +function stop({ quiet = false } = {}) { + const kind = requirePlatform(); + const { definition } = paths(kind); + + const result = kind === 'launchd' + ? run('launchctl', ['bootout', `gui/${process.getuid()}/${LABEL}`]) + : run('systemctl', ['--user', 'disable', '--now', UNIT]); + + if (result.code !== 0 && !quiet && !/not (find|loaded)|No such process/i.test(result.stderr)) { + throw new UserError(`Could not stop the service: ${result.stderr || result.stdout}`); + } + + waitUntilUnloaded(kind); + return definition; +} + +function uninstall() { + const kind = requirePlatform(); + requireInstalled(kind); + + stop({ quiet: true }); + const { definition } = paths(kind); + fs.rmSync(definition, { force: true }); + if (kind === 'systemd') run('systemctl', ['--user', 'daemon-reload']); + return definition; +} + +function restart() { + const kind = requirePlatform(); + requireInstalled(kind); + + // Restarting a loaded job in one step, rather than unloading and loading + // again: no window where the job is half-registered, and no race with an + // unload that has been asked for but not finished. + if (isLoaded(kind)) { + const result = kind === 'launchd' + ? run('launchctl', ['kickstart', '-k', `gui/${process.getuid()}/${LABEL}`]) + : run('systemctl', ['--user', 'restart', UNIT]); + + if (result.code === 0) return; + // Fall through: a job registered but not startable is worth reloading. + } + + stop({ quiet: true }); + start(); +} + +/** Whether the service manager currently has it running, and its pid. */ +function runningState(kind = platform()) { + if (kind === 'launchd') { + const result = run('launchctl', ['list', LABEL]); + if (result.code !== 0) return { running: false, pid: null }; + const match = result.stdout.match(/"PID"\s*=\s*(\d+)/); + return { running: Boolean(match), pid: match ? Number(match[1]) : null }; + } + + const active = run('systemctl', ['--user', 'is-active', UNIT]); + const pid = run('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', UNIT]); + const parsed = Number(pid.stdout); + return { + running: active.stdout === 'active', + pid: Number.isFinite(parsed) && parsed > 0 ? parsed : null, + }; +} + +function status() { + const kind = requirePlatform(); + const { definition, log } = paths(kind); + const installed = isInstalled(kind); + + return { + kind, + installed, + definition, + log, + port: installedPort(kind) || DEFAULT_PORT, + ...(installed ? runningState(kind) : { running: false, pid: null }), + }; +} + +/** + * Confirms something is actually answering on the port, and that it is us. + * + * The service manager only reports whether it launched the process. A + * dashboard that started and then failed to bind — because the port was taken + * — still looks "running" to launchd, so ask the port itself. + */ +function probe(port, timeoutMs = 1500) { + return new Promise(resolve => { + const http = require('http'); + const req = http.request( + { host: '127.0.0.1', port, path: '/', method: 'GET', timeout: timeoutMs }, + res => { + let text = ''; + res.setEncoding('utf8'); + res.on('data', chunk => { + text += chunk; + // The title is in the first bytes; no need for the whole page. + if (text.length > 4096) req.destroy(); + }); + res.on('end', () => resolve(text.includes('<title>shadowtools'))); + res.on('error', () => resolve(false)); + } + ); + req.on('timeout', () => req.destroy()); + req.on('error', () => resolve(false)); + req.end(); + }); +} + +/** The service's recent output, however this platform keeps it. */ +function logs({ lines = 50, follow = false } = {}) { + const kind = requirePlatform(); + + if (kind === 'systemd') { + const args = ['--user', '-u', UNIT, '-n', String(lines), '--no-pager']; + if (follow) args.push('-f'); + // Inherit stdio so following streams straight through to the terminal. + const result = spawnSync('journalctl', args, { stdio: 'inherit' }); + if (result.error) throw new UserError(`Could not read the journal: ${result.error.message}`); + return null; + } + + const { log } = paths(kind); + if (!fs.existsSync(log)) return ''; + if (follow) { + spawnSync('tail', ['-n', String(lines), '-f', log], { stdio: 'inherit' }); + return null; + } + return fs.readFileSync(log, 'utf8').split('\n').slice(-lines).join('\n').trim(); +} + +module.exports = { + LABEL, + UNIT, + DEFAULT_PORT, + platform, + paths, + isLoaded, + serviceEnvironment, + secretsInEnvironment, + launchdPlist, + systemdUnit, + programPaths, + installedPort, + isInstalled, + install, + uninstall, + start, + stop, + restart, + status, + probe, + logs, +}; diff --git a/shadowtools.js b/shadowtools.js index 861967e..ebda13f 100755 --- a/shadowtools.js +++ b/shadowtools.js @@ -15,7 +15,7 @@ const certSha256 = process.env.OUTLINE_CERT_SHA256 || ''; // recommended: certSh const qrcode = require('qrcode-terminal'); const { UserError } = require('./lib/errors'); -const { createStore } = require('./lib/config'); +const { createStore, readToken, rotateToken, tokenPath } = require('./lib/config'); const { createRegistry } = require('./lib/registry'); const { formatBytes, parseBytes, rewriteAccessUrl, printTable, printCsv } = require('./lib/format'); @@ -36,7 +36,13 @@ Commands: servers add Save a server, reading its access code from stdin servers use Choose the server other commands act on servers remove Forget a saved server (the server itself is untouched) - ui Open the admin dashboard in your browser + ui Serve the admin dashboard until you stop it + service install Run the dashboard in the background, from login on + service status Whether it is running, and the URL to open + service start|stop|restart Control the background service + service logs Show what the background service has printed + service url Print the dashboard URL + service uninstall Stop it and remove the service definition may be either a key id or a key name. may be either a server id or a server name. @@ -47,7 +53,10 @@ Options: --csv Output CSV instead of a table (list, usage, servers) --limit Data limit for a newly created key (add) --server Act on this server instead of the active one - --port Port for the dashboard (ui, default 8787) + --port Port for the dashboard (ui, service install; default 8787) + --lines How many log lines to show (service logs, default 50) + --follow Keep printing new log lines (service logs) + --rotate Mint a new token, invalidating existing links (service url) -h, --help Show this help Sizes accept a unit suffix, e.g. 10GB, 500MB, 2TB. @@ -65,6 +74,7 @@ Configuration: SHADOWTOOLS_CONFIG Override where saved servers are stored. Examples: + node shadowtools.js service install node shadowtools.js list --qr node shadowtools.js add Alice --limit 50GB node shadowtools.js limit Alice 10GB @@ -87,6 +97,8 @@ function parseArgs(argv) { flags.port = argv[++i]; } else if (arg === '--server') { flags.server = argv[++i]; + } else if (arg === '--lines') { + flags.lines = argv[++i]; } else if (arg.startsWith('--')) { flags[arg.slice(2)] = true; } else { @@ -378,31 +390,55 @@ async function serversCommand(registry, args, flags) { await run(registry, rest, flags); } +/** Validates a --port value, since a typo here is otherwise a confusing crash. */ +function parsePort(given, fallback = 8787) { + if (given === undefined) return fallback; + const port = Number(given); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new UserError(`"${given}" is not a valid port.`); + } + return port; +} + async function uiCommand(registry, args, flags) { const { start } = require('./lib/server'); - const port = flags.port === undefined ? 8787 : Number(flags.port); - if (!Number.isInteger(port) || port < 0 || port > 65535) { - throw new UserError(`"${flags.port}" is not a valid port.`); - } + const port = parsePort(flags.port); let started; try { - started = await start({ registry, port }); + // The stored token, so this URL and the background service's agree and + // a bookmark keeps working whichever one is serving. + started = await start({ registry, port, token: readToken() }); } catch (err) { if (err.code === 'EADDRINUSE') { - throw new UserError(`Port ${port} is already in use. Choose another with --port.`); + throw new UserError( + `Port ${port} is already in use. If that is the background service, ` + + 'it is already serving the dashboard — run "shadowtools service status" ' + + 'for its URL. Otherwise choose another port with --port.' + ); } throw err; } - console.log('Dashboard running. Open this URL:'); - console.log(`\n ${started.url}\n`); - console.log('It listens on localhost only, and the token in the URL authorises it.'); - if (!registry.list().length) { - console.log('No servers configured yet — add your first one from the Servers section.'); + // Under a service manager stdout is a log file, not a terminal — and the + // token in that URL is the only thing standing between another local user + // and this dashboard. A log is the wrong place for it, so print the URL + // whole only when a person is looking at it. + if (process.stdout.isTTY) { + console.log('Dashboard running. Open this URL:'); + console.log(`\n ${started.url}\n`); + console.log('It listens on localhost only, and the token in the URL authorises it.'); + if (!registry.list().length) { + console.log('No servers configured yet — add your first one from the Servers section.'); + } + console.log('Press Ctrl+C to stop.'); + } else { + console.log( + `[${new Date().toISOString()}] dashboard listening on 127.0.0.1:${started.port} — ` + + 'run "shadowtools service url" for the address to open' + ); } - console.log('Press Ctrl+C to stop.'); // Resolve only when the server closes, so the CLI stays alive serving it. await new Promise(resolve => { @@ -413,6 +449,157 @@ async function uiCommand(registry, args, flags) { }); } +/* --------------------------------------------------------------------------- + * The background service. + * ------------------------------------------------------------------------- */ + +const dashboardUrl = port => `http://127.0.0.1:${port}/?t=${readToken()}`; + +const serviceCommands = { + async install(service, args, flags) { + const port = parsePort(flags.port, service.DEFAULT_PORT); + const result = service.install({ port }); + + console.log(`Installed ${result.kind === 'launchd' ? service.LABEL : service.UNIT} and started it.`); + console.log(`\nDashboard: ${dashboardUrl(port)}\n`); + console.log(`Definition: ${result.definition}`); + if (result.log) console.log(`Log: ${result.log}`); + console.log('It starts at login and restarts if it exits. It listens on localhost only.'); + + // A service does not inherit the shell it was installed from, so a + // server that exists only as an environment variable would silently + // vanish from the dashboard. Say so now rather than let it puzzle. + if (result.skipped.length) { + console.log( + `\n${result.skipped.join(' and ')} ${result.skipped.length > 1 ? 'are' : 'is'} set here but ` + + 'deliberately not written into the service definition, which is not a ' + + 'place for credentials. The service sees only saved servers, so add ' + + 'that one with "servers add" if you want it in the background dashboard.' + ); + } + + await reportReachable(service, port); + }, + + async uninstall(service) { + const { log } = service.paths(); + const definition = service.uninstall(); + + console.log(`Stopped the service and removed ${definition}.`); + console.log('Your saved servers and keys are untouched.'); + // Kept deliberately: it is the record of why the service stopped, and + // the one thing worth reading after an uninstall you did not intend. + if (log && require('fs').existsSync(log)) console.log(`Its log remains at ${log}.`); + }, + + async start(service) { + service.start(); + const { port } = service.status(); + console.log(`Started. Dashboard: ${dashboardUrl(port)}`); + await reportReachable(service, port); + }, + + async stop(service) { + service.stop(); + console.log('Stopped.'); + }, + + async restart(service) { + service.restart(); + const { port } = service.status(); + console.log(`Restarted. Dashboard: ${dashboardUrl(port)}`); + await reportReachable(service, port); + }, + + async status(service, args, flags) { + const state = service.status(); + + if (flags.json) { + console.log(JSON.stringify({ ...state, answering: await service.probe(state.port) }, null, 2)); + return; + } + + if (!state.installed) { + console.log('not installed'); + console.log('\nRun "shadowtools service install" to run the dashboard in the background.'); + return; + } + + const answering = await service.probe(state.port); + const where = state.running ? `pid ${state.pid || '?'}` : 'not running'; + console.log(`${state.running ? 'running' : 'stopped'} ${where} port ${state.port}`); + console.log(`Dashboard: ${dashboardUrl(state.port)}`); + console.log(`Definition: ${state.definition}`); + if (state.log) console.log(`Log: ${state.log}`); + + // The service manager only knows it launched the process. Whether the + // dashboard actually bound the port is a different question. + if (state.running && !answering) { + console.log( + `\nIt is running but nothing is answering on port ${state.port}. ` + + 'Check "shadowtools service logs" — the port may be taken by something else.' + ); + } + }, + + async logs(service, args, flags) { + const lines = flags.lines === undefined ? 50 : Number(flags.lines); + if (!Number.isInteger(lines) || lines <= 0) { + throw new UserError(`"${flags.lines}" is not a number of lines.`); + } + + const text = service.logs({ lines, follow: Boolean(flags.follow) }); + // A follower streams straight to the terminal and returns nothing. + if (text === null) return; + console.log(text || '(the service has printed nothing yet)'); + }, + + async url(service, args, flags) { + if (flags.rotate) { + rotateToken(); + console.log('Minted a new token. Every link handed out before now is dead.'); + console.log(`Stored in ${tokenPath()}`); + if (service.isInstalled() && service.status().running) { + console.log('\nRestart the service to serve with it: shadowtools service restart'); + } + console.log(''); + } + + // Useful even where no service manager is supported, or none is + // installed: the URL is the same one `ui` serves. + const installed = service.platform() && service.isInstalled(); + console.log(dashboardUrl(installed ? service.status().port : service.DEFAULT_PORT)); + }, +}; + +/** Warns when the service is up but the port is not answering yet. */ +async function reportReachable(service, port) { + // Give it a moment: the service manager returns before the process binds. + for (let attempt = 0; attempt < 10; attempt++) { + if (await service.probe(port, 500)) return true; + await new Promise(resolve => setTimeout(resolve, 200)); + } + console.log( + `\nNothing is answering on port ${port} yet. If it does not come up, ` + + 'check "shadowtools service logs".' + ); + return false; +} + +async function serviceCommand(args, flags) { + const service = require('./lib/service'); + const [sub, ...rest] = args; + + const run = serviceCommands[sub || 'status']; + if (!run) { + throw new UserError( + `Unknown service command "${sub}". Try: install, uninstall, start, stop, ` + + 'restart, status, logs, url.' + ); + } + await run(service, rest, flags); +} + /** Builds the registry from the environment and the saved config file. */ function buildRegistry() { // The placeholder means "not configured", not "a server at xx.xx.xx.xxx". @@ -449,6 +636,7 @@ async function main() { // These two manage or serve the configuration itself, so they must work // before any Outline server exists — that is how the first one gets added. if (commandName === 'servers') return serversCommand(registry, args, flags); + if (commandName === 'service') return serviceCommand(args, flags); if (commandName === 'ui') return uiCommand(registry, args, flags); // Listing is the historical default, so bare `node shadowtools.js` still works. diff --git a/test/config.test.js b/test/config.test.js index 64349fd..ad97a61 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -10,7 +10,10 @@ const { createStore, normalizeConfig, parseAccessCode, + readToken, redactApiUrl, + rotateToken, + tokenPath, ENV_SERVER_ID, } = require('../lib/config'); const { UserError } = require('../lib/errors'); @@ -137,3 +140,70 @@ test('normalizeConfig keeps the environment server as a valid active choice', () const config = normalizeConfig({ activeServerId: ENV_SERVER_ID, servers: [] }); assert.strictEqual(config.activeServerId, ENV_SERVER_ID); }); + +/* -------------------------------------------------------------------------- + * The dashboard token, which is what makes the service's URL bookmarkable. + * ------------------------------------------------------------------------ */ + +/** Points the config helpers at a scratch directory for one test. */ +function tempConfigHome(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shadowtools-token-')); + const previous = process.env.SHADOWTOOLS_CONFIG; + process.env.SHADOWTOOLS_CONFIG = path.join(dir, 'config.json'); + t.after(() => { + if (previous === undefined) delete process.env.SHADOWTOOLS_CONFIG; + else process.env.SHADOWTOOLS_CONFIG = previous; + fs.rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +test('the token is minted once and then reused, so a bookmark keeps working', async t => { + tempConfigHome(t); + + const first = readToken(); + assert.match(first, /^[0-9a-f]{48}$/); + assert.strictEqual(readToken(), first); + assert.strictEqual(readToken(), first); +}); + +test('the token file is readable only by its owner', async t => { + // It authorises full administrative control of every saved server, so it + // gets the same treatment as the config file sitting beside it. + tempConfigHome(t); + readToken(); + + assert.strictEqual(fs.statSync(tokenPath()).mode & 0o777, 0o600); + assert.strictEqual(fs.statSync(path.dirname(tokenPath())).mode & 0o777, 0o700); +}); + +test('rotating replaces the token and keeps the new one', async t => { + tempConfigHome(t); + + const before = readToken(); + const after = rotateToken(); + assert.notStrictEqual(after, before); + assert.strictEqual(readToken(), after); + assert.strictEqual(fs.statSync(tokenPath()).mode & 0o777, 0o600); +}); + +test('a damaged token file is replaced rather than served', async t => { + // A truncated or hand-edited token would authorise nothing and fail with a + // 401 that says the URL is wrong, which sends you looking in the wrong place. + tempConfigHome(t); + readToken(); + + for (const junk of ['', ' ', 'not-a-token', 'abc123', 'f'.repeat(47), 'g'.repeat(48)]) { + fs.writeFileSync(tokenPath(), junk); + const recovered = readToken(); + assert.match(recovered, /^[0-9a-f]{48}$/, `for ${JSON.stringify(junk)}`); + assert.strictEqual(readToken(), recovered, 'the replacement must then stick'); + } +}); + +test('a token written with a trailing newline reads back clean', async t => { + tempConfigHome(t); + const token = readToken(); + assert.ok(fs.readFileSync(tokenPath(), 'utf8').endsWith('\n')); + assert.strictEqual(token.trim(), token); +}); diff --git a/test/service.test.js b/test/service.test.js new file mode 100644 index 0000000..5867635 --- /dev/null +++ b/test/service.test.js @@ -0,0 +1,199 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const service = require('../lib/service'); + +const PROGRAM = { node: '/usr/local/bin/node', script: '/opt/shadowtools/shadowtools.js' }; + +const hasPlutil = process.platform === 'darwin' && (() => { + try { + execFileSync('plutil', ['-help'], { stdio: 'ignore' }); + return true; + } catch (err) { + return false; + } +})(); + +test('the launchd plist is valid property list XML', { skip: !hasPlutil }, t => { + // A malformed plist fails at load time with a message that says nothing + // useful, so it is worth knowing the generator produces a real one. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shadowtools-plist-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const file = path.join(dir, 'test.plist'); + fs.writeFileSync(file, service.launchdPlist({ + ...PROGRAM, + port: 8787, + log: '/tmp/shadowtools.log', + environment: { SHADOWTOOLS_CONFIG: '/tmp/a & b/config.json' }, + })); + + execFileSync('plutil', ['-lint', file], { stdio: 'ignore' }); + + const parsed = JSON.parse(execFileSync('plutil', ['-convert', 'json', '-o', '-', file])); + assert.strictEqual(parsed.Label, service.LABEL); + assert.deepStrictEqual(parsed.ProgramArguments, + [PROGRAM.node, PROGRAM.script, 'ui', '--port', '8787']); + assert.strictEqual(parsed.RunAtLoad, true); + assert.strictEqual(parsed.KeepAlive, true); + // The ampersand has to survive XML escaping intact. + assert.strictEqual(parsed.EnvironmentVariables.SHADOWTOOLS_CONFIG, '/tmp/a & b/config.json'); +}); + +test('the launchd plist escapes XML metacharacters in every path it embeds', () => { + const plist = service.launchdPlist({ + node: '/opt/a&b/node', + script: '/opt//shadowtools.js', + port: 8787, + log: '/tmp/a&b.log', + environment: {}, + }); + + assert.ok(!/[^<]*&(?!amp;|lt;|gt;)/.test(plist), 'a bare ampersand would break the plist'); + assert.ok(plist.includes('/opt/a&b/node')); + assert.ok(plist.includes('/opt/<x>/shadowtools.js')); +}); + +test('a plist with no environment omits the dictionary rather than emitting an empty one', () => { + const plist = service.launchdPlist({ ...PROGRAM, port: 1, log: '/tmp/l', environment: {} }); + assert.ok(!plist.includes('EnvironmentVariables')); +}); + +test('the systemd unit starts the dashboard and comes back after a failure', () => { + const unit = service.systemdUnit({ + ...PROGRAM, + port: 9001, + environment: { SHADOWTOOLS_CONFIG: '/tmp/c.json' }, + }); + + assert.match(unit, /^ExecStart=\/usr\/local\/bin\/node \/opt\/shadowtools\/shadowtools\.js ui --port 9001$/m); + assert.match(unit, /^Restart=on-failure$/m); + assert.match(unit, /^Environment=SHADOWTOOLS_CONFIG=\/tmp\/c\.json$/m); + assert.match(unit, /^WantedBy=default\.target$/m); +}); + +test('a Management API URL is never written into a service definition', () => { + // These files sit in plain directories that other tooling reads and backup + // software copies. Credentials belong in the config file, which is 0600. + const env = { + OUTLINE_API_URL: 'https://1.2.3.4:16942/SecretPath', + OUTLINE_CERT_SHA256: 'e3823f9bb490d354', + SHADOWTOOLS_CONFIG: '/tmp/c.json', + OUTLINE_TIMEOUT_MS: '20000', + }; + + const carried = service.serviceEnvironment(env); + assert.deepStrictEqual(carried, { SHADOWTOOLS_CONFIG: '/tmp/c.json', OUTLINE_TIMEOUT_MS: '20000' }); + + for (const text of [ + service.launchdPlist({ ...PROGRAM, port: 1, log: '/tmp/l', environment: carried }), + service.systemdUnit({ ...PROGRAM, port: 1, environment: carried }), + ]) { + assert.ok(!text.includes('SecretPath'), 'the Management API URL leaked into a service definition'); + assert.ok(!text.includes('OUTLINE_API_URL')); + assert.ok(!text.includes('OUTLINE_CERT_SHA256')); + } +}); + +test('secretsInEnvironment names what was deliberately left out', () => { + assert.deepStrictEqual(service.secretsInEnvironment({}), []); + assert.deepStrictEqual( + service.secretsInEnvironment({ OUTLINE_API_URL: 'x', OUTLINE_CERT_SHA256: 'y' }), + ['OUTLINE_API_URL', 'OUTLINE_CERT_SHA256'] + ); +}); + +test('the installed port is read back out of either definition', async t => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shadowtools-port-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + // installedPort parses the file the installer wrote, which is how status + // knows which port to report after a reinstall moved it. + const plist = path.join(dir, 'a.plist'); + fs.writeFileSync(plist, service.launchdPlist({ ...PROGRAM, port: 9123, log: '/tmp/l', environment: {} })); + assert.match(fs.readFileSync(plist, 'utf8'), /--port<\/string>\s*9123<\/string>/); + + const unit = service.systemdUnit({ ...PROGRAM, port: 9124, environment: {} }); + assert.match(unit, /ExecStart=.*--port\s+9124/); +}); + +test('programPaths points at this installation, absolutely', () => { + // A service manager has no working directory of ours, so both must be absolute. + const { node, script } = service.programPaths(); + assert.ok(path.isAbsolute(node), node); + assert.ok(path.isAbsolute(script), script); + assert.strictEqual(path.basename(script), 'shadowtools.js'); + assert.ok(fs.existsSync(script)); +}); + +test('paths are per-user, never system-wide', () => { + // Nothing here should need root, and nothing should hold one user's + // credentials open to another's. + const home = os.homedir(); + for (const kind of ['launchd', 'systemd']) { + const { definition } = service.paths(kind); + assert.ok(definition.startsWith(home), `${kind}: ${definition}`); + } +}); + +test('an unsupported platform is refused with advice rather than a crash', () => { + assert.strictEqual(service.platform(), process.platform === 'darwin' ? 'launchd' + : process.platform === 'linux' ? 'systemd' : null); +}); + +test('the dashboard keeps its token out of non-terminal output', async t => { + // Under a service manager stdout is a log file. launchd creates those + // world-readable, and the token is the only thing standing between another + // local user and this dashboard, so it must not be written there. This + // runs the real CLI with stdout piped, which is exactly that situation. + const { spawn } = require('node:child_process'); + const { readToken } = require('../lib/config'); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shadowtools-tty-')); + const configPath = path.join(dir, 'config.json'); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const env = { ...process.env, SHADOWTOOLS_CONFIG: configPath }; + const token = (() => { + const previous = process.env.SHADOWTOOLS_CONFIG; + process.env.SHADOWTOOLS_CONFIG = configPath; + try { + return readToken(); + } finally { + if (previous === undefined) delete process.env.SHADOWTOOLS_CONFIG; + else process.env.SHADOWTOOLS_CONFIG = previous; + } + })(); + + const cli = path.resolve(__dirname, '..', 'shadowtools.js'); + const child = spawn(process.execPath, [cli, 'ui', '--port', '0'], { env, stdio: 'pipe' }); + + let output = ''; + const line = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`no output; got: ${output}`)), 10000); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', chunk => { + output += chunk; + if (output.includes('\n')) { + clearTimeout(timer); + resolve(output); + } + }); + child.on('error', reject); + }); + + child.kill(); + await new Promise(resolve => child.once('exit', resolve)); + + assert.ok(!line.includes(token), `the token was printed to a pipe:\n${line}`); + assert.ok(!/[?&]t=/.test(line), `a tokenised URL was printed to a pipe:\n${line}`); + assert.match(line, /dashboard listening on 127\.0\.0\.1:\d+/); + // Ctrl+C is not advice a log file can act on. + assert.ok(!line.includes('Ctrl+C')); +});