cmux-ssh-here 0.1.0 → 0.2.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 +19 -17
- package/bin.js +63 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,39 +1,41 @@
|
|
|
1
1
|
# cmux-ssh-here
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One command spins up an ephemeral, token-authenticated SSH server on this machine and prints a [cmux SSH deep link](https://cmux.com/en/docs/ssh). Open the link in [cmux](https://cmux.com) and you instantly land in a shell as the current user over the local network — no `sshd` setup, no keys, no passwords.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx cmux-ssh-here
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Output:
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
|
|
13
|
-
(shell
|
|
12
|
+
Connect to this machine via cmux over the LAN
|
|
13
|
+
(shell as you; Ctrl-C here to stop):
|
|
14
14
|
|
|
15
15
|
https://cmux.com/deeplink/ssh?host=192.168.1.42&port=52968&user=wudUKicRFRw3&host-key-policy=accept-new&title=your-mac
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Open the link — cmux connects on its own. `Ctrl-C` in the terminal stops the server.
|
|
19
19
|
|
|
20
|
-
##
|
|
20
|
+
## How it works
|
|
21
21
|
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
22
|
+
- Its own SSH server (`ssh2`) with an ephemeral host key and token — both live only while the process runs.
|
|
23
|
+
- The token is passed in the deep link's `user=`; the server only accepts the correct token.
|
|
24
|
+
- cmux deep links deliberately carry no passwords or keys, so the secret is the token itself.
|
|
25
|
+
- Full PTY shell via `node-pty`, with window-resize support.
|
|
26
|
+
- When `tmux` is available, the interactive shell runs inside it: sessions persist and are shared across connections, and the session list (`choose-tree`) is shown on connect. Without `tmux`, it falls back to a plain login shell.
|
|
26
27
|
|
|
27
|
-
##
|
|
28
|
+
## Requirements
|
|
28
29
|
|
|
29
30
|
- Node 18+.
|
|
30
|
-
- [cmux](https://cmux.com)
|
|
31
|
-
- macOS
|
|
31
|
+
- [cmux](https://cmux.com) on the device you open the link from.
|
|
32
|
+
- macOS or Linux on the host sharing the shell. Windows host is not supported.
|
|
33
|
+
- `tmux` (optional) for persistent, shared server-side sessions.
|
|
32
34
|
|
|
33
|
-
##
|
|
35
|
+
## Security
|
|
34
36
|
|
|
35
|
-
⚠️
|
|
37
|
+
⚠️ **The token in the link is a bearer secret that grants a shell as your user.** Trusted local network only. Don't publish the link or send it over untrusted channels. Close the terminal and the token and host key are gone.
|
|
36
38
|
|
|
37
|
-
##
|
|
39
|
+
## Options
|
|
38
40
|
|
|
39
|
-
- `PORT=2222 npx cmux-ssh-here` —
|
|
41
|
+
- `PORT=2222 npx cmux-ssh-here` — fixed port (random free port by default).
|
package/bin.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// npx cmux-ssh-here
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// ponytail:
|
|
6
|
-
import ssh2 from "ssh2"; // ponytail: ssh2
|
|
3
|
+
// Spins up an ephemeral token-auth SSH server and prints a cmux deep link.
|
|
4
|
+
// Anyone on the LAN who opens the link lands in a shell as the current user.
|
|
5
|
+
// ponytail: the token is a bearer secret. Trusted LAN only, not the internet.
|
|
6
|
+
import ssh2 from "ssh2"; // ponytail: ssh2 is CJS, no named exports
|
|
7
7
|
const { Server } = ssh2;
|
|
8
8
|
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
|
9
9
|
import os from "node:os";
|
|
@@ -11,7 +11,8 @@ import { chmodSync } from "node:fs";
|
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
|
|
14
|
-
// ponytail: prebuilt spawn-helper
|
|
14
|
+
// ponytail: node-pty's prebuilt spawn-helper sometimes unpacks without +x
|
|
15
|
+
// (packaging bug) -> "posix_spawnp failed". Fix it before importing.
|
|
15
16
|
if (process.platform !== "win32") {
|
|
16
17
|
try {
|
|
17
18
|
const root = dirname(dirname(createRequire(import.meta.url).resolve("node-pty")));
|
|
@@ -20,60 +21,92 @@ if (process.platform !== "win32") {
|
|
|
20
21
|
}
|
|
21
22
|
const { default: pty } = await import("node-pty");
|
|
22
23
|
|
|
23
|
-
const token = randomBytes(9).toString("base64url"); //
|
|
24
|
+
const token = randomBytes(9).toString("base64url"); // secret carried in user=<token>
|
|
24
25
|
const { privateKey } = generateKeyPairSync("rsa", {
|
|
25
|
-
// ponytail: rsa PEM
|
|
26
|
+
// ponytail: rsa PEM parses cleanly in ssh2 (ed25519 PKCS8 is hit-or-miss)
|
|
26
27
|
modulusLength: 2048,
|
|
27
28
|
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
|
28
29
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
29
30
|
});
|
|
30
31
|
|
|
32
|
+
const shellPath = process.env.SHELL || "/bin/zsh";
|
|
33
|
+
|
|
31
34
|
const lanIP = () =>
|
|
32
35
|
Object.values(os.networkInterfaces())
|
|
33
36
|
.flat()
|
|
34
37
|
.find((i) => i?.family === "IPv4" && !i.internal && !i.address.startsWith("169.254"))?.address;
|
|
35
38
|
|
|
39
|
+
// Run a command in a PTY and bridge it to an ssh2 channel.
|
|
40
|
+
function bridge(stream, args, term) {
|
|
41
|
+
const child = pty.spawn(shellPath, args, {
|
|
42
|
+
name: term.term,
|
|
43
|
+
cols: term.cols,
|
|
44
|
+
rows: term.rows,
|
|
45
|
+
cwd: os.homedir(),
|
|
46
|
+
env: { ...process.env, ...term.env },
|
|
47
|
+
});
|
|
48
|
+
child.onData((d) => stream.write(d));
|
|
49
|
+
stream.on("data", (d) => child.write(d.toString()));
|
|
50
|
+
child.onExit(({ exitCode }) => {
|
|
51
|
+
try {
|
|
52
|
+
stream.exit(exitCode ?? 0);
|
|
53
|
+
} catch {}
|
|
54
|
+
stream.end();
|
|
55
|
+
});
|
|
56
|
+
return child;
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
const server = new Server({ hostKeys: [privateKey] }, (client) => {
|
|
37
60
|
client.on("authentication", (ctx) =>
|
|
38
61
|
ctx.username === token ? ctx.accept() : ctx.reject()
|
|
39
62
|
);
|
|
40
63
|
client.on("session", (accept) => {
|
|
41
64
|
const session = accept();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
65
|
+
// Defaults; overwritten by pty/env requests before shell/exec.
|
|
66
|
+
const term = { term: "xterm-256color", cols: 80, rows: 24, env: {} };
|
|
67
|
+
let child;
|
|
68
|
+
|
|
69
|
+
session.on("pty", (a, _r, info) => {
|
|
70
|
+
term.term = info.term || term.term;
|
|
71
|
+
term.cols = info.cols || term.cols;
|
|
72
|
+
term.rows = info.rows || term.rows;
|
|
73
|
+
a?.();
|
|
74
|
+
});
|
|
75
|
+
session.on("env", (a, _r, info) => {
|
|
76
|
+
term.env[info.key] = info.val;
|
|
77
|
+
a?.();
|
|
78
|
+
});
|
|
79
|
+
session.on("window-change", (a, _r, info) => {
|
|
80
|
+
child?.resize(info.cols, info.rows);
|
|
45
81
|
a?.();
|
|
46
82
|
});
|
|
47
83
|
session.on("shell", (acc) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
a?.();
|
|
61
|
-
});
|
|
62
|
-
shell.onExit(() => stream.end());
|
|
84
|
+
// ponytail: route the interactive shell through tmux so sessions persist
|
|
85
|
+
// and are shared across LAN connections; show the session list on connect.
|
|
86
|
+
// Falls back to a plain login shell when tmux is absent.
|
|
87
|
+
const start =
|
|
88
|
+
'if command -v tmux >/dev/null 2>&1; then ' +
|
|
89
|
+
'exec tmux new-session -A -s main \\; choose-tree -Zs; ' +
|
|
90
|
+
'else printf "[cmux-ssh-here] tmux not found; opening a plain shell (no server-side sessions)\\n"; ' +
|
|
91
|
+
'exec "$SHELL" -l; fi';
|
|
92
|
+
child = bridge(acc(), ["-lc", start], term);
|
|
93
|
+
});
|
|
94
|
+
session.on("exec", (acc, _r, info) => {
|
|
95
|
+
child = bridge(acc(), ["-c", info.command], term);
|
|
63
96
|
});
|
|
64
97
|
});
|
|
65
98
|
});
|
|
66
99
|
|
|
67
100
|
server.on("error", (e) => {
|
|
68
|
-
console.error(
|
|
101
|
+
console.error(`Server error: ${e.message}`);
|
|
69
102
|
process.exit(1);
|
|
70
103
|
});
|
|
71
104
|
|
|
72
|
-
const PORT = Number(process.env.PORT) || 0; // 0 =
|
|
105
|
+
const PORT = Number(process.env.PORT) || 0; // 0 = random free port
|
|
73
106
|
server.listen(PORT, "0.0.0.0", function () {
|
|
74
107
|
const ip = lanIP();
|
|
75
108
|
if (!ip) {
|
|
76
|
-
console.error("
|
|
109
|
+
console.error("No LAN IPv4 address found");
|
|
77
110
|
process.exit(1);
|
|
78
111
|
}
|
|
79
112
|
const port = this.address().port;
|
|
@@ -84,7 +117,7 @@ server.listen(PORT, "0.0.0.0", function () {
|
|
|
84
117
|
"host-key-policy": "accept-new",
|
|
85
118
|
title: os.hostname(),
|
|
86
119
|
});
|
|
87
|
-
console.log(`\n
|
|
88
|
-
console.log(` (shell
|
|
120
|
+
console.log(`\n Connect to this machine via cmux over the LAN`);
|
|
121
|
+
console.log(` (shell as ${os.userInfo().username}; Ctrl-C here to stop):\n`);
|
|
89
122
|
console.log(` https://cmux.com/deeplink/ssh?${params}\n`);
|
|
90
123
|
});
|