cmux-ssh-here 0.1.0 → 0.2.1

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.
Files changed (3) hide show
  1. package/README.md +20 -17
  2. package/bin.js +82 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,39 +1,42 @@
1
1
  # cmux-ssh-here
2
2
 
3
- Одной командой поднимает на этой машине эфемерный SSH-сервер с авторизацией по одноразовому токену и печатает [cmux SSH deep link](https://cmux.com/ru/docs/ssh). Кто откроет ссылку в [cmux](https://cmux.com) мгновенно попадёт в shell под текущим пользователем по локальной сети, без настройки `sshd`, ключей и паролей.
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
- Подключение к этой машине через cmux по LAN
13
- (shell под пользователем you, Ctrl-C тут выключить):
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
- Открой ссылку — cmux подключится сам. `Ctrl-C` в терминале выключает сервер.
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
- - Свой SSH-сервер (`ssh2`) с эфемерными host-key и токеномживут только пока запущен процесс.
23
- - Токен передаётся в `user=` диплинка; сервер пускает только с верным токеном.
24
- - Диплинк cmux намеренно не несёт паролей/ключей, поэтому секрет это сам токен.
25
- - Полноценный shell через PTY (`node-pty`), с поддержкой размера окна.
22
+ - Its own SSH server (`ssh2`) with an ephemeral host key and tokenboth 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 или Linux (на хосте, который раздаёт shell). Windows-хост не поддержан.
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
- ⚠️ **Токен в ссылке bearer-секрет, дающий shell под твоим пользователем.** Только для доверенной локальной сети. Не публикуй ссылку и не шли по недоверенным каналам. Закрыл терминал токен и host-key мертвы.
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).
42
+ - `CMUX_SSH_DEBUG=1 npx cmux-ssh-here` — log incoming auth/env/exec/shell requests to stderr (troubleshooting).
package/bin.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // npx cmux-ssh-here
3
- // Поднимает эфемерный SSH-сервер с авторизацией по токену и печатает cmux deep link.
4
- // Любой в LAN, открывший ссылку, попадает в shell под текущим пользователем.
5
- // ponytail: токен = bearer-секрет. Для доверенной локальной сети, не для интернета.
6
- import ssh2 from "ssh2"; // ponytail: ssh2 CJS, именованного экспорта нет
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 иногда распаковывается без +x (баг упаковки node-pty) — чиним до import
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,108 @@ if (process.platform !== "win32") {
20
21
  }
21
22
  const { default: pty } = await import("node-pty");
22
23
 
23
- const token = randomBytes(9).toString("base64url"); // секрет в ссылке (user=<token>)
24
+ // ponytail: hex (not base64url) cmux rejects a user that starts with "-",
25
+ // and an ssh client treats a leading-dash username as a flag. Hex is URL/ssh-safe.
26
+ const token = randomBytes(12).toString("hex"); // secret carried in user=<token>
24
27
  const { privateKey } = generateKeyPairSync("rsa", {
25
- // ponytail: rsa PEM ssh2 парсит host-key без сюрпризов (ed25519 PKCS8 ssh2 берёт не всегда)
28
+ // ponytail: rsa PEM parses cleanly in ssh2 (ed25519 PKCS8 is hit-or-miss)
26
29
  modulusLength: 2048,
27
30
  privateKeyEncoding: { type: "pkcs1", format: "pem" },
28
31
  publicKeyEncoding: { type: "spki", format: "pem" },
29
32
  });
30
33
 
34
+ const shellPath = process.env.SHELL || "/bin/zsh";
35
+
31
36
  const lanIP = () =>
32
37
  Object.values(os.networkInterfaces())
33
38
  .flat()
34
39
  .find((i) => i?.family === "IPv4" && !i.internal && !i.address.startsWith("169.254"))?.address;
35
40
 
