Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -69,6 +69,7 @@ node shadowboxKey.js <command> [options]
| `limit server <size\|none>` | Set or clear the server-wide default data limit |
| `usage` | Show how much data each key has transferred |
| `qr <key>` | Print a key's access URL as a scannable QR code |
| `ui` | Open a local web interface covering all of the above |

`<key>` 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.

Expand All @@ -78,6 +79,7 @@ node shadowboxKey.js <command> [options]
| `--json` | `list`, `usage` | Output JSON instead of a table |
| `--csv` | `list`, `usage` | Output CSV instead of a table |
| `--limit <size>` | `add` | Give the new key a data limit straight away |
| `--port <n>` | `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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions lib/outline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) || {};
Expand Down
248 changes: 248 additions & 0 deletions lib/server.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading