cmux-ssh-here 0.4.1 → 0.6.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 +48 -19
- package/bin.js +39 -34
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,46 +1,75 @@
|
|
|
1
1
|
# cmux-ssh-here
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Share a shell on this machine in one command — no SSH setup, no keys, no passwords.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/cmux-ssh-here)
|
|
6
|
+
[](https://github.com/viktor-silakov/cmux-ssh-here/actions/workflows/ci.yml)
|
|
7
|
+
[](./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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
19
|
+
The terminal turns into a live dashboard — the link, a countdown bar for its lifetime, and who's connected:
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
Open the link and cmux connects on its own — one click, straight into the shell:
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
## Why you'll like it
|
|
17
28
|
|
|
18
|
-
|
|
29
|
+
- ⚡ **Zero setup** — one `npx` command, no SSH server administration.
|
|
30
|
+
- 🔑 **No credentials to share** — auth is a one-time token baked into the link.
|
|
31
|
+
- ⏳ **Self-expiring** — the link rotates every 3 minutes; leaked links go stale on their own. Live sessions stay connected.
|
|
32
|
+
- 👀 **Live dashboard** — see the current link, a countdown bar, and every connected client at a glance.
|
|
33
|
+
- 🧩 **Real SSH** — full PTY shell, `scp`/`sftp`, and the exec channel cmux needs to bootstrap remote workspaces.
|
|
34
|
+
- 🪟 **Persistent sessions** — when `tmux` is present, sessions survive disconnects and are shared across connections.
|
|
19
35
|
|
|
20
|
-
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# on the machine you want to reach
|
|
40
|
+
npx cmux-ssh-here
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then open the printed `https://cmux.com/deeplink/ssh?…` link in cmux on any device on the same network.
|
|
21
44
|
|
|
22
45
|
## How it works
|
|
23
46
|
|
|
24
47
|
- Its own SSH server (`ssh2`) with an ephemeral host key and token — both live only while the process runs.
|
|
25
|
-
- The token
|
|
48
|
+
- The token rides in the deep link's `user=`; the server accepts only that token, and rotates it every 3 minutes.
|
|
26
49
|
- cmux deep links deliberately carry no passwords or keys, so the secret is the token itself.
|
|
27
|
-
- Full PTY shell via `node-pty`,
|
|
28
|
-
-
|
|
29
|
-
|
|
50
|
+
- 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).
|
|
51
|
+
- 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.
|
|
52
|
+
|
|
53
|
+
## Compatibility
|
|
30
54
|
|
|
31
|
-
|
|
55
|
+
| Host running `npx cmux-ssh-here` | Supported |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| macOS | ✅ |
|
|
58
|
+
| Linux | ✅ |
|
|
59
|
+
| Windows | ❌ — needs a POSIX shell, and cmux's remote daemon has no Windows build |
|
|
32
60
|
|
|
33
|
-
|
|
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.
|
|
61
|
+
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
62
|
|
|
38
63
|
## Security
|
|
39
64
|
|
|
40
|
-
⚠️ **The token in the link is a bearer secret that grants a shell as your user.**
|
|
65
|
+
⚠️ **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
66
|
|
|
42
67
|
## Options
|
|
43
68
|
|
|
44
69
|
- `PORT=2222 npx cmux-ssh-here` — fixed port (random free port by default).
|
|
45
70
|
- `CMUX_SSH_TTL=600 npx cmux-ssh-here` — link/token lifetime in seconds before regeneration (default 180).
|
|
46
71
|
- `CMUX_SSH_DEBUG=1 npx cmux-ssh-here` — log incoming auth/env/exec/shell requests to stderr (disables the live dashboard).
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
package/bin.js
CHANGED
|
@@ -19,6 +19,14 @@ import { chmodSync } from "node:fs";
|
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
20
|
import { dirname, join, resolve } from "node:path";
|
|
21
21
|
|
|
22
|
+
// ponytail: the host side needs a POSIX shell + scp/exec semantics, and cmux's
|
|
23
|
+
// own remote daemon only ships linux/darwin/freebsd builds — so a Windows host
|
|
24
|
+
// can't serve `cmux ssh`. Fail fast with a clear message instead of crashing later.
|
|
25
|
+
if (process.platform === "win32") {
|
|
26
|
+
console.error("cmux-ssh-here must run on a macOS or Linux host (POSIX shell required). Windows is not supported.");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
// ponytail: node-pty's prebuilt spawn-helper sometimes unpacks without +x
|
|
23
31
|
// (packaging bug) -> "posix_spawnp failed". Fix it before importing.
|
|
24
32
|
if (process.platform !== "win32") {
|
|
@@ -33,10 +41,10 @@ const { default: pty } = await import("node-pty");
|
|
|
33
41
|
// and an ssh client treats a leading-dash username as a flag. Hex is URL/ssh-safe.
|
|
34
42
|
const TTL_SECONDS = Number(process.env.CMUX_SSH_TTL) || 180; // link/token lifetime before regeneration
|
|
35
43
|
let token; // secret carried in user=<token>; rotated every TTL_SECONDS
|
|
36
|
-
let
|
|
44
|
+
let expiry; // epoch ms when the current token expires
|
|
37
45
|
const regenerateToken = () => {
|
|
38
46
|
token = randomBytes(12).toString("hex");
|
|
39
|
-
|
|
47
|
+
expiry = Date.now() + TTL_SECONDS * 1000;
|
|
40
48
|
};
|
|
41
49
|
regenerateToken();
|
|
42
50
|
|
|
@@ -50,7 +58,9 @@ const { privateKey } = generateKeyPairSync("rsa", {
|
|
|
50
58
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
51
59
|
});
|
|
52
60
|
|
|
53
|
-
|
|
61
|
+
// ponytail: $SHELL is set on real logins; the fallback only matters in bare
|
|
62
|
+
// envs — bash on Linux, zsh on macOS.
|
|
63
|
+
const shellPath = process.env.SHELL || (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash");
|
|
54
64
|
const debug = process.env.CMUX_SSH_DEBUG ? (...a) => console.error("[debug]", ...a) : () => {};
|
|
55
65
|
|
|
56
66
|
const lanIP = () =>
|
|
@@ -251,13 +261,11 @@ const server = new Server(serverCfg, (client, info) => {
|
|
|
251
261
|
return ctx.username === token ? ctx.accept() : ctx.reject();
|
|
252
262
|
});
|
|
253
263
|
client.on("ready", () => {
|
|
264
|
+
// ponytail: just track the connection; the 5s timer redraws — no per-event
|
|
265
|
+
// repaint (that was the screen-churn that broke copying).
|
|
254
266
|
const id = nextSid++;
|
|
255
267
|
sessions.set(id, { ip: info?.ip || "?", since: Date.now() });
|
|
256
|
-
|
|
257
|
-
client.on("close", () => {
|
|
258
|
-
sessions.delete(id);
|
|
259
|
-
render();
|
|
260
|
-
});
|
|
268
|
+
client.on("close", () => sessions.delete(id));
|
|
261
269
|
});
|
|
262
270
|
client.on("session", (accept) => {
|
|
263
271
|
const session = accept();
|
|
@@ -324,16 +332,18 @@ server.listen(PORT, "0.0.0.0", function () {
|
|
|
324
332
|
|
|
325
333
|
// ponytail: in debug mode skip the dashboard so logs stay readable.
|
|
326
334
|
const liveUI = !process.env.CMUX_SSH_DEBUG;
|
|
335
|
+
const REFRESH_MS = 5000; // ponytail: 5s cadence — long enough to select & copy the link
|
|
327
336
|
const ago = (since) => `${Math.floor((Date.now() - since) / 1000)}s`;
|
|
337
|
+
const remainingSec = () => Math.max(0, Math.ceil((expiry - Date.now()) / 1000));
|
|
328
338
|
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
339
|
+
// Decreasing progress bar for the link's remaining lifetime.
|
|
340
|
+
const bar = (rem) => {
|
|
341
|
+
const W = 28;
|
|
342
|
+
const filled = Math.round((rem / TTL_SECONDS) * W);
|
|
343
|
+
return `[${"█".repeat(filled)}${"░".repeat(W - filled)}]`;
|
|
344
|
+
};
|
|
334
345
|
|
|
335
|
-
// Collapse
|
|
336
|
-
// ControlMaster, the daemon channel, short-lived probes) into one row per IP.
|
|
346
|
+
// Collapse cmux's several SSH connections from one machine into one row per IP.
|
|
337
347
|
const sessionRows = () => {
|
|
338
348
|
const byIp = new Map();
|
|
339
349
|
for (const s of sessions.values()) {
|
|
@@ -346,7 +356,9 @@ server.listen(PORT, "0.0.0.0", function () {
|
|
|
346
356
|
);
|
|
347
357
|
};
|
|
348
358
|
|
|
349
|
-
|
|
359
|
+
render = () => {
|
|
360
|
+
if (!liveUI) return;
|
|
361
|
+
const rem = remainingSec();
|
|
350
362
|
const lines = [
|
|
351
363
|
"",
|
|
352
364
|
` cmux-ssh-here — shell as ${user} over the LAN`,
|
|
@@ -354,33 +366,26 @@ server.listen(PORT, "0.0.0.0", function () {
|
|
|
354
366
|
" Open in cmux:",
|
|
355
367
|
` ${buildLink()}`,
|
|
356
368
|
"",
|
|
369
|
+
` Link valid ${bar(rem)} ${rem}s`,
|
|
370
|
+
"",
|
|
357
371
|
];
|
|
358
372
|
const rows = sessionRows();
|
|
359
373
|
if (rows.length) lines.push(` Connected (${rows.length}):`, ...rows);
|
|
360
374
|
else lines.push(" No active sessions yet.");
|
|
361
|
-
lines.push("");
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// Repaint the block, then leave the cursor parked on the countdown line.
|
|
366
|
-
render = () => {
|
|
367
|
-
if (!liveUI) return;
|
|
368
|
-
process.stdout.write(`\n${block()}\n${countdownLine()}`);
|
|
375
|
+
lines.push("", " Updates every 5s · Ctrl-C to stop.", "");
|
|
376
|
+
// Home + clear-to-end (not full \x1b[2J): redraw in the same spot every 5s.
|
|
377
|
+
process.stdout.write(`\x1b[H\x1b[J${lines.join("\n")}\n`);
|
|
369
378
|
};
|
|
370
379
|
|
|
371
380
|
if (liveUI) render();
|
|
372
|
-
else console.log(`\n Open in cmux (regenerates in ${
|
|
381
|
+
else console.log(`\n Open in cmux (regenerates in ${remainingSec()}s):\n ${buildLink()}\n`);
|
|
373
382
|
|
|
383
|
+
// Refresh every 5s: regenerate the link when it expires, then redraw.
|
|
374
384
|
setInterval(() => {
|
|
375
|
-
|
|
376
|
-
if (remaining <= 0) {
|
|
385
|
+
if (remainingSec() <= 0) {
|
|
377
386
|
regenerateToken();
|
|
378
|
-
|
|
379
|
-
if (liveUI) render();
|
|
380
|
-
else console.log(`\n [link regenerated]\n ${buildLink()}\n`);
|
|
381
|
-
return;
|
|
387
|
+
if (!liveUI) console.log(`\n [link regenerated]\n ${buildLink()}\n`);
|
|
382
388
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}, 1000);
|
|
389
|
+
render();
|
|
390
|
+
}, REFRESH_MS);
|
|
386
391
|
});
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cmux-ssh-here",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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"
|