cmux-ssh-here 0.5.0 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +51 -19
  2. package/bin.js +49 -8
  3. package/package.json +5 -1
package/README.md CHANGED
@@ -1,46 +1,78 @@
1
1
  # cmux-ssh-here
2
2
 
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.
3
+ **Share a shell on this machine in one command — no SSH setup, no keys, no passwords.**
4
+
5
+ [![npm](https://img.shields.io/npm/v/cmux-ssh-here.svg)](https://www.npmjs.com/package/cmux-ssh-here)
6
+ [![CI](https://github.com/viktor-silakov/cmux-ssh-here/actions/workflows/ci.yml/badge.svg)](https://github.com/viktor-silakov/cmux-ssh-here/actions/workflows/ci.yml)
7
+ [![license](https://img.shields.io/npm/l/cmux-ssh-here.svg)](./LICENSE)
8
+
9
+ `cmux-ssh-here` spins up a throwaway, token-authenticated SSH server and prints a [cmux](https://cmux.com) deep link. Send the link to any device on your LAN, open it in cmux, and you're instantly in a terminal on this machine. When you're done, hit `Ctrl-C` — the server, token, and host key vanish.
4
10
 
5
11
  ```bash
6
12
  npx cmux-ssh-here
7
13
  ```
8
14
 
9
- Output:
15
+ That's it. No `sshd` to configure, no `~/.ssh/authorized_keys` to edit, no firewall dance.
10
16
 
11
- ```
12
- Connect to this machine via cmux over the LAN
13
- (shell as you; Ctrl-C here to stop):
17
+ ---
14
18
 
15
- https://cmux.com/deeplink/ssh?host=192.168.1.42&port=52968&user=wudUKicRFRw3&host-key-policy=accept-new&title=your-mac
16
- ```
19
+ The terminal turns into a live dashboard — the link, a scannable QR code, a countdown bar for its lifetime, and who's connected:
20
+
21
+ ![cmux-ssh-here dashboard](https://raw.githubusercontent.com/viktor-silakov/cmux-ssh-here/main/assets/dashboard.png)
22
+
23
+ Open the link and cmux connects on its own — one click, straight into the shell:
24
+
25
+ ![Open in cmux](https://raw.githubusercontent.com/viktor-silakov/cmux-ssh-here/main/assets/open-in-cmux.png)
26
+
27
+ ## Why you'll like it
17
28
 
18
- Open the linkcmux connects on its own. The server terminal shows a live dashboard: the current link, a countdown until it regenerates, and the list of connected sessions. `Ctrl-C` stops the server.
29
+ - **Zero setup** one `npx` command, no SSH server administration.
30
+ - 📱 **Scan to connect** — a QR code in the terminal opens the link on a phone or tablet.
31
+ - 🔑 **No credentials to share** — auth is a one-time token baked into the link.
32
+ - ⏳ **Self-expiring** — the link rotates every 3 minutes; leaked links go stale on their own. Live sessions stay connected.
33
+ - 🎯 **One-time mode** — `--once` locks the link to the first device that connects and rejects everyone else.
34
+ - 👀 **Live dashboard** — see the current link, a countdown bar, and every connected client at a glance.
35
+ - 🧩 **Real SSH** — full PTY shell, `scp`/`sftp`, and the exec channel cmux needs to bootstrap remote workspaces.
36
+ - 🪟 **Persistent sessions** — when `tmux` is present, sessions survive disconnects and are shared across connections.
19
37
 
20
- The link (and its token) is valid for **3 minutes**, then a fresh one is generated — already-connected sessions stay alive; only new connections need the new link.
38
+ ## Quick start
39
+
40
+ ```bash
41
+ # on the machine you want to reach
42
+ npx cmux-ssh-here
43
+ ```
44
+
45
+ Then open the printed `https://cmux.com/deeplink/ssh?…` link in cmux on any device on the same network.
21
46
 
22
47
  ## How it works
23
48
 
24
49
  - Its own SSH server (`ssh2`) with an ephemeral host key and token — both live only while the process runs.
25
- - The token is passed in the deep link's `user=`; the server only accepts the correct token.
50
+ - The token rides in the deep link's `user=`; the server accepts only that token, and rotates it every 3 minutes.
26
51
  - cmux deep links deliberately carry no passwords or keys, so the secret is the token itself.
27
- - Full PTY shell via `node-pty`, with window-resize support.
28
- - Real exec channel (raw pipes) and a filesystem-backed SFTP server, so `scp`/`sftp` work this is what lets `cmux ssh` upload and run its remote helper. A shell-only server is not enough for cmux.
29
- - 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.
52
+ - Full PTY shell via `node-pty`, a raw-pipe exec channel, and a filesystem-backed SFTP server — together they let `cmux ssh` upload and run its remote helper (a shell-only server isn't enough).
53
+ - With `tmux` available, the interactive shell runs inside it: sessions persist, are shared across connections, and the session list (`choose-tree`) is shown on connect.
54
+
55
+ ## Compatibility
30
56
 
31
- ## Requirements
57
+ | Host running `npx cmux-ssh-here` | Supported |
58
+ | --- | --- |
59
+ | macOS | ✅ |
60
+ | Linux | ✅ |
61
+ | Windows | ❌ — needs a POSIX shell, and cmux's remote daemon has no Windows build |
32
62
 
33
- - Node 18+.
34
- - [cmux](https://cmux.com) on the device you open the link from.
35
- - macOS or Linux on the host sharing the shell. Windows host is not supported.
36
- - `tmux` (optional) for persistent, shared server-side sessions.
63
+ The device you open the link from just needs [cmux](https://cmux.com) installed. Node 18+ is required on the host. `tmux` is optional (for persistent, shared sessions).
37
64
 
38
65
  ## Security
39
66
 
40
- ⚠️ **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.
67
+ ⚠️ **The token in the link is a bearer secret that grants a shell as your user.** Use it on a 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.
41
68
 
42
69
  ## Options
43
70
 
71
+ - `npx cmux-ssh-here --once` — single-use link: locks to the first device that connects, rejects any other.
44
72
  - `PORT=2222 npx cmux-ssh-here` — fixed port (random free port by default).
45
73
  - `CMUX_SSH_TTL=600 npx cmux-ssh-here` — link/token lifetime in seconds before regeneration (default 180).
46
74
  - `CMUX_SSH_DEBUG=1 npx cmux-ssh-here` — log incoming auth/env/exec/shell requests to stderr (disables the live dashboard).
75
+
76
+ ## License
77
+
78
+ MIT
package/bin.js CHANGED
@@ -18,6 +18,15 @@ import { spawn } from "node:child_process";
18
18
  import { chmodSync } from "node:fs";
19
19
  import { createRequire } from "node:module";
20
20
  import { dirname, join, resolve } from "node:path";
21
+ import qrcodeTerminal from "qrcode-terminal";
22
+
23
+ // ponytail: the host side needs a POSIX shell + scp/exec semantics, and cmux's
24
+ // own remote daemon only ships linux/darwin/freebsd builds — so a Windows host
25
+ // can't serve `cmux ssh`. Fail fast with a clear message instead of crashing later.
26
+ if (process.platform === "win32") {
27
+ console.error("cmux-ssh-here must run on a macOS or Linux host (POSIX shell required). Windows is not supported.");
28
+ process.exit(1);
29
+ }
21
30
 
22
31
  // ponytail: node-pty's prebuilt spawn-helper sometimes unpacks without +x
23
32
  // (packaging bug) -> "posix_spawnp failed". Fix it before importing.
@@ -40,6 +49,13 @@ const regenerateToken = () => {
40
49
  };
41
50
  regenerateToken();
42
51
 
52
+ // --once: single-use link. After the first client connects, lock to its IP and
53
+ // stop rotating — new machines are rejected, but that client's several
54
+ // connections (cmux opens ControlMaster + daemon + probes) keep working.
55
+ const ONCE = process.argv.includes("--once");
56
+ let consumed = false;
57
+ let lockedIp = null;
58
+
43
59
  // Active authenticated connections, shown live in the server terminal.
44
60
  const sessions = new Map();
45
61
  let nextSid = 0;
@@ -50,7 +66,9 @@ const { privateKey } = generateKeyPairSync("rsa", {
50
66
  publicKeyEncoding: { type: "spki", format: "pem" },
51
67
  });
52
68
 
53
- const shellPath = process.env.SHELL || "/bin/zsh";
69
+ // ponytail: $SHELL is set on real logins; the fallback only matters in bare
70
+ // envs — bash on Linux, zsh on macOS.
71
+ const shellPath = process.env.SHELL || (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash");
54
72
  const debug = process.env.CMUX_SSH_DEBUG ? (...a) => console.error("[debug]", ...a) : () => {};
55
73
 
56
74
  const lanIP = () =>
@@ -248,13 +266,19 @@ let render = () => {}; // assigned once the server is listening (knows ip/port)
248
266
  const server = new Server(serverCfg, (client, info) => {
249
267
  client.on("authentication", (ctx) => {
250
268
  debug("auth", ctx.method, ctx.username);
251
- return ctx.username === token ? ctx.accept() : ctx.reject();
269
+ // Correct token, plus (in --once after consumption) only the locked-in IP.
270
+ const ok = ctx.username === token && (!consumed || info?.ip === lockedIp);
271
+ return ok ? ctx.accept() : ctx.reject();
252
272
  });
253
273
  client.on("ready", () => {
254
274
  // ponytail: just track the connection; the 5s timer redraws — no per-event
255
275
  // repaint (that was the screen-churn that broke copying).
256
276
  const id = nextSid++;
257
277
  sessions.set(id, { ip: info?.ip || "?", since: Date.now() });
278
+ if (ONCE && !consumed) {
279
+ consumed = true;
280
+ lockedIp = info?.ip || "?";
281
+ }
258
282
  client.on("close", () => sessions.delete(id));
259
283
  });
260
284
  client.on("session", (accept) => {
@@ -333,6 +357,16 @@ server.listen(PORT, "0.0.0.0", function () {
333
357
  return `[${"█".repeat(filled)}${"░".repeat(W - filled)}]`;
334
358
  };
335
359
 
360
+ // ASCII QR of the current link — scan with a phone/tablet to open in cmux.
361
+ const qrFor = (text) => {
362
+ let out = "";
363
+ qrcodeTerminal.generate(text, { small: true }, (q) => (out = q));
364
+ return out
365
+ .split("\n")
366
+ .map((l) => " " + l)
367
+ .join("\n");
368
+ };
369
+
336
370
  // Collapse cmux's several SSH connections from one machine into one row per IP.
337
371
  const sessionRows = () => {
338
372
  const byIp = new Map();
@@ -346,19 +380,25 @@ server.listen(PORT, "0.0.0.0", function () {
346
380
  );
347
381
  };
348
382
 
383
+ const mode = ONCE ? " (one-time link)" : "";
384
+
349
385
  render = () => {
350
386
  if (!liveUI) return;
351
387
  const rem = remainingSec();
388
+ const link = buildLink();
352
389
  const lines = [
353
390
  "",
354
- ` cmux-ssh-here — shell as ${user} over the LAN`,
391
+ ` cmux-ssh-here — shell as ${user} over the LAN${mode}`,
355
392
  "",
356
- " Open in cmux:",
357
- ` ${buildLink()}`,
393
+ " Open in cmux (or scan to open on a phone/tablet):",
394
+ ` ${link}`,
358
395
  "",
359
- ` Link valid ${bar(rem)} ${rem}s`,
396
+ qrFor(link),
360
397
  "",
361
398
  ];
399
+ if (consumed) lines.push(` 🔒 One-time link used — locked to ${lockedIp}.`);
400
+ else lines.push(` Link valid ${bar(rem)} ${rem}s`);
401
+ lines.push("");
362
402
  const rows = sessionRows();
363
403
  if (rows.length) lines.push(` Connected (${rows.length}):`, ...rows);
364
404
  else lines.push(" No active sessions yet.");
@@ -370,9 +410,10 @@ server.listen(PORT, "0.0.0.0", function () {
370
410
  if (liveUI) render();
371
411
  else console.log(`\n Open in cmux (regenerates in ${remainingSec()}s):\n ${buildLink()}\n`);
372
412
 
373
- // Refresh every 5s: regenerate the link when it expires, then redraw.
413
+ // Refresh every 5s: regenerate the link when it expires (unless a one-time
414
+ // link has already been consumed — then it's frozen), then redraw.
374
415
  setInterval(() => {
375
- if (remainingSec() <= 0) {
416
+ if (!consumed && remainingSec() <= 0) {
376
417
  regenerateToken();
377
418
  if (!liveUI) console.log(`\n [link regenerated]\n ${buildLink()}\n`);
378
419
  }
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "cmux-ssh-here",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
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": {
7
7
  "cmux-ssh-here": "bin.js"
8
8
  },
9
+ "scripts": {
10
+ "test": "node --test"
11
+ },
9
12
  "files": [
10
13
  "bin.js",
11
14
  "README.md"
@@ -31,6 +34,7 @@
31
34
  },
32
35
  "dependencies": {
33
36
  "node-pty": "^1.0.0",
37
+ "qrcode-terminal": "^0.12.0",
34
38
  "ssh2": "^1.16.0"
35
39
  }
36
40
  }