cmux-ssh-here 0.6.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 +4 -1
  2. package/bin.js +38 -7
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@ That's it. No `sshd` to configure, no `~/.ssh/authorized_keys` to edit, no firew
16
16
 
17
17
  ---
18
18
 
19
- The terminal turns into a live dashboard — the link, a countdown bar for its lifetime, and who's connected:
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
20
 
21
21
  ![cmux-ssh-here dashboard](https://raw.githubusercontent.com/viktor-silakov/cmux-ssh-here/main/assets/dashboard.png)
22
22
 
@@ -27,8 +27,10 @@ Open the link and cmux connects on its own — one click, straight into the shel
27
27
  ## Why you'll like it
28
28
 
29
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.
30
31
  - 🔑 **No credentials to share** — auth is a one-time token baked into the link.
31
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.
32
34
  - 👀 **Live dashboard** — see the current link, a countdown bar, and every connected client at a glance.
33
35
  - 🧩 **Real SSH** — full PTY shell, `scp`/`sftp`, and the exec channel cmux needs to bootstrap remote workspaces.
34
36
  - 🪟 **Persistent sessions** — when `tmux` is present, sessions survive disconnects and are shared across connections.
@@ -66,6 +68,7 @@ The device you open the link from just needs [cmux](https://cmux.com) installed.
66
68
 
67
69
  ## Options
68
70
 
71
+ - `npx cmux-ssh-here --once` — single-use link: locks to the first device that connects, rejects any other.
69
72
  - `PORT=2222 npx cmux-ssh-here` — fixed port (random free port by default).
70
73
  - `CMUX_SSH_TTL=600 npx cmux-ssh-here` — link/token lifetime in seconds before regeneration (default 180).
71
74
  - `CMUX_SSH_DEBUG=1 npx cmux-ssh-here` — log incoming auth/env/exec/shell requests to stderr (disables the live dashboard).
package/bin.js CHANGED
@@ -18,6 +18,7 @@ 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";
21
22
 
22
23
  // ponytail: the host side needs a POSIX shell + scp/exec semantics, and cmux's
23
24
  // own remote daemon only ships linux/darwin/freebsd builds — so a Windows host
@@ -48,6 +49,13 @@ const regenerateToken = () => {
48
49
  };
49
50
  regenerateToken();
50
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
+
51
59
  // Active authenticated connections, shown live in the server terminal.
52
60
  const sessions = new Map();
53
61
  let nextSid = 0;
@@ -258,13 +266,19 @@ let render = () => {}; // assigned once the server is listening (knows ip/port)
258
266
  const server = new Server(serverCfg, (client, info) => {
259
267
  client.on("authentication", (ctx) => {
260
268
  debug("auth", ctx.method, ctx.username);
261
- 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();
262
272
  });
263
273
  client.on("ready", () => {
264
274
  // ponytail: just track the connection; the 5s timer redraws — no per-event
265
275
  // repaint (that was the screen-churn that broke copying).
266
276
  const id = nextSid++;
267
277
  sessions.set(id, { ip: info?.ip || "?", since: Date.now() });
278
+ if (ONCE && !consumed) {
279
+ consumed = true;
280
+ lockedIp = info?.ip || "?";
281
+ }
268
282
  client.on("close", () => sessions.delete(id));
269
283
  });
270
284
  client.on("session", (accept) => {
@@ -343,6 +357,16 @@ server.listen(PORT, "0.0.0.0", function () {
343
357
  return `[${"█".repeat(filled)}${"░".repeat(W - filled)}]`;
344
358
  };
345
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
+
346
370
  // Collapse cmux's several SSH connections from one machine into one row per IP.
347
371
  const sessionRows = () => {
348
372
  const byIp = new Map();
@@ -356,19 +380,25 @@ server.listen(PORT, "0.0.0.0", function () {
356
380
  );
357
381
  };
358
382
 
383
+ const mode = ONCE ? " (one-time link)" : "";
384
+
359
385
  render = () => {
360
386
  if (!liveUI) return;
361
387
  const rem = remainingSec();
388
+ const link = buildLink();
362
389
  const lines = [
363
390
  "",
364
- ` cmux-ssh-here — shell as ${user} over the LAN`,
391
+ ` cmux-ssh-here — shell as ${user} over the LAN${mode}`,
365
392
  "",
366
- " Open in cmux:",
367
- ` ${buildLink()}`,
393
+ " Open in cmux (or scan to open on a phone/tablet):",
394
+ ` ${link}`,
368
395
  "",
369
- ` Link valid ${bar(rem)} ${rem}s`,
396
+ qrFor(link),
370
397
  "",
371
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("");
372
402
  const rows = sessionRows();
373
403
  if (rows.length) lines.push(` Connected (${rows.length}):`, ...rows);
374
404
  else lines.push(" No active sessions yet.");
@@ -380,9 +410,10 @@ server.listen(PORT, "0.0.0.0", function () {
380
410
  if (liveUI) render();
381
411
  else console.log(`\n Open in cmux (regenerates in ${remainingSec()}s):\n ${buildLink()}\n`);
382
412
 
383
- // 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.
384
415
  setInterval(() => {
385
- if (remainingSec() <= 0) {
416
+ if (!consumed && remainingSec() <= 0) {
386
417
  regenerateToken();
387
418
  if (!liveUI) console.log(`\n [link regenerated]\n ${buildLink()}\n`);
388
419
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cmux-ssh-here",
3
- "version": "0.6.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": {
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "node-pty": "^1.0.0",
37
+ "qrcode-terminal": "^0.12.0",
37
38
  "ssh2": "^1.16.0"
38
39
  }
39
40
  }