@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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { FwdError } from '../errors.js';
|
|
2
|
+
import { commands, optionsFor } from './index.js';
|
|
3
|
+
|
|
4
|
+
/** @typedef {import('./common.js').Completion} Completion */
|
|
5
|
+
/** @typedef {Exclude<Completion, 'commands'>} Candidates Completion with command names filled in. */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The parts of the command definitions that completion scripts need.
|
|
9
|
+
*/
|
|
10
|
+
function model() {
|
|
11
|
+
const visible = commands.filter((cmd) => !cmd.hidden);
|
|
12
|
+
const names = visible.map((cmd) => cmd.name);
|
|
13
|
+
// Command names are fixed when the script is generated.
|
|
14
|
+
/** @type {(c: Completion) => Candidates} */
|
|
15
|
+
const resolve = (c) => (c === 'commands' ? names : c);
|
|
16
|
+
return {
|
|
17
|
+
commands: visible.map((cmd) => ({
|
|
18
|
+
name: cmd.name,
|
|
19
|
+
summary: cmd.summary,
|
|
20
|
+
positionals: (cmd.positionals ?? []).map(resolve),
|
|
21
|
+
options: Object.entries(optionsFor(cmd)).map(([name, opt]) => ({
|
|
22
|
+
flag: `--${name}`,
|
|
23
|
+
help: opt.help,
|
|
24
|
+
takesValue: opt.type === 'string',
|
|
25
|
+
complete: opt.complete && resolve(opt.complete),
|
|
26
|
+
})),
|
|
27
|
+
})),
|
|
28
|
+
// Flags whose next word is a value, not a positional argument.
|
|
29
|
+
valueFlags: [
|
|
30
|
+
...new Set(
|
|
31
|
+
visible.flatMap((cmd) =>
|
|
32
|
+
Object.entries(optionsFor(cmd))
|
|
33
|
+
.filter(([, opt]) => opt.type === 'string')
|
|
34
|
+
.map(([name]) => `--${name}`),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {string} text */
|
|
42
|
+
function quote(text) {
|
|
43
|
+
return `'${text.replaceAll("'", `'\\''`)}'`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Shell code (bash and zsh share the syntax) producing candidate words.
|
|
48
|
+
* @param {'hosts' | 'ports'} completion
|
|
49
|
+
*/
|
|
50
|
+
function wordsCommand(completion) {
|
|
51
|
+
return completion === 'hosts'
|
|
52
|
+
? 'fwd __complete hosts 2>/dev/null'
|
|
53
|
+
: 'fwd __complete ports "${host[@]}" 2>/dev/null';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Shared by bash and zsh: walk the words before the cursor, counting
|
|
57
|
+
// positional arguments and noting --host so `ports` uses the right host.
|
|
58
|
+
/**
|
|
59
|
+
* @param {ReturnType<typeof model>} m
|
|
60
|
+
* @param {string} words The shell's array of words.
|
|
61
|
+
* @param {number} first Index of the first word after the subcommand.
|
|
62
|
+
* @param {string} current Index of the word being completed.
|
|
63
|
+
*/
|
|
64
|
+
function scanWords(m, words, first, current) {
|
|
65
|
+
return ` for ((i = ${first}; i < ${current}; i++)); do
|
|
66
|
+
case \${${words}[i]} in
|
|
67
|
+
--host) host=(--host "\${${words}[i+1]}"); ((i++)) ;;
|
|
68
|
+
${m.valueFlags.filter((f) => f !== '--host').join('|')}) ((i++)) ;;
|
|
69
|
+
-*) ;;
|
|
70
|
+
*) ((pos++)) ;;
|
|
71
|
+
esac
|
|
72
|
+
done`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {ReturnType<typeof model>} m */
|
|
76
|
+
function bash(m) {
|
|
77
|
+
/** @param {Candidates} c */
|
|
78
|
+
const words = (c) => (Array.isArray(c) ? c.join(' ') : `$(${wordsCommand(c)})`);
|
|
79
|
+
const cases = m.commands.map((cmd) => {
|
|
80
|
+
const values = cmd.options
|
|
81
|
+
.filter((o) => o.takesValue)
|
|
82
|
+
.map((o) => (o.complete ? `${o.flag}) words="${words(o.complete)}" ;;` : `${o.flag}) return ;;`));
|
|
83
|
+
const positionals = cmd.positionals.map((c, i) => `${i}) words="${words(c)}" ;;`);
|
|
84
|
+
return ` ${cmd.name})
|
|
85
|
+
opts=${quote(cmd.options.map((o) => o.flag).join(' '))}
|
|
86
|
+
case $prev in ${values.join(' ')} *) case $pos in ${positionals.join(' ')} esac ;; esac ;;`;
|
|
87
|
+
});
|
|
88
|
+
return `# bash completion for fwd. Generated by \`fwd completion bash\`.
|
|
89
|
+
_fwd() {
|
|
90
|
+
local cur=\${COMP_WORDS[COMP_CWORD]} prev=\${COMP_WORDS[COMP_CWORD-1]} cmd=\${COMP_WORDS[1]}
|
|
91
|
+
local opts='' words='' i pos=0 host=()
|
|
92
|
+
if ((COMP_CWORD == 1)); then
|
|
93
|
+
COMPREPLY=($(compgen -W ${quote(`${m.commands.map((c) => c.name).join(' ')} --help --version`)} -- "$cur"))
|
|
94
|
+
return
|
|
95
|
+
fi
|
|
96
|
+
${scanWords(m, 'COMP_WORDS', 2, 'COMP_CWORD')}
|
|
97
|
+
case $cmd in
|
|
98
|
+
${cases.join('\n')}
|
|
99
|
+
esac
|
|
100
|
+
if [[ $cur == -* ]]; then
|
|
101
|
+
COMPREPLY=($(compgen -W "$opts" -- "$cur"))
|
|
102
|
+
else
|
|
103
|
+
COMPREPLY=($(compgen -W "$words" -- "$cur"))
|
|
104
|
+
fi
|
|
105
|
+
}
|
|
106
|
+
complete -F _fwd fwd
|
|
107
|
+
`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @param {ReturnType<typeof model>} m */
|
|
111
|
+
function zsh(m) {
|
|
112
|
+
/** @param {Candidates} c */
|
|
113
|
+
const words = (c) => (Array.isArray(c) ? `(${c.join(' ')})` : `(\${(f)"$(${wordsCommand(c)})"})`);
|
|
114
|
+
/** @param {{ name: string, help: string }[]} items */
|
|
115
|
+
const described = (items) => items.map((i) => quote(`${i.name}:${i.help}`)).join(' ');
|
|
116
|
+
const cases = m.commands.map((cmd) => {
|
|
117
|
+
const values = cmd.options
|
|
118
|
+
.filter((o) => o.takesValue)
|
|
119
|
+
.map((o) => (o.complete ? `${o.flag}) vals=${words(o.complete)} ;;` : `${o.flag}) return ;;`));
|
|
120
|
+
const positionals = cmd.positionals.map((c, i) => `${i}) vals=${words(c)} ;;`);
|
|
121
|
+
return ` ${cmd.name})
|
|
122
|
+
opts=(${described(cmd.options.map((o) => ({ name: o.flag, help: o.help })))})
|
|
123
|
+
case $prev in ${values.join(' ')} *) case $pos in ${positionals.join(' ')} esac ;; esac ;;`;
|
|
124
|
+
});
|
|
125
|
+
return `#compdef fwd
|
|
126
|
+
# zsh completion for fwd. Generated by \`fwd completion zsh\`.
|
|
127
|
+
_fwd() {
|
|
128
|
+
local cmd=\${words[2]} prev=\${words[CURRENT-1]} i pos=0
|
|
129
|
+
local -a host opts vals
|
|
130
|
+
if ((CURRENT == 2)); then
|
|
131
|
+
local -a cmds=(${described(m.commands.map((c) => ({ name: c.name, help: c.summary })))})
|
|
132
|
+
_describe -t commands command cmds
|
|
133
|
+
return
|
|
134
|
+
fi
|
|
135
|
+
${scanWords(m, 'words', 3, 'CURRENT')}
|
|
136
|
+
case $cmd in
|
|
137
|
+
${cases.join('\n')}
|
|
138
|
+
esac
|
|
139
|
+
if [[ \${words[CURRENT]} == -* ]]; then
|
|
140
|
+
_describe -t options option opts
|
|
141
|
+
else
|
|
142
|
+
compadd -a vals
|
|
143
|
+
fi
|
|
144
|
+
}
|
|
145
|
+
if [[ \${zsh_eval_context[-1]} == loadautofunc ]]; then
|
|
146
|
+
_fwd "$@"
|
|
147
|
+
else
|
|
148
|
+
compdef _fwd fwd
|
|
149
|
+
fi
|
|
150
|
+
`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @param {ReturnType<typeof model>} m */
|
|
154
|
+
function fish(m) {
|
|
155
|
+
/** @param {Candidates} c */
|
|
156
|
+
const words = (c) =>
|
|
157
|
+
Array.isArray(c) ? quote(c.join(' ')) : `'(fwd __complete ${c}${c === 'ports' ? ' (__fwd_host)' : ''} 2>/dev/null)'`;
|
|
158
|
+
/** @param {string} cmd */
|
|
159
|
+
const seen = (cmd) => `__fish_seen_subcommand_from ${cmd}`;
|
|
160
|
+
const lines = [
|
|
161
|
+
'# fish completion for fwd. Generated by `fwd completion fish`.',
|
|
162
|
+
'',
|
|
163
|
+
'# Number of positional arguments before the cursor, after the subcommand.',
|
|
164
|
+
'function __fwd_pos',
|
|
165
|
+
' set -l n 0',
|
|
166
|
+
' set -l skip 0',
|
|
167
|
+
' for t in (commandline -opc)[3..-1]',
|
|
168
|
+
' if test $skip = 1',
|
|
169
|
+
' set skip 0',
|
|
170
|
+
' else if contains -- $t ' + m.valueFlags.join(' '),
|
|
171
|
+
' set skip 1',
|
|
172
|
+
" else if not string match -q -- '-*' $t",
|
|
173
|
+
' set n (math $n + 1)',
|
|
174
|
+
' end',
|
|
175
|
+
' end',
|
|
176
|
+
' echo $n',
|
|
177
|
+
'end',
|
|
178
|
+
'',
|
|
179
|
+
'# "--host <host>" if given on the command line, for completing saved ports.',
|
|
180
|
+
'function __fwd_host',
|
|
181
|
+
' set -l tokens (commandline -opc)',
|
|
182
|
+
' if set -l i (contains -i -- --host $tokens); and set -q tokens[(math $i + 1)]',
|
|
183
|
+
' echo --host',
|
|
184
|
+
' echo $tokens[(math $i + 1)]',
|
|
185
|
+
' end',
|
|
186
|
+
'end',
|
|
187
|
+
'',
|
|
188
|
+
'complete -c fwd -f',
|
|
189
|
+
"complete -c fwd -n __fish_use_subcommand -s h -l help -d 'Show help'",
|
|
190
|
+
"complete -c fwd -n __fish_use_subcommand -s v -l version -d 'Show the version'",
|
|
191
|
+
...m.commands.map((c) => `complete -c fwd -n __fish_use_subcommand -a ${c.name} -d ${quote(c.summary)}`),
|
|
192
|
+
];
|
|
193
|
+
for (const cmd of m.commands) {
|
|
194
|
+
lines.push('');
|
|
195
|
+
for (const o of cmd.options) {
|
|
196
|
+
const value = o.takesValue ? ` -x${o.complete ? ` -a ${words(o.complete)}` : ''}` : '';
|
|
197
|
+
lines.push(`complete -c fwd -n '${seen(cmd.name)}' -l ${o.flag.slice(2)}${value} -d ${quote(o.help)}`);
|
|
198
|
+
}
|
|
199
|
+
cmd.positionals.forEach((c, i) => {
|
|
200
|
+
lines.push(`complete -c fwd -n '${seen(cmd.name)}; and test (__fwd_pos) -eq ${i}' -a ${words(c)}`);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return `${lines.join('\n')}\n`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const generators = { bash, zsh, fish };
|
|
207
|
+
|
|
208
|
+
/** @type {import('./common.js').Command} */
|
|
209
|
+
export default {
|
|
210
|
+
name: 'completion',
|
|
211
|
+
summary: 'Print a shell completion script',
|
|
212
|
+
usage: ['fwd completion bash|zsh|fish'],
|
|
213
|
+
description: [
|
|
214
|
+
'Install with:',
|
|
215
|
+
' bash fwd completion bash > ~/.local/share/bash-completion/completions/fwd',
|
|
216
|
+
' zsh fwd completion zsh > "${fpath[1]}/_fwd" (or add `source <(fwd completion zsh)` to ~/.zshrc)',
|
|
217
|
+
' fish fwd completion fish > ~/.config/fish/completions/fwd.fish',
|
|
218
|
+
].join('\n'),
|
|
219
|
+
args: [1, 1],
|
|
220
|
+
host: 'none',
|
|
221
|
+
readonly: true,
|
|
222
|
+
positionals: [Object.keys(generators)],
|
|
223
|
+
|
|
224
|
+
async run({ args: [shell] }) {
|
|
225
|
+
if (!Object.hasOwn(generators, shell)) {
|
|
226
|
+
throw new FwdError(`Unknown shell "${shell}". Use ${Object.keys(generators).join(', ')}.`);
|
|
227
|
+
}
|
|
228
|
+
process.stdout.write(generators[/** @type {keyof typeof generators} */ (shell)](model()));
|
|
229
|
+
},
|
|
230
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { FwdError } from '../errors.js';
|
|
2
|
+
import { saveState } from '../state.js';
|
|
3
|
+
import { validateHost } from './common.js';
|
|
4
|
+
|
|
5
|
+
/** @type {import('./common.js').Command} */
|
|
6
|
+
export default {
|
|
7
|
+
name: 'config',
|
|
8
|
+
summary: 'Get or set the default host',
|
|
9
|
+
usage: ['fwd config set host <host>', 'fwd config get host', 'fwd config unset host'],
|
|
10
|
+
description: [
|
|
11
|
+
'The default host is used when --host is not given. An ~/.ssh/config alias works',
|
|
12
|
+
'best, so keys, user and IP all come from your SSH setup:',
|
|
13
|
+
'',
|
|
14
|
+
' Host vm',
|
|
15
|
+
' HostName 192.168.64.7',
|
|
16
|
+
' User cj',
|
|
17
|
+
'',
|
|
18
|
+
' fwd config set host vm',
|
|
19
|
+
].join('\n'),
|
|
20
|
+
args: [2, 3],
|
|
21
|
+
host: 'none',
|
|
22
|
+
positionals: [['set', 'get', 'unset'], ['host'], 'hosts'],
|
|
23
|
+
|
|
24
|
+
async run({ args: [action, key, value], state }) {
|
|
25
|
+
if (key !== 'host') throw new FwdError(`Unknown config key "${key}". Valid keys: host.`);
|
|
26
|
+
if ((action === 'set') !== (value !== undefined)) {
|
|
27
|
+
throw new FwdError(action === 'set' ? 'Missing value. Usage: fwd config set host <host>' : `Unexpected argument "${value}".`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
switch (action) {
|
|
31
|
+
case 'get':
|
|
32
|
+
if (!state.defaultHost) return 1;
|
|
33
|
+
return void console.log(state.defaultHost);
|
|
34
|
+
case 'set':
|
|
35
|
+
validateHost(value);
|
|
36
|
+
state.defaultHost = value;
|
|
37
|
+
await saveState(state);
|
|
38
|
+
return void console.log(`Default host set to ${value}`);
|
|
39
|
+
case 'unset':
|
|
40
|
+
state.defaultHost = null;
|
|
41
|
+
await saveState(state);
|
|
42
|
+
return void console.log('Default host cleared');
|
|
43
|
+
default:
|
|
44
|
+
throw new FwdError(`Unknown action "${action}". Use set, get or unset.`);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { checkMaster, removeSocket, stopMaster } from '../ssh.js';
|
|
2
|
+
import { getForwards } from '../state.js';
|
|
3
|
+
|
|
4
|
+
/** @type {import('./common.js').Command} */
|
|
5
|
+
export default {
|
|
6
|
+
name: 'down',
|
|
7
|
+
summary: 'Stop the SSH master (saved forwards are kept)',
|
|
8
|
+
usage: ['fwd down [--host <host>]'],
|
|
9
|
+
description: 'Closes the connection and every forward on it. Saved forwards are kept so `fwd up` can restore them.',
|
|
10
|
+
args: [0, 0],
|
|
11
|
+
|
|
12
|
+
async run({ state, host }) {
|
|
13
|
+
if (!(await checkMaster(host)).alive) {
|
|
14
|
+
await removeSocket(host);
|
|
15
|
+
return void console.log(`SSH master for ${host} is not running.`);
|
|
16
|
+
}
|
|
17
|
+
await stopMaster(host);
|
|
18
|
+
console.log(`Stopped SSH master for ${host}.`);
|
|
19
|
+
const count = getForwards(state, host).length;
|
|
20
|
+
if (count) console.log(`${count} saved forward${count === 1 ? '' : 's'} kept; \`fwd up\` restores them.`);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { FwdError } from '../errors.js';
|
|
2
|
+
import { commandHelp, mainHelp } from '../help.js';
|
|
3
|
+
import { findCommand } from './index.js';
|
|
4
|
+
|
|
5
|
+
/** @type {import('./common.js').Command} */
|
|
6
|
+
export default {
|
|
7
|
+
name: 'help',
|
|
8
|
+
summary: 'Show help for fwd or a command',
|
|
9
|
+
usage: ['fwd help [<command>]'],
|
|
10
|
+
args: [0, 1],
|
|
11
|
+
host: 'none',
|
|
12
|
+
readonly: true,
|
|
13
|
+
positionals: ['commands'],
|
|
14
|
+
|
|
15
|
+
async run({ args: [name] }) {
|
|
16
|
+
if (name === undefined) return void console.log(mainHelp());
|
|
17
|
+
const cmd = findCommand(name);
|
|
18
|
+
if (cmd.hidden) throw new FwdError(`Unknown command "${name}". Run \`fwd help\` to see the commands.`);
|
|
19
|
+
console.log(commandHelp(cmd));
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import add from './add.js';
|
|
2
|
+
import complete from './complete.js';
|
|
3
|
+
import completion from './completion.js';
|
|
4
|
+
import config from './config.js';
|
|
5
|
+
import down from './down.js';
|
|
6
|
+
import help from './help.js';
|
|
7
|
+
import ls from './ls.js';
|
|
8
|
+
import rm from './rm.js';
|
|
9
|
+
import status from './status.js';
|
|
10
|
+
import up from './up.js';
|
|
11
|
+
import watch from './watch.js';
|
|
12
|
+
import { FwdError } from '../errors.js';
|
|
13
|
+
|
|
14
|
+
/** @typedef {import('./common.js').Command} Command */
|
|
15
|
+
/** @typedef {import('./common.js').Option} Option */
|
|
16
|
+
|
|
17
|
+
/** @type {Command[]} */
|
|
18
|
+
export const commands = [add, rm, ls, up, down, status, watch, config, completion, help, complete];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} name
|
|
22
|
+
* @returns {Command}
|
|
23
|
+
*/
|
|
24
|
+
export function findCommand(name) {
|
|
25
|
+
const cmd = commands.find((c) => c.name === name);
|
|
26
|
+
if (!cmd) throw new FwdError(`Unknown command "${name}". Run \`fwd help\` to see the commands.`);
|
|
27
|
+
return cmd;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @type {Record<string, Option>} */
|
|
31
|
+
export const hostOption = {
|
|
32
|
+
host: { type: 'string', value: 'host', complete: 'hosts', help: 'SSH host to use instead of the default' },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** @type {Record<string, Option>} */
|
|
36
|
+
export const helpOption = { help: { type: 'boolean', short: 'h', help: 'Show this help' } };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every option a command accepts, including the shared ones.
|
|
40
|
+
* @param {Command} cmd
|
|
41
|
+
* @returns {Record<string, Option>}
|
|
42
|
+
*/
|
|
43
|
+
export function optionsFor(cmd) {
|
|
44
|
+
return { ...cmd.options, ...(cmd.host === 'none' ? {} : hostOption), ...helpOption };
|
|
45
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { FwdError } from '../errors.js';
|
|
2
|
+
import { describe } from '../spec.js';
|
|
3
|
+
import { checkMaster } from '../ssh.js';
|
|
4
|
+
import { getForwards } from '../state.js';
|
|
5
|
+
import { forwardStatus, pidSuffix, resolveHost } from './common.js';
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../state.js').State} State */
|
|
8
|
+
|
|
9
|
+
/** @param {string | undefined} iso */
|
|
10
|
+
function formatDate(iso) {
|
|
11
|
+
const date = iso ? new Date(iso) : null;
|
|
12
|
+
if (!date || Number.isNaN(date.getTime())) return '-';
|
|
13
|
+
/** @param {number} n */
|
|
14
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
15
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {State} state
|
|
20
|
+
* @param {string} host
|
|
21
|
+
*/
|
|
22
|
+
async function hostStatus(state, host) {
|
|
23
|
+
const master = await checkMaster(host);
|
|
24
|
+
const forwards = await Promise.all(
|
|
25
|
+
getForwards(state, host).map(async (fwd) => ({ ...fwd, status: await forwardStatus(fwd, master.alive) })),
|
|
26
|
+
);
|
|
27
|
+
return { host, master: { alive: master.alive, pid: master.alive ? master.pid : null }, forwards };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {State} state
|
|
32
|
+
* @param {Awaited<ReturnType<typeof hostStatus>>} status
|
|
33
|
+
*/
|
|
34
|
+
function print(state, { host, master, forwards }) {
|
|
35
|
+
console.log(`${host}: master ${master.alive ? `running${pidSuffix(master.pid)}` : 'not running'}`);
|
|
36
|
+
if (!forwards.length) return void console.log('No saved forwards. Add one with `fwd add <port>`.');
|
|
37
|
+
|
|
38
|
+
const rows = [
|
|
39
|
+
['FORWARD', 'STATUS', 'ADDED'],
|
|
40
|
+
...forwards.map((f) => [describe(f, host), f.status, formatDate(f.addedAt)]),
|
|
41
|
+
];
|
|
42
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(' ').trimEnd());
|
|
45
|
+
}
|
|
46
|
+
if (forwards.some((f) => f.status !== 'active')) {
|
|
47
|
+
const flag = host === state.defaultHost ? '' : ` --host ${host}`;
|
|
48
|
+
console.log(`\nRun \`fwd up${flag}\` to restore forwards that are down or broken.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @type {import('./common.js').Command} */
|
|
53
|
+
export default {
|
|
54
|
+
name: 'ls',
|
|
55
|
+
summary: 'List saved forwards and their status',
|
|
56
|
+
usage: ['fwd ls [--json] [--host <host> | --all-hosts]'],
|
|
57
|
+
description: [
|
|
58
|
+
'Statuses:',
|
|
59
|
+
' active the local port is forwarded',
|
|
60
|
+
' down the SSH master is not running (`fwd up` restores it)',
|
|
61
|
+
' broken the master is up but the port is not listening (`fwd up` re-applies it)',
|
|
62
|
+
'',
|
|
63
|
+
'Local forwards are checked by connecting to the local port, which ssh passes on',
|
|
64
|
+
'to the remote port, so the remote service sees a brief connection. Reverse',
|
|
65
|
+
'forwards listen on the host and are reported active whenever the master is up.',
|
|
66
|
+
].join('\n'),
|
|
67
|
+
args: [0, 0],
|
|
68
|
+
host: 'self',
|
|
69
|
+
readonly: true,
|
|
70
|
+
options: {
|
|
71
|
+
'all-hosts': { type: 'boolean', help: 'List forwards for every host' },
|
|
72
|
+
json: { type: 'boolean', help: 'Print machine-readable JSON' },
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async run({ flags, state }) {
|
|
76
|
+
if (flags['all-hosts'] && flags.host !== undefined) throw new FwdError('Use either --host or --all-hosts, not both.');
|
|
77
|
+
const hosts = flags['all-hosts'] ? Object.keys(state.hosts) : [resolveHost(state, flags.host)];
|
|
78
|
+
const statuses = await Promise.all(hosts.map((host) => hostStatus(state, host)));
|
|
79
|
+
|
|
80
|
+
if (flags.json) {
|
|
81
|
+
return void console.log(JSON.stringify(flags['all-hosts'] ? statuses : statuses[0], null, 2));
|
|
82
|
+
}
|
|
83
|
+
if (!statuses.length) return void console.log('No saved forwards. Add one with `fwd add <port>`.');
|
|
84
|
+
statuses.forEach((status, i) => {
|
|
85
|
+
if (i > 0) console.log();
|
|
86
|
+
print(state, status);
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { FwdError } from '../errors.js';
|
|
2
|
+
import { isPortListening } from '../ports.js';
|
|
3
|
+
import { describe, forwardKey, keyOf, parsePort } from '../spec.js';
|
|
4
|
+
import { cancelForward, checkMaster } from '../ssh.js';
|
|
5
|
+
import { findForward, getForwards, removeForward, saveState } from '../state.js';
|
|
6
|
+
|
|
7
|
+
/** @type {import('./common.js').Command} */
|
|
8
|
+
export default {
|
|
9
|
+
name: 'rm',
|
|
10
|
+
summary: 'Stop forwarding a port and forget it',
|
|
11
|
+
usage: ['fwd rm <port> [--reverse] [--host <host>]', 'fwd rm --all [--host <host>]'],
|
|
12
|
+
description: [
|
|
13
|
+
'Cancels the forward and removes it from saved state. If the SSH master is not',
|
|
14
|
+
'running, only the saved state is updated. <port> is the listening port: the',
|
|
15
|
+
'local port, or the remote port with --reverse.',
|
|
16
|
+
].join('\n'),
|
|
17
|
+
args: [0, 1],
|
|
18
|
+
positionals: ['ports'],
|
|
19
|
+
options: {
|
|
20
|
+
all: { type: 'boolean', help: 'Remove every forward for the host' },
|
|
21
|
+
reverse: { type: 'boolean', help: 'Remove the reverse forward listening on <port> on the host' },
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async run({ args, flags, state, host }) {
|
|
25
|
+
if (Boolean(flags.all) === (args.length === 1)) {
|
|
26
|
+
throw new FwdError('Pass either a port or --all. Usage: fwd rm <port> or fwd rm --all');
|
|
27
|
+
}
|
|
28
|
+
let targets = getForwards(state, host);
|
|
29
|
+
if (!flags.all) {
|
|
30
|
+
const port = parsePort(args[0], 'port');
|
|
31
|
+
const direction = flags.reverse ? 'remote' : 'local';
|
|
32
|
+
const fwd = findForward(state, host, forwardKey(direction, port));
|
|
33
|
+
if (!fwd) {
|
|
34
|
+
const flipped = findForward(state, host, forwardKey(direction === 'local' ? 'remote' : 'local', port));
|
|
35
|
+
const hint = flipped ? ` Did you mean \`fwd rm ${port}${flags.reverse ? '' : ' --reverse'}\`?` : ' See `fwd ls`.';
|
|
36
|
+
throw new FwdError(`No saved ${flags.reverse ? 'reverse ' : ''}forward on port ${port} for ${host}.${hint}`);
|
|
37
|
+
}
|
|
38
|
+
targets = [fwd];
|
|
39
|
+
}
|
|
40
|
+
if (!targets.length) return void console.log(`No saved forwards for ${host}.`);
|
|
41
|
+
|
|
42
|
+
const master = await checkMaster(host);
|
|
43
|
+
let failed = 0;
|
|
44
|
+
for (const fwd of targets) {
|
|
45
|
+
try {
|
|
46
|
+
if (master.alive) await cancelForward(host, fwd);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// Cancel fails if the forward wasn't active (e.g. "broken"); that's
|
|
49
|
+
// fine to forget. If a local port is still listening, keep it saved.
|
|
50
|
+
if (fwd.direction === 'local' && (await isPortListening(fwd.local))) {
|
|
51
|
+
console.error(`fwd: ${/** @type {Error} */ (err).message}`);
|
|
52
|
+
console.error(`fwd: kept port ${fwd.local} in state; \`fwd down\` stops all forwards for ${host}.`);
|
|
53
|
+
failed++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
removeForward(state, host, keyOf(fwd));
|
|
58
|
+
console.log(`Removed ${describe(fwd, host)}`);
|
|
59
|
+
}
|
|
60
|
+
await saveState(state);
|
|
61
|
+
return failed ? 1 : 0;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { checkMaster, logPath, socketPath } from '../ssh.js';
|
|
2
|
+
import { getForwards, statePath } from '../state.js';
|
|
3
|
+
|
|
4
|
+
/** @type {import('./common.js').Command} */
|
|
5
|
+
export default {
|
|
6
|
+
name: 'status',
|
|
7
|
+
summary: 'Show the SSH master for a host',
|
|
8
|
+
usage: ['fwd status [--host <host>]'],
|
|
9
|
+
description: 'Shows the host, control socket, whether the master is alive, and its PID.',
|
|
10
|
+
args: [0, 0],
|
|
11
|
+
readonly: true,
|
|
12
|
+
|
|
13
|
+
async run({ state, host }) {
|
|
14
|
+
const master = await checkMaster(host);
|
|
15
|
+
const rows = [
|
|
16
|
+
['Host', `${host}${host === state.defaultHost ? ' (default)' : ''}`],
|
|
17
|
+
['Master', master.alive ? 'running' : 'not running'],
|
|
18
|
+
['PID', master.alive && master.pid ? String(master.pid) : '-'],
|
|
19
|
+
['Forwards', `${getForwards(state, host).length} saved`],
|
|
20
|
+
['Socket', socketPath(host)],
|
|
21
|
+
['Log', logPath(host)],
|
|
22
|
+
['State', statePath()],
|
|
23
|
+
];
|
|
24
|
+
for (const [label, value] of rows) console.log(`${label.padEnd(9)} ${value}`);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getForwards } from '../state.js';
|
|
2
|
+
import { bringUp, pidSuffix, printResults } from './common.js';
|
|
3
|
+
|
|
4
|
+
/** @type {import('./common.js').Command} */
|
|
5
|
+
export default {
|
|
6
|
+
name: 'up',
|
|
7
|
+
summary: 'Start the SSH master and restore saved forwards',
|
|
8
|
+
usage: ['fwd up [--host <host>]'],
|
|
9
|
+
description: 'Use after a reboot, VM restart or network drop. Forwards that are already active are left alone.',
|
|
10
|
+
args: [0, 0],
|
|
11
|
+
|
|
12
|
+
async run({ state, host }) {
|
|
13
|
+
const { started, pid, results } = await bringUp(host, getForwards(state, host));
|
|
14
|
+
if (!started) console.log(`SSH master for ${host} is already running${pidSuffix(pid)}`);
|
|
15
|
+
printResults(host, results);
|
|
16
|
+
return results.some((r) => r.error) ? 1 : 0;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import { FwdError } from '../errors.js';
|
|
3
|
+
import { withLock } from '../lock.js';
|
|
4
|
+
import { checkMaster } from '../ssh.js';
|
|
5
|
+
import { getForwards, loadState } from '../state.js';
|
|
6
|
+
import { bringUp, forwardStatus, printResults, resolveHost } from './common.js';
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('./common.js').Log} Log */
|
|
9
|
+
|
|
10
|
+
/** @type {Log} */
|
|
11
|
+
function log(message) {
|
|
12
|
+
console.log(`[${new Date().toTimeString().slice(0, 8)}] ${message}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check one host and repair it if the master is down or a forward is broken.
|
|
17
|
+
* State is re-read every time so forwards added or removed meanwhile are
|
|
18
|
+
* picked up.
|
|
19
|
+
* @param {string} host
|
|
20
|
+
*/
|
|
21
|
+
async function check(host) {
|
|
22
|
+
const forwards = getForwards(await loadState(), host);
|
|
23
|
+
if (!forwards.length) return;
|
|
24
|
+
const master = await checkMaster(host);
|
|
25
|
+
const statuses = await Promise.all(forwards.map((fwd) => forwardStatus(fwd, master.alive)));
|
|
26
|
+
if (statuses.every((s) => s === 'active')) return;
|
|
27
|
+
|
|
28
|
+
log(master.alive ? `${host}: some forwards are broken, re-applying` : `${host}: master is down, reconnecting`);
|
|
29
|
+
await withLock(async () => {
|
|
30
|
+
const { results } = await bringUp(host, getForwards(await loadState(), host), log);
|
|
31
|
+
printResults(host, results.filter((r) => r.result !== 'active'), log);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @type {import('./common.js').Command} */
|
|
36
|
+
export default {
|
|
37
|
+
name: 'watch',
|
|
38
|
+
summary: 'Keep forwards up, reconnecting when the connection drops',
|
|
39
|
+
usage: ['fwd watch [--interval <seconds>] [--host <host> | --all-hosts]'],
|
|
40
|
+
description: [
|
|
41
|
+
'Runs in the foreground, checking every few seconds. If the master has died',
|
|
42
|
+
'(VM restart, sleep, network change) it reconnects and restores saved forwards;',
|
|
43
|
+
'if a local forward stopped listening it is re-applied. Stop with Ctrl+C; the',
|
|
44
|
+
'master keeps running.',
|
|
45
|
+
].join('\n'),
|
|
46
|
+
args: [0, 0],
|
|
47
|
+
host: 'self',
|
|
48
|
+
readonly: true,
|
|
49
|
+
options: {
|
|
50
|
+
interval: { type: 'string', value: 'seconds', help: 'How often to check (default 5)' },
|
|
51
|
+
'all-hosts': { type: 'boolean', help: 'Watch every host with saved forwards' },
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async run({ flags, state }) {
|
|
55
|
+
if (flags['all-hosts'] && flags.host !== undefined) throw new FwdError('Use either --host or --all-hosts, not both.');
|
|
56
|
+
const host = flags['all-hosts'] ? null : resolveHost(state, flags.host);
|
|
57
|
+
const seconds = flags.interval === undefined ? 5 : Number(flags.interval);
|
|
58
|
+
if (!(seconds > 0)) throw new FwdError(`Invalid --interval "${flags.interval}": expected a positive number of seconds.`);
|
|
59
|
+
|
|
60
|
+
const stop = new AbortController();
|
|
61
|
+
for (const signal of /** @type {const} */ (['SIGINT', 'SIGTERM'])) process.once(signal, () => stop.abort());
|
|
62
|
+
|
|
63
|
+
log(`Watching ${host ?? 'all hosts'} every ${seconds}s. Press Ctrl+C to stop.`);
|
|
64
|
+
// Only report an error when it changes, so an unreachable host doesn't
|
|
65
|
+
// print the same line every interval.
|
|
66
|
+
/** @type {Map<string, string>} */
|
|
67
|
+
const lastError = new Map();
|
|
68
|
+
while (!stop.signal.aborted) {
|
|
69
|
+
const hosts = host ? [host] : Object.keys((await loadState()).hosts);
|
|
70
|
+
for (const h of hosts) {
|
|
71
|
+
try {
|
|
72
|
+
await check(h);
|
|
73
|
+
if (lastError.delete(h)) log(`${h}: recovered`);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const message = /** @type {Error} */ (err).message;
|
|
76
|
+
if (lastError.get(h) !== message) log(`${h}: ${message}`);
|
|
77
|
+
lastError.set(h, message);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
await sleep(seconds * 1000, undefined, { signal: stop.signal }).catch(() => {});
|
|
81
|
+
}
|
|
82
|
+
log('Stopped watching.');
|
|
83
|
+
},
|
|
84
|
+
};
|
package/src/errors.js
ADDED