@hahahhh/sshx 0.0.1-rc.2
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 +287 -0
- package/bin/sshx.js +89 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# sshx
|
|
2
|
+
|
|
3
|
+
> Transparent SSH enhancement — add remote-to-local commands, auto port forwarding, and local domains to your SSH workflow. Zero side effects when you don't need them.
|
|
4
|
+
|
|
5
|
+
**sshx** is a drop-in wrapper around OpenSSH. Wrap it as `alias ssh=sshx` and your existing SSH workflow works exactly as before — every flag, config, and connection passes through verbatim. But when you connect to a host with sshx-aware features enabled, you unlock a persistent, shared remote server that gives you:
|
|
6
|
+
|
|
7
|
+
- 🔄 **Reverse command bridge** — run `sshx local <cmd>` *on the remote* to execute commands on your local machine, with stdout, stderr, exit code, and stdin all propagated.
|
|
8
|
+
- 🔌 **Automatic port forwarding** — remote loopback listeners (e.g., a dev server on `localhost:8080`) are automatically detected and forwarded to your local machine.
|
|
9
|
+
- 🌐 **Local domain binding** — access forwarded ports as `<host>.<your-user>.sshx:<port>` in your local browser, no manual `-L` flags needed.
|
|
10
|
+
|
|
11
|
+
## Why sshx?
|
|
12
|
+
|
|
13
|
+
| Without sshx | With sshx |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `ssh remote` — works normally | `ssh remote` — works normally, *plus* server starts in background |
|
|
16
|
+
| Need to copy a file *from* local to remote mid-session | `sshx local cat ~/file.txt` — runs on local, output streams to remote |
|
|
17
|
+
| Dev server on `localhost:3000` inaccessible | Open `http://debian.<your-user>.sshx:3000` in your local browser after `sshx debian` |
|
|
18
|
+
| Multiple terminals each need their own `-L` forwards | One shared daemon, one forward, all terminals benefit |
|
|
19
|
+
| Forget to set up forwarding before connecting | Ports detected and forwarded automatically |
|
|
20
|
+
|
|
21
|
+
sshx is designed to be **safe to alias**. Hosts without sshx configuration are untouched — no daemon starts, no files are created, no performance overhead.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
### 📦 Drop-in Compatibility
|
|
28
|
+
|
|
29
|
+
- `sshx [any ssh args...]` delegates to the real OpenSSH.
|
|
30
|
+
- All SSH flags pass through: `-F`, `-o`, `-J`, `-L`, `-R`, `-D`, `-N`, `-T`, `-V`, `-G`, `-Q`, etc.
|
|
31
|
+
- `~/.ssh/config` resolution is handled by OpenSSH — no reimplementation.
|
|
32
|
+
- Bypass with `sshx --no-wrap` or `SSHX_DISABLE=1` at any time.
|
|
33
|
+
|
|
34
|
+
### 🔄 Remote-to-Local Command Bridge
|
|
35
|
+
|
|
36
|
+
Run commands on your **local machine** from inside an SSH session:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
# On the remote, inside an sshx-wrapped session:
|
|
40
|
+
sshx local cat ~/my-local-file.txt
|
|
41
|
+
sshx local open -a "Google Chrome" "http://localhost:3000"
|
|
42
|
+
sshx local pbcopy < /tmp/some-data
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- stdout, stderr, and exit codes propagate correctly.
|
|
46
|
+
- stdin is sent in batch mode — pipe data in and it reaches the local command.
|
|
47
|
+
- Policy: a configurable deny list controls which commands are blocked.
|
|
48
|
+
|
|
49
|
+
### 🔌 Automatic Port Detection & Forwarding
|
|
50
|
+
|
|
51
|
+
When a process on the remote starts listening on `127.0.0.1` (e.g., `npm run dev` on port 3000), sshx detects it and:
|
|
52
|
+
|
|
53
|
+
1. Broadcasts the port to the local daemon.
|
|
54
|
+
2. Creates a shared TCP forward over SSH.
|
|
55
|
+
3. Binds the SSH target domain, e.g. `debian.<your-user>.sshx`.
|
|
56
|
+
|
|
57
|
+
sshx first tries to use the same local port as the remote listener. If that local port is already occupied, it automatically tries the next port (`+1`) until it finds a free one. Run `sshx forward` to see the active mapping.
|
|
58
|
+
|
|
59
|
+
### 🌐 Local Domains (macOS, Linux)
|
|
60
|
+
|
|
61
|
+
- A local DNS responder on `127.0.0.1:53` resolves `*.sshx` names dynamically.
|
|
62
|
+
- The domain resolves to localhost; the URL port selects the forwarded local listener.
|
|
63
|
+
- On macOS, `/etc/resolver/<suffix>` is configured once (with `sudo` when needed).
|
|
64
|
+
- All terminals on the same host share one DNS resolver and forwarding daemon.
|
|
65
|
+
|
|
66
|
+
### 🏗️ Shared Server Architecture
|
|
67
|
+
|
|
68
|
+
- One **server daemon** per remote host per user, shared across all concurrent SSH sessions.
|
|
69
|
+
- Client connects via a hidden `socket-proxy` SSH channel.
|
|
70
|
+
- Server manages port sniffing, forwarding state, and command bridge routing centrally.
|
|
71
|
+
- Server stays alive through brief disconnects, exiting after an idle timeout with no clients.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Installation
|
|
76
|
+
|
|
77
|
+
### From npm
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
npm install -g @hahahhh/sshx@next
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### From Source
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
git clone https://github.com/xiaot623/sshx.git
|
|
87
|
+
cd sshx
|
|
88
|
+
go build -o ./bin/sshx ./cmd/sshx
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Copy the binary to a location in your `$PATH`:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
cp ./bin/sshx /usr/local/bin/sshx
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Shell Alias (Recommended)
|
|
98
|
+
|
|
99
|
+
Add to your `~/.bashrc` or `~/.zshrc`:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
alias ssh=sshx
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The alias is safe — unmatched hosts have zero overhead and zero side effects.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Quick Start
|
|
110
|
+
|
|
111
|
+
### 1. Create your config
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
mkdir -p ~/.sshx
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`~/.sshx/config.yaml`:
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
features:
|
|
121
|
+
commandBridge: true
|
|
122
|
+
ports:
|
|
123
|
+
auto: true
|
|
124
|
+
domains:
|
|
125
|
+
enabled: true
|
|
126
|
+
|
|
127
|
+
commands:
|
|
128
|
+
deny: []
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### 2. Connect normally
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
sshx my-server
|
|
135
|
+
sshx my-server uname -s
|
|
136
|
+
sshx -p 2222 user@my-server hostname
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
All existing SSH options work — `-F`, `-o`, `-J`, `ProxyJump`, etc. are handled by OpenSSH.
|
|
140
|
+
|
|
141
|
+
### 3. Try the command bridge
|
|
142
|
+
|
|
143
|
+
Inside your SSH session on the remote:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
sshx local uname -s
|
|
147
|
+
# → Darwin (your local machine's OS)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### 4. Start a dev server on the remote
|
|
151
|
+
|
|
152
|
+
On the remote, start a server listening on `localhost`:
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
python3 -m http.server 8080 --bind 127.0.0.1
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
On your **local** machine, open:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
http://my-server.<your-user>.sshx:8080
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
No `-L` flags, no manual forwarding.
|
|
165
|
+
|
|
166
|
+
If local port `8080` is already occupied, sshx will try `8081`, then `8082`, and so on. Check the chosen port with:
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
sshx forward
|
|
170
|
+
# 8080 -> my-server:8080
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Configuration Reference
|
|
176
|
+
|
|
177
|
+
`~/.sshx/config.yaml`:
|
|
178
|
+
|
|
179
|
+
```yaml
|
|
180
|
+
# Strict mode: if the sshx server fails, refuse the connection instead of
|
|
181
|
+
# falling back to plain SSH. Default: false (graceful fallback).
|
|
182
|
+
strict: false
|
|
183
|
+
|
|
184
|
+
features:
|
|
185
|
+
# Remote-to-local command bridge (`sshx local <cmd>` on the remote)
|
|
186
|
+
commandBridge: true
|
|
187
|
+
|
|
188
|
+
ports:
|
|
189
|
+
# Auto-detect loopback TCP listeners on the remote and forward them.
|
|
190
|
+
auto: true
|
|
191
|
+
# Future: also detect 0.0.0.0 listeners.
|
|
192
|
+
# bindAll: false
|
|
193
|
+
|
|
194
|
+
domains:
|
|
195
|
+
# Enable local domain binding (<host>.<user>.sshx:<port>).
|
|
196
|
+
enabled: true
|
|
197
|
+
# Custom domain suffix. Default: <local-user>.sshx
|
|
198
|
+
suffix: user.sshx
|
|
199
|
+
|
|
200
|
+
commands:
|
|
201
|
+
# Commands blocked from bridge execution.
|
|
202
|
+
deny: []
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## How It Works
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
|
|
211
|
+
│ Your Local Machine │ │ Remote Host │
|
|
212
|
+
│ │ │ │
|
|
213
|
+
│ Terminal A ── SSH ─────────────┼─────┼── sshx server (shared daemon) │
|
|
214
|
+
│ Terminal B ── SSH ─────────────┼─────┼── │ │
|
|
215
|
+
│ Terminal C ── SSH ─────────────┼─────┼── ├── Port sniffing │
|
|
216
|
+
│ │ │ ├── Port forwarding │
|
|
217
|
+
│ sshx local daemon │ │ ├── Command bridge routing │
|
|
218
|
+
│ ├─ Port proxy (shared) │ │ └── Socket-proxy endpoint │
|
|
219
|
+
│ └─ DNS responder (127.0.0.1) │ │ │
|
|
220
|
+
└─────────────────────────────────┘ └─────────────────────────────────┘
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
1. **Connection**: `sshx remote` opens a normal SSH session and starts (or connects to) a shared `sshx server` on the remote.
|
|
224
|
+
2. **Bridge channel**: A hidden `socket-proxy` SSH channel links the local daemon to the remote server.
|
|
225
|
+
3. **Port sniffing**: The server reads `/proc/net/tcp*` (Linux) to detect loopback listeners.
|
|
226
|
+
4. **Forwarding**: Detected ports are forwarded through a single shared local daemon using `ssh -W`.
|
|
227
|
+
5. **Domains**: The local DNS responder maps `<target>.<suffix>` → localhost. The browser's URL port selects the local forwarded port.
|
|
228
|
+
|
|
229
|
+
When `sshx` is invoked for a **non-matching host** (no sshx config, or host not in scope), it `exec`s the real `ssh` directly — no daemon, no installation, no overhead.
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## Safety & Bypass
|
|
234
|
+
|
|
235
|
+
- `sshx --no-wrap ...` — skip all sshx behavior and call raw `ssh`.
|
|
236
|
+
- `SSHX_DISABLE=1 sshx ...` — same as `--no-wrap`, useful in scripts.
|
|
237
|
+
- `sshx local ...` on a **client** (not inside a remote session) — errors immediately with a clear message. `local` is globally reserved.
|
|
238
|
+
- Unmatched hosts are pure passthrough — no files created, no processes started.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Platform Support
|
|
243
|
+
|
|
244
|
+
| Platform | Client | Server |
|
|
245
|
+
|---|---|---|
|
|
246
|
+
| macOS | ✅ | — |
|
|
247
|
+
| Linux | ✅ | ✅ |
|
|
248
|
+
| Windows | 🔜 | — |
|
|
249
|
+
|
|
250
|
+
- **Client**: macOS and Linux are fully supported.
|
|
251
|
+
- **Server**: Linux is required for the remote sshx server (uses `/proc/net/tcp*` for port detection).
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Project Structure
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
sshx/
|
|
259
|
+
├── cmd/sshx/ # Main entry point
|
|
260
|
+
├── internal/
|
|
261
|
+
│ ├── cli/ # CLI parsing, host detection
|
|
262
|
+
│ ├── sshcompat/ # SSH argument compatibility
|
|
263
|
+
│ ├── config/ # YAML configuration
|
|
264
|
+
│ ├── protocol/ # Client-server wire protocol
|
|
265
|
+
│ ├── bridge/ # Command bridge (remote → local execution)
|
|
266
|
+
│ ├── ports/ # Port sniffing (/proc/net/tcp*)
|
|
267
|
+
│ ├── forward/ # TCP forwarding
|
|
268
|
+
│ ├── domain/ # DNS resolver
|
|
269
|
+
│ └── locald/ # Local daemon (socket, DNS, forwarding)
|
|
270
|
+
├── scripts/ # Integration tests
|
|
271
|
+
├── go.mod
|
|
272
|
+
├── go.sum
|
|
273
|
+
└── README.md
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Roadmap
|
|
279
|
+
|
|
280
|
+
- [x] **v1** — Command bridge (non-interactive), auto port forwarding, domain binding, shared server
|
|
281
|
+
- [ ] **v2** — Streaming stdin for command bridge, remote FUSE mounting, GitHub binary releases, Windows client support
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
MIT
|
package/bin/sshx.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const platformByNode = new Map([
|
|
17
|
+
["darwin", "darwin"],
|
|
18
|
+
["linux", "linux"],
|
|
19
|
+
["win32", "windows"],
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const archByNode = new Map([
|
|
23
|
+
["arm64", "arm64"],
|
|
24
|
+
["x64", "amd64"],
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const platform = platformByNode.get(process.platform);
|
|
28
|
+
const arch = archByNode.get(process.arch);
|
|
29
|
+
|
|
30
|
+
if (!platform || !arch) {
|
|
31
|
+
console.error(`sshx: unsupported platform ${process.platform}/${process.arch}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
36
|
+
const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
37
|
+
const version = packageJson.version;
|
|
38
|
+
const binaryName = `sshx-${platform}-${arch}${platform === "windows" ? ".exe" : ""}`;
|
|
39
|
+
const cacheRoot =
|
|
40
|
+
process.env.SSHX_CACHE_DIR ||
|
|
41
|
+
process.env.XDG_CACHE_HOME ||
|
|
42
|
+
(process.platform === "win32"
|
|
43
|
+
? join(process.env.LOCALAPPDATA || tmpdir(), "sshx")
|
|
44
|
+
: join(homedir(), ".cache", "sshx"));
|
|
45
|
+
const binaryPath = join(cacheRoot, version, binaryName);
|
|
46
|
+
|
|
47
|
+
if (!existsSync(binaryPath)) {
|
|
48
|
+
await downloadBinary(binaryPath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const result = spawnSync(binaryPath, process.argv.slice(2), {
|
|
52
|
+
stdio: "inherit",
|
|
53
|
+
env: process.env,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (result.error) {
|
|
57
|
+
console.error(`sshx: failed to launch native binary: ${result.error.message}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
process.exit(result.status ?? 0);
|
|
62
|
+
|
|
63
|
+
async function downloadBinary(destination) {
|
|
64
|
+
const baseUrl =
|
|
65
|
+
process.env.SSHX_RELEASE_BASE_URL ||
|
|
66
|
+
"https://github.com/xiaot623/sshx/releases/download";
|
|
67
|
+
const url = `${baseUrl}/v${version}/${binaryName}`;
|
|
68
|
+
const tmpPath = `${destination}.${process.pid}.tmp`;
|
|
69
|
+
|
|
70
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
71
|
+
rmSync(tmpPath, { force: true });
|
|
72
|
+
|
|
73
|
+
console.error(`sshx: downloading ${url}`);
|
|
74
|
+
|
|
75
|
+
const response = await fetch(url);
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
console.error(`sshx: GitHub returned ${response.status} ${response.statusText}`);
|
|
78
|
+
console.error("sshx: failed to download native binary from GitHub Release");
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
writeFileSync(tmpPath, Buffer.from(await response.arrayBuffer()));
|
|
83
|
+
|
|
84
|
+
if (platform !== "windows") {
|
|
85
|
+
chmodSync(tmpPath, 0o755);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
renameSync(tmpPath, destination);
|
|
89
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hahahhh/sshx",
|
|
3
|
+
"version": "0.0.1-rc.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Transparent SSH enhancement wrapper for OpenSSH",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/xiaot623/sshx.git"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"ssh",
|
|
12
|
+
"sshx",
|
|
13
|
+
"openssh",
|
|
14
|
+
"cli"
|
|
15
|
+
],
|
|
16
|
+
"bin": {
|
|
17
|
+
"sshx": "./bin/sshx.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin/sshx.js"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "node scripts/build-native.js",
|
|
24
|
+
"build:go": "go build -trimpath -o sshx ./cmd/sshx",
|
|
25
|
+
"check": "go vet ./...",
|
|
26
|
+
"test": "go test ./...",
|
|
27
|
+
"pre_release": "npm version prerelease --preid=rc -m \"chore: pre-release v%s\" && git push origin HEAD --follow-tags",
|
|
28
|
+
"publish:rc": "npm publish --access public --tag next",
|
|
29
|
+
"publish:release": "npm publish --access public --tag latest",
|
|
30
|
+
"release:patch": "node scripts/release.js patch",
|
|
31
|
+
"release:minor": "node scripts/release.js minor",
|
|
32
|
+
"release:major": "node scripts/release.js major"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
}
|
|
37
|
+
}
|