@dshn/agent 0.1.2 → 0.1.4
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 +14 -1
- package/client.js +62 -4
- package/lib/index.js +53 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -111,7 +111,20 @@ Agent environment (all optional; sensible defaults):
|
|
|
111
111
|
| `DSHN_STATE` | `~/.dsh/dshn-agent.json` | legacy state file (creds now live in `settings.yaml`) |
|
|
112
112
|
| `DSH_HOME` | `~/.dsh` | dsh home directory |
|
|
113
113
|
|
|
114
|
-
##
|
|
114
|
+
## Self-host your own network
|
|
115
|
+
|
|
116
|
+
You don't have to use `ds.hn` — run the whole thing on your own domain. The relay
|
|
117
|
+
ships as **`@dshn/relay`** (npm) and a Docker image; your agents point at it with
|
|
118
|
+
`DSHN_RELAY_HOST`. Full guide, including DNS + TLS options: **[SELF-HOSTING.md](./SELF-HOSTING.md)**.
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
# your server
|
|
122
|
+
DSHN_APEX=tunnel.example.com DSHN_COOKIE_SECRET=$(openssl rand -hex 32) npx @dshn/relay
|
|
123
|
+
# your dsh
|
|
124
|
+
DSHN_RELAY_HOST=wss://tunnel.example.com dsh --profile web
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Or from source:
|
|
115
128
|
|
|
116
129
|
```sh
|
|
117
130
|
pnpm install && pnpm build
|
package/client.js
CHANGED
|
@@ -331,6 +331,13 @@ window.__ModuleLoader__.load({
|
|
|
331
331
|
.dshn-err { color: var(--dsw-alias-state-error-primary, #e5484d); font-size: 11.5px; margin-bottom: 9px; }
|
|
332
332
|
.dshn-hint { color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 10.5px; margin-top: 3px; }
|
|
333
333
|
.dshn-note { color: var(--dsw-alias-state-warn-primary, #d98324); font-size: 10.5px; margin: 2px 0 10px; }
|
|
334
|
+
.dshn-adv { margin: 2px 0 12px; }
|
|
335
|
+
.dshn-adv-toggle { border: 0; background: transparent; cursor: pointer; padding: 4px 0; font-size: 11.5px;
|
|
336
|
+
color: var(--dsw-alias-label-tertiary, #8b9099); font-family: inherit; }
|
|
337
|
+
.dshn-adv-toggle:hover { color: var(--dsw-alias-label-primary, #1c1e21); }
|
|
338
|
+
.dshn-adv-body { display: flex; flex-direction: column; gap: 10px; margin-top: 6px; padding: 11px 12px; border-radius: 10px;
|
|
339
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,134,142,.25)); background: var(--dsw-alias-bg-layer-2, #f4f5f7); }
|
|
340
|
+
.dshn-ca { min-height: 54px; resize: vertical; font-family: ui-monospace, Menlo, monospace; font-size: 11px; line-height: 1.4; }
|
|
334
341
|
.dshn-e2e-box { margin: 4px 0 12px; padding: 11px 12px; border-radius: 10px;
|
|
335
342
|
border: 1px solid var(--dsw-alias-border-l2, rgba(128,134,142,.25)); background: var(--dsw-alias-bg-layer-2, #f4f5f7); }
|
|
336
343
|
.dshn-e2e-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px;
|
|
@@ -384,7 +391,11 @@ window.__ModuleLoader__.load({
|
|
|
384
391
|
localOnly: '公网转发的配置只能在本机(打开 dsh 的这台机器)进行。', loading: '加载中…',
|
|
385
392
|
openSettings: '打开设置', open: '打开', addrLabel: '公网地址',
|
|
386
393
|
dcTitle: '确认断开公网转发?', dcWarn: '断开会立即切断公网访问。云端不保存你的密码、无法找回;若你没有另存密码,之后可能无法用同一前缀重新连接。',
|
|
387
|
-
dcConfirm: '确认断开', dcCancel: '取消', dcCopyFirst: '先复制密码'
|
|
394
|
+
dcConfirm: '确认断开', dcCancel: '取消', dcCopyFirst: '先复制密码',
|
|
395
|
+
advanced: '高级 · 自托管中继', relayHost: '中继地址',
|
|
396
|
+
relayHostHint: '自托管时填你自己的中继,如 wss://tunnel.example.com;留空即用默认。改动后点“连接/重新连接”生效。',
|
|
397
|
+
relayCa: '中继证书(自签名,可选)',
|
|
398
|
+
relayCaHint: '仅当你的中继用自签名证书时:粘贴其 PEM 证书以固定信任(公有证书/套 Cloudflare 时留空)。' }
|
|
388
399
|
: { brand: 'Public forwarding · ds.hn', connecting: 'connecting…', live: 'live', off: 'off', notset: 'not set up',
|
|
389
400
|
setupTitle: 'Set up public forwarding', setupSub: 'Pick a subdomain prefix and an access password — the two are your credential.',
|
|
390
401
|
connTitle: 'Public forwarding', prefix: 'Subdomain prefix', password: 'Access password', confirm: 'Confirm password',
|
|
@@ -404,7 +415,11 @@ window.__ModuleLoader__.load({
|
|
|
404
415
|
localOnly: 'Public-forwarding settings can only be changed on this machine (where dsh is running).', loading: 'Loading…',
|
|
405
416
|
openSettings: 'open settings', open: 'open', addrLabel: 'Public address',
|
|
406
417
|
dcTitle: 'Disconnect public forwarding?', dcWarn: 'This immediately cuts off public access. The cloud does not store your password and cannot recover it — if you have not saved it elsewhere, you may not be able to reconnect with the same prefix.',
|
|
407
|
-
dcConfirm: 'Disconnect', dcCancel: 'Cancel', dcCopyFirst: 'Copy password first'
|
|
418
|
+
dcConfirm: 'Disconnect', dcCancel: 'Cancel', dcCopyFirst: 'Copy password first',
|
|
419
|
+
advanced: 'Advanced · self-hosted relay', relayHost: 'Relay host',
|
|
420
|
+
relayHostHint: 'For self-hosting, your own relay, e.g. wss://tunnel.example.com; blank uses the default. Connect / Reconnect to apply.',
|
|
421
|
+
relayCa: 'Relay CA (self-signed, optional)',
|
|
422
|
+
relayCaHint: 'Only when your relay uses a self-signed cert: paste its PEM to pin trust (leave blank for a public cert / behind Cloudflare).' }
|
|
408
423
|
|
|
409
424
|
function strength(pw) {
|
|
410
425
|
if (pw.length < MIN_PW) return { score: 0, ok: false }
|
|
@@ -467,7 +482,6 @@ window.__ModuleLoader__.load({
|
|
|
467
482
|
function Panel(props) {
|
|
468
483
|
const s = props.status
|
|
469
484
|
const configured = s.configured
|
|
470
|
-
const apex = s.apex || 'ds.hn'
|
|
471
485
|
const [prefix, setPrefix] = react.useState(configured ? (s.subdomain || '') : '')
|
|
472
486
|
const [pw, setPw] = react.useState(configured ? (s.password || '') : '')
|
|
473
487
|
const [confirm, setConfirm] = react.useState('')
|
|
@@ -480,6 +494,11 @@ window.__ModuleLoader__.load({
|
|
|
480
494
|
const [e2eBusy, setE2eBusy] = react.useState(false)
|
|
481
495
|
const [e2eErr, setE2eErr] = react.useState('')
|
|
482
496
|
const [e2eMsg, setE2eMsg] = react.useState('')
|
|
497
|
+
// Self-hosted relay overrides (advanced). Pre-filled from the saved settings.
|
|
498
|
+
const rs0 = s.relaySettings || {}
|
|
499
|
+
const [relayHost, setRelayHost] = react.useState(rs0.relayHost || '')
|
|
500
|
+
const [originCa, setOriginCa] = react.useState(rs0.originCa || '')
|
|
501
|
+
const [showAdvanced, setShowAdvanced] = react.useState(!!rs0.relayHost)
|
|
483
502
|
|
|
484
503
|
// When the saved passwords arrive from a later status poll, fill them once.
|
|
485
504
|
react.useEffect(() => {
|
|
@@ -488,6 +507,27 @@ window.__ModuleLoader__.load({
|
|
|
488
507
|
react.useEffect(() => {
|
|
489
508
|
if (configured && s.e2ePassword && e2e === '') setE2e(s.e2ePassword)
|
|
490
509
|
}, [s.e2ePassword])
|
|
510
|
+
// Fill the self-hosted relay fields once the saved settings arrive; auto-open
|
|
511
|
+
// the advanced section when a custom relay is already configured.
|
|
512
|
+
react.useEffect(() => {
|
|
513
|
+
const rs = s.relaySettings
|
|
514
|
+
if (!rs) return
|
|
515
|
+
if (rs.relayHost && relayHost === '') { setRelayHost(rs.relayHost); setShowAdvanced(true) }
|
|
516
|
+
if (rs.originCa && originCa === '') setOriginCa(rs.originCa)
|
|
517
|
+
}, [s.relaySettings && s.relaySettings.relayHost])
|
|
518
|
+
|
|
519
|
+
// The domain suffix reflects the self-hosted relay AS YOU TYPE it (a
|
|
520
|
+
// client-side mirror of the server's apex derivation), so the preview shows
|
|
521
|
+
// YOUR domain — not the default ds.hn — before you've connected. Empty →
|
|
522
|
+
// the server's current apex.
|
|
523
|
+
const apex = (() => {
|
|
524
|
+
const rh = relayHost.trim()
|
|
525
|
+
if (rh === '') return s.apex || 'ds.hn'
|
|
526
|
+
const bare = rh.replace(/^wss?:\/\//, '').replace(/:\d+$/, '').replace(/\/.*$/, '')
|
|
527
|
+
const parts = bare.split('.')
|
|
528
|
+
if (parts.length > 2 && (parts[0] === 'relay' || parts[0] === 'origin')) return parts.slice(1).join('.')
|
|
529
|
+
return bare || (s.apex || 'ds.hn')
|
|
530
|
+
})()
|
|
491
531
|
|
|
492
532
|
const copyText = (text, tag) => {
|
|
493
533
|
if (!text || !navigator.clipboard) return
|
|
@@ -519,7 +559,7 @@ window.__ModuleLoader__.load({
|
|
|
519
559
|
setBusy(true); setErr('')
|
|
520
560
|
const e2eVal = e2e.trim()
|
|
521
561
|
fetch('/dshn/configure', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
522
|
-
body: JSON.stringify({ subdomain: prefix.trim().toLowerCase(), password: pw }) })
|
|
562
|
+
body: JSON.stringify({ subdomain: prefix.trim().toLowerCase(), password: pw, relayHost: relayHost.trim(), originCa: originCa.trim() }) })
|
|
523
563
|
.then((r) => r.json().then((j) => ({ ok: r.ok, j })))
|
|
524
564
|
.then(async ({ ok, j }) => {
|
|
525
565
|
if (!ok) { setBusy(false); setErr((j && j.error) || 'failed'); return }
|
|
@@ -657,6 +697,24 @@ window.__ModuleLoader__.load({
|
|
|
657
697
|
h('div', { className: 'dshn-hint', style: { marginTop: '7px' } }, T.e2eHint + ' ' + T.e2eIndep))
|
|
658
698
|
})() : null,
|
|
659
699
|
|
|
700
|
+
// Advanced — self-hosted relay. Point the agent at your own @dshn/relay
|
|
701
|
+
// instead of the default ds.hn. Collapsed unless a custom relay is already
|
|
702
|
+
// set; applied on Connect/Reconnect (changing it reconnects the tunnel).
|
|
703
|
+
h('div', { className: 'dshn-adv' },
|
|
704
|
+
h('button', { type: 'button', className: 'dshn-adv-toggle', onClick: () => setShowAdvanced(!showAdvanced) },
|
|
705
|
+
(showAdvanced ? '▾ ' : '▸ ') + T.advanced),
|
|
706
|
+
showAdvanced ? h('div', { className: 'dshn-adv-body' },
|
|
707
|
+
h('label', { className: 'dshn-field' },
|
|
708
|
+
h('span', null, T.relayHost),
|
|
709
|
+
h('input', { className: 'dshn-input', value: relayHost, placeholder: s.defaultRelayHost || 'relay.ds.hn',
|
|
710
|
+
autoComplete: 'off', spellCheck: false, onChange: (ev) => setRelayHost(ev.target.value) }),
|
|
711
|
+
h('div', { className: 'dshn-hint' }, T.relayHostHint)),
|
|
712
|
+
h('label', { className: 'dshn-field' },
|
|
713
|
+
h('span', null, T.relayCa),
|
|
714
|
+
h('textarea', { className: 'dshn-input dshn-ca', value: originCa, rows: 3, spellCheck: false,
|
|
715
|
+
placeholder: '-----BEGIN CERTIFICATE-----', onChange: (ev) => setOriginCa(ev.target.value) }),
|
|
716
|
+
h('div', { className: 'dshn-hint' }, T.relayCaHint))) : null),
|
|
717
|
+
|
|
660
718
|
// Disconnecting severs public access and the password is unrecoverable
|
|
661
719
|
// from the cloud — so it takes an explicit, spelled-out confirmation.
|
|
662
720
|
confirmDc ? h('div', { className: 'dshn-dcwarn' },
|
package/lib/index.js
CHANGED
|
@@ -4625,7 +4625,9 @@ var CREDS_SCHEMA = Schema.object({
|
|
|
4625
4625
|
subdomain: Schema.string().default(""),
|
|
4626
4626
|
password: Schema.string().role("secret").default(""),
|
|
4627
4627
|
e2ePassword: Schema.string().role("secret").default(""),
|
|
4628
|
-
e2eSalt: Schema.string().default("")
|
|
4628
|
+
e2eSalt: Schema.string().default(""),
|
|
4629
|
+
relayHost: Schema.string().default(""),
|
|
4630
|
+
originCa: Schema.string().default("")
|
|
4629
4631
|
});
|
|
4630
4632
|
function readCredsFile(path) {
|
|
4631
4633
|
try {
|
|
@@ -4635,7 +4637,9 @@ function readCredsFile(path) {
|
|
|
4635
4637
|
subdomain: raw.subdomain,
|
|
4636
4638
|
password: raw.password,
|
|
4637
4639
|
e2ePassword: typeof raw.e2ePassword === "string" && raw.e2ePassword !== "" ? raw.e2ePassword : void 0,
|
|
4638
|
-
e2eSalt: typeof raw.e2eSalt === "string" ? raw.e2eSalt : void 0
|
|
4640
|
+
e2eSalt: typeof raw.e2eSalt === "string" ? raw.e2eSalt : void 0,
|
|
4641
|
+
relayHost: typeof raw.relayHost === "string" && raw.relayHost !== "" ? raw.relayHost : void 0,
|
|
4642
|
+
originCa: typeof raw.originCa === "string" && raw.originCa !== "" ? raw.originCa : void 0
|
|
4639
4643
|
};
|
|
4640
4644
|
}
|
|
4641
4645
|
} catch {
|
|
@@ -4655,14 +4659,16 @@ function settingsStore(scope, migrateFrom) {
|
|
|
4655
4659
|
const store = {
|
|
4656
4660
|
load: () => {
|
|
4657
4661
|
const v = scope.get() ?? {};
|
|
4658
|
-
return typeof v.subdomain === "string" && v.subdomain !== "" ? { subdomain: v.subdomain, password: v.password ?? "", e2ePassword: v.e2ePassword || void 0, e2eSalt: v.e2eSalt || void 0 } : null;
|
|
4662
|
+
return typeof v.subdomain === "string" && v.subdomain !== "" ? { subdomain: v.subdomain, password: v.password ?? "", e2ePassword: v.e2ePassword || void 0, e2eSalt: v.e2eSalt || void 0, relayHost: v.relayHost || void 0, originCa: v.originCa || void 0 } : null;
|
|
4659
4663
|
},
|
|
4660
4664
|
save: (creds) => {
|
|
4661
4665
|
Promise.resolve(scope.update({
|
|
4662
4666
|
subdomain: creds?.subdomain ?? "",
|
|
4663
4667
|
password: creds?.password ?? "",
|
|
4664
4668
|
e2ePassword: creds?.e2ePassword ?? "",
|
|
4665
|
-
e2eSalt: creds?.e2eSalt ?? ""
|
|
4669
|
+
e2eSalt: creds?.e2eSalt ?? "",
|
|
4670
|
+
relayHost: creds?.relayHost ?? "",
|
|
4671
|
+
originCa: creds?.originCa ?? ""
|
|
4666
4672
|
})).catch(() => {
|
|
4667
4673
|
});
|
|
4668
4674
|
}
|
|
@@ -4796,13 +4802,15 @@ var AgentTunnel = class {
|
|
|
4796
4802
|
* mirrors the relay's so the user sees the error locally before a round trip.
|
|
4797
4803
|
* @returns null on success, or a human-readable error.
|
|
4798
4804
|
*/
|
|
4799
|
-
configure(subdomain, password) {
|
|
4805
|
+
configure(subdomain, password, relayHost, originCa) {
|
|
4800
4806
|
const label = String(subdomain).trim().toLowerCase();
|
|
4801
4807
|
if (!isValidSubdomainLabel(label))
|
|
4802
4808
|
return "Invalid subdomain: 4\u201332 lowercase letters, digits or hyphens (not reserved).";
|
|
4803
4809
|
if (String(password).length < 8)
|
|
4804
4810
|
return "Password must be at least 8 characters.";
|
|
4805
|
-
|
|
4811
|
+
const rh = typeof relayHost === "string" ? relayHost.trim() : this.creds?.relayHost ?? "";
|
|
4812
|
+
const ca = typeof originCa === "string" ? originCa.trim() : this.creds?.originCa ?? "";
|
|
4813
|
+
this.creds = { subdomain: label, password: String(password), e2ePassword: this.creds?.e2ePassword, e2eSalt: this.creds?.e2eSalt, relayHost: rh || void 0, originCa: ca || void 0 };
|
|
4806
4814
|
this.refreshE2E();
|
|
4807
4815
|
this.saveCreds(this.creds);
|
|
4808
4816
|
this.status.configured = true;
|
|
@@ -4850,11 +4858,34 @@ var AgentTunnel = class {
|
|
|
4850
4858
|
e2eInfo() {
|
|
4851
4859
|
return { enabled: this.e2eKey !== null, salt: this.e2eSalt };
|
|
4852
4860
|
}
|
|
4861
|
+
/** The relay authority actually dialled: the user's self-hosted override if set, else the env/default. */
|
|
4862
|
+
effectiveRelayHost() {
|
|
4863
|
+
const c = this.creds?.relayHost;
|
|
4864
|
+
return typeof c === "string" && c !== "" ? c : this.config.relayHost;
|
|
4865
|
+
}
|
|
4866
|
+
/** The origin CA to pin, as PEM bytes: inline PEM from the UI, or an env-configured file path. */
|
|
4867
|
+
effectiveOriginCa() {
|
|
4868
|
+
const raw = this.creds?.originCa && this.creds.originCa !== "" ? this.creds.originCa : this.config.originCa;
|
|
4869
|
+
if (raw === "" || raw === void 0)
|
|
4870
|
+
return null;
|
|
4871
|
+
if (raw.includes("BEGIN CERTIFICATE"))
|
|
4872
|
+
return Buffer.from(raw);
|
|
4873
|
+
try {
|
|
4874
|
+
return readFileSync(raw);
|
|
4875
|
+
} catch {
|
|
4876
|
+
return null;
|
|
4877
|
+
}
|
|
4878
|
+
}
|
|
4879
|
+
/** The raw self-hosted overrides, for the settings UI to pre-fill (loopback callers only). */
|
|
4880
|
+
relaySettings() {
|
|
4881
|
+
return { relayHost: this.creds?.relayHost ?? "", originCa: this.creds?.originCa ?? "" };
|
|
4882
|
+
}
|
|
4853
4883
|
/** Connection details for the local panel (relay, mode, uptime, latency, throughput). */
|
|
4854
4884
|
info() {
|
|
4855
|
-
const
|
|
4885
|
+
const host = this.effectiveRelayHost();
|
|
4886
|
+
const direct = /^wss?:\/\//.test(host) || this.effectiveOriginCa() !== null;
|
|
4856
4887
|
return {
|
|
4857
|
-
relayHost:
|
|
4888
|
+
relayHost: host.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""),
|
|
4858
4889
|
direct,
|
|
4859
4890
|
connectedSince: this.connectedSince,
|
|
4860
4891
|
served: this.served,
|
|
@@ -4908,15 +4939,12 @@ var AgentTunnel = class {
|
|
|
4908
4939
|
clearTimeout(this.reconnectTimer);
|
|
4909
4940
|
this.reconnectTimer = null;
|
|
4910
4941
|
}
|
|
4911
|
-
const
|
|
4942
|
+
const relayHost = this.effectiveRelayHost();
|
|
4943
|
+
const base = relayHost.includes("://") ? relayHost : `wss://${relayHost}`;
|
|
4912
4944
|
const wsOpts = { maxPayload: 512 * 1024 * 1024 };
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
} catch (err) {
|
|
4917
|
-
this.status.lastError = `cannot read origin CA: ${err.message}`;
|
|
4918
|
-
}
|
|
4919
|
-
}
|
|
4945
|
+
const ca = this.effectiveOriginCa();
|
|
4946
|
+
if (ca !== null)
|
|
4947
|
+
wsOpts.ca = ca;
|
|
4920
4948
|
const ws = new import_websocket.default(`${base}${AGENT_WS_PATH}`, wsOpts);
|
|
4921
4949
|
this.control = ws;
|
|
4922
4950
|
if (process.env.DSHN_DEBUG)
|
|
@@ -5313,12 +5341,17 @@ function apply(ctx, rawConfig) {
|
|
|
5313
5341
|
subdomain: tunnel.status.subdomain,
|
|
5314
5342
|
lastError: tunnel.status.lastError,
|
|
5315
5343
|
configurable: loopback,
|
|
5316
|
-
apex: publicApex(
|
|
5344
|
+
apex: publicApex(info.relayHost),
|
|
5317
5345
|
// The saved password is the only recoverable copy (cloud stores a hash).
|
|
5318
5346
|
// Only ever handed to a loopback caller — the local machine's owner.
|
|
5319
5347
|
password: loopback ? tunnel.revealPassword() : null,
|
|
5320
5348
|
e2ePassword: loopback ? tunnel.revealE2EPassword() : null,
|
|
5321
5349
|
e2eEnabled: tunnel.e2eInfo().enabled,
|
|
5350
|
+
// Self-hosted relay overrides, so the settings UI can pre-fill them
|
|
5351
|
+
// (loopback only). `defaultRelayHost` is what an empty override falls back
|
|
5352
|
+
// to, shown as the field's placeholder.
|
|
5353
|
+
relaySettings: loopback ? tunnel.relaySettings() : null,
|
|
5354
|
+
defaultRelayHost: config.relayHost,
|
|
5322
5355
|
// Connection details for the panel.
|
|
5323
5356
|
relayHost: info.relayHost,
|
|
5324
5357
|
mode: info.direct ? "direct" : "cloudflare",
|
|
@@ -5349,7 +5382,9 @@ function apply(ctx, rawConfig) {
|
|
|
5349
5382
|
if (body === null || typeof body.subdomain !== "string" || typeof body.password !== "string") {
|
|
5350
5383
|
return json(res, 400, { error: "expected { subdomain, password }" });
|
|
5351
5384
|
}
|
|
5352
|
-
const
|
|
5385
|
+
const relayHost = typeof body.relayHost === "string" ? body.relayHost : void 0;
|
|
5386
|
+
const originCa = typeof body.originCa === "string" ? body.originCa : void 0;
|
|
5387
|
+
const err = tunnel.configure(body.subdomain, body.password, relayHost, originCa);
|
|
5353
5388
|
if (err !== null)
|
|
5354
5389
|
return json(res, 400, { error: err });
|
|
5355
5390
|
json(res, 200, { ok: true, subdomain: tunnel.status.subdomain });
|