@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 +99 -2
- package/bin/fwd.js +70 -0
- package/package.json +38 -4
- package/src/commands/add.js +56 -0
- package/src/commands/common.js +173 -0
- package/src/commands/complete.js +29 -0
- package/src/commands/completion.js +230 -0
- package/src/commands/config.js +47 -0
- package/src/commands/down.js +22 -0
- package/src/commands/help.js +21 -0
- package/src/commands/index.js +45 -0
- package/src/commands/ls.js +89 -0
- package/src/commands/rm.js +63 -0
- package/src/commands/status.js +26 -0
- package/src/commands/up.js +18 -0
- package/src/commands/watch.js +84 -0
- package/src/errors.js +9 -0
- package/src/help.js +45 -0
- package/src/lock.js +76 -0
- package/src/paths.js +10 -0
- package/src/ports.js +45 -0
- package/src/spec.js +145 -0
- package/src/ssh.js +180 -0
- package/src/state.js +130 -0
package/src/help.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { commands, helpOption, hostOption, optionsFor } from './commands/index.js';
|
|
3
|
+
|
|
4
|
+
/** @typedef {import('./commands/common.js').Command} Command */
|
|
5
|
+
/** @typedef {import('./commands/common.js').Option} Option */
|
|
6
|
+
|
|
7
|
+
export const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
8
|
+
|
|
9
|
+
/** @param {[string, string][]} rows */
|
|
10
|
+
function table(rows) {
|
|
11
|
+
const width = Math.max(...rows.map(([left]) => left.length));
|
|
12
|
+
return rows.map(([left, right]) => ` ${left.padEnd(width)} ${right}`).join('\n');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Record<string, Option>} options
|
|
17
|
+
* @returns {[string, string][]}
|
|
18
|
+
*/
|
|
19
|
+
function optionRows(options) {
|
|
20
|
+
return Object.entries(options).map(([name, opt]) => [
|
|
21
|
+
`${opt.short ? `-${opt.short}, ` : ''}--${name}${opt.type === 'string' ? ` <${opt.value ?? name}>` : ''}`,
|
|
22
|
+
opt.help,
|
|
23
|
+
]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function mainHelp() {
|
|
27
|
+
return `fwd ${version}: manage SSH port forwards over a single ControlMaster connection
|
|
28
|
+
|
|
29
|
+
Usage: fwd <command> [options]
|
|
30
|
+
|
|
31
|
+
Commands:
|
|
32
|
+
${table(commands.filter((c) => !c.hidden).map((c) => [c.name, c.summary]))}
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
${table([...optionRows({ ...hostOption, ...helpOption }), ['-v, --version', 'Show the version']])}
|
|
36
|
+
|
|
37
|
+
Run \`fwd help <command>\` for details. Start with \`fwd config set host <host>\`.`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {Command} cmd */
|
|
41
|
+
export function commandHelp(cmd) {
|
|
42
|
+
const usage = cmd.usage.map((line, i) => `${i === 0 ? 'Usage: ' : ' '}${line}`).join('\n');
|
|
43
|
+
const description = cmd.description ? `\n\n${cmd.description}` : '';
|
|
44
|
+
return `${usage}\n\n${cmd.summary}.${description}\n\nOptions:\n${table(optionRows(optionsFor(cmd)))}`;
|
|
45
|
+
}
|
package/src/lock.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { link, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { FwdError } from './errors.js';
|
|
4
|
+
import { configDir } from './paths.js';
|
|
5
|
+
|
|
6
|
+
const TIMEOUT_MS = 60_000;
|
|
7
|
+
|
|
8
|
+
function lockPath() {
|
|
9
|
+
return join(configDir(), 'lock');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** @param {number} pid */
|
|
13
|
+
function isRunning(pid) {
|
|
14
|
+
try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return /** @type {NodeJS.ErrnoException} */ (err).code === 'EPERM';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create the lock file atomically with our pid already in it: write a temp
|
|
24
|
+
* file, then hard-link it into place (link fails if the lock exists).
|
|
25
|
+
* @returns {Promise<boolean>}
|
|
26
|
+
*/
|
|
27
|
+
async function tryAcquire() {
|
|
28
|
+
const file = lockPath();
|
|
29
|
+
const tmp = `${file}.${process.pid}`;
|
|
30
|
+
await writeFile(tmp, String(process.pid));
|
|
31
|
+
try {
|
|
32
|
+
await link(tmp, file);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'EEXIST') return false;
|
|
36
|
+
throw err;
|
|
37
|
+
} finally {
|
|
38
|
+
await rm(tmp, { force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run fn while holding an exclusive lock, so concurrent fwd commands can't
|
|
44
|
+
* lose each other's state updates or start two masters for one host. A lock
|
|
45
|
+
* left by a process that no longer exists is taken over.
|
|
46
|
+
* @template T
|
|
47
|
+
* @param {() => Promise<T>} fn
|
|
48
|
+
* @returns {Promise<T>}
|
|
49
|
+
*/
|
|
50
|
+
export async function withLock(fn) {
|
|
51
|
+
await mkdir(configDir(), { recursive: true });
|
|
52
|
+
const started = Date.now();
|
|
53
|
+
let waiting = false;
|
|
54
|
+
|
|
55
|
+
while (!(await tryAcquire())) {
|
|
56
|
+
const holder = Number(await readFile(lockPath(), 'utf8').catch(() => ''));
|
|
57
|
+
if (holder && !isRunning(holder)) {
|
|
58
|
+
await rm(lockPath(), { force: true });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (Date.now() - started > TIMEOUT_MS) {
|
|
62
|
+
throw new FwdError(`Another fwd command (pid ${holder}) is still running. If it's stuck, stop it and try again.`);
|
|
63
|
+
}
|
|
64
|
+
if (!waiting && Date.now() - started > 1000) {
|
|
65
|
+
console.error(`fwd: waiting for another fwd command (pid ${holder}) to finish...`);
|
|
66
|
+
waiting = true;
|
|
67
|
+
}
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return await fn();
|
|
73
|
+
} finally {
|
|
74
|
+
await rm(lockPath(), { force: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// State, sockets and logs live in $XDG_CONFIG_HOME/fwd (~/.config/fwd by
|
|
5
|
+
// default). FWD_CONFIG_DIR is an override so tests don't touch the real one.
|
|
6
|
+
export function configDir() {
|
|
7
|
+
if (process.env.FWD_CONFIG_DIR) return process.env.FWD_CONFIG_DIR;
|
|
8
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
9
|
+
return join(base, 'fwd');
|
|
10
|
+
}
|
package/src/ports.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True if something accepts TCP connections on 127.0.0.1:<port>.
|
|
5
|
+
* @param {number} port
|
|
6
|
+
* @param {number} [timeoutMs]
|
|
7
|
+
* @returns {Promise<boolean>}
|
|
8
|
+
*/
|
|
9
|
+
export function isPortListening(port, timeoutMs = 500) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const socket = net.connect({ port, host: '127.0.0.1' });
|
|
12
|
+
/** @param {boolean} result */
|
|
13
|
+
const done = (result) => {
|
|
14
|
+
socket.destroy();
|
|
15
|
+
resolve(result);
|
|
16
|
+
};
|
|
17
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
18
|
+
socket.once('connect', () => done(true));
|
|
19
|
+
socket.once('error', () => done(false));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {number} port
|
|
25
|
+
* @returns {Promise<boolean>}
|
|
26
|
+
*/
|
|
27
|
+
function canBind(port) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const server = net.createServer();
|
|
30
|
+
server.once('error', () => resolve(false));
|
|
31
|
+
server.listen({ port, host: '127.0.0.1', exclusive: true }, () => {
|
|
32
|
+
server.close(() => resolve(true));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* True if ssh should be able to listen on localhost:<port>. Both checks are
|
|
39
|
+
* needed: on macOS a wildcard listener doesn't always block a 127.0.0.1 bind.
|
|
40
|
+
* @param {number} port
|
|
41
|
+
* @returns {Promise<boolean>}
|
|
42
|
+
*/
|
|
43
|
+
export async function isPortFree(port) {
|
|
44
|
+
return !(await isPortListening(port)) && (await canBind(port));
|
|
45
|
+
}
|
package/src/spec.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { FwdError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 'local' listens on this machine and connects from the host (ssh -L).
|
|
5
|
+
* 'remote' listens on the host and connects from this machine (ssh -R).
|
|
6
|
+
* @typedef {'local' | 'remote'} Direction
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} Forward
|
|
11
|
+
* @property {Direction} direction
|
|
12
|
+
* @property {number} local Port on this machine.
|
|
13
|
+
* @property {number} remote Port on the host.
|
|
14
|
+
* @property {string} to Address dialled by the connecting side: from the host
|
|
15
|
+
* for local forwards, from this machine for remote ones.
|
|
16
|
+
* @property {string} [addedAt]
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TO = '127.0.0.1';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} value
|
|
23
|
+
* @param {string} label
|
|
24
|
+
* @returns {number}
|
|
25
|
+
*/
|
|
26
|
+
export function parsePort(value, label) {
|
|
27
|
+
if (!/^\d+$/.test(value)) {
|
|
28
|
+
throw new FwdError(`Invalid ${label} "${value}": expected a number.`);
|
|
29
|
+
}
|
|
30
|
+
const port = Number(value);
|
|
31
|
+
if (port < 1 || port > 65535) {
|
|
32
|
+
throw new FwdError(`Invalid ${label} ${port}: must be between 1 and 65535.`);
|
|
33
|
+
}
|
|
34
|
+
return port;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse "5173" or "8080:3000" (<local>:<remote> in both directions).
|
|
39
|
+
* @param {string} input
|
|
40
|
+
* @param {{ to?: string, reverse?: boolean }} [options]
|
|
41
|
+
* @returns {Forward}
|
|
42
|
+
*/
|
|
43
|
+
export function parseSpec(input, { to, reverse = false } = {}) {
|
|
44
|
+
const parts = input.split(':');
|
|
45
|
+
if (parts.length > 2) {
|
|
46
|
+
throw new FwdError(
|
|
47
|
+
`Invalid forward "${input}". Use <local> or <local>:<remote>, and --to <addr> for a different address.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const local = parsePort(parts[0], 'local port');
|
|
51
|
+
const remote = parts.length === 2 ? parsePort(parts[1], 'remote port') : local;
|
|
52
|
+
/** @type {Forward} */
|
|
53
|
+
const fwd = { direction: reverse ? 'remote' : 'local', local, remote, to: parseAddress(to) };
|
|
54
|
+
|
|
55
|
+
const listen = listenPort(fwd);
|
|
56
|
+
if (listen < 1024) {
|
|
57
|
+
const suggestion = reverse ? `${local}:${remote + 8000} --reverse` : `${local + 8000}:${remote}`;
|
|
58
|
+
throw new FwdError(
|
|
59
|
+
`${reverse ? 'Remote' : 'Local'} port ${listen} is privileged (below 1024). Listen on a higher port, e.g. \`fwd add ${suggestion}\`.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return fwd;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string | undefined} to
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function parseAddress(to) {
|
|
70
|
+
if (to === undefined) return DEFAULT_TO;
|
|
71
|
+
// Accept "[::1]" as well as "::1"; brackets are added back when talking to ssh.
|
|
72
|
+
const address = to.replace(/^\[(.*)\]$/, '$1');
|
|
73
|
+
if (!address || /[\s[\]/]/.test(address)) {
|
|
74
|
+
throw new FwdError(`Invalid --to address "${to}".`);
|
|
75
|
+
}
|
|
76
|
+
return address;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The port that is listened on: on this machine for local forwards, on the
|
|
81
|
+
* host for remote ones.
|
|
82
|
+
* @param {Forward} fwd
|
|
83
|
+
*/
|
|
84
|
+
export function listenPort(fwd) {
|
|
85
|
+
return fwd.direction === 'local' ? fwd.local : fwd.remote;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Identifies a forward within a host: two forwards with the same key would
|
|
90
|
+
* listen on the same port.
|
|
91
|
+
* @param {Direction} direction
|
|
92
|
+
* @param {number} port The listen port.
|
|
93
|
+
*/
|
|
94
|
+
export function forwardKey(direction, port) {
|
|
95
|
+
return `${direction}:${port}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {Forward} fwd */
|
|
99
|
+
export function keyOf(fwd) {
|
|
100
|
+
return forwardKey(fwd.direction, listenPort(fwd));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @param {string} address */
|
|
104
|
+
function bracket(address) {
|
|
105
|
+
return address.includes(':') ? `[${address}]` : address;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The -L/-R arguments for ssh. `-O cancel` must receive exactly the same
|
|
110
|
+
* spec that `-O forward` did.
|
|
111
|
+
* @param {Forward} fwd
|
|
112
|
+
* @returns {[string, string]}
|
|
113
|
+
*/
|
|
114
|
+
export function toSshArgs(fwd) {
|
|
115
|
+
return fwd.direction === 'local'
|
|
116
|
+
? ['-L', `${fwd.local}:${bracket(fwd.to)}:${fwd.remote}`]
|
|
117
|
+
: ['-R', `${fwd.remote}:${bracket(fwd.to)}:${fwd.local}`];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {Forward} a
|
|
122
|
+
* @param {Forward} b
|
|
123
|
+
*/
|
|
124
|
+
export function sameTarget(a, b) {
|
|
125
|
+
return a.direction === b.direction && a.local === b.local && a.remote === b.remote && a.to === b.to;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {string} address */
|
|
129
|
+
function isLoopback(address) {
|
|
130
|
+
return address === DEFAULT_TO || address === 'localhost';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Human-readable forward, e.g. "localhost:5173 -> vm:5173",
|
|
135
|
+
* "localhost:5432 -> db:5432 (via vm)" or "vm:3000 -> localhost:3000".
|
|
136
|
+
* @param {Forward} fwd
|
|
137
|
+
* @param {string} host
|
|
138
|
+
*/
|
|
139
|
+
export function describe(fwd, host) {
|
|
140
|
+
if (fwd.direction === 'remote') {
|
|
141
|
+
return `${host}:${fwd.remote} -> ${isLoopback(fwd.to) ? 'localhost' : bracket(fwd.to)}:${fwd.local}`;
|
|
142
|
+
}
|
|
143
|
+
const target = isLoopback(fwd.to) ? `${host}:${fwd.remote}` : `${bracket(fwd.to)}:${fwd.remote} (via ${host})`;
|
|
144
|
+
return `localhost:${fwd.local} -> ${target}`;
|
|
145
|
+
}
|
package/src/ssh.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The only module that knows ssh's command-line syntax.
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { FwdError } from './errors.js';
|
|
7
|
+
import { configDir } from './paths.js';
|
|
8
|
+
import { describe, toSshArgs } from './spec.js';
|
|
9
|
+
|
|
10
|
+
/** @typedef {import('./spec.js').Forward} Forward */
|
|
11
|
+
|
|
12
|
+
// FWD_SSH lets tests (or users) point at a wrapper, e.g. one that adds -F.
|
|
13
|
+
function sshBin() {
|
|
14
|
+
return process.env.FWD_SSH || 'ssh';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sockDir() {
|
|
18
|
+
return join(configDir(), 'sock');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* macOS caps Unix socket paths at 104 bytes, so use a short hash rather than
|
|
23
|
+
* the raw host string.
|
|
24
|
+
* @param {string} host
|
|
25
|
+
*/
|
|
26
|
+
export function socketPath(host) {
|
|
27
|
+
const hash = createHash('sha256').update(host).digest('hex').slice(0, 16);
|
|
28
|
+
return join(sockDir(), hash);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @param {string} host */
|
|
32
|
+
export function logPath(host) {
|
|
33
|
+
return `${socketPath(host)}.log`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string[]} args
|
|
38
|
+
* @returns {Promise<{ code: number, stderr: string }>}
|
|
39
|
+
*/
|
|
40
|
+
function run(args) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
execFile(sshBin(), args, { timeout: 15_000 }, (err, _stdout, stderr) => {
|
|
43
|
+
if (err && /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
|
|
44
|
+
reject(notFound());
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const code = err ? (typeof err.code === 'number' ? err.code : 1) : 0;
|
|
48
|
+
resolve({ code, stderr: stderr.replace(/\r/g, '').trim() });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function notFound() {
|
|
54
|
+
return new FwdError(`Could not run "${sshBin()}". Is OpenSSH installed and on your PATH?`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Control commands only talk to the master's socket, so they skip the ssh
|
|
59
|
+
* config: otherwise `-O forward` would also send every LocalForward in it.
|
|
60
|
+
* @param {string} host
|
|
61
|
+
* @param {string} op
|
|
62
|
+
* @param {string[]} [extra]
|
|
63
|
+
*/
|
|
64
|
+
function control(host, op, extra = []) {
|
|
65
|
+
return run(['-F', 'none', '-S', socketPath(host), '-O', op, ...extra, '--', host]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Turn ssh's stderr from a failed control command into a readable error.
|
|
70
|
+
* @param {string} host
|
|
71
|
+
* @param {string} action
|
|
72
|
+
* @param {string} stderr
|
|
73
|
+
*/
|
|
74
|
+
function controlError(host, action, stderr) {
|
|
75
|
+
if (/control socket connect|no such file|connection refused/i.test(stderr)) {
|
|
76
|
+
return new FwdError(`Could not ${action}: the SSH master for ${host} is not running.`);
|
|
77
|
+
}
|
|
78
|
+
const detail = stderr.replace(/^mux_?client\w*: /gm, '');
|
|
79
|
+
return new FwdError(`Could not ${action}: ${detail || 'ssh exited with an error'}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} host
|
|
84
|
+
* @returns {Promise<{ alive: true, pid: number | null } | { alive: false }>}
|
|
85
|
+
*/
|
|
86
|
+
export async function checkMaster(host) {
|
|
87
|
+
const { code, stderr } = await control(host, 'check');
|
|
88
|
+
if (code !== 0) return { alive: false };
|
|
89
|
+
const match = /pid=(\d+)/.exec(stderr);
|
|
90
|
+
return { alive: true, pid: match ? Number(match[1]) : null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Start a ControlMaster in the background. stdin is inherited and ssh uses
|
|
95
|
+
* /dev/tty for passphrase and host-key prompts. stdout/stderr are not
|
|
96
|
+
* inherited because the backgrounded master would hold them open forever
|
|
97
|
+
* (breaking `fwd up | cat`); ssh logs to a file instead and we show that on
|
|
98
|
+
* failure.
|
|
99
|
+
*
|
|
100
|
+
* ClearAllForwardings drops LocalForward/RemoteForward lines from the user's
|
|
101
|
+
* ssh config: fwd's state is the only source of forwards, and with
|
|
102
|
+
* ExitOnForwardFailure a busy config port would stop the master starting.
|
|
103
|
+
* @param {string} host
|
|
104
|
+
*/
|
|
105
|
+
export async function startMaster(host) {
|
|
106
|
+
await mkdir(sockDir(), { recursive: true, mode: 0o700 });
|
|
107
|
+
const log = logPath(host);
|
|
108
|
+
await writeFile(log, '');
|
|
109
|
+
const args = [
|
|
110
|
+
'-M',
|
|
111
|
+
'-S', socketPath(host),
|
|
112
|
+
'-fN',
|
|
113
|
+
'-E', log,
|
|
114
|
+
'-o', 'ControlPersist=no',
|
|
115
|
+
'-o', 'ExitOnForwardFailure=yes',
|
|
116
|
+
'-o', 'ClearAllForwardings=yes',
|
|
117
|
+
'-o', 'ConnectTimeout=10',
|
|
118
|
+
'-o', 'ServerAliveInterval=15',
|
|
119
|
+
'-o', 'ServerAliveCountMax=3',
|
|
120
|
+
'--', host,
|
|
121
|
+
];
|
|
122
|
+
/** @type {number} */
|
|
123
|
+
const code = await new Promise((resolve, reject) => {
|
|
124
|
+
const child = spawn(sshBin(), args, { stdio: ['inherit', 'ignore', 'ignore'] });
|
|
125
|
+
child.once('error', (err) => {
|
|
126
|
+
reject(/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT' ? notFound() : err);
|
|
127
|
+
});
|
|
128
|
+
child.once('exit', (exitCode) => resolve(exitCode ?? 1));
|
|
129
|
+
});
|
|
130
|
+
if (code !== 0) {
|
|
131
|
+
const output = (await readFile(log, 'utf8').catch(() => '')).trim();
|
|
132
|
+
const detail = output ? `\n${output.replace(/^/gm, ' ')}` : '';
|
|
133
|
+
throw new FwdError(`Could not connect to ${host} (ssh exited with code ${code}).${detail}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {string} host
|
|
139
|
+
*/
|
|
140
|
+
export async function stopMaster(host) {
|
|
141
|
+
const { code, stderr } = await control(host, 'exit');
|
|
142
|
+
if (code !== 0) throw controlError(host, 'stop the SSH master', stderr);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Remove a socket file left behind by a master that died uncleanly.
|
|
147
|
+
* @param {string} host
|
|
148
|
+
*/
|
|
149
|
+
export async function removeSocket(host) {
|
|
150
|
+
await rm(socketPath(host), { force: true });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* ssh can exit 0 even when the master rejected the request (e.g. cancelling a
|
|
155
|
+
* forward it doesn't have), so check stderr as well.
|
|
156
|
+
* @param {string} host
|
|
157
|
+
* @param {'forward' | 'cancel'} op
|
|
158
|
+
* @param {Forward} fwd
|
|
159
|
+
* @param {string} action
|
|
160
|
+
*/
|
|
161
|
+
async function forwardRequest(host, op, fwd, action) {
|
|
162
|
+
const { code, stderr } = await control(host, op, toSshArgs(fwd));
|
|
163
|
+
if (code !== 0 || /request failed/i.test(stderr)) throw controlError(host, action, stderr);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {string} host
|
|
168
|
+
* @param {Forward} fwd
|
|
169
|
+
*/
|
|
170
|
+
export function addForward(host, fwd) {
|
|
171
|
+
return forwardRequest(host, 'forward', fwd, `forward ${describe(fwd, host)}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {string} host
|
|
176
|
+
* @param {Forward} fwd
|
|
177
|
+
*/
|
|
178
|
+
export function cancelForward(host, fwd) {
|
|
179
|
+
return forwardRequest(host, 'cancel', fwd, `cancel ${describe(fwd, host)}`);
|
|
180
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { FwdError } from './errors.js';
|
|
4
|
+
import { configDir } from './paths.js';
|
|
5
|
+
import { keyOf, listenPort } from './spec.js';
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('./spec.js').Forward} Forward */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} State
|
|
11
|
+
* @property {string | null} defaultHost
|
|
12
|
+
* @property {Record<string, { forwards: Forward[] }>} hosts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Bump when the file format changes, and migrate older versions in loadState.
|
|
16
|
+
const STATE_VERSION = 2;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Version 1 (and unversioned 0.1.0 files) only had local forwards, with the
|
|
20
|
+
* dialled address in `remoteHost`.
|
|
21
|
+
* @param {{ local: number, remote: number, remoteHost: string, addedAt?: string }} fwd
|
|
22
|
+
* @returns {Forward}
|
|
23
|
+
*/
|
|
24
|
+
function migrateV1Forward({ local, remote, remoteHost, addedAt }) {
|
|
25
|
+
return { direction: 'local', local, remote, to: remoteHost, ...(addedAt && { addedAt }) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function statePath() {
|
|
29
|
+
return join(configDir(), 'state.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @returns {Promise<State>} */
|
|
33
|
+
export async function loadState() {
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = await readFile(statePath(), 'utf8');
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
|
|
39
|
+
return { defaultHost: null, hosts: {} };
|
|
40
|
+
}
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
let data;
|
|
44
|
+
try {
|
|
45
|
+
data = JSON.parse(raw);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new FwdError(`State file ${statePath()} is not valid JSON: ${/** @type {Error} */ (err).message}`);
|
|
48
|
+
}
|
|
49
|
+
// Files written by 0.1.0 have no version; they're the same format as 1.
|
|
50
|
+
const version = data.version ?? 1;
|
|
51
|
+
if (version > STATE_VERSION) {
|
|
52
|
+
throw new FwdError(`State file ${statePath()} was written by a newer version of fwd. Upgrade fwd to use it.`);
|
|
53
|
+
}
|
|
54
|
+
const hosts = data.hosts ?? {};
|
|
55
|
+
if (version < 2) {
|
|
56
|
+
for (const entry of Object.values(hosts)) entry.forwards = entry.forwards.map(migrateV1Forward);
|
|
57
|
+
}
|
|
58
|
+
return { defaultHost: data.defaultHost ?? null, hosts };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Write to a temp file and rename it into place so a crash can't leave a
|
|
63
|
+
* half-written state file.
|
|
64
|
+
* @param {State} state
|
|
65
|
+
*/
|
|
66
|
+
export async function saveState(state) {
|
|
67
|
+
const file = statePath();
|
|
68
|
+
await mkdir(dirname(file), { recursive: true });
|
|
69
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
70
|
+
try {
|
|
71
|
+
await writeFile(tmp, `${JSON.stringify({ version: STATE_VERSION, ...state }, null, 2)}\n`);
|
|
72
|
+
await rename(tmp, file);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
await rm(tmp, { force: true });
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {State} state
|
|
81
|
+
* @param {string} host
|
|
82
|
+
* @returns {Forward[]}
|
|
83
|
+
*/
|
|
84
|
+
export function getForwards(state, host) {
|
|
85
|
+
return state.hosts[host]?.forwards ?? [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {State} state
|
|
90
|
+
* @param {string} host
|
|
91
|
+
* @param {string} key From forwardKey/keyOf.
|
|
92
|
+
* @returns {Forward | undefined}
|
|
93
|
+
*/
|
|
94
|
+
export function findForward(state, host, key) {
|
|
95
|
+
return getForwards(state, host).find((f) => keyOf(f) === key);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Store forwards sorted by direction then listen port, dropping the host once
|
|
100
|
+
* it has none.
|
|
101
|
+
* @param {State} state
|
|
102
|
+
* @param {string} host
|
|
103
|
+
* @param {Forward[]} forwards
|
|
104
|
+
*/
|
|
105
|
+
function setForwards(state, host, forwards) {
|
|
106
|
+
if (forwards.length) {
|
|
107
|
+
state.hosts[host] = { ...state.hosts[host], forwards: forwards.sort((a, b) => a.direction.localeCompare(b.direction) || listenPort(a) - listenPort(b)) };
|
|
108
|
+
} else {
|
|
109
|
+
delete state.hosts[host];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Add or replace the forward that listens on the same port.
|
|
115
|
+
* @param {State} state
|
|
116
|
+
* @param {string} host
|
|
117
|
+
* @param {Forward} fwd
|
|
118
|
+
*/
|
|
119
|
+
export function putForward(state, host, fwd) {
|
|
120
|
+
setForwards(state, host, [...getForwards(state, host).filter((f) => keyOf(f) !== keyOf(fwd)), fwd]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {State} state
|
|
125
|
+
* @param {string} host
|
|
126
|
+
* @param {string} key From forwardKey/keyOf.
|
|
127
|
+
*/
|
|
128
|
+
export function removeForward(state, host, key) {
|
|
129
|
+
setForwards(state, host, getForwards(state, host).filter((f) => keyOf(f) !== key));
|
|
130
|
+
}
|