@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 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
- ## Run the relay (your server)
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
- this.creds = { subdomain: label, password: String(password), e2ePassword: this.creds?.e2ePassword, e2eSalt: this.creds?.e2eSalt };
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 direct = /^wss?:\/\//.test(this.config.relayHost) || this.config.originCa !== "";
4885
+ const host = this.effectiveRelayHost();
4886
+ const direct = /^wss?:\/\//.test(host) || this.effectiveOriginCa() !== null;
4856
4887
  return {
4857
- relayHost: this.config.relayHost.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""),
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 base = this.config.relayHost.includes("://") ? this.config.relayHost : `wss://${this.config.relayHost}`;
4942
+ const relayHost = this.effectiveRelayHost();
4943
+ const base = relayHost.includes("://") ? relayHost : `wss://${relayHost}`;
4912
4944
  const wsOpts = { maxPayload: 512 * 1024 * 1024 };
4913
- if (this.config.originCa !== "") {
4914
- try {
4915
- wsOpts.ca = readFileSync(this.config.originCa);
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(config.relayHost),
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 err = tunnel.configure(body.subdomain, body.password);
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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dshn/agent",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Forward a local dsh web service to the public internet over ds.hn (bundled).",
5
5
  "keywords": [
6
6
  "dsh",