@w3cj/fwd 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,100 @@
1
- # Temporary Holding Version
1
+ # fwd
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A dead simple zero dependency SSH port forward manager. No extra background processes, uses OpenSSH ControlMaster.
4
+
5
+ ```bash
6
+ fwd add 5173 # localhost:5173 -> vm:5173
7
+ fwd add 8080:3000 # localhost:8080 -> vm:3000
8
+ fwd add 3000 --reverse # vm:3000 -> localhost:3000
9
+ fwd ls
10
+ fwd rm 5173
11
+ ```
12
+
13
+ One OpenSSH [ControlMaster](https://man.openbsd.org/ssh_config#ControlMaster) process holds the connection, and `fwd` asks it to add or cancel forwards with `ssh -O forward` / `ssh -O cancel`. OpenSSH does all the networking; `fwd` remembers what you asked for, so it can show status and restore forwards after a reboot or VM restart.
14
+
15
+ ## Install
16
+
17
+ Requires Node 20+ and OpenSSH on macOS or Linux. Windows isn't supported, because its OpenSSH doesn't support ControlMaster. There are no runtime dependencies.
18
+
19
+ ```bash
20
+ npm install -g @w3cj/fwd
21
+ ```
22
+
23
+ ## Setup
24
+
25
+ Point `fwd` at an alias in `~/.ssh/config`, so keys, user and IP all come from your normal SSH setup:
26
+
27
+ ```
28
+ Host vm
29
+ HostName 192.168.64.7
30
+ User cj
31
+ ```
32
+
33
+ ```bash
34
+ fwd config set host vm
35
+ ```
36
+
37
+ Check that it works with `ssh vm` first. Passphrase and host-key prompts still work when `fwd` starts the connection.
38
+
39
+ ## Commands
40
+
41
+ | Command | What it does |
42
+ |---|---|
43
+ | `fwd add <local>[:<remote>] [--reverse] [--to <addr>]` | Forward `localhost:<local>` to `<remote>` on the host (same port by default). Starts the master if needed. `--to` connects to another address as seen from the host, e.g. `fwd add 5432 --to db`. |
44
+ | `fwd rm <port> [--reverse]` / `fwd rm --all` | Cancel a forward and forget it. `<port>` is the listening port. |
45
+ | `fwd ls [--json] [--all-hosts]` | List saved forwards with their status: `active`, `down` (master not running) or `broken` (master up, port not listening). `--all-hosts` lists every host. |
46
+ | `fwd up` | Start the master and re-apply every saved forward. Use it after a reboot, VM restart or network drop. |
47
+ | `fwd down` | Stop the master. Saved forwards are kept for `fwd up`. |
48
+ | `fwd status` | Show the host, control socket, master PID and log file. |
49
+ | `fwd watch [--interval <s>] [--all-hosts]` | Stay in the foreground and reconnect and restore forwards whenever the connection drops. |
50
+ | `fwd completion bash\|zsh\|fish` | Print a shell completion script. |
51
+ | `fwd config set\|get\|unset host` | Manage the default host. |
52
+
53
+ Every command accepts `--host <host>`, so several hosts can be used side by side, with one master each. `fwd <command> --help` shows details.
54
+
55
+ ## Reverse forwards
56
+
57
+ `--reverse` makes the host listen and forward back to this machine (`ssh -R`). That's useful when something in the VM needs to reach a service on your laptop:
58
+
59
+ ```bash
60
+ fwd add 3000 --reverse # vm:3000 -> localhost:3000
61
+ fwd add 3000:8000 --reverse # vm:8000 -> localhost:3000
62
+ fwd add 5432 --reverse --to 10.0.0.5 # vm:5432 -> 10.0.0.5:5432, reached from this machine
63
+ fwd rm 8000 --reverse
64
+ ```
65
+
66
+ The spec is always `<local>:<remote>`; `--reverse` only changes which side listens. The host listens on its loopback interface unless its sshd sets `GatewayPorts`. `fwd ls` can't check reverse forwards individually, so it shows them as `active` whenever the master is up.
67
+
68
+ ## Staying connected
69
+
70
+ `fwd watch` checks every 5 seconds (`--interval` changes that). If the master has died because the VM restarted, slept or changed network, `watch` reconnects and restores your saved forwards. If a local forward stopped listening, it's re-applied. Forwards you add or remove while it runs are picked up. Stop it with Ctrl+C; the master keeps running.
71
+
72
+ ## Shell completion
73
+
74
+ Completion covers commands, options, saved hosts for `--host`, and saved ports for `fwd rm`:
75
+
76
+ ```bash
77
+ fwd completion bash > ~/.local/share/bash-completion/completions/fwd
78
+ fwd completion zsh > "${fpath[1]}/_fwd" # or: source <(fwd completion zsh) in ~/.zshrc
79
+ fwd completion fish > ~/.config/fish/completions/fwd.fish
80
+ ```
81
+
82
+ ## Notes
83
+
84
+ - Local ports below 1024 are rejected. Use a higher local port, e.g. `fwd add 8080:80`.
85
+ - `active` means the local end of the forward is listening. It doesn't check that anything is running on the remote port. `ls` checks by briefly connecting to each local port, which ssh passes through, so the remote service sees a short connection.
86
+ - `LocalForward` and `RemoteForward` lines in your ssh config are ignored for `fwd`'s connection; `fwd` only opens the forwards you add.
87
+ - Connecting gives up after 10 seconds if the host doesn't respond.
88
+ - Commands that change state take a lock in the config directory, so running several `fwd` commands at once is safe.
89
+ - If the connection drops (the VM sleeps, the network changes), the master exits within about 45 seconds. The next `fwd add` or `fwd up` reconnects and restores forwards.
90
+ - Cancelling a forward stops new connections; connections that are already open stay open until they close.
91
+ - State, control sockets and the master's log live in `$XDG_CONFIG_HOME/fwd` (default `~/.config/fwd`).
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ npm test # unit tests, plus end-to-end tests against a throwaway local sshd
97
+ npm run typecheck # tsc over the JSDoc types; there's no build step
98
+ ```
99
+
100
+ The end-to-end tests start `sshd` as your user on a random localhost port. They're skipped if `sshd` isn't installed, or if `FWD_SKIP_INTEGRATION=1` is set.
package/bin/fwd.js ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { resolveHost } from '../src/commands/common.js';
4
+ import { findCommand, optionsFor } from '../src/commands/index.js';
5
+ import { FwdError } from '../src/errors.js';
6
+ import { commandHelp, mainHelp, version } from '../src/help.js';
7
+ import { withLock } from '../src/lock.js';
8
+ import { loadState } from '../src/state.js';
9
+
10
+ /** @typedef {import('../src/commands/common.js').Command} Command */
11
+
12
+ /**
13
+ * @param {Command} cmd
14
+ * @param {string[]} argv
15
+ */
16
+ function parse(cmd, argv) {
17
+ const options = Object.fromEntries(
18
+ Object.entries(optionsFor(cmd)).map(([name, { type, short }]) => [name, short ? { type, short } : { type }]),
19
+ );
20
+ try {
21
+ return parseArgs({ args: argv, options, allowPositionals: true, strict: true });
22
+ } catch (err) {
23
+ // Drop Node's advice about "--" that follows unknown-option errors.
24
+ const message = /** @type {Error} */ (err).message.replace(/\. To specify a positional.*$/s, '.');
25
+ throw new FwdError(`${message}\nRun \`fwd ${cmd.name} --help\` for usage.`);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * @param {string[]} argv
31
+ * @returns {Promise<number>}
32
+ */
33
+ async function main([name, ...rest]) {
34
+ if (name === undefined || name === '-h' || name === '--help') {
35
+ console.log(mainHelp());
36
+ return 0;
37
+ }
38
+ if (name === '-v' || name === '--version') {
39
+ console.log(version);
40
+ return 0;
41
+ }
42
+
43
+ const cmd = findCommand(name);
44
+ const { values: flags, positionals: args } = parse(cmd, rest);
45
+ if (flags.help) {
46
+ console.log(commandHelp(cmd));
47
+ return 0;
48
+ }
49
+ const [min, max] = cmd.args;
50
+ if (args.length > max) {
51
+ throw new FwdError(`Unexpected argument "${args[max]}". Run \`fwd ${name} --help\` for usage.`);
52
+ }
53
+ if (args.length < min) {
54
+ throw new FwdError(`Missing argument. Usage: ${cmd.usage.join(' | ')}`);
55
+ }
56
+
57
+ const run = async () => {
58
+ const state = await loadState();
59
+ const host = cmd.host ? '' : resolveHost(state, flags.host);
60
+ return (await cmd.run({ args, flags, state, host })) ?? 0;
61
+ };
62
+ return cmd.readonly ? run() : withLock(run);
63
+ }
64
+
65
+ try {
66
+ process.exitCode = await main(process.argv.slice(2));
67
+ } catch (err) {
68
+ console.error(err instanceof FwdError ? `fwd: ${err.message}` : err);
69
+ process.exitCode = 1;
70
+ }
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@w3cj/fwd",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A dead simple zero dependency SSH port forward manager.",
5
+ "type": "module",
6
+ "bin": {
7
+ "fwd": "./bin/fwd.js"
8
+ },
9
+ "files": [
10
+ "README.md",
11
+ "bin",
12
+ "src"
13
+ ],
14
+ "scripts": {
15
+ "test": "node --test test/*.test.js",
16
+ "typecheck": "tsc",
17
+ "prepublishOnly": "npm run typecheck && npm test"
18
+ },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "os": [
23
+ "!win32"
24
+ ],
25
+ "keywords": [
26
+ "ssh",
27
+ "port-forward",
28
+ "tunnel",
29
+ "controlmaster",
30
+ "cli"
31
+ ],
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20.19.43",
38
+ "typescript": "^7.0.2"
39
+ }
40
+ }
@@ -0,0 +1,56 @@
1
+ import { FwdError } from '../errors.js';
2
+ import { describe, keyOf, listenPort, parseSpec, sameTarget } from '../spec.js';
3
+ import { findForward, getForwards, putForward, saveState } from '../state.js';
4
+ import { applyForward, bringUp, printResults } from './common.js';
5
+
6
+ /** @type {import('./common.js').Command} */
7
+ export default {
8
+ name: 'add',
9
+ summary: 'Forward a port to (or, with --reverse, from) the host',
10
+ usage: ['fwd add <local>[:<remote>] [--reverse] [--to <addr>] [--host <host>]'],
11
+ description: [
12
+ 'Forwards localhost:<local> to <remote> on the host (defaults to the same port).',
13
+ 'With --reverse, the host listens on <remote> and forwards to localhost:<local>.',
14
+ 'Starts the SSH master if it is not running and restores saved forwards.',
15
+ '',
16
+ 'Examples:',
17
+ ' fwd add 5173 localhost:5173 -> host:5173',
18
+ ' fwd add 8080:3000 localhost:8080 -> host:3000',
19
+ ' fwd add 5432 --to db localhost:5432 -> db:5432, as reached from the host',
20
+ ' fwd add 3000 --reverse host:3000 -> localhost:3000',
21
+ ' fwd add 3000:8000 --reverse host:8000 -> localhost:3000',
22
+ ].join('\n'),
23
+ args: [1, 1],
24
+ options: {
25
+ reverse: { type: 'boolean', help: 'Listen on the host and forward to this machine (ssh -R)' },
26
+ to: { type: 'string', value: 'addr', help: 'Address to connect to from the other side (default 127.0.0.1)' },
27
+ },
28
+
29
+ async run({ args, flags, state, host }) {
30
+ const fwd = parseSpec(args[0], {
31
+ to: typeof flags.to === 'string' ? flags.to : undefined,
32
+ reverse: Boolean(flags.reverse),
33
+ });
34
+
35
+ // Local listeners share this machine, so they clash across hosts; remote
36
+ // listeners only clash on the same host.
37
+ const key = keyOf(fwd);
38
+ for (const savedHost of fwd.direction === 'local' ? Object.keys(state.hosts) : [host]) {
39
+ const other = findForward(state, savedHost, key);
40
+ if (other && !(savedHost === host && sameTarget(other, fwd))) {
41
+ const rm = `fwd rm ${listenPort(other)}${other.direction === 'remote' ? ' --reverse' : ''} --host ${savedHost}`;
42
+ throw new FwdError(`That port is already used by ${describe(other, savedHost)}. Run \`${rm}\` first.`);
43
+ }
44
+ }
45
+
46
+ const saved = findForward(state, host, key);
47
+ const others = getForwards(state, host).filter((f) => f !== saved);
48
+ const { started, results } = await bringUp(host, others);
49
+ printResults(host, results.filter((r) => r.result !== 'active'));
50
+
51
+ const result = await applyForward(host, fwd, { ours: !started && saved !== undefined });
52
+ putForward(state, host, { ...fwd, addedAt: saved?.addedAt ?? new Date().toISOString() });
53
+ await saveState(state);
54
+ console.log(`${result === 'active' ? 'Already forwarding' : 'Forwarding'} ${describe(fwd, host)}`);
55
+ },
56
+ };
@@ -0,0 +1,173 @@
1
+ import { FwdError } from '../errors.js';
2
+ import { isPortFree, isPortListening } from '../ports.js';
3
+ import { describe } from '../spec.js';
4
+ import { addForward, checkMaster, removeSocket, startMaster } from '../ssh.js';
5
+
6
+ /** @typedef {import('../spec.js').Forward} Forward */
7
+ /** @typedef {import('../state.js').State} State */
8
+
9
+ /**
10
+ * @typedef {object} Option
11
+ * @property {'string' | 'boolean'} type
12
+ * @property {string} help
13
+ * @property {string} [short]
14
+ * @property {string} [value] Placeholder shown in help, e.g. "addr" for --to <addr>.
15
+ * @property {Completion} [complete] How shells complete the option's value.
16
+ */
17
+
18
+ /**
19
+ * What shell completion offers for an argument: fixed words, command names,
20
+ * saved hosts, or the saved listen ports for the host.
21
+ * @typedef {string[] | 'commands' | 'hosts' | 'ports'} Completion
22
+ */
23
+
24
+ /** @typedef {(message: string) => void} Log */
25
+
26
+ /**
27
+ * @typedef {object} Context
28
+ * @property {string[]} args
29
+ * @property {Record<string, string | boolean | undefined>} flags
30
+ * @property {State} state
31
+ * @property {string} host The resolved host; empty unless `Command.host` is unset.
32
+ */
33
+
34
+ /**
35
+ * @typedef {object} Command
36
+ * @property {string} name
37
+ * @property {string} summary
38
+ * @property {string[]} usage
39
+ * @property {string} [description]
40
+ * @property {[min: number, max: number]} args Allowed number of positional arguments.
41
+ * @property {'none' | 'self'} [host] By default the host is resolved for the command.
42
+ * 'self': --host is accepted but the command resolves it. 'none': no host at all.
43
+ * @property {true} [readonly] Doesn't change state or the master, so skips the lock.
44
+ * @property {true} [hidden] Left out of help and shell completion.
45
+ * @property {Completion[]} [positionals] Shell completion for each positional argument.
46
+ * @property {Record<string, Option>} [options]
47
+ * @property {(ctx: Context) => Promise<number | void>} run
48
+ */
49
+
50
+ /** @param {string} host */
51
+ export function validateHost(host) {
52
+ if (!host || host.startsWith('-') || /\s/.test(host)) {
53
+ throw new FwdError(`Invalid host "${host}".`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * @param {State} state
59
+ * @param {string | boolean | undefined} flag
60
+ */
61
+ export function resolveHost(state, flag) {
62
+ const host = typeof flag === 'string' ? flag : state.defaultHost;
63
+ if (!host) {
64
+ throw new FwdError('No host set. Run `fwd config set host <host>` or pass --host <host>.');
65
+ }
66
+ validateHost(host);
67
+ return host;
68
+ }
69
+
70
+ /** @param {number | null | undefined} pid */
71
+ export function pidSuffix(pid) {
72
+ return pid ? ` (pid ${pid})` : '';
73
+ }
74
+
75
+ /**
76
+ * Make sure a master is running for host, replacing a stale socket if the
77
+ * previous one died uncleanly. `started` means no forwards are active yet.
78
+ * @param {string} host
79
+ * @returns {Promise<{ started: boolean, pid: number | null }>}
80
+ */
81
+ async function ensureMaster(host) {
82
+ const status = await checkMaster(host);
83
+ if (status.alive) return { started: false, pid: status.pid };
84
+
85
+ await removeSocket(host);
86
+ await startMaster(host);
87
+
88
+ // -f returns once the master is set up, but give it a moment just in case.
89
+ for (let i = 0; i < 10; i++) {
90
+ const check = await checkMaster(host);
91
+ if (check.alive) return { started: true, pid: check.pid };
92
+ await new Promise((resolve) => setTimeout(resolve, 100));
93
+ }
94
+ throw new FwdError(`Started ssh for ${host}, but its control socket isn't responding.`);
95
+ }
96
+
97
+ /**
98
+ * Send a forward to the master unless it's already active. `ours` says
99
+ * whether the master may already have it: then a listener on a local port is
100
+ * taken to be the forward; otherwise it belongs to another process.
101
+ *
102
+ * Remote ports can't be probed from here, but the master accepts a repeated
103
+ * request for a forward it already has, so they're always (re)sent.
104
+ * @param {string} host
105
+ * @param {Forward} fwd
106
+ * @param {{ ours: boolean }} options
107
+ * @returns {Promise<'active' | 'added'>}
108
+ */
109
+ export async function applyForward(host, fwd, { ours }) {
110
+ if (fwd.direction === 'local') {
111
+ if (ours && (await isPortListening(fwd.local))) return 'active';
112
+ if (!(await isPortFree(fwd.local))) {
113
+ throw new FwdError(`Local port ${fwd.local} is already in use by another process.`);
114
+ }
115
+ }
116
+ await addForward(host, fwd);
117
+ return ours && fwd.direction === 'remote' ? 'active' : 'added';
118
+ }
119
+
120
+ /**
121
+ * 'down' if the master isn't running, 'broken' if a local forward's port isn't
122
+ * listening, else 'active'. Remote forwards can't be checked individually.
123
+ * @param {Forward} fwd
124
+ * @param {boolean} masterAlive
125
+ * @returns {Promise<'active' | 'down' | 'broken'>}
126
+ */
127
+ export async function forwardStatus(fwd, masterAlive) {
128
+ if (!masterAlive) return 'down';
129
+ if (fwd.direction === 'remote') return 'active';
130
+ return (await isPortListening(fwd.local)) ? 'active' : 'broken';
131
+ }
132
+
133
+ /**
134
+ * @typedef {object} ForwardResult
135
+ * @property {Forward} fwd
136
+ * @property {'active' | 'added'} [result]
137
+ * @property {Error} [error]
138
+ */
139
+
140
+ /**
141
+ * Start the master if needed, then apply each forward, collecting failures
142
+ * instead of stopping at the first.
143
+ * @param {string} host
144
+ * @param {Forward[]} forwards
145
+ * @param {Log} [log]
146
+ */
147
+ export async function bringUp(host, forwards, log = console.log) {
148
+ const master = await ensureMaster(host);
149
+ if (master.started) log(`Started SSH master for ${host}${pidSuffix(master.pid)}`);
150
+
151
+ /** @type {ForwardResult[]} */
152
+ const results = [];
153
+ for (const fwd of forwards) {
154
+ try {
155
+ results.push({ fwd, result: await applyForward(host, fwd, { ours: !master.started }) });
156
+ } catch (err) {
157
+ results.push({ fwd, error: /** @type {Error} */ (err) });
158
+ }
159
+ }
160
+ return { ...master, results };
161
+ }
162
+
163
+ /**
164
+ * @param {string} host
165
+ * @param {ForwardResult[]} results
166
+ * @param {Log} [log]
167
+ */
168
+ export function printResults(host, results, log = console.log) {
169
+ for (const { fwd, result, error } of results) {
170
+ const label = (error ? 'failed' : result ?? '').padEnd(6);
171
+ log(` ${label} ${describe(fwd, host)}${error ? `: ${error.message}` : ''}`);
172
+ }
173
+ }
@@ -0,0 +1,29 @@
1
+ import { listenPort } from '../spec.js';
2
+ import { getForwards } from '../state.js';
3
+
4
+ // Called by the completion scripts for values that depend on saved state.
5
+ /** @type {import('./common.js').Command} */
6
+ export default {
7
+ name: '__complete',
8
+ summary: 'Print completion candidates',
9
+ usage: ['fwd __complete hosts|ports [--host <host>]'],
10
+ args: [1, 1],
11
+ host: 'self',
12
+ readonly: true,
13
+ hidden: true,
14
+
15
+ async run({ args: [what], flags, state }) {
16
+ /** @type {(string | number)[]} */
17
+ let values = [];
18
+ if (what === 'hosts') {
19
+ values = [state.defaultHost ?? '', ...Object.keys(state.hosts)];
20
+ } else if (what === 'ports') {
21
+ const host = typeof flags.host === 'string' ? flags.host : state.defaultHost;
22
+ values = host ? getForwards(state, host).map(listenPort) : [];
23
+ }
24
+ // Write strings, not console.log(number): numbers get ANSI colours when
25
+ // FORCE_COLOR is set, which would end up in the completions.
26
+ const lines = [...new Set(values.map(String))].filter(Boolean);
27
+ if (lines.length) process.stdout.write(`${lines.join('\n')}\n`);
28
+ },
29
+ };