diff --git a/README.md b/README.md index 1de9df2..52dc621 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/rahmanow/ShadowboxKeys/actions/workflows/ci.yml/badge.svg)](https://github.com/rahmanow/ShadowboxKeys/actions/workflows/ci.yml) -Manage access keys on your [Outline VPN](https://getoutline.org/) (Shadowbox) server, from the terminal or from 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) 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. 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. @@ -69,6 +69,7 @@ node shadowboxKey.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 | `` 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. @@ -78,6 +79,7 @@ node shadowboxKey.js [options] | `--json` | `list`, `usage` | Output JSON instead of a table | | `--csv` | `list`, `usage` | 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) | | `-h`, `--help` | — | Show usage | Sizes accept a unit suffix: `10GB`, `500MB`, `2TB`, or a plain byte count. @@ -133,6 +135,52 @@ Export usage for a spreadsheet: node shadowboxKey.js usage --csv > usage.csv ``` +## Web interface + +If you would rather click than type: + +```bash +node shadowboxKey.js ui +``` + +It prints a URL to open: + +``` +Web interface running. Open this URL: + + http://127.0.0.1:8787/?t=979ea2e12d7c5ba8e1d38631b2effd32583bca3b035775f3 + +It listens on localhost only, and the token in the URL authorises it. +Press Ctrl+C to stop. +``` + +The page lists 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. + +### How it is secured + +The Management API URL is full administrative control of your Outline server, so +the interface 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. +- **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 + printed URL. Every API call must present it in a header, so another page open + in the same browser cannot drive it, and requiring a custom header means a + cross-origin attempt hits a CORS preflight that is never answered. +- **Host-checked.** Requests whose `Host` header is not the loopback address are + 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. + ## Using it from code Everything the CLI does is available as a module. `require` the package and you get three helpers plus the underlying client: @@ -209,6 +257,8 @@ shadowboxKey.js CLI entry point: argument parsing and commands lib/outline.js Outline Management API client 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 test/ Tests, run with the built-in Node test runner ``` @@ -258,6 +308,8 @@ 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). +- **The web interface 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/outline.js b/lib/outline.js index c04c030..65fd3c3 100644 --- a/lib/outline.js +++ b/lib/outline.js @@ -175,6 +175,11 @@ class OutlineClient { return this.request('DELETE', '/server/access-key-data-limit'); } + /** Server-wide configuration, including the default access-key data limit. */ + getServerInfo() { + return this.request('GET', '/server'); + } + async getTransferMetrics() { const data = await this.request('GET', '/metrics/transfer'); return (data && data.bytesTransferredByUserId) || {}; diff --git a/lib/server.js b/lib/server.js new file mode 100644 index 0000000..c8964b5 --- /dev/null +++ b/lib/server.js @@ -0,0 +1,248 @@ +'use strict'; + +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 { page } = require('./web'); + +/** + * A local web UI 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 + * it never leaves this process — the browser talks only to this server, which + * holds the credential and proxies. Three things guard that: + * + * - it listens on the loopback interface only, so nothing off-machine can + * reach it; + * - every /api request must carry a random token minted at startup and handed + * over in the printed URL, so another page in the same browser cannot drive + * 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. + */ + +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']); + +/** True when the Host header names this server on the loopback interface. */ +function hostIsLoopback(hostHeader, port) { + if (!hostHeader) return false; + const lastColon = hostHeader.lastIndexOf(':'); + const hasPort = lastColon > hostHeader.lastIndexOf(']'); + const host = hasPort ? hostHeader.slice(0, lastColon) : hostHeader; + const givenPort = hasPort ? hostHeader.slice(lastColon + 1) : ''; + + if (!LOOPBACK_HOSTS.has(host)) return false; + return givenPort === '' || givenPort === String(port); +} + +/** Reads a JSON request body, with a cap so a stray upload cannot exhaust memory. */ +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let raw = ''; + req.on('data', chunk => { + raw += chunk; + if (raw.length > 64 * 1024) { + reject(new UserError('Request body too large.')); + req.destroy(); + } + }); + req.on('end', () => { + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch (err) { + reject(new UserError('Could not parse the request body as JSON.')); + } + }); + req.on('error', reject); + }); +} + +/** Accepts a byte count, a size string like "10GB", or null/"" to mean no limit. */ +function toBytes(value) { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'number') { + if (!Number.isFinite(value) || value < 0) throw new UserError('Invalid data limit.'); + return Math.floor(value); + } + return parseBytes(String(value)); +} + +function renderQr(text) { + return new Promise(resolve => { + qrcode.generate(text, { small: true }, code => resolve(code)); + }); +} + +/** + * 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; + + async function state() { + const [keys, transferred, server] = await Promise.all([ + client.listKeys(), + client.getTransferMetrics(), + client.getServerInfo().catch(() => null), + ]); + + return { + host: host(), + serverName: server && server.name ? server.name : null, + serverLimitBytes: server && server.accessKeyDataLimit + ? server.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()), + })), + }; + } + + const routes = [ + ['GET', /^\/api\/state$/, () => state()], + + ['POST', /^\/api\/keys$/, async (m, body) => { + const key = await client.createKey((body.name || '').trim()); + const limit = toBytes(body.limitBytes); + if (limit !== null && key) await client.setKeyDataLimit(key.id, limit); + return state(); + }], + + ['DELETE', /^\/api\/keys\/([^/]+)$/, async m => { + await client.removeKey(decodeURIComponent(m[1])); + return state(); + }], + + ['PUT', /^\/api\/keys\/([^/]+)\/name$/, async (m, body) => { + const name = (body.name || '').trim(); + if (!name) throw new UserError('A key name cannot be empty.'); + await client.renameKey(decodeURIComponent(m[1]), name); + return state(); + }], + + ['PUT', /^\/api\/keys\/([^/]+)\/limit$/, async (m, body) => { + const id = decodeURIComponent(m[1]); + const bytes = toBytes(body.bytes); + if (bytes === null) await client.clearKeyDataLimit(id); + else await client.setKeyDataLimit(id, bytes); + return state(); + }], + + ['PUT', /^\/api\/server\/limit$/, async (m, body) => { + const bytes = toBytes(body.bytes); + if (bytes === null) await client.clearServerDataLimit(); + else await client.setServerDataLimit(bytes); + return state(); + }], + + ['GET', /^\/api\/keys\/([^/]+)\/qr$/, async m => { + const id = decodeURIComponent(m[1]); + 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 || '' }; + }], + ]; + + return async function handle(req, res) { + const send = (status, body, type = 'application/json') => { + const payload = type === 'application/json' ? JSON.stringify(body) : body; + res.writeHead(status, { + 'Content-Type': `${type}; charset=utf-8`, + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + // The page is entirely self-contained; forbid any outside loading. + // connect-src must be explicit: it falls back to default-src, + // and 'none' would block the page's own fetch calls. + 'Content-Security-Policy': + "default-src 'none'; connect-src 'self'; style-src 'unsafe-inline'; " + + "script-src 'unsafe-inline'; img-src data:; form-action 'none'; base-uri 'none'", + 'Referrer-Policy': 'no-referrer', + }); + res.end(payload); + }; + + if (!hostIsLoopback(req.headers.host, port)) { + return send(403, { error: 'This interface is only reachable on localhost.' }); + } + + const path = (req.url || '/').split('?')[0]; + + if (req.method === 'GET' && (path === '/' || path === '/index.html')) { + return send(200, page(), 'text/html'); + } + + if (!path.startsWith('/api/')) { + return send(404, { error: 'Not found.' }); + } + + // Constant-time compare so a wrong token cannot be guessed by timing. + const given = String(req.headers['x-auth-token'] || ''); + const expected = Buffer.from(token); + const actual = Buffer.from(given); + if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) { + 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.' }); + + try { + const body = method === 'GET' || method === 'DELETE' ? {} : await readJsonBody(req); + return send(200, await run(match, body)); + } catch (err) { + const known = err instanceof UserError; + if (!known) console.error(err); + return send(known ? 400 : 500, { error: known ? err.message : 'Something went wrong.' }); + } + } + + return send(404, { error: 'Not found.' }); + }; +} + +/** 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); + const 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 + // the requested port would reject every request. + let handler; + const server = http.createServer((req, res) => { + handler(req, res).catch(err => { + console.error(err); + if (!res.headersSent) res.writeHead(500); + res.end(); + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + // Loopback only: never expose an admin interface on every interface. + server.listen(port, '127.0.0.1', resolve); + }); + + const actualPort = server.address().port; + handler = createHandler({ client, domain, token, port: actualPort }); + return { server, port: actualPort, url: `http://127.0.0.1:${actualPort}/?t=${token}`, token }; +} + +module.exports = { start, createHandler, hostIsLoopback, toBytes }; diff --git a/lib/web.js b/lib/web.js new file mode 100644 index 0000000..537c253 --- /dev/null +++ b/lib/web.js @@ -0,0 +1,407 @@ +'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. + */ + +const CSS = ` +:root { + color-scheme: light dark; + --bg: #f6f7f9; + --panel: #ffffff; + --line: #e2e5ea; + --ink: #1a1d21; + --muted: #6b7280; + --accent: #2563eb; + --danger: #dc2626; + --ok: #059669; + --warn: #d97706; + --radius: 10px; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #14161a; --panel: #1c1f25; --line: #2b3038; --ink: #e8eaed; + --muted: #9aa2ad; --accent: #60a5fa; --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; +} +header { + display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; + padding: 20px 24px; border-bottom: 1px solid var(--line); background: var(--panel); +} +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; +} +.bar .spacer { flex: 1; } +button, input, select { + font: inherit; color: inherit; + border: 1px solid var(--line); background: var(--panel); + border-radius: 8px; padding: 7px 11px; +} +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.danger { color: var(--danger); } +button.link { + border: none; background: none; padding: 2px 4px; color: var(--accent); + text-decoration: underline; text-underline-offset: 2px; +} +input { min-width: 0; } +input:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + +.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; } +.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; } +tbody tr:last-child td { border-bottom: none; } +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; +} +.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 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; + 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; +} +dialog { + border: 1px solid var(--line); border-radius: var(--radius); padding: 0; + background: var(--panel); color: var(--ink); max-width: 92vw; +} +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; } +.dlg input { width: 100%; } +.dlg .row { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; } +.hint { font-size: 12px; color: var(--muted); margin-top: 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; + 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; +} +`; + +const JS = ` +'use strict'; +const token = new URLSearchParams(location.search).get('t') || ''; +let state = { keys: [], host: '', serverLimitBytes: null, serverName: null }; + +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; +}; + +function formatBytes(bytes) { + const n = Number(bytes) || 0; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0, v = n; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + if (i === 0) return n + ' B'; + return (v >= 10 ? v.toFixed(0) : v.toFixed(1)) + ' ' + units[i]; +} + +async function api(method, path, body) { + const res = await fetch(path, { + method, + headers: Object.assign({ 'X-Auth-Token': token }, body ? { 'Content-Type': 'application/json' } : {}), + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || ('Request failed with ' + res.status)); + return data; +} + +function showError(message) { + const box = $('#error'); + box.textContent = message || ''; + box.hidden = !message; +} + +/** Runs an action, shows any failure, and refreshes from whatever it returns. */ +async function act(fn) { + showError(''); + document.body.style.cursor = 'progress'; + try { + const next = await fn(); + if (next && next.keys) { state = next; render(); } + } catch (err) { + showError(err.message); + } finally { + document.body.style.cursor = ''; + } +} + +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 }); + 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'; + + 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'; + + const body = $('#rows'); + body.replaceChildren(); + + 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)); + } +} + +async function copy(text) { + try { + await navigator.clipboard.writeText(text); + } 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(); + } +} + +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(); + }); +} + +async function addKey() { + const name = await ask({ + title: 'New access key', label: 'Name', okText: 'Create', placeholder: 'e.g. Alice', + }); + if (name === null) return; + act(() => api('POST', '/api/keys', { name })); +} + +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 })); +} + +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', + }); + 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', + }); + 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 showQr(id) { + 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(); + } catch (err) { + showError(err.message); + } +} + +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(); + act(() => api('GET', '/api/state')); +} + +document.addEventListener('DOMContentLoaded', init); +`; + +const HTML = ` + + + + +Outline access keys + + + +
+

Outline access keys

+ + +
+ +
+ + +
+ + + + + +
+ +
+ + + + + + + + +
IDNameUsedLimitAccess URL
Loading…
+
+ + +
+ + +
+

+ + +
+
+ + +
+
+
+ + +
+

+

+    
+
+
+
+ + + +`; + +function page() { + return HTML; +} + +module.exports = { page }; diff --git a/shadowboxKey.js b/shadowboxKey.js index a572c65..17598a7 100755 --- a/shadowboxKey.js +++ b/shadowboxKey.js @@ -27,6 +27,7 @@ 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 may be either a key id or a key name. @@ -35,6 +36,7 @@ Options: --json Output JSON instead of a table (list, usage) --csv Output CSV instead of a table (list, usage) --limit Data limit for a newly created key (add) + --port Port for the web interface (ui, default 8787) -h, --help Show this help Sizes accept a unit suffix, e.g. 10GB, 500MB, 2TB. @@ -51,6 +53,7 @@ Examples: node shadowboxKey.js add Alice --limit 50GB node shadowboxKey.js limit Alice 10GB node shadowboxKey.js usage --csv + node shadowboxKey.js ui --port 9000 `; /** Splits argv into positional arguments and a flag map. */ @@ -63,6 +66,8 @@ function parseArgs(argv) { flags.help = true; } else if (arg === '--limit') { flags.limit = argv[++i]; + } else if (arg === '--port') { + flags.port = argv[++i]; } else if (arg.startsWith('--')) { flags[arg.slice(2)] = true; } else { @@ -232,6 +237,38 @@ const commands = { } }, + async ui(client, host, 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({ 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; + } + + 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); + }); + }, + 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]); diff --git a/test/server.test.js b/test/server.test.js new file mode 100644 index 0000000..72914d1 --- /dev/null +++ b/test/server.test.js @@ -0,0 +1,265 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const http = require('node:http'); + +const { createHandler, hostIsLoopback, toBytes } = require('../lib/server'); +const { UserError } = require('../lib/errors'); + +const GB = 1024 ** 3; +const TOKEN = 'a'.repeat(48); + +test('hostIsLoopback accepts the loopback interface, with or without the port', () => { + for (const host of ['127.0.0.1:8787', '127.0.0.1', 'localhost:8787', 'localhost', '[::1]:8787']) { + assert.strictEqual(hostIsLoopback(host, 8787), true, host); + } +}); + +test('hostIsLoopback rejects anything else, which is what blocks DNS rebinding', () => { + for (const host of ['evil.example.com', 'evil.example.com:8787', '10.0.0.5:8787', '', undefined]) { + assert.strictEqual(hostIsLoopback(host, 8787), false, String(host)); + } +}); + +test('hostIsLoopback rejects a loopback name carrying someone else\'s port', () => { + assert.strictEqual(hostIsLoopback('127.0.0.1:9999', 8787), false); +}); + +test('toBytes accepts sizes, byte counts and "no limit"', () => { + assert.strictEqual(toBytes('10GB'), 10 * GB); + assert.strictEqual(toBytes(1024), 1024); + assert.strictEqual(toBytes(null), null); + assert.strictEqual(toBytes(''), null); + assert.strictEqual(toBytes(undefined), null); +}); + +test('toBytes rejects sizes it cannot parse', () => { + assert.throws(() => toBytes('nonsense'), UserError); + assert.throws(() => toBytes(-5), UserError); + assert.throws(() => toBytes(Infinity), UserError); +}); + +/** A stand-in for OutlineClient, so these tests need no Outline server. */ +function fakeClient() { + const calls = []; + let nextId = 2; + const keys = [ + { id: '0', name: 'Alice', port: 443, accessUrl: 'ss://a@10.0.0.1:443/?outline=1', dataLimit: { bytes: 10 * GB } }, + { id: '1', name: 'Bob', port: 444, accessUrl: 'ss://b@10.0.0.1:444/?outline=1' }, + ]; + let serverLimit = null; + + const find = id => keys.find(k => k.id === id); + + return { + calls, + hostname: '10.0.0.1', + async listKeys() { return keys.map(k => ({ ...k })); }, + async getTransferMetrics() { return { '0': 3 * GB, '1': 0 }; }, + async getServerInfo() { + return { name: 'Test', accessKeyDataLimit: serverLimit ? { bytes: serverLimit } : undefined }; + }, + async createKey(name) { + const key = { id: String(nextId++), name, port: 500, accessUrl: `ss://n@10.0.0.1:500/?outline=1` }; + keys.push(key); + calls.push(['create', name]); + return key; + }, + async removeKey(id) { + calls.push(['remove', id]); + const i = keys.findIndex(k => k.id === id); + if (i >= 0) keys.splice(i, 1); + }, + async renameKey(id, name) { calls.push(['rename', id, name]); const k = find(id); if (k) k.name = name; }, + async setKeyDataLimit(id, bytes) { + calls.push(['limit', id, bytes]); + const k = find(id); if (k) k.dataLimit = { bytes }; + }, + async clearKeyDataLimit(id) { calls.push(['clearLimit', id]); const k = find(id); if (k) delete k.dataLimit; }, + async setServerDataLimit(bytes) { calls.push(['serverLimit', bytes]); serverLimit = bytes; }, + async clearServerDataLimit() { calls.push(['clearServerLimit']); serverLimit = null; }, + }; +} + +/** Starts the handler on a real socket so headers and status codes are exercised. */ +async function startServer(client, domain) { + 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({ client, domain, token: TOKEN, port }); + return { server, port }; +} + +/** One request, with full control over headers including Host. */ +function request(port, method, path, { token, host, body } = {}) { + return new Promise((resolve, reject) => { + const payload = body === undefined ? undefined : JSON.stringify(body); + const headers = {}; + if (token !== null) headers['X-Auth-Token'] = token === undefined ? TOKEN : token; + if (host) headers.Host = host; + if (payload) headers['Content-Type'] = 'application/json'; + + const req = http.request({ host: '127.0.0.1', port, method, path, headers }, res => { + let text = ''; + res.setEncoding('utf8'); + res.on('data', c => (text += c)); + res.on('end', () => { + let json = null; + try { json = JSON.parse(text); } catch (err) { /* HTML or empty */ } + resolve({ status: res.statusCode, headers: res.headers, text, json }); + }); + }); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +test('web interface', async t => { + const client = fakeClient(); + const { server, port } = await startServer(client, 'vpn.example.com'); + const call = (method, path, opts) => request(port, method, path, opts); + + try { + await t.test('serves the page without a token', async () => { + 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>/); + }); + + await t.test('the page policy allows its own fetch calls', async () => { + // connect-src falls back to default-src, so 'none' would break the UI. + const res = await call('GET', '/', { token: null }); + assert.match(res.headers['content-security-policy'], /connect-src 'self'/); + }); + + await t.test('the API refuses a request with no token', async () => { + const res = await call('GET', '/api/state', { token: null }); + assert.strictEqual(res.status, 401); + }); + + await t.test('the API refuses a wrong token of either length', async () => { + for (const token of ['b'.repeat(48), 'short']) { + const res = await call('GET', '/api/state', { token }); + assert.strictEqual(res.status, 401, token); + } + }); + + await t.test('the API refuses a foreign Host header', async () => { + const res = await call('GET', '/api/state', { host: 'evil.example.com' }); + assert.strictEqual(res.status, 403); + }); + + await t.test('state lists keys with usage and the configured domain', async () => { + const res = await call('GET', '/api/state'); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.json.host, 'vpn.example.com'); + assert.strictEqual(res.json.keys.length, 2); + assert.deepStrictEqual(res.json.keys[0], { + id: '0', name: 'Alice', port: 443, + dataLimitBytes: 10 * GB, bytes: 3 * GB, + accessUrl: 'ss://a@vpn.example.com:443/?outline=1', + }); + assert.strictEqual(res.json.keys[1].dataLimitBytes, null); + }); + + await t.test('creating a key applies an optional limit', async () => { + const res = await call('POST', '/api/keys', { body: { name: 'Carol', limitBytes: '5GB' } }); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual(client.calls.at(-1), ['limit', '2', 5 * GB]); + assert.strictEqual(res.json.keys.length, 3); + }); + + await t.test('renaming rejects an empty name', async () => { + const res = await call('PUT', '/api/keys/0/name', { body: { name: ' ' } }); + assert.strictEqual(res.status, 400); + assert.match(res.json.error, /cannot be empty/); + }); + + await t.test('a limit can be set and cleared', async () => { + let res = await call('PUT', '/api/keys/1/limit', { body: { bytes: '2GB' } }); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual(client.calls.at(-1), ['limit', '1', 2 * GB]); + + res = await call('PUT', '/api/keys/1/limit', { body: { bytes: null } }); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual(client.calls.at(-1), ['clearLimit', '1']); + }); + + await t.test('the server-wide limit round-trips into state', async () => { + let res = await call('PUT', '/api/server/limit', { body: { bytes: '100GB' } }); + assert.strictEqual(res.json.serverLimitBytes, 100 * GB); + + res = await call('PUT', '/api/server/limit', { body: { bytes: null } }); + assert.strictEqual(res.json.serverLimitBytes, null); + }); + + await t.test('an unparseable size is a clear 400, not a crash', async () => { + const res = await call('PUT', '/api/keys/0/limit', { body: { bytes: 'nonsense' } }); + assert.strictEqual(res.status, 400); + assert.match(res.json.error, /Could not understand the size/); + }); + + await t.test('the QR endpoint returns a code for the rewritten URL', async () => { + const res = await call('GET', '/api/keys/0/qr'); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.json.accessUrl, 'ss://a@vpn.example.com:443/?outline=1'); + assert.ok(res.json.qr.includes('█'), 'expected block characters'); + }); + + await t.test('the QR endpoint 400s on an unknown key', async () => { + const res = await call('GET', '/api/keys/nope/qr'); + assert.strictEqual(res.status, 400); + }); + + await t.test('deleting removes the key', async () => { + const res = await call('DELETE', '/api/keys/2'); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual(res.json.keys.map(k => k.id), ['0', '1']); + }); + + await t.test('unknown paths 404 and wrong methods 405', async () => { + assert.strictEqual((await call('GET', '/api/nope')).status, 404); + assert.strictEqual((await call('GET', '/nope', { token: null })).status, 404); + assert.strictEqual((await call('POST', '/api/state')).status, 405); + }); + + await t.test('a malformed body is rejected without touching the server', async () => { + const before = client.calls.length; + const res = await new Promise((resolve, reject) => { + const req = http.request({ + host: '127.0.0.1', port, method: 'POST', path: '/api/keys', + headers: { 'X-Auth-Token': TOKEN, 'Content-Type': 'application/json' }, + }, r => { + let text = ''; + r.on('data', c => (text += c)); + r.on('end', () => resolve({ status: r.statusCode, text })); + }); + req.on('error', reject); + req.write('{not json'); + req.end(); + }); + assert.strictEqual(res.status, 400); + assert.strictEqual(client.calls.length, before, 'no Outline call should have been made'); + }); + } finally { + server.close(); + } +}); + +test('the interface falls back to the server hostname when no domain is set', async () => { + const client = fakeClient(); + const { server, port } = await startServer(client, ''); + try { + const res = await request(port, 'GET', '/api/state'); + assert.strictEqual(res.json.host, '10.0.0.1'); + assert.strictEqual(res.json.keys[0].accessUrl, 'ss://a@10.0.0.1:443/?outline=1'); + } finally { + server.close(); + } +});