diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1207cd4..3cf35c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: # node --check only looks at its first argument, so check each file in turn. - name: Check syntax run: | - for file in shadowboxKey.js lib/*.js test/*.js; do + for file in index.js shadowboxKey.js lib/*.js test/*.js; do node --check "$file" done @@ -42,6 +42,15 @@ jobs: - name: Check the CLI starts and prints help run: node shadowboxKey.js --help + - name: Check the module exports load + run: | + node -e ' + const api = require("./"); + for (const name of ["listKeys", "getUsage", "getKeys", "OutlineClient"]) { + if (!api[name]) throw new Error("missing export: " + name); + } + ' + audit: name: Dependency audit runs-on: ubuntu-latest diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..18c9147 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..11e08a9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2026 rahmanow + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index f782449..1de9df2 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ [![CI](https://github.com/rahmanow/ShadowboxKeys/actions/workflows/ci.yml/badge.svg)](https://github.com/rahmanow/ShadowboxKeys/actions/workflows/ci.yml) -A command-line tool for managing access keys on your [Outline VPN](https://getoutline.org/) (Shadowbox) server. 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 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. -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. +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. + +This project supersedes [outline-br](https://github.com/rahmanow/outline-br), whose `getKeys()` module lives on here. See [migrating from outline-br](#migrating-from-outline-br). ## Prerequisites @@ -131,14 +133,83 @@ Export usage for a spreadsheet: node shadowboxKey.js usage --csv > usage.csv ``` +## 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: + +```js +const { listKeys, getUsage, getKeys, OutlineClient } = require('shadowbox-keys'); + +const API = 'https://1.2.3.4:16942/AbCdEf123'; +const options = { domain: 'vpn.example.com', certSha256: 'E3823F9B...52F5A584' }; + +// Structured key data +const keys = await listKeys(API, options); +// [{ id: '0', name: 'Alice', port: 443, dataLimitBytes: 10737418240, +// accessUrl: 'ss://...@vpn.example.com:443/?outline=1' }, ...] + +// Who is using how much +const usage = await getUsage(API, options); +// [{ id: '0', name: 'Alice', bytes: 3221225472, dataLimitBytes: 10737418240 }, ...] +``` + +`options` is optional throughout: `domain` swaps the host in each access URL, and `certSha256` pins the server's certificate exactly as the CLI does. + +For anything the helpers do not cover — creating, renaming and deleting keys, or setting data limits — use the client directly: + +```js +const client = new OutlineClient(API, 'E3823F9B...52F5A584'); + +const key = await client.createKey('Alice'); +await client.setKeyDataLimit(key.id, 50 * 1024 ** 3); // 50 GB +await client.renameKey(key.id, 'Alice B'); +await client.clearKeyDataLimit(key.id); +await client.removeKey(key.id); + +await client.setServerDataLimit(100 * 1024 ** 3); // server-wide default +await client.clearServerDataLimit(); +``` + +Failures caused by bad input or a bad response throw `UserError`, which is also exported, so you can tell them apart from bugs: + +```js +const { UserError } = require('shadowbox-keys'); + +try { + await listKeys(API, { certSha256: expected }); +} catch (err) { + if (err instanceof UserError) console.error(err.message); // e.g. wrong certificate + else throw err; +} +``` + +### Migrating from outline-br + +This project absorbed [outline-br](https://github.com/rahmanow/outline-br). Its `getKeys(managementApiUrl, newDomain)` is exported here with the same signature, the same `Name -> ss://...` output and the same printing behaviour, so existing code only needs its import changed: + +```js +// before +const keys = require('outline-br'); +keys('https://1.2.3.4:16942/AbCdEf123', '87.65.43.21'); + +// after +const { getKeys } = require('shadowbox-keys'); +await getKeys('https://1.2.3.4:16942/AbCdEf123', '87.65.43.21'); +``` + +Two differences, both additive: `getKeys` now also returns the lines it printed, and it accepts a third `options` argument for `certSha256`. Nothing that worked before behaves differently. + +New code should prefer `listKeys()`, which returns structured data and leaves presentation to you, rather than printing to stdout. + ## Project layout ``` +index.js Programmatic API: listKeys, getUsage, getKeys 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 -test/ Unit tests, run with the built-in Node test runner +test/ Tests, run with the built-in Node test runner ``` ## Development @@ -192,4 +263,4 @@ The HTTP calls use the built-in `node:https` module because the global `fetch()` ## License -No license has been specified yet. Until one is added, all rights are reserved by the author. +[MIT](LICENSE), carried over from outline-br when the two projects merged. diff --git a/index.js b/index.js new file mode 100644 index 0000000..1d704b8 --- /dev/null +++ b/index.js @@ -0,0 +1,100 @@ +'use strict'; + +/** + * Programmatic entry point. + * + * The CLI in shadowboxKey.js is one consumer of this API; the exports below are + * the same building blocks it uses, so anything the CLI can do is reachable from + * code. getKeys() is kept signature-compatible with the outline-br module this + * project absorbed, so existing callers keep working. + */ + +const { OutlineClient } = require('./lib/outline'); +const { formatBytes, parseBytes, rewriteAccessUrl } = require('./lib/format'); +const { UserError } = require('./lib/errors'); + +/** + * Lists every access key on the server. + * + * @param {string} managementApiUrl Management API URL from Outline Manager. + * @param {object} [options] + * @param {string} [options.domain] Host to use in access URLs instead of the server IP. + * @param {string} [options.certSha256] Pin the server's certificate to this fingerprint. + * @returns {Promise>} + */ +async function listKeys(managementApiUrl, options = {}) { + const client = new OutlineClient(managementApiUrl, options.certSha256); + const host = options.domain || client.hostname; + + const keys = await client.listKeys(); + return keys.map(key => ({ + id: key.id, + name: key.name || '', + port: key.port, + dataLimitBytes: key.dataLimit ? key.dataLimit.bytes : null, + accessUrl: rewriteAccessUrl(key.accessUrl, client.hostname, host), + })); +} + +/** + * Reports how much data each key has transferred, busiest first. + * + * @param {string} managementApiUrl + * @param {object} [options] Same options as listKeys. + * @returns {Promise>} + */ +async function getUsage(managementApiUrl, options = {}) { + const client = new OutlineClient(managementApiUrl, options.certSha256); + const [keys, transferred] = await Promise.all([ + client.listKeys(), + client.getTransferMetrics(), + ]); + + return keys + .map(key => ({ + id: key.id, + name: key.name || '', + bytes: transferred[key.id] || 0, + dataLimitBytes: key.dataLimit ? key.dataLimit.bytes : null, + })) + .sort((a, b) => b.bytes - a.bytes); +} + +/** + * Prints every key as "Name -> ss://..." and returns those lines. + * + * This is the outline-br entry point, kept for callers migrating from that + * module: same two arguments, same output format and same printing behaviour. + * It returns the lines as well, which outline-br did not, so existing callers + * are unaffected. New code should prefer listKeys(), which returns structured + * data and leaves the printing to you. + * + * @param {string} managementApiUrl + * @param {string} [newDomain] Host to use in access URLs instead of the server IP. + * @param {object} [options] Additionally accepts certSha256. + * @returns {Promise} + */ +async function getKeys(managementApiUrl, newDomain, options = {}) { + const keys = await listKeys(managementApiUrl, { ...options, domain: newDomain }); + + // outline-br trimmed the /?outline=1 suffix from the printed URL. + const lines = keys.map(key => `${key.name || 'Noname'} -> ${key.accessUrl.split('/?')[0]}`); + lines.forEach(line => console.log(line)); + return lines; +} + +module.exports = { + // High-level helpers + listKeys, + getUsage, + getKeys, + // The full Management API client, for anything the helpers do not cover + OutlineClient, + // Utilities shared with the CLI + formatBytes, + parseBytes, + rewriteAccessUrl, + UserError, +}; diff --git a/lib/outline.js b/lib/outline.js index 760d5bc..c04c030 100644 --- a/lib/outline.js +++ b/lib/outline.js @@ -11,7 +11,13 @@ const { UserError } = require('./errors'); // supported way to relax certificate checks for a single request: it ignores // the agent option, and its dispatcher lives in undici, which is not reachable // without taking on a dependency. -const agent = new https.Agent({ rejectUnauthorized: false }); +// +// maxCachedSessions: 0 disables TLS session resumption. A resumed session skips +// the certificate exchange, so getPeerCertificate() returns nothing and the +// pinning check below could not run on any request after the first — which for +// a command issuing several requests meant rejecting the legitimate server. +// Forcing a full handshake each time costs a few milliseconds per request. +const agent = new https.Agent({ rejectUnauthorized: false, maxCachedSessions: 0 }); /** * Normalises a SHA-256 certificate fingerprint to bare lowercase hex, accepting diff --git a/package.json b/package.json index ab816af..8c8d41e 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,17 @@ { "name": "shadowbox-keys", - "version": "2.0.0", - "description": "Command-line manager for Outline VPN (Shadowbox) access keys: list, create, rename, delete, set data limits, report usage and print QR codes", - "main": "shadowboxKey.js", + "version": "3.0.0", + "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", + "license": "MIT", + "main": "index.js", "bin": { "shadowbox-keys": "shadowboxKey.js" }, + "files": [ + "index.js", + "shadowboxKey.js", + "lib/" + ], "scripts": { "start": "node shadowboxKey.js", "test": "node --test" @@ -17,7 +23,8 @@ "shadowsocks", "access-keys", "cli", - "qrcode" + "qrcode", + "outline-br" ], "repository": { "type": "git", diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..38f7706 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,162 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const https = require('node:https'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { listKeys, getUsage, getKeys } = require('../index'); + +const GB = 1024 ** 3; + +const hasOpenssl = (() => { + try { + execFileSync('openssl', ['version'], { stdio: 'ignore' }); + return true; + } catch (err) { + return false; + } +})(); + +/** Creates a throwaway self-signed certificate, mimicking an Outline server's. */ +function createCertificate() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shadowbox-keys-test-')); + const keyPath = path.join(dir, 'key.pem'); + const certPath = path.join(dir, 'cert.pem'); + + execFileSync('openssl', [ + 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', keyPath, '-out', certPath, + '-days', '1', '-subj', '/CN=localhost', + ], { stdio: 'ignore' }); + + const fingerprint = execFileSync('openssl', [ + 'x509', '-in', certPath, '-noout', '-fingerprint', '-sha256', + ]).toString().split('=')[1].trim(); + + return { + dir, + fingerprint, + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath), + }; +} + +const KEYS = [ + { id: '0', name: 'Alice', port: 443, accessUrl: 'ss://a@127.0.0.1:443/?outline=1', dataLimit: { bytes: 10 * GB } }, + { id: '1', name: '', port: 444, accessUrl: 'ss://b@127.0.0.1:444/?outline=1' }, +]; +const TRANSFER = { '0': 3 * GB, '1': 512 * 1024 ** 2 }; + +/** Serves just enough of the Management API for these tests. */ +function startServer(credentials) { + const server = https.createServer(credentials, (req, res) => { + const body = req.url.endsWith('/metrics/transfer') + ? { bytesTransferredByUserId: TRANSFER } + : { accessKeys: KEYS }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }); + + return new Promise(resolve => { + server.listen(0, '127.0.0.1', () => resolve(server)); + }); +} + +/** Runs fn with console.log captured, returning the lines it printed. */ +async function captureOutput(fn) { + const lines = []; + const original = console.log; + console.log = (...args) => lines.push(args.join(' ')); + try { + await fn(); + } finally { + console.log = original; + } + return lines; +} + +test('programmatic API', { skip: hasOpenssl ? false : 'openssl is not available' }, async t => { + const credentials = createCertificate(); + const server = await startServer(credentials); + const apiUrl = `https://127.0.0.1:${server.address().port}/SecretPath`; + + try { + await t.test('listKeys returns structured keys', async () => { + const keys = await listKeys(apiUrl); + assert.strictEqual(keys.length, 2); + assert.deepStrictEqual(keys[0], { + id: '0', + name: 'Alice', + port: 443, + dataLimitBytes: 10 * GB, + accessUrl: 'ss://a@127.0.0.1:443/?outline=1', + }); + assert.strictEqual(keys[1].dataLimitBytes, null, 'a key with no cap reports null'); + }); + + await t.test('listKeys rewrites the host when a domain is given', async () => { + const keys = await listKeys(apiUrl, { domain: 'vpn.example.com' }); + assert.strictEqual(keys[0].accessUrl, 'ss://a@vpn.example.com:443/?outline=1'); + }); + + await t.test('getUsage reports transfer per key, busiest first', async () => { + const usage = await getUsage(apiUrl); + assert.deepStrictEqual(usage.map(row => row.id), ['0', '1']); + assert.strictEqual(usage[0].bytes, 3 * GB); + assert.strictEqual(usage[0].dataLimitBytes, 10 * GB); + assert.strictEqual(usage[1].dataLimitBytes, null); + }); + + await t.test('getKeys prints outline-br\'s format and returns the lines', async () => { + let returned; + const printed = await captureOutput(async () => { + returned = await getKeys(apiUrl, 'vpn.example.com'); + }); + + assert.deepStrictEqual(printed, [ + 'Alice -> ss://a@vpn.example.com:443', + 'Noname -> ss://b@vpn.example.com:444', + ]); + assert.deepStrictEqual(returned, printed, 'the printed lines are also returned'); + }); + + await t.test('getKeys keeps the server host when no domain is given', async () => { + const printed = await captureOutput(() => getKeys(apiUrl)); + assert.deepStrictEqual(printed, [ + 'Alice -> ss://a@127.0.0.1:443', + 'Noname -> ss://b@127.0.0.1:444', + ]); + }); + + await t.test('a matching certificate fingerprint is accepted', async () => { + const keys = await listKeys(apiUrl, { certSha256: credentials.fingerprint }); + assert.strictEqual(keys.length, 2); + }); + + await t.test('pinning still holds on requests after the first', async () => { + // Regression: with TLS session resumption enabled, the second + // connection skips the certificate exchange, so the check saw no + // certificate and rejected the legitimate server. Any command + // issuing more than one request hit this. + const { certSha256 } = { certSha256: credentials.fingerprint }; + await listKeys(apiUrl, { certSha256 }); + await listKeys(apiUrl, { certSha256 }); + const usage = await getUsage(apiUrl, { certSha256 }); + assert.strictEqual(usage.length, 2); + }); + + await t.test('a mismatched certificate fingerprint is refused', async () => { + await assert.rejects( + listKeys(apiUrl, { certSha256: 'a'.repeat(64) }), + /unexpected TLS certificate/ + ); + }); + } finally { + server.close(); + fs.rmSync(credentials.dir, { recursive: true, force: true }); + } +});