@riceawa/dsh-lan-gateway 0.6.2 → 0.7.0

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.
@@ -1,19 +1,23 @@
1
1
  /**
2
- * The lan-gateway settings card, rendered by the official DSH Plugins page
3
- * through its `plugins.item` slot.
2
+ * The lan-gateway configuration card, rendered by the official DSH Plugins page
3
+ * on this bundle's own page through its `plugins.bundle.config` slot.
4
4
  *
5
5
  * ModLens-style: the card carries NO injected services. It reads and writes
6
6
  * the loopback-only `/lan-gateway/config` host route (the browser never sees
7
7
  * the settings seam or any secret), so the only platform service it needs is
8
8
  * the `slots` service every plugin already has.
9
9
  *
10
+ * The page — not the card — draws the plugin's title, icon, and crumb, and the
11
+ * card is the page body: `view: 'summary'` renders nothing (bundle
12
+ * configuration is `page`-only).
13
+ *
10
14
  * @module @riceawa/dsh-lan-gateway/client/card
11
15
  */
12
16
 
13
17
  import { useEffect, useState, type ChangeEvent, type ReactNode } from 'react'
14
18
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
15
- // Type-only. The Plugins page owns the `plugins.item` contract, and its own
16
- // doc says a registrant merges that contract with `import type` instead of
19
+ // Type-only. The Plugins page owns the `plugins.bundle.config` contract, and its
20
+ // own doc says a registrant merges that contract with `import type` instead of
17
21
  // importing the package at runtime. Taking the contract from its owner is also
18
22
  // what turns the next upstream rename of this slot into a compile error here,
19
23
  // rather than a card that quietly stops rendering.
@@ -29,12 +33,12 @@ import {
29
33
  } from '../config-fields.ts'
30
34
 
31
35
  /**
32
- * Props the renderer binds for this card. The Plugins page asks for either the
33
- * card's one-liner (`summary`) or the body of its own page (`page`), and draws
34
- * the page's title, icon, and crumb itself. The card needs no injected face —
35
- * it fetches its own route.
36
+ * Props the renderer binds for this card. The Plugins page asks a bundle's
37
+ * configuration entry only for the body of its own page (`page`); the shared
38
+ * contract still carries `summary`, which bundle configuration never renders.
39
+ * The card needs no injected face — it fetches its own route.
36
40
  */
37
- export type LanGatewayCardProps = PropsRuntime<'plugins.item'>
41
+ export type LanGatewayCardProps = PropsRuntime<'plugins.bundle.config'>
38
42
 
39
43
  /**
40
44
  * The card's field table and value codecs live in `config-fields.ts`, shared
@@ -97,6 +101,23 @@ export function passwordProblem(password: string, confirm: string): PasswordProb
97
101
  return null
98
102
  }
99
103
 
104
+ /**
105
+ * Whether the config route's refusal came from the gateway rather than from dsh
106
+ * itself.
107
+ *
108
+ * The gateway owns the `/lan-gateway*` prefix and answers it with a bare 403
109
+ * `forbidden` whenever the client is not on the host; dsh, when it answers the
110
+ * route at all, does not produce that pair. Telling the two apart is what lets
111
+ * the card say "you are not on the host loopback" instead of the generic
112
+ * "cannot read the configuration".
113
+ * @param status - the HTTP status of the failed read.
114
+ * @param body - that response's body.
115
+ * @returns true when the gateway itself refused the read.
116
+ */
117
+ export function refusedByGateway(status: number, body: string): boolean {
118
+ return status === 403 && body.trim() === 'forbidden'
119
+ }
120
+
100
121
  /* ------------------------------------------------------------------ */
101
122
  /* Bilingual copy (ModLens-style: two small sets, picked by browser) */
102
123
  /* ------------------------------------------------------------------ */