41
+ // Run a command in a PTY and bridge it to an ssh2 channel.
42
+ function bridge(stream, args, term) {
43
+ const child = pty.spawn(shellPath, args, {
44
+ name: term.term,
45
+ cols: term.cols,
46
+ rows: term.rows,
47
+ cwd: os.homedir(),
48
+ env: { ...process.env, ...term.env },
49
+ });
50
+ child.onData((d) => stream.write(d));
51
+ stream.on("data", (d) => child.write(d.toString()));
52
+ child.onExit(({ exitCode }) => {
53
+ try {
54
+ stream.exit(exitCode ?? 0);
55
+ } catch {}
56
+ stream.end();
57
+ });
58
+ return child;
59
+ }
60
+
61
+ const debug = process.env.CMUX_SSH_DEBUG ? (...a) => console.error("[debug]", ...a) : () => {};
62
+
36
63
  const server = new Server({ hostKeys: [privateKey] }, (client) => {
37
- client.on("authentication", (ctx) =>
38
- ctx.username === token ? ctx.accept() : ctx.reject()
39
- );
64
+ client.on("authentication", (ctx) => {
65
+ debug("auth", ctx.method, ctx.username);
66
+ return ctx.username === token ? ctx.accept() : ctx.reject();
67
+ });
40
68
  client.on("session", (accept) => {
41
69
  const session = accept();
42
- let info = { term: "xterm-256color", cols: 80, rows: 24 };
43
- session.on("pty", (a, _r, i) => {
44
- info = i;
70
+ // Defaults; overwritten by pty/env requests before shell/exec.
71
+ const term = { term: "xterm-256color", cols: 80, rows: 24, env: {} };
72
+ let child;
73
+
74
+ session.on("pty", (a, _r, info) => {
75
+ debug("pty", info.term, info.cols, info.rows);
76
+ term.term = info.term || term.term;
77
+ term.cols = info.cols || term.cols;
78
+ term.rows = info.rows || term.rows;
79
+ a?.();
80
+ });
81
+ session.on("env", (a, _r, info) => {
82
+ debug("env", info.key, "=", info.val);
83
+ term.env[info.key] = info.val;
84
+ a?.();
85
+ });
86
+ session.on("window-change", (a, _r, info) => {
87
+ child?.resize(info.cols, info.rows);
45
88
  a?.();
46
89
  });
47
90
  session.on("shell", (acc) => {
48
- const stream = acc();
49
- const shell = pty.spawn(process.env.SHELL || "/bin/zsh", [], {
50
- name: info.term,
51
- cols: info.cols,
52
- rows: info.rows,
53
- cwd: os.homedir(),
54
- env: process.env,
55
- });
56
- shell.onData((d) => stream.write(d));
57
- stream.on("data", (d) => shell.write(d));
58
- session.on("window-change", (a, _r, i) => {
59
- shell.resize(i.cols, i.rows);
60
- a?.();
61
- });
62
- shell.onExit(() => stream.end());
91
+ debug("shell");
92
+ // ponytail: route the interactive shell through tmux so sessions persist
93
+ // and are shared across LAN connections; show the session list on connect.
94
+ // Falls back to a plain login shell when tmux is absent.
95
+ const start =
96
+ 'if command -v tmux >/dev/null 2>&1; then ' +
97
+ 'exec tmux new-session -A -s main \\; choose-tree -Zs; ' +
98
+ 'else printf "[cmux-ssh-here] tmux not found; opening a plain shell (no server-side sessions)\\n"; ' +
99
+ 'exec "$SHELL" -l; fi';
100
+ child = bridge(acc(), ["-lc", start], term);
101
+ });
102
+ session.on("exec", (acc, _r, info) => {
103
+ debug("exec", info.command);
104
+ // ponytail: login shell (-lc) so PATH includes things like homebrew tmux;
105
+ // cmux runs remote commands (tmux probes, tmux -CC) over this channel.
106
+ child = bridge(acc(), ["-lc", info.command], term);
107
+ });
108
+ session.on("subsystem", (_acc, rej, info) => {
109
+ debug("subsystem", info.name);
110
+ // ponytail: scp/sftp not supported; reject so the client fails fast
111
+ rej?.();
63
112
  });
64
113
  });
65
114
  });
66
115
 
67
116
  server.on("error", (e) => {
68
- console.error(`Ошибка сервера: ${e.message}`);
117
+ console.error(`Server error: ${e.message}`);
69
118
  process.exit(1);
70
119
  });
71
120
 
72
- const PORT = Number(process.env.PORT) || 0; // 0 = случайный свободный порт
121
+ const PORT = Number(process.env.PORT) || 0; // 0 = random free port
73
122
  server.listen(PORT, "0.0.0.0", function () {
74
123
  const ip = lanIP();
75
124
  if (!ip) {
76
- console.error("Нет LAN IPv4-адреса");
125
+ console.error("No LAN IPv4 address found");
77
126
  process.exit(1);
78
127
  }
79
128
  const port = this.address().port;
@@ -84,7 +133,7 @@ server.listen(PORT, "0.0.0.0", function () {
84
133
  "host-key-policy": "accept-new",
85
134
  title: os.hostname(),
86
135
  });
87
- console.log(`\n Подключение к этой машине через cmux по LAN`);
88
- console.log(` (shell под пользователем ${os.userInfo().username}, Ctrl-C тут выключить):\n`);
136
+ console.log(`\n Connect to this machine via cmux over the LAN`);
137
+ console.log(` (shell as ${os.userInfo().username}; Ctrl-C here to stop):\n`);
89
138
  console.log(` https://cmux.com/deeplink/ssh?${params}\n`);
90
139
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cmux-ssh-here",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Spin up a token-auth SSH server and print a cmux deep link to connect over LAN",
5
5
  "type": "module",
6
6
  "bin": {