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.
- package/CHANGELOG.md +115 -0
- package/CONTRIBUTING.md +50 -0
- package/LICENSE +21 -0
- package/README.md +279 -0
- package/README.zh.md +298 -0
- package/SECURITY.md +41 -0
- package/bin/dsh-remote.js +768 -0
- package/docs/client-preview.png +0 -0
- package/docs/market-submission.md +82 -0
- package/docs/multi-tenant.md +136 -0
- package/docs/multi-tenant.zh.md +127 -0
- package/docs/self-host.md +398 -0
- package/docs/self-host.zh.md +369 -0
- package/docs/tenants-preview.png +0 -0
- package/lib/client.js +1120 -0
- package/lib/core/assets/server-setup.sh.tpl +344 -0
- package/lib/core/credential.js +123 -0
- package/lib/core/instance.js +360 -0
- package/lib/core/messages.js +549 -0
- package/lib/core/paths.js +59 -0
- package/lib/core/preflight.js +410 -0
- package/lib/core/proxy.js +924 -0
- package/lib/core/serversetup.js +133 -0
- package/lib/core/snippets.js +193 -0
- package/lib/core/tailscale.js +158 -0
- package/lib/core/tenancy.js +267 -0
- package/lib/core/tenant.js +272 -0
- package/lib/core/tunnel.js +320 -0
- package/lib/index.js +1097 -0
- package/package.json +76 -0
|
@@ -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).
|