@@ -110,6 +131,7 @@ interface Labels {
110
131
  discard: string
111
132
  reset: string
112
133
  readOnly: string
134
+ readOnlyGateway: string
113
135
  saveFailed: string
114
136
  loadFailed: string
115
137
  emptyMeansClear: string
@@ -146,7 +168,8 @@ const LABELS: Record<'zh' | 'en', Labels> = {
146
168
  saving: '保存中…',
147
169
  discard: '放弃',
148
170
  reset: '重置',
149
- readOnly: '网关设置只能在宿主机本机打开 dsh web 时修改:配置路由仅监听回环地址,经网关远程访问的浏览器会被拒绝。远程请改用 lan_gateway 工具。',
171
+ readOnly: '读不到网关配置:这个接口只在宿主机本机应答——Host 必须是回环地址且同源。请在宿主机上打开 dsh web 时修改,远程请改用 lan_gateway 工具。',
172
+ readOnlyGateway: '网关拒绝了这次读取:/lan-gateway/* 管理面由网关独占,只放行「TCP 来源为回环 且 地址写的是 127.0.0.1 / localhost」的浏览器。你现在不是从宿主机回环地址访问的——请在宿主机上用 127.0.0.1 或 localhost 打开本页(局域网 IP、域名都不算),或远程改用 lan_gateway 工具。',
150
173
  saveFailed: '保存未生效,请检查输入后重试。',
151
174
  loadFailed: '无法读取网关配置',
152
175
  emptyMeansClear: '留空 = 使用默认',
@@ -211,7 +234,8 @@ const LABELS: Record<'zh' | 'en', Labels> = {
211
234
  saving: 'Saving…',
212
235
  discard: 'Discard',
213
236
  reset: 'Reset',
214
- readOnly: 'Gateway settings can only be changed where dsh web runs locally: the config route listens on loopback only, so a browser reaching dsh through the gateway is refused. Use the lan_gateway tool remotely.',
237
+ readOnly: 'Cannot read the configuration: the route answers on the host only — the Host must be a loopback address and same-origin. Change the settings where dsh web runs on the host, or use the lan_gateway tool remotely.',
238
+ readOnlyGateway: 'The gateway refused this read: it owns the /lan-gateway/* management plane and admits only browsers that are both loopback-sourced and using a 127.0.0.1 / localhost address. You are not on the host loopback — open this page on the host via 127.0.0.1 or localhost (a LAN IP or a hostname does not qualify), or use the lan_gateway tool remotely.',
215
239
  saveFailed: 'The save did not land — check the inputs and retry.',
216
240
  loadFailed: 'Cannot read the gateway configuration',
217
241
  emptyMeansClear: 'Empty = default',
@@ -275,34 +299,24 @@ function labels(): Labels {
275
299
  return lang.startsWith('zh') ? LABELS.zh : LABELS.en
276
300
  }
277
301
 
278
- /**
279
- * The card's title in the browser's language, for the Plugins page's list
280
- * entry. A thunk so the label follows the page's locale without re-registering.
281
- * @returns the localized card title.
282
- */
283
- export function cardTitle(): string {
284
- return labels().title
285
- }
286
-
287
302
  /* ------------------------------------------------------------------ */
288
303
  /* Card */
289
304
  /* ------------------------------------------------------------------ */
290
305
 
291
306
  /**
292
- * Render the LAN gateway card. Self-loading: fetches the config route on
293
- * mount, posts the edited config on save.
307
+ * Render the LAN gateway configuration card. Self-loading: fetches the config
308
+ * route on mount, posts the edited config on save.
294
309
  *
295
- * `view` swaps between the card's one-liner and its page body, so the branch
296
- * sits after the hooks: the Plugins page re-renders one contribution under the
297
- * other view when the card is opened.
310
+ * `summary` renders nothing: dsh asks a bundle's own configuration for its page
311
+ * body only, and the card's former one-liner (a list entry on the official
312
+ * plugin card) went away with `plugins.item`.
298
313
  * @param props - the view the Plugins page is asking for.
299
- * @returns the one-liner, the card, or nothing while the route is unreachable.
314
+ * @returns the page body, or nothing while the view is `summary`.
300
315
  */
301
316
  export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
302
317
  const t = labels()
303
- const [open, setOpen] = useState(false)
304
318
  const [route, setRoute] = useState<RouteState | null>(null)
305
- const [loadFailed, setLoadFailed] = useState(false)
319
+ const [failure, setFailure] = useState<'gateway' | 'other' | null>(null)
306
320
  const [drafts, setDrafts] = useState<Partial<Record<string, string>>>({})
307
321
  const [saving, setSaving] = useState(false)
308
322
  const [failed, setFailed] = useState<string | null>(null)
@@ -320,38 +334,38 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
320
334
  fetch('/lan-gateway/config')
321
335
  .then(async (response) => {
322
336
  if (cancelled) return
323
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
337
+ if (!response.ok) {
338
+ // The body is only read to tell the gateway's own bare refusal from
339
+ // any other answer; it is never rendered.
340
+ const body = await response.text().catch(() => '')
341
+ if (!cancelled) setFailure(refusedByGateway(response.status, body) ? 'gateway' : 'other')
342
+ return
343
+ }
324
344
  setRoute(await response.json() as RouteState)
325
345
  })
326
346
  .catch(() => {
327
- if (!cancelled) setLoadFailed(true)
347
+ if (!cancelled) setFailure('other')
328
348
  })
329
349
  return () => { cancelled = true }
330
350
  }, [])
331
351
 
332
- // The Plugins page lists this plugin as one card and opens its own page on
333
- // demand: `summary` is the one-liner the list shows, `page` the body. The
334
- // hooks above run for both views, because the same contribution flips
335
- // between them.
336
- if (props.view === 'summary') return t.description
337
-
338
- // A remote browser reaches this card through the gateway, which answers 403
339
- // for the plugin's own prefix by design, so the route is unreachable exactly
340
- // where a user is most likely to go looking for the setting. Rendering
341
- // nothing left them with a blank entry and no way to tell a missing card from
342
- // a broken one; say what is wrong and where the card does work instead.
343
- if (loadFailed) {
352
+ // dsh asks a bundle for its own configuration under `view: 'page'` only, and
353
+ // the page draws the title, icon, and crumb itself — so the card is the page
354
+ // body and has no one-liner. Returning null keeps the contribution honest for
355
+ // a host that ever asks for the other view.
356
+ if (props.view === 'summary') return null
357
+
358
+ // A browser that is not on the host reaches this card through the gateway,
359
+ // which answers 403 for the plugin's own prefix by design, so the route is
360
+ // unreachable exactly where a user is most likely to go looking for the
361
+ // setting. Rendering nothing left them with a blank entry and no way to tell a
362
+ // missing card from a broken one; say what is wrong — and whether the gateway
363
+ // or the host refused — and where the card does work instead.
364
+ if (failure !== null) {
344
365
  return (
345
366
  <div style={styles.card}>
346
- <div style={styles.header}>
347
- <span style={styles.headerTop}>
348
- <span style={styles.name}>{t.title}</span>
349
- </span>
350
- <span style={styles.description}>{t.loadFailed}</span>
351
- </div>
352
- <div style={styles.body}>
353
- <p style={styles.hint}>{t.readOnly}</p>
354
- </div>
367
+ <p style={styles.status}>{t.loadFailed}</p>
368
+ <p style={styles.hint}>{failure === 'gateway' ? t.readOnlyGateway : t.readOnly}</p>
355
369
  </div>
356
370
  )
357
371
  }
@@ -571,114 +585,103 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
571
585
  const statusLine = `${route.running ? t.running : t.stopped} · ${t.tls}: ${route.tls} · :${route.port}`
572
586
 
573
587
  return (
574
- <div style={open ? { ...styles.card, ...styles.cardOpen } : styles.card}>
575
- <button
576
- type="button"
577
- style={styles.header}
578
- aria-expanded={open}
579
- onClick={() => { setOpen(!open) }}
580
- >
581
- <span style={styles.headerTop}>
582
- <span style={styles.name}>{t.title}</span>
583
- <span style={styles.status} title={statusLine}>{statusLine}</span>
584
- {dirty ? <span style={styles.pending}>{t.unsaved}</span> : null}
585
- <span style={open ? { ...styles.chevron, ...styles.chevronOpen } : styles.chevron}>{open ? '▾' : '▸'}</span>
586
- </span>
587
- <span style={styles.description}>{t.description}</span>
588
- </button>
589
- {open
590
- ? (
591
- <div style={styles.body}>
592
- {route.lastError ? <p style={styles.error} role="status">{t.lastError}: {route.lastError}</p> : null}
593
- <div style={styles.section}>
594
- <div style={styles.sectionHead}>
595
- <span style={styles.label}>{t.passwordSection}</span>
596
- <span style={passwordBadge === 'unset' ? styles.badgeAlert : styles.badge}>
597
- {passwordBadge === 'set'
598
- ? t.passwordSet
599
- : passwordBadge === 'unset' ? t.passwordUnset : t.passwordUnknown}
600
- </span>
601
- </div>
602
- <p style={styles.hint}>{t.passwordHint}</p>
603
- {passwordBadge === 'unset' ? <p style={styles.error}>{t.passwordRequired}</p> : null}
604
- {passwordBadge === 'unknown' ? <p style={styles.hint}>{t.passwordHostStale}</p> : null}
605
- <div style={styles.passwordRow}>
606
- <input
607
- id="lan-gw-password"
608
- type="password"
609
- autoComplete="new-password"
610
- aria-label={t.passwordNew}
611
- style={passwordInput}
612
- placeholder={t.passwordNew}
613
- value={password}
614
- disabled={passwordBusy}
615
- onChange={(e: ChangeEvent<HTMLInputElement>) => {
616
- setPasswordDraft(e.target.value)
617
- setPasswordError(null)
618
- setPasswordNotice(null)
619
- }}
620
- />
621
- <input
622
- id="lan-gw-password-confirm"
623
- type="password"
624
- autoComplete="new-password"
625
- aria-label={t.passwordConfirm}
626
- style={passwordInput}
627
- placeholder={t.passwordConfirm}
628
- value={passwordConfirm}
629
- disabled={passwordBusy}
630
- onChange={(e: ChangeEvent<HTMLInputElement>) => {
631
- setPasswordConfirmDraft(e.target.value)
632
- setPasswordError(null)
633
- setPasswordNotice(null)
634
- }}
635
- />
636
- </div>
637
- <div style={styles.passwordFoot}>
638
- {passwordError !== null
639
- ? <p style={styles.error} role="alert">{passwordError}</p>
640
- : passwordNotice !== null
641
- ? <p style={styles.notice} role="status">{passwordNotice}</p>
642
- : passwordTyping && passwordDraftProblem !== null
643
- ? (
644
- <p style={styles.error} role="status">
645
- {passwordDraftProblem === 'tooShort' ? t.passwordTooShort : t.passwordMismatch}
646
- </p>
647
- )
648
- : null}
649
- <button
650
- type="button"
651
- style={styles.save}
652
- disabled={passwordBusy || passwordDraftProblem !== null}
653
- onClick={() => { void changePassword() }}
654
- >
655
- {passwordBusy ? t.saving : t.passwordChange}
656
- </button>
657
- </div>
658
- </div>
659
- {FIELDS.map(def => <div key={def.field}>{renderControl(def)}</div>)}
660
- <div style={styles.footer}>
661
- {failed ? <p style={styles.error} role="status">{failed}</p> : null}
662
- <button
663
- type="button"
664
- style={styles.discard}
665
- disabled={!dirty || saving}
666
- onClick={discard}
667
- >
668
- {t.discard}
669
- </button>
670
- <button
671
- type="button"
672
- style={styles.save}
673
- disabled={!dirty || invalid() || saving}
674
- onClick={() => { void save() }}
675
- >
676
- {saving ? t.saving : t.save}
677
- </button>
678
- </div>
588
+ <div style={styles.card}>
589
+ {/* The page above draws the plugin's title itself, so the card opens with
590
+ the live listener status and the unsaved marker — the two facts an
591
+ operator wants before touching a field. */}
592
+ <div style={styles.statusRow}>
593
+ <span style={styles.status} title={statusLine}>{statusLine}</span>
594
+ {dirty ? <span style={styles.pending}>{t.unsaved}</span> : null}
595
+ </div>
596
+ <div style={styles.body}>
597
+ {route.lastError ? <p style={styles.error} role="status">{t.lastError}: {route.lastError}</p> : null}
598
+ <div style={styles.section}>
599
+ <div style={styles.sectionHead}>
600
+ <span style={styles.label}>{t.passwordSection}</span>
601
+ <span style={passwordBadge === 'unset' ? styles.badgeAlert : styles.badge}>
602
+ {passwordBadge === 'set'
603
+ ? t.passwordSet
604
+ : passwordBadge === 'unset' ? t.passwordUnset : t.passwordUnknown}
605
+ </span>
679
606
  </div>
680
- )
681
- : null}
607
+ <p style={styles.hint}>{t.passwordHint}</p>
608
+ {passwordBadge === 'unset' ? <p style={styles.error}>{t.passwordRequired}</p> : null}
609
+ {passwordBadge === 'unknown' ? <p style={styles.hint}>{t.passwordHostStale}</p> : null}
610
+ <div style={styles.passwordRow}>
611
+ <input
612
+ id="lan-gw-password"
613
+ type="password"
614
+ autoComplete="new-password"
615
+ aria-label={t.passwordNew}
616
+ style={passwordInput}
617
+ placeholder={t.passwordNew}
618
+ value={password}
619
+ disabled={passwordBusy}
620
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
621
+ setPasswordDraft(e.target.value)
622
+ setPasswordError(null)
623
+ setPasswordNotice(null)
624
+ }}
625
+ />
626
+ <input
627
+ id="lan-gw-password-confirm"
628
+ type="password"
629
+ autoComplete="new-password"
630
+ aria-label={t.passwordConfirm}
631
+ style={passwordInput}
632
+ placeholder={t.passwordConfirm}
633
+ value={passwordConfirm}
634
+ disabled={passwordBusy}
635
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
636
+ setPasswordConfirmDraft(e.target.value)
637
+ setPasswordError(null)
638
+ setPasswordNotice(null)
639
+ }}
640
+ />
641
+ </div>
642
+ <div style={styles.passwordFoot}>
643
+ {passwordError !== null
644
+ ? <p style={styles.error} role="alert">{passwordError}</p>
645
+ : passwordNotice !== null
646
+ ? <p style={styles.notice} role="status">{passwordNotice}</p>
647
+ : passwordTyping && passwordDraftProblem !== null
648
+ ? (
649
+ <p style={styles.error} role="status">
650
+ {passwordDraftProblem === 'tooShort' ? t.passwordTooShort : t.passwordMismatch}
651
+ </p>
652
+ )
653
+ : null}
654
+ <button
655
+ type="button"
656
+ style={styles.save}
657
+ disabled={passwordBusy || passwordDraftProblem !== null}
658
+ onClick={() => { void changePassword() }}
659
+ >
660
+ {passwordBusy ? t.saving : t.passwordChange}
661
+ </button>
662
+ </div>
663
+ </div>
664
+ {FIELDS.map(def => <div key={def.field}>{renderControl(def)}</div>)}
665
+ <div style={styles.footer}>
666
+ {failed ? <p style={styles.error} role="status">{failed}</p> : null}
667
+ <button
668
+ type="button"
669
+ style={styles.discard}
670
+ disabled={!dirty || saving}
671
+ onClick={discard}
672
+ >
673
+ {t.discard}
674
+ </button>
675
+ <button
676
+ type="button"
677
+ style={styles.save}
678
+ disabled={!dirty || invalid() || saving}
679
+ onClick={() => { void save() }}
680
+ >
681
+ {saving ? t.saving : t.save}
682
+ </button>
683
+ </div>
684
+ </div>
682
685
  </div>
