dsh-plugin-remote-connect-beta 0.1.0-beta.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.
@@ -0,0 +1,398 @@
1
+ # Self-hosting the public entry (your server + your domain)
2
+
3
+ [English](self-host.md) | [中文](self-host.zh.md)
4
+
5
+ This is the long-form version of the `selfhost` backend: what has to exist on **your** server
6
+ before `dsh-remote serve --public` can work, and how to check each piece. The short version is
7
+ in the [README](../README.md); if you have no server at all, use `--tunnel tailscale` or
8
+ `--tunnel cloudflared` instead and skip this page.
9
+
10
+ Nothing here is specific to one hosting provider. Sample values: domain `dsh.example.com`,
11
+ server `203.0.113.10`, tunnel account `dshtunnel`, ssh port `22022`, remote port `8788`.
12
+
13
+ ---
14
+
15
+ ## 0. The shape of the link
16
+
17
+ ```
18
+ any browser ──https──► your server: nginx/Caddy ──► 127.0.0.1:8788 (loopback only)
19
+ TLS + edge password ▲
20
+ │ ssh -R (your Mac dials out)
21
+
22
+ your Mac: access key gate ──► Harness on 127.0.0.1:<its own port>
23
+ ```
24
+
25
+ Three properties make this safe to run on a machine that can execute commands:
26
+
27
+ 1. The tunnel is **outbound**: your Mac never accepts an inbound connection for the public path.
28
+ 2. The reverse-proxy target is **loopback on the server** (`127.0.0.1:8788`), never a public port.
29
+ 3. The Harness itself keeps listening on `127.0.0.1` only — this plugin never rebinds it.
30
+
31
+ ---
32
+
33
+ ## 1. Generate what you need
34
+
35
+ ```bash
36
+ # an access key for the plugin's own gate (independent from the edge password)
37
+ openssl rand -hex 16
38
+
39
+ # a tunnel-only key pair (no shell access on the server)
40
+ npx dsh-plugin-remote-connect-beta keygen --out ~/.ssh/dsh_remote_tunnel
41
+ ```
42
+
43
+ `keygen` prints a ready-to-paste `authorized_keys` line that is restricted to one forward.
44
+
45
+ ---
46
+
47
+ ## 2. Server side: the scripted path (recommended)
48
+
49
+ ```bash
50
+ # on your machine: render the installer (it prints by default, it never touches the server)
51
+ npx dsh-plugin-remote-connect-beta setup-server \
52
+ --domain dsh.example.com --ssh-user dshtunnel --remote-port 8788 \
53
+ --out /tmp/dsh-server-setup.sh
54
+
55
+ # copy it over and follow the three-step ritual
56
+ scp /tmp/dsh-server-setup.sh you@203.0.113.10:/tmp/
57
+ ssh you@203.0.113.10 'bash /tmp/dsh-server-setup.sh probe' # what exists already
58
+ ssh you@203.0.113.10 'sudo bash /tmp/dsh-server-setup.sh install --dry-run' # what it would change
59
+ ssh you@203.0.113.10 'sudo bash /tmp/dsh-server-setup.sh install' # do it
60
+ ```
61
+
62
+ The installer **only writes files it owns**, so it is safe next to an existing site:
63
+
64
+ | File | Purpose |
65
+ | --- | --- |
66
+ | `/etc/nginx/conf.d/dsh-remote.conf` | your entry's `server` block (or drop it in `sites-enabled` yourself with `--nginx-conf`) |
67
+ | `/etc/ssh/sshd_config.d/60-dsh-remote.conf` | `AllowTcpForwarding yes` + `ClientAliveInterval 30` (delete the file to undo) |
68
+ | `/etc/letsencrypt/renewal-hooks/deploy/10-reload-web.sh` | reloads the web server after a renewal, so the served certificate cannot go stale |
69
+
70
+ Useful switches: `--skip-cert` (you manage certificates yourself), `--purge-user` (uninstall
71
+ also removes the tunnel account), `install --dry-run` (print the plan, change nothing).
72
+ Uninstall is the same script: `sudo bash /tmp/dsh-server-setup.sh uninstall`.
73
+
74
+ ---
75
+
76
+ ## 3. Server side: the manual path
77
+
78
+ If you prefer your own layout, `npx dsh-plugin-remote-connect-beta snippets --domain dsh.example.com`
79
+ prints the same content as text. The parts that actually matter:
80
+
81
+ ```nginx
82
+ # http context (once)
83
+ map $http_upgrade $connection_upgrade { default upgrade; '' close; }
84
+ # the access key travels in the query string: keep it out of the access log
85
+ log_format dsh_nokey '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" '
86
+ '$status $body_bytes_sent "$http_referer" "$http_user_agent"';
87
+
88
+ server {
89
+ listen 443 ssl;
90
+ http2 on;
91
+ server_name dsh.example.com;
92
+
93
+ ssl_certificate /etc/letsencrypt/live/<lineage>/fullchain.pem;
94
+ ssl_certificate_key /etc/letsencrypt/live/<lineage>/privkey.pem;
95
+
96
+ access_log /var/log/nginx/dsh.access.log dsh_nokey;
97
+
98
+ client_max_body_size 64m; # images/uploads
99
+ auth_basic "DSH";
100
+ auth_basic_user_file /etc/nginx/.htpasswd-dsh-remote;
101
+
102
+ location / {
103
+ proxy_pass http://127.0.0.1:8788;
104
+ proxy_http_version 1.1;
105
+ proxy_set_header Upgrade $http_upgrade; # WebSocket: without this the UI says "disconnected"
106
+ proxy_set_header Connection $connection_upgrade;
107
+ proxy_set_header Host $host;
108
+ proxy_buffering off; # streaming output dies with buffering on
109
+ proxy_read_timeout 3600s; # long turns
110
+ proxy_send_timeout 3600s;
111
+ }
112
+ }
113
+ ```
114
+
115
+ Deliberately **no port-80 `server` block** for this name: an exact `server_name` on port 80
116
+ would shadow `/.well-known/acme-challenge/` and break issuance/renewal. Let your existing
117
+ default block handle http→https.
118
+
119
+ Caddy equivalent:
120
+
121
+ ```caddyfile
122
+ dsh.example.com {
123
+ basic_auth {
124
+ dsh <bcrypt-hash>
125
+ }
126
+ reverse_proxy 127.0.0.1:8788 {
127
+ flush_interval -1
128
+ }
129
+ log {
130
+ output file /var/log/caddy/dsh.access.log
131
+ format filter {
132
+ wrap json
133
+ fields {
134
+ request>uri query {
135
+ delete ?k
136
+ }
137
+ }
138
+ }
139
+ }
140
+ }
141
+ ```
142
+
143
+ ---
144
+
145
+ ## 4. TLS
146
+
147
+ Extend the certificate that already covers your domain so its SAN list includes the entry name,
148
+ then reload the web server:
149
+
150
+ ```bash
151
+ sudo certbot certonly --webroot -w /var/www/html --expand -d dsh.example.com -d example.com
152
+ sudo nginx -t && sudo systemctl reload nginx # without this the old certificate stays in memory
153
+ ```
154
+
155
+ Verify from **outside** that the served certificate is the one on disk:
156
+
157
+ ```bash
158
+ echo | openssl s_client -connect dsh.example.com:443 -servername dsh.example.com 2>/dev/null \
159
+ | openssl x509 -noout -dates -ext subjectAltName -fingerprint -sha256
160
+ openssl x509 -in /etc/letsencrypt/live/<lineage>/fullchain.pem -noout -fingerprint -sha256
161
+ ```
162
+
163
+ The two SHA-256 values must match. Copy the on-disk one and pin it from the client side:
164
+
165
+ ```bash
166
+ npx dsh-plugin-remote-connect-beta doctor --domain dsh.example.com \
167
+ --expect-cert-sha256 <sha256> --ssh-user dshtunnel --ssh-host dsh.example.com --ssh-port 22022
168
+ ```
169
+
170
+ ---
171
+
172
+ ## 4.5 The edge credential (do this instead of inventing a password)
173
+
174
+ The edge password is the only door in front of your machine, so do not improvise it:
175
+
176
+ ```bash
177
+ npx dsh-plugin-remote-connect-beta credential # a typeable passphrase (~46 bit) + the exact commands
178
+ npx dsh-plugin-remote-connect-beta credential --random # or a 24-character random one (~141 bit)
179
+ ```
180
+
181
+ It prints the password **once** (save it in your password manager) and the two ways to install
182
+ it on the server. The password travels over stdin, so it never lands in the process list or shell
183
+ history:
184
+
185
+ ```bash
186
+ printf %s '<the password>' | sudo htpasswd -i -c /etc/nginx/.htpasswd-dsh dsh
187
+ sudo chmod 640 /etc/nginx/.htpasswd-dsh && sudo nginx -t && sudo systemctl reload nginx
188
+ ```
189
+
190
+ Or let the generated installer do it, also over stdin:
191
+
192
+ ```bash
193
+ printf %s '<the password>' | sudo bash /tmp/dsh-server-setup.sh install --auth-password-stdin
194
+ ```
195
+
196
+ Verify the gate, then verify it opens with the password:
197
+
198
+ ```bash
199
+ curl -sS -o /dev/null -w '%{http_code}\n' https://dsh.example.com/ # 401 without credentials
200
+ curl -sS -o /dev/null -w '%{http_code}\n' -u 'dsh:<the password>' https://dsh.example.com/ # not 401
201
+ ```
202
+
203
+ Rotating this password does **not** affect the plugin's `?k=` key, and rotating `?k=` does not
204
+ affect this one — two independent doors. Never put the password in a URL
205
+ (`https://user:pass@host/`): browsers strip it and it leaks into history and logs.
206
+
207
+ ### 4.6 Naming and rotating the edge user
208
+
209
+ `setup-server --edge-user <name>` changes the user name (default `dsh`); the installer records the
210
+ previous name it wrote and deletes that entry first, so a renamed account cannot be logged into with
211
+ the old name. `--edge-password auto` (default) generates a password and prints it once;
212
+ `--edge-password prompt` lets the server-side installer ask interactively; `--edge-password <text>`
213
+ takes your own (validated: at least 12 characters, no common weak values). The password always
214
+ travelled over stdin — never in `argv`, `ps` output or shell history.
215
+
216
+ After rotating, delete the old password from your browser/password manager before retrying: a browser
217
+ that keeps replaying an old password can pile up 401s and trip your provider's rate limiting.
218
+
219
+ ### 4.7 Changing the access password requires the current one
220
+
221
+ The plugin's own access password (the `?k=` in the link) cannot be changed with one click:
222
+
223
+ 1. The panel asks for **current password + new password + confirmation**, checks them locally
224
+ (at least 12 characters, both new entries equal, new differs from current), then posts the current
225
+ password to `POST /access-key/verify`.
226
+ 2. Only if that verification passes does it call `POST /access-key/rotate`, which writes the new value,
227
+ bumps the key epoch (every issued cookie stops matching) and invalidates every old link and QR code.
228
+ A failed verification writes nothing, so a wrong "current password" can never leave you locked out.
229
+ 3. Five consecutive failures put the entry into a five-minute cooldown, and each failure is written to
230
+ `$DSH_HOME/remote-connect/audit.log` (time and event only — never the password).
231
+ 4. **Reset** is a separate, deliberately degraded path for "I forgot it": no current password is
232
+ required, but it must be confirmed explicitly (the panel says so), it invalidates old links and
233
+ sessions, and it leaves an audit line.
234
+
235
+ The password never appears in `argv`, logs, telemetry or the UI after submission.
236
+
237
+ ### 4.8 A password that is already in use is refused
238
+
239
+ A password is a credential, not a name, so the same value must not be shared by two identities — and
240
+ reusing your own previous password means "nothing actually changed" while you believe it did. Before
241
+ writing, the plugin checks the candidate against the current key **and** against the SHA-256
242
+ fingerprints of every key that has been active (stored as 16-hex prefixes; the plaintext of old keys is
243
+ never kept). A hit is refused with "this password is already in use, pick another" — it never says
244
+ whose it is. The check happens only **after** the current password was verified (otherwise the endpoint
245
+ would be an oracle for "is anyone using this password?") and it shares one lock with the write, so two
246
+ concurrent changes cannot both pass.
247
+
248
+ ## 5. The tunnel account
249
+
250
+ ```bash
251
+ sudo useradd -m -s /usr/sbin/nologin dshtunnel
252
+ sudo install -d -m 700 -o dshtunnel -g dshtunnel /home/dshtunnel/.ssh
253
+ # paste the restricted line printed by `keygen` into /home/dshtunnel/.ssh/authorized_keys
254
+ sudo chown dshtunnel:dshtunnel /home/dshtunnel/.ssh/authorized_keys
255
+ sudo chmod 600 /home/dshtunnel/.ssh/authorized_keys
256
+ ```
257
+
258
+ The line looks like this (one line, `permitlisten` is what keeps the forward on loopback):
259
+
260
+ ```
261
+ restrict,remote-port-forwarding,permitlisten="127.0.0.1:8788" ssh-ed25519 AAAAC3Nza... dsh-tunnel
262
+ ```
263
+
264
+ The generated drop-in `/etc/ssh/sshd_config.d/60-dsh-remote.conf` contains exactly two lines —
265
+ `AllowTcpForwarding yes` and `ClientAliveInterval 30` — and is owned by the installer, so deleting
266
+ it reverts the change.
267
+
268
+ There is no CLI flag for that path: if your security baseline wants a `Match User dshtunnel` block
269
+ (or a different filename), edit the generated script before running it, or call
270
+ `buildServerSetupScript` from `lib/core/serversetup.js` with your own `sshdDropin` / `deployHook`
271
+ values. The two requirements are only: forwarding allowed for this account, and a keep-alive long
272
+ enough that the tunnel survives idle periods. Do **not** set `GatewayPorts yes`.
273
+
274
+ ---
275
+
276
+ ### 5.5 If your provider rate-limits SSH connections
277
+
278
+ Some hosts cap **new connections per IP** (a common firewall rule is 20 per minute, 8 concurrent).
279
+ A tunnel that retries on a fixed short interval will hit that cap, and the symptom is not an error
280
+ message — SSH simply times out, which is very hard to diagnose. The plugin therefore backs off
281
+ **exponentially with jitter** (5 s, 10 s, 20 s … capped at 60 s, ±25 %), and only resets that
282
+ counter after the tunnel has been stable for two minutes. If your provider is stricter, tune
283
+ `backoffBaseMs` / `backoffMaxMs` in the tunnel options rather than retrying harder.
284
+
285
+ ## 6. DNS
286
+
287
+ One `A` record for the entry name pointing at the server. That is all this plugin needs.
288
+
289
+ If you run your own authoritative DNS (BIND, CoreDNS, …) with several views or several
290
+ nameservers, remember that **every** copy must agree, and bump the zone serial so secondaries
291
+ pick the change up. A record that exists in only one view gives you "sometimes it resolves"
292
+ behaviour that looks like a plugin bug and is not.
293
+
294
+ ---
295
+
296
+ ## 7. Client side
297
+
298
+ ```bash
299
+ export DSH_REMOTE_KEY=$(openssl rand -hex 16)
300
+ npx dsh-plugin-remote-connect-beta serve --public --key "$DSH_REMOTE_KEY" \
301
+ --domain dsh.example.com --tunnel ssh \
302
+ --ssh-user dshtunnel --ssh-host dsh.example.com --ssh-key ~/.ssh/dsh_remote_tunnel --ssh-port 22022
303
+ ```
304
+
305
+ Or keep it running as part of the Harness (recommended) — one row in
306
+ `profiles/web/cordis.patch.yml`:
307
+
308
+ ```yaml
309
+ - insert:
310
+ - id: dsh-plugin-remote-connect-beta
311
+ name: dsh-plugin-remote-connect-beta
312
+ config:
313
+ lan: { enabled: true, port: 8787 }
314
+ public:
315
+ enabled: true
316
+ domain: dsh.example.com
317
+ port: 8788
318
+ accessKey: <the key from step 1>
319
+ tunnel: ssh
320
+ ssh: { user: dshtunnel, host: dsh.example.com, port: 22022, keyPath: ~/.ssh/dsh_remote_tunnel, remotePort: 8788 }
321
+ ```
322
+
323
+ The panel then shows the entry URL (`https://dsh.example.com/?k=…`) and a QR code; the
324
+ `Check server` button runs the same checks as `doctor`.
325
+
326
+ ---
327
+
328
+ ## 8. Verify, then keep it verified
329
+
330
+ ```bash
331
+ npx dsh-plugin-remote-connect-beta check --domain dsh.example.com --user dsh --password '***' \
332
+ --ssh-user dshtunnel --ssh-host dsh.example.com --ssh-port 22022
333
+ npx dsh-plugin-remote-connect-beta doctor --domain dsh.example.com \
334
+ --expect-cert-sha256 <sha256> --ssh-user dshtunnel --ssh-host dsh.example.com --ssh-port 22022
335
+ ```
336
+
337
+ `check` covers DNS / certificate / edge password / ssh tunnel; `doctor` adds upstream and token
338
+ provenance, a proxy self-test, the certificate actually served, the fingerprint comparison, and
339
+ the checks only the server can run (access-log leak, deploy hook). Run `doctor` again after any
340
+ certificate renewal or nginx edit.
341
+
342
+ ---
343
+
344
+ ### 8.5 When a link does not work: read `X-DSH-Reason`
345
+
346
+ Every failure keeps the same HTTP status (404, so a stranger cannot probe for the entry) but now says
347
+ *why* in a response header and in a distinct page:
348
+
349
+ | `X-DSH-Reason` | Meaning | Page you see |
350
+ | --- | --- | --- |
351
+ | `no-key` | the request carried no access key at all | short "no access key" page |
352
+ | `bad-key` | the value does not match the current key (truncated or mistyped) | "that key is not correct" |
353
+ | `key-unusable` | the value has the right shape but is a rotated/old key | "that key is no longer valid" |
354
+ | `host-not-allowed` | the request arrived with an unexpected Host header | plain 404 |
355
+
356
+ ```bash
357
+ curl -sS -D - -o /dev/null "https://dsh.example.com/?k=00001111" | grep -i '^x-dsh-reason'
358
+ curl -sS "https://dsh.example.com/_dsh/health" # no key needed, never returns the key
359
+ ```
360
+
361
+ `/_dsh/health` reports the tunnel state, the key fingerprint, when it was created, how many times it
362
+ has been rotated, the last successful access and the last 24 hours of failures by reason — enough to
363
+ tell "tunnel is down" from "your link is old" without reading any server log. Failures are also logged
364
+ by the plugin as `reason=… key_len=… key_fp8=<sha256 prefix>` — never the key itself.
365
+
366
+ The access key is persisted (0600) the first time it exists, so restarts, tunnel reconnects and
367
+ reboots do not change it; it only changes when you explicitly reset or change it. The panel shows its
368
+ fingerprint, creation time and rotation count so you can check at a glance whether the link in someone's
369
+ hand is the current one.
370
+
371
+ ## 9. Troubleshooting
372
+
373
+ | Symptom | Cause | Fix |
374
+ | --- | --- | --- |
375
+ | `502 Bad Gateway` | proxy points at the wrong port | target must be the plugin's port (`8788`), not the Harness port |
376
+ | Page loads, output never streams | response buffering | `proxy_buffering off` (Caddy: `flush_interval -1`) |
377
+ | UI keeps saying "disconnected" | WebSocket upgrade dropped | forward `Upgrade`/`Connection` (the `map` above) |
378
+ | Long tasks cut off | default 60s read timeout | `proxy_read_timeout 3600s` |
379
+ | `413` on uploads | body size limit | `client_max_body_size 64m` |
380
+ | Blank page under a subpath | Harness needs the root path | serve at `/`, not `/dsh/` |
381
+ | `Permission denied (publickey)` | key not installed for the account, or wrong permissions | check `authorized_keys` content, owner, `600` |
382
+ | `remote port forwarding failed` | `permitlisten` missing/mismatched | it must be `127.0.0.1:8788`, matching `remotePort` |
383
+ | Certificate "old" after renewal | server never reloaded | reload, and keep the `renewal-hooks/deploy` hook installed |
384
+ | Works on some networks only | a DNS view/secondary is stale | update every zone copy, bump the serial |
385
+ | Works with `curl`, not in the browser | browser too old | the Harness UI needs Chrome/Edge 119+, Safari 17.4+, Firefox 121+ |
386
+
387
+ ---
388
+
389
+ ## 10. What this page deliberately does not do
390
+
391
+ - No hosted relay, no shared entry point, no third-party account: the plugin only ever talks to
392
+ the server you configured.
393
+ - No IP allowlist / geo restriction options — access control is the two credentials
394
+ (edge password + `?k=` key) plus the Harness session, by design.
395
+ - No "multi-user inside one Harness": a Harness instance is single-user by construction, and this
396
+ guide only wires one ingress to one instance. Serving several people is a separate layer — the
397
+ gateway gives each tenant their own instance, credentials, port and token; see
398
+ [`multi-tenant.md`](multi-tenant.md).