683
686
  )
684
687
  }
@@ -697,13 +700,10 @@ function tk(token: string, fallback: string): string {
697
700
  const L = {
698
701
  border: tk('--dsw-alias-border-l2', 'rgba(127,127,127,0.35)'),
699
702
  bg: tk('--dsw-alias-bg-layer-3', 'transparent'),
700
- bgOpen: tk('--dsw-alias-bg-layer-2', 'transparent'),
701
703
  labelPrimary: tk('--dsw-alias-label-primary', 'inherit'),
702
704
  labelSecondary: tk('--dsw-alias-label-secondary', 'inherit'),
703
705
  labelTertiary: tk('--dsw-alias-label-tertiary', 'rgba(127,127,127,0.8)'),
704
- labelDimmed: tk('--dsw-alias-label-dimmed', 'rgba(127,127,127,0.6)'),
705
706
  error: tk('--dsw-alias-label-error', '#d1242f'),
706
- brand: tk('--dsw-alias-brand-primary', '#4f6ef7'),
707
707
  badgeBg: tk('--dsw-alias-bg-module-platform', 'rgba(127,127,127,0.14)'),
708
708
  }
709
709
 
@@ -713,36 +713,25 @@ const styles: Record<string, React.CSSProperties> = {
713
713
  border: `1px solid ${L.border}`,
714
714
  borderRadius: '12px',
715
715
  background: L.bg,
716
- transition: 'border-color .16s, background .16s',
716
+ display: 'flex',
717
+ flexDirection: 'column',
717
718
  overflow: 'hidden',
718
719
  },
719
- cardOpen: {
720
- background: L.bgOpen,
721
- borderColor: L.labelDimmed,
722
- },
723
- header: {
720
+ // The page draws the plugin's title above this body, so the card leads with
721
+ // the live listener status and the unsaved marker instead.
722
+ statusRow: {
724
723
  display: 'flex',
725
- flexDirection: 'column',
726
- alignItems: 'stretch',
727
- gap: '6px',
728
- width: '100%',
729
- padding: '14px 16px',
730
- border: 0,
731
- background: 'none',
732
- font: 'inherit',
733
- color: 'inherit',
734
- textAlign: 'left',
735
- cursor: 'pointer',
724
+ alignItems: 'center',
725
+ gap: '12px',
726
+ padding: '12px 16px',
727
+ borderBottom: `1px solid ${L.border}`,
736
728
  },
737
- headerTop: { display: 'flex', alignItems: 'center', gap: '12px', width: '100%' },
738
- name: { flex: '1 1 auto', minWidth: 0, fontSize: '15px', fontWeight: 600, lineHeight: 1.4, color: L.labelPrimary },
739
729
  // The status carries a verbose TLS cert summary; cap it and ellipsize so it
740
- // can never swallow the row or squeeze the title (the old nowrap alone
741
- // caused the description to be pushed into a thin wrapping column).
730
+ // can never swallow the row.
742
731
  status: {
743
732
  flex: '0 1 auto',
744
733
  minWidth: 0,
745
- maxWidth: '60%',
734
+ maxWidth: '80%',
746
735
  fontSize: '11px',
747
736
  lineHeight: 1.4,
748
737
  color: L.labelTertiary,
@@ -750,15 +739,6 @@ const styles: Record<string, React.CSSProperties> = {
750
739
  overflow: 'hidden',
751
740
  textOverflow: 'ellipsis',
752
741
  },
753
- description: {
754
- display: 'block',
755
- fontSize: '13px',
756
- lineHeight: 1.5,
757
- color: L.labelTertiary,
758
- whiteSpace: 'nowrap',
759
- overflow: 'hidden',
760
- textOverflow: 'ellipsis',
761
- },
762
742
  pending: {
763
743
  flex: 'none',
764
744
  borderRadius: '999px',
@@ -770,12 +750,8 @@ const styles: Record<string, React.CSSProperties> = {
770
750
  background: L.badgeBg,
771
751
  color: L.labelSecondary,
772
752
  },
773
- chevron: { flex: 'none', color: L.labelTertiary, fontSize: '12px', transition: 'transform .16s' },
774
- chevronOpen: { transform: 'rotate(180deg)' },
775
753
  body: {
776
- borderTop: `1px solid ${L.border}`,
777
- margin: '0 16px',
778
- paddingBottom: '8px',
754
+ padding: '4px 16px 8px',
779
755
  display: 'flex',
780
756
  flexDirection: 'column',
781
757
  },
package/src/gateway.ts CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  } from './login.ts'
54
54
  import {
55
55
  downstreamResponseHeaders,
56
+ isLoopbackAuthority,
56
57
  isOwnedPath,
57
58
  loginOriginAllowed,
58
59
  pathOf,
@@ -265,6 +266,24 @@ export class LanGateway {
265
266
  : classifySource(req.socket.remoteAddress, this.config.lanCidrs)
266
267
  }
267
268
 
269
+ /**
270
+ * Whether a request for the gateway-owned management prefix comes from the
271
+ * host itself, and may therefore be relayed to the loopback-only config and
272
+ * password routes.
273
+ *
274
+ * Both tests are required and neither is sufficient alone. The socket source
275
+ * is what a remote client cannot forge; the authority the client named is what
276
+ * a trusted TLS terminator deployment cannot blur, because there every socket
277
+ * source is the terminator's own loopback address while the browser still
278
+ * names the public host it dialed. A local browser that used `127.0.0.1` (or
279
+ * `localhost`) is the same operator the native route already trusts, and it
280
+ * still has to clear the session gate and the same-site fence below before
281
+ * anything is forwarded.
282
+ */
283
+ private localManagementRequest(req: http.IncomingMessage, source: SourceClass): boolean {
284
+ return source === 'loopback' && isLoopbackAuthority(req.headers.host)
285
+ }
286
+
268
287
  /**
269
288
  * The session a request carries, or undefined when it presents none, presents
270
289
  * one that no longer verifies under the current epoch, or presents one whose
@@ -323,7 +342,10 @@ export class LanGateway {
323
342
  return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? '; Secure' : ''}`
324
343
  }
325
344
 
326
- /** Handle one HTTP request: login surface → owned-path refuse → session gate → same-site gate → relay. */
345
+ /**
346
+ * Handle one HTTP request: login surface → owned-path gate → session gate →
347
+ * same-site gate → relay.
348
+ */
327
349
  private async handleHttp(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
328
350
  const url = req.url ?? '/'
329
351
  const pathname = pathOf(url)
@@ -338,10 +360,14 @@ export class LanGateway {
338
360
  return
339
361
  }
340
362
 
341
- // The gateway's own management surface never reaches dsh: an unauthenticated
342
- // remote request must not be able to touch the loopback-only config route by
343
- // having the gateway rewrite Host to loopback for it.
344
- if (isOwnedPath(pathname)) {
363
+ // The gateway's own management surface never reaches dsh from anywhere but
364
+ // the host itself: an unauthenticated remote request must not be able to
365
+ // touch the loopback-only config route by having the gateway rewrite Host to
366
+ // loopback for it. A host-local browser (`localManagementRequest`) is
367
+ // exempt, so the settings card on the gateway origin behaves like the native
368
+ // card instead of being refused exactly where the user goes looking for it;
369
+ // it still has to clear the session and same-site gates below.
370
+ if (isOwnedPath(pathname) && !this.localManagementRequest(req, source)) {
345
371
  res.writeHead(403, this.securityHeaders())
346
372
  res.end('forbidden')
347
373
  return
@@ -122,6 +122,42 @@ export function isLoopbackHost(hostname: string): boolean {
122
122
  )
123
123
  }
124
124
 
125
+ /**
126
+ * Whether a `Host` header names a loopback authority (127/8, `localhost`, ::1).
127
+ *
128
+ * This is the second half of the gateway's local-management exemption, and it
129
+ * answers a different question from {@link isLoopbackHost}'s own callers: not
130
+ * "is this authority loopback" but "did the browser itself use a loopback
131
+ * address". The two tests are combined on purpose. The socket source is what a
132
+ * remote client cannot forge; the Host is what a deployment cannot blur — behind
133
+ * a trusted TLS terminator every socket source is the terminator's loopback
134
+ * address, so the source test alone would readmit every remote browser, while a
135
+ * remote browser names the public host it dialed and stays refused.
136
+ *
137
+ * A client that can set an arbitrary Host (curl, not a browser) must still pass
138
+ * the socket-source test and hold a gateway session to reach anything, and the
139
+ * route behind the prefix is the one the native loopback listener already
140
+ * answers with no credential at all.
141
+ * @param host - the `Host` header value, or undefined.
142
+ * @returns true only when it parses and names a loopback authority.
143
+ */
144
+ export function isLoopbackAuthority(host: string | undefined): boolean {
145
+ if (host === undefined || host === '') return false
146
+ let url: URL
147
+ try {
148
+ url = new URL(`http://${host}`)
149
+ } catch {
150
+ return false
151
+ }
152
+ // A Host header is nothing but an authority. Anything URL parsing had to read
153
+ // beyond `host[:port]` — userinfo, a path, a query — means the value is not
154
+ // one, and `http://evil.com@127.0.0.1` must not read as loopback.
155
+ if (url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '') {
156
+ return false
157
+ }
158
+ return isLoopbackHost(url.hostname)
159
+ }
160
+
125
161
  /** Whether this source must present a gateway session (default: everyone). */
126
162
  export function requiresLogin(source: SourceClass, lanPasswordless: boolean): boolean {
127
163
  return !(lanPasswordless && source !== 'internet')