@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.10
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.i18n.yaml +2 -2
- package/README.md +77 -468
- package/README.zh.md +83 -113
- package/lib/client.js +993 -203
- package/lib/client.js.map +1 -1
- package/lib/index.js +1136 -1466
- package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
- package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/RemoteEntry.d.ts +7 -4
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +7 -10
- package/lib/types/client/RemotePanel.d.ts +2 -2
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +56 -3
- package/lib/types/client/deep-link.d.ts +10 -13
- package/lib/types/client/deep-link.d.ts.map +1 -1
- package/lib/types/client/deep-link.js +14 -74
- package/lib/types/client/index.d.ts +2 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +79 -39
- package/lib/types/client/locales.d.ts +32 -12
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +42 -22
- package/lib/types/client/mobile-adapt.d.ts +49 -0
- package/lib/types/client/mobile-adapt.d.ts.map +1 -0
- package/lib/types/client/mobile-adapt.js +1002 -0
- package/lib/types/client/pair-api.d.ts +24 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +9 -14
- package/lib/types/client/remote-channel.d.ts +3 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +35 -3
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/firewall.d.ts +75 -0
- package/lib/types/firewall.d.ts.map +1 -0
- package/lib/types/firewall.js +184 -0
- package/lib/types/gate.d.ts +11 -5
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +11 -5
- package/lib/types/index.d.ts +39 -10
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +206 -58
- package/lib/types/inner-auth.d.ts +32 -0
- package/lib/types/inner-auth.d.ts.map +1 -0
- package/lib/types/inner-auth.js +72 -0
- package/lib/types/lan-bind-plan.d.ts +55 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -0
- package/lib/types/lan-bind-plan.js +60 -0
- package/lib/types/lan-bind.d.ts +69 -0
- package/lib/types/lan-bind.d.ts.map +1 -0
- package/lib/types/lan-bind.js +128 -0
- package/lib/types/loopback-proxy.d.ts +9 -2
- package/lib/types/loopback-proxy.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +24 -2
- package/lib/types/pairing.d.ts +15 -3
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +20 -6
- package/lib/types/remote-api.d.ts +30 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +55 -42
- package/lib/types/remote-channel-boot.d.ts +21 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +65 -3
- package/lib/types/remote-channel-rules.d.ts +8 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +9 -2
- package/lib/types/remote-methods.d.ts +45 -11
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +56 -27
- package/lib/types/remote-presence-pet.d.ts +51 -0
- package/lib/types/remote-presence-pet.d.ts.map +1 -0
- package/lib/types/remote-presence-pet.js +101 -0
- package/lib/types/routes.d.ts +76 -4
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +336 -19
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -1
- package/package.json +20 -18
- package/src/client/FooterRemoteEntry.tsx +9 -10
- package/src/client/RemoteEntry.tsx +13 -14
- package/src/client/RemotePanel.tsx +5 -16
- package/src/client/RemoteSettingsCard.tsx +72 -13
- package/src/client/deep-link.ts +14 -75
- package/src/client/index.ts +82 -39
- package/src/client/locales.ts +42 -22
- package/src/client/mobile-adapt.ts +954 -0
- package/src/client/pair-api.ts +26 -15
- package/src/client/remote-channel.ts +31 -3
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/firewall.ts +230 -0
- package/src/gate.ts +11 -5
- package/src/index.ts +235 -65
- package/src/inner-auth.ts +85 -0
- package/src/lan-bind-plan.ts +81 -0
- package/src/lan-bind.ts +145 -0
- package/src/loopback-proxy.ts +34 -0
- package/src/pairing.persist.test.ts +1 -1
- package/src/pairing.ts +20 -6
- package/src/remote-api.ts +67 -45
- package/src/remote-channel-boot.ts +70 -3
- package/src/remote-channel-rules.ts +17 -2
- package/src/remote-methods.ts +58 -27
- package/src/remote-presence-pet.ts +129 -0
- package/src/routes.ts +358 -19
- package/src/tunnel.ts +7 -1
- package/assets/apple-touch-icon.png +0 -0
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +0 -63
- package/lib/mobile.js +0 -17658
- package/lib/mobile.js.map +0 -1
- package/lib/types/mobile/PairRequiredView.d.ts +0 -7
- package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.js +0 -28
- package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.test.js +0 -24
- package/lib/types/mobile/api.d.ts +0 -145
- package/lib/types/mobile/api.d.ts.map +0 -1
- package/lib/types/mobile/api.js +0 -103
- package/lib/types/mobile/index.d.ts +0 -10
- package/lib/types/mobile/index.d.ts.map +0 -1
- package/lib/types/mobile/index.js +0 -38
- package/lib/types/mobile/markdown.d.ts +0 -34
- package/lib/types/mobile/markdown.d.ts.map +0 -1
- package/lib/types/mobile/markdown.js +0 -285
- package/lib/types/mobile/markdown.test.d.ts +0 -2
- package/lib/types/mobile/markdown.test.d.ts.map +0 -1
- package/lib/types/mobile/markdown.test.js +0 -79
- package/lib/types/mobile/messages.d.ts +0 -149
- package/lib/types/mobile/messages.d.ts.map +0 -1
- package/lib/types/mobile/messages.js +0 -561
- package/lib/types/mobile/messages.test.d.ts +0 -2
- package/lib/types/mobile/messages.test.d.ts.map +0 -1
- package/lib/types/mobile/messages.test.js +0 -356
- package/lib/types/mobile/mobile-styles.d.ts +0 -6
- package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
- package/lib/types/mobile/mobile-styles.js +0 -1438
- package/lib/types/mobile/mobile-theme.d.ts +0 -22
- package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.js +0 -70
- package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
- package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.test.js +0 -71
- package/lib/types/mobile/mux.d.ts +0 -128
- package/lib/types/mobile/mux.d.ts.map +0 -1
- package/lib/types/mobile/mux.js +0 -305
- package/lib/types/mobile/mux.test.d.ts +0 -2
- package/lib/types/mobile/mux.test.d.ts.map +0 -1
- package/lib/types/mobile/mux.test.js +0 -267
- package/lib/types/mobile/pairing.d.ts +0 -31
- package/lib/types/mobile/pairing.d.ts.map +0 -1
- package/lib/types/mobile/pairing.js +0 -62
- package/lib/types/mobile/rpc.d.ts +0 -38
- package/lib/types/mobile/rpc.d.ts.map +0 -1
- package/lib/types/mobile/rpc.js +0 -83
- package/lib/types/mobile/theme-toggle.d.ts +0 -12
- package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
- package/lib/types/mobile/theme-toggle.js +0 -26
- package/lib/types/mobile/views/App.d.ts +0 -57
- package/lib/types/mobile/views/App.d.ts.map +0 -1
- package/lib/types/mobile/views/App.js +0 -150
- package/lib/types/mobile/views/App.test.d.ts +0 -2
- package/lib/types/mobile/views/App.test.d.ts.map +0 -1
- package/lib/types/mobile/views/App.test.js +0 -52
- package/lib/types/mobile/views/ChatView.d.ts +0 -63
- package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.js +0 -821
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
- package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.test.js +0 -718
- package/lib/types/mobile/views/SessionListView.d.ts +0 -27
- package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.js +0 -139
- package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.test.js +0 -167
- package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.js +0 -93
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
- package/lib/types/mobile-api.d.ts +0 -65
- package/lib/types/mobile-api.d.ts.map +0 -1
- package/lib/types/mobile-api.js +0 -404
- package/lib/types/mobile-pending.d.ts +0 -38
- package/lib/types/mobile-pending.d.ts.map +0 -1
- package/lib/types/mobile-pending.js +0 -59
- package/lib/types/mobile-pwa.d.ts +0 -6
- package/lib/types/mobile-pwa.d.ts.map +0 -1
- package/lib/types/mobile-pwa.js +0 -17
- package/lib/types/mobile-routes.d.ts +0 -13
- package/lib/types/mobile-routes.d.ts.map +0 -1
- package/lib/types/mobile-routes.js +0 -217
- package/lib/types/paired-model-catalog.d.ts +0 -17
- package/lib/types/paired-model-catalog.d.ts.map +0 -1
- package/lib/types/paired-model-catalog.js +0 -311
- package/src/mobile/PairRequiredView.test.tsx +0 -28
- package/src/mobile/PairRequiredView.tsx +0 -58
- package/src/mobile/api.ts +0 -229
- package/src/mobile/index.tsx +0 -41
- package/src/mobile/markdown.test.ts +0 -91
- package/src/mobile/markdown.ts +0 -291
- package/src/mobile/messages.test.ts +0 -402
- package/src/mobile/messages.ts +0 -685
- package/src/mobile/mobile-styles.ts +0 -1439
- package/src/mobile/mobile-theme.test.ts +0 -84
- package/src/mobile/mobile-theme.ts +0 -76
- package/src/mobile/mux.test.ts +0 -314
- package/src/mobile/mux.ts +0 -336
- package/src/mobile/pairing.ts +0 -77
- package/src/mobile/rpc.ts +0 -90
- package/src/mobile/theme-toggle.tsx +0 -47
- package/src/mobile/views/App.test.tsx +0 -62
- package/src/mobile/views/App.tsx +0 -213
- package/src/mobile/views/ChatView.stream.test.tsx +0 -268
- package/src/mobile/views/ChatView.test.tsx +0 -816
- package/src/mobile/views/ChatView.tsx +0 -1342
- package/src/mobile/views/SessionListView.test.tsx +0 -207
- package/src/mobile/views/SessionListView.tsx +0 -244
- package/src/mobile/views/WorkspaceView.test.tsx +0 -227
- package/src/mobile/views/WorkspaceView.tsx +0 -245
- package/src/mobile-api.ts +0 -418
- package/src/mobile-pending.ts +0 -88
- package/src/mobile-pwa.ts +0 -19
- package/src/mobile-routes.ts +0 -231
- package/src/paired-model-catalog.ts +0 -272
package/src/gate.ts
CHANGED
|
@@ -6,9 +6,15 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Policy: loopback requests (the desktop) pass without a device identity;
|
|
8
8
|
* every non-loopback /api request must carry a live, non-revoked device
|
|
9
|
-
* cookie
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* cookie — intended to make the QR the only way into a LAN-exposed dsh web
|
|
10
|
+
* and to give "停止" real teeth for direct /api callers.
|
|
11
|
+
*
|
|
12
|
+
* Cohort reality (0.1.2-alpha.2): NOTHING emits api/gate in the official
|
|
13
|
+
* runtime, so this listener never fires there and direct /api is governed
|
|
14
|
+
* solely by the harness fence + browser-auth cookie. The pairing gate with
|
|
15
|
+
* real teeth lives on the plugin's own /remote channel (remote-api.ts); the
|
|
16
|
+
* listener stays wired for cohort lines that do carry the seam, and the
|
|
17
|
+
* README security model states the gap plainly.
|
|
12
18
|
*/
|
|
13
19
|
|
|
14
20
|
import type { IncomingMessage } from 'node:http'
|
|
@@ -100,8 +106,8 @@ export function makeGateListener(
|
|
|
100
106
|
|
|
101
107
|
/**
|
|
102
108
|
* Whether a request carries a live, non-revoked paired-device cookie for
|
|
103
|
-
* this service. Sibling host routes outside /api (
|
|
104
|
-
* the same check via the remoteWebUiPairing service.
|
|
109
|
+
* this service. Sibling host routes outside /api (the right-panel
|
|
110
|
+
* /sidebar/* routes, etc.) use the same check via the remoteWebUiPairing service.
|
|
105
111
|
* @param service - the pairing service that owns the device table.
|
|
106
112
|
* @param request - the incoming HTTP request.
|
|
107
113
|
* @returns true when the cookie names a live session (and lastSeenAt was refreshed).
|
package/src/index.ts
CHANGED
|
@@ -13,23 +13,27 @@ import { join } from 'node:path'
|
|
|
13
13
|
import { setInterval as nodeSetInterval, setTimeout as nodeSetTimeout } from 'node:timers'
|
|
14
14
|
import type { IncomingMessage } from 'node:http'
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
|
-
import {
|
|
16
|
+
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
17
17
|
import z from 'schemastery'
|
|
18
18
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
19
19
|
import type {} from '@deepseek-ai/dsh-agent'
|
|
20
20
|
import type {} from '@deepseek-ai/dsh-commands'
|
|
21
|
+
// Type-only: pulls the host-side Context merge (ctx.connection, whose
|
|
22
|
+
// authenticatedUrl the /pair-accept redirect consumes).
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-client-connection'
|
|
21
24
|
import { DEFAULT_IDLE_EXPIRE_MS, PairingService, type PairingConfig } from './pairing.ts'
|
|
22
25
|
import { dshHome } from './dsh-home.ts'
|
|
23
26
|
import { isPairedDeviceRequest, makeGateListener } from './gate.ts'
|
|
24
27
|
import { RemoteWebUiPairing } from './pairing-access.ts'
|
|
25
28
|
import { isTrustedApiRequest, makeRoutes } from './routes.ts'
|
|
26
|
-
import { makeMobileRoutes } from './mobile-routes.ts'
|
|
27
|
-
import { makeMobileApiRoutes } from './mobile-api.ts'
|
|
28
|
-
import { PendingTracker } from './mobile-pending.ts'
|
|
29
|
-
import { makePairedModelCatalogRoutes } from './paired-model-catalog.ts'
|
|
30
29
|
import { makeRemoteApiRoutes, makeRemoteApiUpgradeRoutes } from './remote-api.ts'
|
|
30
|
+
import { startRemotePresencePet, type PresencePetSeam } from './remote-presence-pet.ts'
|
|
31
31
|
import { claimPostureKey, postureTargets, probePosture, releasePostureKey } from './posture.ts'
|
|
32
32
|
import { lanIPv4Addresses } from './lan.ts'
|
|
33
|
+
import { ensureFirewallRule, firewallSummary, removeFirewallRule } from './firewall.ts'
|
|
34
|
+
import { lanBindState, writeLanBind } from './lan-bind.ts'
|
|
35
|
+
import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, type AppliedFirewallState, type StartupFacts } from './lan-bind-plan.ts'
|
|
36
|
+
import { createInnerAuth } from './inner-auth.ts'
|
|
33
37
|
import { TunnelManager, type TunnelInfo } from './tunnel.ts'
|
|
34
38
|
import {
|
|
35
39
|
checkUpdates,
|
|
@@ -54,6 +58,12 @@ declare module '@deepseek-ai/cordis' {
|
|
|
54
58
|
* fires this per /api request before bridging to the API proxy on
|
|
55
59
|
* deployments that carry the pairing/revocation seam; call `next()` to
|
|
56
60
|
* delegate, return false (without calling it) to veto with 403.
|
|
61
|
+
*
|
|
62
|
+
* Cohort note (0.1.2-alpha.2): the official runtime ships NO emitter
|
|
63
|
+
* for this event, so the listener below never fires there and direct
|
|
64
|
+
* /api stays under the harness fence + browser auth. It is wired anyway
|
|
65
|
+
* so cohort lines that do carry the seam get pairing enforcement on
|
|
66
|
+
* direct /api without a plugin change.
|
|
57
67
|
*/
|
|
58
68
|
'api/gate'(
|
|
59
69
|
this: Context,
|
|
@@ -68,14 +78,14 @@ declare module '@deepseek-ai/cordis' {
|
|
|
68
78
|
export const name = 'remote-web-ui'
|
|
69
79
|
|
|
70
80
|
/** Services required before the pairing surfaces can mount. */
|
|
71
|
-
export const inject = ['webServer', '
|
|
81
|
+
export const inject = ['webServer', 'typertGateway', 'connection']
|
|
72
82
|
|
|
73
83
|
/**
|
|
74
84
|
* Settings namespace of the remote-control capability — the section the web
|
|
75
85
|
* settings surface edits. Spelled here rather than imported: the browser
|
|
76
86
|
* half spells the same value and must not depend on a Host package.
|
|
77
87
|
*/
|
|
78
|
-
export const REMOTE_WEB_UI_SETTINGS_NAMESPACE =
|
|
88
|
+
export const REMOTE_WEB_UI_SETTINGS_NAMESPACE = 'remote-web-ui' as SettingsNamespace
|
|
79
89
|
|
|
80
90
|
/** Plugin config, validated by the same-named schemastery schema. */
|
|
81
91
|
export interface Config {
|
|
@@ -95,9 +105,14 @@ export interface Config {
|
|
|
95
105
|
/**
|
|
96
106
|
* When true (default), a desktop Web GUI opened at a non-loopback origin
|
|
97
107
|
* rides the gated `/remote/api` channel and must carry a live paired-device
|
|
98
|
-
* cookie — the QR is the only way into remote desktop, and stop()
|
|
99
|
-
*
|
|
100
|
-
*
|
|
108
|
+
* cookie — the QR is the only way into remote desktop, and stop()/revoke()
|
|
109
|
+
* cut the /remote channel and the pairing cookie off immediately. Scope
|
|
110
|
+
* note for this cohort: direct /api is governed by the harness fence +
|
|
111
|
+
* browser-auth cookie (the api/gate seam has no emitter on 0.1.2-alpha.2),
|
|
112
|
+
* so a harness browser credential a device has already redeemed is not
|
|
113
|
+
* invalidated by stop() — see the README security model. Set false to keep
|
|
114
|
+
* the desktop on plain `/api` (only useful when that origin is already
|
|
115
|
+
* trusted for `/api`).
|
|
101
116
|
*/
|
|
102
117
|
requirePairingForLan?: boolean
|
|
103
118
|
/**
|
|
@@ -125,12 +140,26 @@ export interface Config {
|
|
|
125
140
|
*/
|
|
126
141
|
autoTunnel?: boolean
|
|
127
142
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
143
|
+
* LAN bind toggle. When the user flips it (true or false) the plugin
|
|
144
|
+
* writes the managed webserver block into the profile patch — true pins
|
|
145
|
+
* the bind default to 0.0.0.0 (an explicit --host flag still wins), false
|
|
146
|
+
* pins it back to 127.0.0.1 — and maintains the matching host firewall
|
|
147
|
+
* rule (Windows netsh; Linux firewalld/ufw/iptables; other platforms
|
|
148
|
+
* report the firewall as unmanaged). The block takes effect when the
|
|
149
|
+
* process next applies the profile — the live patch watcher recomposes on
|
|
150
|
+
* some profile shapes, otherwise on the next start — and the settings
|
|
151
|
+
* card reports the divergence (pendingRestart) instead of guessing.
|
|
152
|
+
* While the toggle has never been set (undefined), the plugin does not
|
|
153
|
+
* touch the patch file at all.
|
|
132
154
|
*/
|
|
133
|
-
|
|
155
|
+
lanBind?: boolean
|
|
156
|
+
/**
|
|
157
|
+
* The profile whose cordis.patch.yml the LAN bind toggle manages.
|
|
158
|
+
* Defaults to the DSH_PROFILE environment variable, then "web". Must be
|
|
159
|
+
* a single safe path segment (the DSH_PROFILE env fallback bypasses this
|
|
160
|
+
* schema, so the path builder asserts containment independently).
|
|
161
|
+
*/
|
|
162
|
+
profile?: string
|
|
134
163
|
/** Master switch for the plugin (browser half + host pairing surfaces). */
|
|
135
164
|
enabled?: boolean
|
|
136
165
|
}
|
|
@@ -145,7 +174,8 @@ export const Config: z<Config> = z.object({
|
|
|
145
174
|
publicBaseUrl: z.string(),
|
|
146
175
|
devicesFile: z.string(),
|
|
147
176
|
autoTunnel: z.boolean().default(false),
|
|
148
|
-
|
|
177
|
+
lanBind: z.boolean(),
|
|
178
|
+
profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
149
179
|
enabled: z.boolean().default(true),
|
|
150
180
|
})
|
|
151
181
|
|
|
@@ -157,9 +187,12 @@ const SWEEP_INTERVAL_MS = 10_000
|
|
|
157
187
|
* which legitimately resolves to `undefined` when unset (the schema keeps it
|
|
158
188
|
* optional, so `Required` alone would over-narrow it to `string`).
|
|
159
189
|
*/
|
|
160
|
-
type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile'>> & {
|
|
190
|
+
type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile' | 'lanBind' | 'profile'>> & {
|
|
161
191
|
publicBaseUrl: string | undefined
|
|
162
192
|
devicesFile: string
|
|
193
|
+
/** undefined until the user flips the toggle once; undefined never writes the patch. */
|
|
194
|
+
lanBind: boolean | undefined
|
|
195
|
+
profile: string
|
|
163
196
|
}
|
|
164
197
|
|
|
165
198
|
/**
|
|
@@ -198,7 +231,8 @@ const DEFAULTS: ResolvedConfig = {
|
|
|
198
231
|
publicBaseUrl: undefined,
|
|
199
232
|
devicesFile: defaultDevicesFile(),
|
|
200
233
|
autoTunnel: false,
|
|
201
|
-
|
|
234
|
+
lanBind: undefined,
|
|
235
|
+
profile: process.env.DSH_PROFILE ?? 'web',
|
|
202
236
|
enabled: true,
|
|
203
237
|
}
|
|
204
238
|
|
|
@@ -220,12 +254,13 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
220
254
|
publicBaseUrl: config?.publicBaseUrl,
|
|
221
255
|
devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
|
|
222
256
|
autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
223
|
-
|
|
257
|
+
lanBind: config?.lanBind,
|
|
258
|
+
profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
|
|
224
259
|
enabled: config?.enabled ?? DEFAULTS.enabled,
|
|
225
260
|
}
|
|
226
261
|
// The live source the pairing service and the gate read: the settings
|
|
227
262
|
// section once the web settings surface is served, the composition entry
|
|
228
|
-
// otherwise (
|
|
263
|
+
// otherwise (installSection swaps it when the namespace registers).
|
|
229
264
|
let current: () => Config = () => config ?? {}
|
|
230
265
|
const resolve = (): ResolvedConfig => {
|
|
231
266
|
const value = current()
|
|
@@ -239,7 +274,8 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
239
274
|
publicBaseUrl: value.publicBaseUrl,
|
|
240
275
|
devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
|
|
241
276
|
autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
242
|
-
|
|
277
|
+
lanBind: value.lanBind,
|
|
278
|
+
profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
|
|
243
279
|
enabled: value.enabled ?? DEFAULTS.enabled,
|
|
244
280
|
}
|
|
245
281
|
}
|
|
@@ -247,7 +283,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
247
283
|
|
|
248
284
|
// ── auto tunnel ─────────────────────────────────────────────────────────
|
|
249
285
|
// The minted public URL becomes the QR base (and the pairing fence's
|
|
250
|
-
// trusted host). Phone /api traffic rides the plugin's own /
|
|
286
|
+
// trusted host). Phone /api traffic rides the plugin's own /remote channel,
|
|
251
287
|
// which is NOT subject to the connection trust fence — so no fence
|
|
252
288
|
// mutation is needed here (a distributable plugin must not change the
|
|
253
289
|
// harness's connection plugin).
|
|
@@ -277,11 +313,18 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
277
313
|
// sampling stance. The QR can only advertise addresses the fence accepts;
|
|
278
314
|
// every interface gets its own base URL so a multi-homed machine can pick
|
|
279
315
|
// the network the phone can actually reach.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
316
|
+
// A recompose pass can hand the plugin a webServer whose facts have not
|
|
317
|
+
// settled yet (host unset, port undefined); deriving LAN bases from that
|
|
318
|
+
// would advertise :undefined links or clear a working set, so the bases
|
|
319
|
+
// only update when both facts are readable.
|
|
320
|
+
const bindKnown = (ctx.webServer.host === '0.0.0.0' || ctx.webServer.host === '127.0.0.1')
|
|
321
|
+
&& Number.isFinite(ctx.webServer.port)
|
|
322
|
+
if (bindKnown) {
|
|
323
|
+
const lanBases = ctx.webServer.host === '0.0.0.0'
|
|
324
|
+
? lanIPv4Addresses().map(address => ({ address, base: `http://${address}:${String(ctx.webServer.port)}` }))
|
|
325
|
+
: []
|
|
326
|
+
service.setLanBases(lanBases)
|
|
327
|
+
}
|
|
285
328
|
|
|
286
329
|
// Push a committed settings section into the service and gate. The service
|
|
287
330
|
// config object is read per operation (token mint, touch, sweep), and the
|
|
@@ -292,12 +335,6 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
292
335
|
// (now vetoing every non-loopback request) instead of opening the fence.
|
|
293
336
|
let disposeRoutes: (() => void) | undefined
|
|
294
337
|
let disposeSweep: (() => void) | undefined
|
|
295
|
-
// The phone's data channel: pairing routes + the /m page + the /m/api
|
|
296
|
-
// proxy (which needs the host ApiProxy service; the plugin injects it).
|
|
297
|
-
const apiProxy = ctx.get('apiProxy')
|
|
298
|
-
if (apiProxy === undefined) {
|
|
299
|
-
console.warn('remote-web-ui: apiProxy service unavailable — the mobile data channel is disabled')
|
|
300
|
-
}
|
|
301
338
|
// ── remote update ────────────────────────────────────────────────────────
|
|
302
339
|
// The dsh-web self-update surface: probe the npm registry for family
|
|
303
340
|
// releases and run `pnpm update --latest` in the owning profile. Resolutions
|
|
@@ -372,29 +409,90 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
372
409
|
})
|
|
373
410
|
},
|
|
374
411
|
})
|
|
412
|
+
// LAN-bind facts for the settings card, re-read per request so a hot
|
|
413
|
+
// rebind (the patch watcher recomposes the process) and a fresh toggle
|
|
414
|
+
// round are both reflected without a restart.
|
|
415
|
+
let lastKnownPort: number | undefined
|
|
416
|
+
let lastFirewallApplied: AppliedFirewallState | undefined
|
|
417
|
+
const lanBindStatus = (): Record<string, unknown> => {
|
|
418
|
+
const resolvedNow = resolve()
|
|
419
|
+
let state: { blockPresent: boolean; host?: string; port?: number }
|
|
420
|
+
try {
|
|
421
|
+
state = lanBindState(resolvedNow.profile)
|
|
422
|
+
} catch {
|
|
423
|
+
// An unsafe profile value must not take the whole status endpoint down.
|
|
424
|
+
state = { blockPresent: false }
|
|
425
|
+
}
|
|
426
|
+
const lanOn = state.host === '0.0.0.0'
|
|
427
|
+
const port = Number.isFinite(ctx.webServer.port) ? ctx.webServer.port : lastKnownPort
|
|
428
|
+
if (Number.isFinite(ctx.webServer.port)) lastKnownPort = ctx.webServer.port
|
|
429
|
+
const startup = ctx.get('webStartup') as StartupFacts | undefined
|
|
430
|
+
const desiredHost = resolvedNow.lanBind === undefined
|
|
431
|
+
? undefined
|
|
432
|
+
: desiredBindHost(resolvedNow.lanBind === true, startup?.host)
|
|
433
|
+
return {
|
|
434
|
+
profile: resolvedNow.profile,
|
|
435
|
+
setting: resolvedNow.lanBind ?? null,
|
|
436
|
+
blockHost: state.host ?? null,
|
|
437
|
+
bindHost: ctx.webServer.host,
|
|
438
|
+
port,
|
|
439
|
+
lanUrls: ctx.webServer.host === '0.0.0.0' && port !== undefined
|
|
440
|
+
? lanIPv4Addresses().map(address => `http://${address}:${String(port)}`)
|
|
441
|
+
: [],
|
|
442
|
+
firewall: port !== undefined ? firewallSummary(port, lanOn) : { ok: true, managed: false },
|
|
443
|
+
platform: process.platform,
|
|
444
|
+
// The running bind does not follow the block on every deployment (the
|
|
445
|
+
// live patch watcher is profile-shape dependent): flag the divergence
|
|
446
|
+
// so the card can ask for a restart instead of looking broken. The
|
|
447
|
+
// comparison uses the effective desired host (a CLI --host wins over
|
|
448
|
+
// the toggle), not the raw toggle.
|
|
449
|
+
pendingRestart: pendingRestartOf(resolvedNow.lanBind, desiredHost, ctx.webServer.host),
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// The process's inner browser credential: the proxied /api re-issues to
|
|
453
|
+
// 127.0.0.1, where the connection route enforces the harness browser-auth
|
|
454
|
+
// cookie (authority-bound; no loopback exemption on this cohort), so a
|
|
455
|
+
// device's own cookie can never satisfy the inner check. The plugin
|
|
456
|
+
// redeems its own launch token once and attaches the cookie to inner
|
|
457
|
+
// requests; the credential is only ever exercised behind the pairing gate
|
|
458
|
+
// in remote-api.ts.
|
|
459
|
+
const innerAuth = createInnerAuth(() => {
|
|
460
|
+
if (!Number.isFinite(ctx.webServer.port)) return undefined
|
|
461
|
+
try {
|
|
462
|
+
return (ctx.connection as { authenticatedUrl?: (base: string) => string }).authenticatedUrl?.(`http://127.0.0.1:${String(ctx.webServer.port)}/`)
|
|
463
|
+
} catch {
|
|
464
|
+
return undefined
|
|
465
|
+
}
|
|
466
|
+
})
|
|
467
|
+
// The official index document for the /pair-app landing, fetched from the
|
|
468
|
+
// inner loopback with the process credential and cached briefly (the shell
|
|
469
|
+
// is static; skins/injections settle right after boot).
|
|
470
|
+
const APP_SHELL_TTL_MS = 30_000
|
|
471
|
+
let appShellCache: { at: number; html: string } | undefined
|
|
472
|
+
const fetchAppShell = async (): Promise<string | undefined> => {
|
|
473
|
+
if (!Number.isFinite(ctx.webServer.port)) return undefined
|
|
474
|
+
if (appShellCache !== undefined && Date.now() - appShellCache.at < APP_SHELL_TTL_MS) return appShellCache.html
|
|
475
|
+
const cookie = await innerAuth.ready()
|
|
476
|
+
try {
|
|
477
|
+
const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, {
|
|
478
|
+
headers: cookie !== undefined ? { cookie } : undefined,
|
|
479
|
+
})
|
|
480
|
+
if (!response.ok) return undefined
|
|
481
|
+
const html = await response.text()
|
|
482
|
+
appShellCache = { at: Date.now(), html }
|
|
483
|
+
return html
|
|
484
|
+
} catch {
|
|
485
|
+
return undefined
|
|
486
|
+
}
|
|
487
|
+
}
|
|
375
488
|
const routes = [
|
|
376
|
-
...makeRoutes({
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
mobileEnterToSend: () => resolve().mobileEnterToSend,
|
|
384
|
-
commandDispatcher: ctx.commands !== undefined && ctx.agents !== undefined
|
|
385
|
-
? {
|
|
386
|
-
async execute(sessionId, line, signal) {
|
|
387
|
-
const agent = ctx.agents.get(sessionId as never)
|
|
388
|
-
if (agent === undefined) return { error: 'session-not-found' as const }
|
|
389
|
-
const execution = await ctx.commands.execute(agent, line, [], signal)
|
|
390
|
-
if (execution === undefined) return { error: 'unknown-command' as const }
|
|
391
|
-
return { ok: true as const, result: execution.result }
|
|
392
|
-
},
|
|
393
|
-
}
|
|
394
|
-
: undefined,
|
|
395
|
-
})
|
|
396
|
-
: []),
|
|
397
|
-
...(apiProxy !== undefined ? makePairedModelCatalogRoutes({ service, apiProxy, lanAddresses }) : []),
|
|
489
|
+
...makeRoutes({
|
|
490
|
+
service,
|
|
491
|
+
lanAddresses: service.lanAddresses,
|
|
492
|
+
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
493
|
+
lanBindStatus,
|
|
494
|
+
indexDocument: fetchAppShell,
|
|
495
|
+
}),
|
|
398
496
|
// The remote desktop channel: policy-gated `/remote` prefix that
|
|
399
497
|
// re-issues fenced paths to loopback (see remote-api.ts). The live
|
|
400
498
|
// requirePairingForLan is re-read per request, same as the gate listener
|
|
@@ -404,6 +502,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
404
502
|
service,
|
|
405
503
|
port: ctx.webServer.port,
|
|
406
504
|
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
505
|
+
auth: innerAuth,
|
|
407
506
|
}),
|
|
408
507
|
...updateRoutes,
|
|
409
508
|
]
|
|
@@ -411,6 +510,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
411
510
|
service,
|
|
412
511
|
port: ctx.webServer.port,
|
|
413
512
|
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
513
|
+
auth: innerAuth,
|
|
414
514
|
})
|
|
415
515
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
|
|
416
516
|
ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate')
|
|
@@ -456,21 +556,89 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
456
556
|
const initialPostureTimer = nodeSetTimeout(() => { runPostureProbe() }, 5_000)
|
|
457
557
|
initialPostureTimer.unref()
|
|
458
558
|
ctx.effect(() => () => { clearTimeout(initialPostureTimer) }, 'remote-web-ui: posture probe boot')
|
|
459
|
-
// Sibling plugins (
|
|
559
|
+
// Sibling plugins (dsh-better-sidebar, …) look this up by name. Absent when this
|
|
460
560
|
// plugin is not installed; stop() / enabled=false still refuse cookies.
|
|
461
561
|
new RemoteWebUiPairing(ctx, (request) => {
|
|
462
562
|
if (!resolve().enabled) return false
|
|
463
563
|
return isPairedDeviceRequest(service, request)
|
|
464
564
|
})
|
|
465
565
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
566
|
+
// Remote-presence to pet-visibility link: while a paired device is online
|
|
567
|
+
// (an active phone mirror), hide the host-global pet through the pet's OWN
|
|
568
|
+
// hide switch; when the last device has been offline for a grace window,
|
|
569
|
+
// show it again (user design; the pet plugin is optional, so the seam is
|
|
570
|
+
// resolved per transition and every failure degrades to a no-op).
|
|
571
|
+
const presencePet = startRemotePresencePet({
|
|
572
|
+
onState: listener => service.onState(listener),
|
|
573
|
+
pet: (): PresencePetSeam | undefined => {
|
|
574
|
+
try {
|
|
575
|
+
return ctx.get('pet') as unknown as PresencePetSeam | undefined
|
|
576
|
+
} catch {
|
|
577
|
+
return undefined
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
})
|
|
581
|
+
ctx.effect(() => presencePet, 'remote-web-ui: remote-presence pet visibility')
|
|
582
|
+
|
|
583
|
+
if (service.lanAddresses.length > 0) {
|
|
584
|
+
const urls = service.lanAddresses.map(ip => `http://${ip}:${String(ctx.webServer.port)}`).join(' , ')
|
|
585
|
+
console.log(`remote-web-ui: the paired Web GUI is reachable on LAN at ${urls}`)
|
|
586
|
+
}
|
|
587
|
+
// Cohort honesty (see the README security model): the pairing gate covers
|
|
588
|
+
// the plugin's own /remote channel; direct /api from LAN origins is
|
|
589
|
+
// governed by the harness fence + browser-auth cookie, and this cohort's
|
|
590
|
+
// api/gate seam has no emitter — so stop()/revoke() cannot invalidate a
|
|
591
|
+
// browser credential a device has already redeemed.
|
|
592
|
+
if (ctx.webServer.host === '0.0.0.0') {
|
|
593
|
+
console.warn('remote-web-ui: LAN-exposed bind — pairing gates the /remote channel; direct /api stays under the harness fence + browser auth (stop() does not revoke an already-redeemed browser credential)')
|
|
469
594
|
}
|
|
470
595
|
|
|
471
596
|
const sync = (): void => {
|
|
472
597
|
const value = resolve()
|
|
473
598
|
service.config = pairingConfigOf(value)
|
|
599
|
+
// LAN bind toggle: only write the managed patch block once the user has
|
|
600
|
+
// flipped it (undefined = untouched, never write). Desired host/port come
|
|
601
|
+
// from the CLI flags when given (flags win), else from the toggle and
|
|
602
|
+
// the currently bound port. The block takes effect on the next start, so
|
|
603
|
+
// the re-assert at every boot keeps it in sync with both the toggle and
|
|
604
|
+
// the flags.
|
|
605
|
+
if (value.lanBind !== undefined) {
|
|
606
|
+
const startup = ctx.get('webStartup') as StartupFacts | undefined
|
|
607
|
+
const desiredHost = desiredBindHost(value.lanBind === true, startup?.host)
|
|
608
|
+
const desiredPort = desiredBindPort(startup?.port, Number.isFinite(ctx.webServer.port) ? ctx.webServer.port : undefined)
|
|
609
|
+
if (desiredPort === undefined) {
|
|
610
|
+
console.error('remote-web-ui: cannot assert the lan-bind block — the web server port is not known yet')
|
|
611
|
+
} else {
|
|
612
|
+
try {
|
|
613
|
+
const current = lanBindState(value.profile)
|
|
614
|
+
if (current.host !== desiredHost || current.port !== desiredPort) {
|
|
615
|
+
writeLanBind(desiredHost, desiredPort, value.profile)
|
|
616
|
+
console.log(`remote-web-ui: lan-bind block written for profile ${value.profile} (${desiredHost}:${String(desiredPort)}); it takes effect when the profile next applies (the card reports pendingRestart until the running bind follows)`)
|
|
617
|
+
}
|
|
618
|
+
} catch (error) {
|
|
619
|
+
console.error(`remote-web-ui: failed to write the lan-bind block: ${error instanceof Error ? error.message : String(error)}`)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// Keep the firewall rule aligned with the toggle (managed platforms
|
|
623
|
+
// only; see firewall.ts). The probes and rule rewrites are blocking
|
|
624
|
+
// subprocesses, so they run only when the desired state actually moved
|
|
625
|
+
// — not on every unrelated settings save. The port is the live bind
|
|
626
|
+
// port; without it there is nothing to align yet.
|
|
627
|
+
const livePort = Number.isFinite(ctx.webServer.port) ? ctx.webServer.port : undefined
|
|
628
|
+
if (livePort === undefined) {
|
|
629
|
+
console.error('remote-web-ui: cannot align the host firewall rule — the web server port is not known yet')
|
|
630
|
+
} else {
|
|
631
|
+
const nextFirewall: AppliedFirewallState = { enabled: value.lanBind === true, port: livePort }
|
|
632
|
+
if (firewallActionNeeded(lastFirewallApplied, nextFirewall)) {
|
|
633
|
+
const ruleOk = nextFirewall.enabled ? ensureFirewallRule(nextFirewall.port) : removeFirewallRule(nextFirewall.port)
|
|
634
|
+
if (ruleOk) {
|
|
635
|
+
lastFirewallApplied = nextFirewall
|
|
636
|
+
} else {
|
|
637
|
+
console.error('remote-web-ui: the host firewall rule could not be updated (admin rights required on managed platforms)')
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
474
642
|
// The auto tunnel owns the public base while enabled: the minted URL
|
|
475
643
|
// lands in the service through the tunnel's phase listener. The manual
|
|
476
644
|
// publicBaseUrl applies only when the auto tunnel is off.
|
|
@@ -545,12 +713,14 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
545
713
|
table.push({ kind: 'script', placement: 'head', text: REMOTE_CHANNEL_BOOT_SCRIPT })
|
|
546
714
|
}), 'remote-web-ui: remote channel boot patch')
|
|
547
715
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
716
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
717
|
+
settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
718
|
+
setSource: (source) => {
|
|
719
|
+
current = source
|
|
720
|
+
sync()
|
|
721
|
+
},
|
|
722
|
+
onChange: sync,
|
|
723
|
+
})
|
|
554
724
|
})
|
|
555
725
|
sync()
|
|
556
726
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inner browser credential for the remote desktop channel. The proxied
|
|
3
|
+
* /api re-issues requests to 127.0.0.1, where the connection plugin's route
|
|
4
|
+
* enforces the harness browser-auth cookie — an authority-bound signed cookie
|
|
5
|
+
* with no loopback exemption on this cohort line, so a cookie minted for the
|
|
6
|
+
* device's own authority can never satisfy the inner check. The process
|
|
7
|
+
* therefore redeems its own launch token (the same one-time exchange every
|
|
8
|
+
* paired device's first navigation performs) and attaches the resulting
|
|
9
|
+
* cookie to inner requests. The credential is only ever exercised behind the
|
|
10
|
+
* pairing gate: while requirePairingForLan is on, every proxied call already
|
|
11
|
+
* carried a live paired-device cookie before this module is consulted.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Browser-auth credential the proxy attaches to loopback-bound requests. */
|
|
15
|
+
export interface InnerAuth {
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the `name=value` cookie pair for inner requests, or undefined
|
|
18
|
+
* when the launch token is unavailable (the proxy then sends without a
|
|
19
|
+
* credential and the inner route answers 401, exactly as before).
|
|
20
|
+
*/
|
|
21
|
+
ready(): Promise<string | undefined>
|
|
22
|
+
/** Drop the cached credential; the next ready() re-redeems. */
|
|
23
|
+
invalidate(): void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The harness browser-auth cookie name prefix (see dsh-client-connection). */
|
|
27
|
+
const BROWSER_AUTH_COOKIE_PREFIX = 'dsh-auth-'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the inner-auth handle.
|
|
31
|
+
* @param launchUrl - the launch-token URL for the inner loopback authority
|
|
32
|
+
* (the connection service's authenticatedUrl of `http://127.0.0.1:<port>/`),
|
|
33
|
+
* or undefined when the connection service or port is unavailable.
|
|
34
|
+
* @param fetchImpl - injectable fetch (tests).
|
|
35
|
+
*/
|
|
36
|
+
export function createInnerAuth(
|
|
37
|
+
launchUrl: () => string | undefined,
|
|
38
|
+
fetchImpl: typeof fetch = fetch,
|
|
39
|
+
): InnerAuth {
|
|
40
|
+
let cached: string | undefined
|
|
41
|
+
let inflight: Promise<string | undefined> | undefined
|
|
42
|
+
|
|
43
|
+
const redeem = async (): Promise<string | undefined> => {
|
|
44
|
+
const url = launchUrl()
|
|
45
|
+
if (url === undefined) return undefined
|
|
46
|
+
try {
|
|
47
|
+
// redirect: 'manual' keeps the 303's set-cookie readable instead of
|
|
48
|
+
// following it; the exchange itself is a plain GET with the token in
|
|
49
|
+
// the query, exactly what a first browser visit performs.
|
|
50
|
+
const response = await fetchImpl(url, { redirect: 'manual' })
|
|
51
|
+
const raw = response.headers.get('set-cookie')
|
|
52
|
+
if (raw === null) return undefined
|
|
53
|
+
// Generated cookie values are base64url and never contain commas, so a
|
|
54
|
+
// comma split followed by a prefix match finds the browser-auth entry
|
|
55
|
+
// even when the exchange sets several cookies.
|
|
56
|
+
const entry = raw
|
|
57
|
+
.split(',')
|
|
58
|
+
.map((part) => part.trim())
|
|
59
|
+
.find((part) => part.startsWith(BROWSER_AUTH_COOKIE_PREFIX))
|
|
60
|
+
if (entry === undefined) return undefined
|
|
61
|
+
const pair = entry.split(';')[0]?.trim()
|
|
62
|
+
return pair !== undefined && pair.includes('=') ? pair : undefined
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
ready(): Promise<string | undefined> {
|
|
70
|
+
if (cached !== undefined) return Promise.resolve(cached)
|
|
71
|
+
inflight ??= redeem().then((value) => {
|
|
72
|
+
if (value !== undefined) cached = value
|
|
73
|
+
inflight = undefined
|
|
74
|
+
return value
|
|
75
|
+
}, () => {
|
|
76
|
+
inflight = undefined
|
|
77
|
+
return undefined
|
|
78
|
+
})
|
|
79
|
+
return inflight
|
|
80
|
+
},
|
|
81
|
+
invalidate(): void {
|
|
82
|
+
cached = undefined
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision helpers shared by the lan-bind sync path (index.ts) and the
|
|
3
|
+
* status path (lanBindStatus). Extracted so the flags-win precedence, the
|
|
4
|
+
* pending-restart divergence check, and the firewall re-apply gate have one
|
|
5
|
+
* implementation and direct unit coverage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The two bind hosts the managed block pins. */
|
|
9
|
+
export type LanBindPlanHost = '0.0.0.0' | '127.0.0.1'
|
|
10
|
+
|
|
11
|
+
/** webStartup facts the CLI hands the process (absent unless a flag was passed). */
|
|
12
|
+
export interface StartupFacts {
|
|
13
|
+
host?: string
|
|
14
|
+
port?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The bind host the managed block should pin: an explicit CLI --host wins,
|
|
19
|
+
* otherwise the toggle decides. CLI hosts outside the two managed literals
|
|
20
|
+
* (a pinned specific IP) are not a state the toggle manages — the toggle
|
|
21
|
+
* decides, and the card surfaces the divergence honestly.
|
|
22
|
+
* @param lanBind - the toggle value.
|
|
23
|
+
* @param startupHost - the CLI-provided bind host, when given.
|
|
24
|
+
* @returns the desired bind host literal.
|
|
25
|
+
*/
|
|
26
|
+
export function desiredBindHost(lanBind: boolean, startupHost: string | undefined): LanBindPlanHost {
|
|
27
|
+
if (startupHost === '0.0.0.0' || startupHost === '127.0.0.1') return startupHost
|
|
28
|
+
return lanBind ? '0.0.0.0' : '127.0.0.1'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The port the managed block should pin: the CLI port when given, else the
|
|
33
|
+
* currently bound port. Undefined when neither is readable yet (the caller
|
|
34
|
+
* skips the write and reports why).
|
|
35
|
+
* @param startupPort - the CLI-provided port, when given.
|
|
36
|
+
* @param livePort - the currently bound port, when finite.
|
|
37
|
+
*/
|
|
38
|
+
export function desiredBindPort(startupPort: number | undefined, livePort: number | undefined): number | undefined {
|
|
39
|
+
if (typeof startupPort === 'number' && Number.isFinite(startupPort)) return startupPort
|
|
40
|
+
return typeof livePort === 'number' && Number.isFinite(livePort) ? livePort : undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether the running bind has not caught up with what the toggle (plus a
|
|
45
|
+
* possibly overriding CLI host) will pin. Compared against the effective
|
|
46
|
+
* desired host — not the raw toggle — so a flag-managed bind is not reported
|
|
47
|
+
* as pending forever.
|
|
48
|
+
* @param lanBind - the toggle value (undefined = untouched, never pending).
|
|
49
|
+
* @param desiredHost - the effective desired host, or undefined when untouched.
|
|
50
|
+
* @param liveHost - the currently bound host.
|
|
51
|
+
*/
|
|
52
|
+
export function pendingRestartOf(
|
|
53
|
+
lanBind: boolean | undefined,
|
|
54
|
+
desiredHost: LanBindPlanHost | undefined,
|
|
55
|
+
liveHost: string | undefined,
|
|
56
|
+
): boolean {
|
|
57
|
+
if (lanBind === undefined || desiredHost === undefined) return false
|
|
58
|
+
return liveHost !== desiredHost
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The last firewall state this process applied. */
|
|
62
|
+
export interface AppliedFirewallState {
|
|
63
|
+
enabled: boolean
|
|
64
|
+
port: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether the firewall rule must be (re)applied: only when the toggle or the
|
|
69
|
+
* bound port moved since the last application. Keeps unrelated settings
|
|
70
|
+
* saves from spawning netsh/firewall-cmd delete+add churn.
|
|
71
|
+
* @param previous - the state applied earlier by this process, if any.
|
|
72
|
+
* @param next - the state the settings now ask for, if derivable.
|
|
73
|
+
*/
|
|
74
|
+
export function firewallActionNeeded(
|
|
75
|
+
previous: AppliedFirewallState | undefined,
|
|
76
|
+
next: AppliedFirewallState | undefined,
|
|
77
|
+
): boolean {
|
|
78
|
+
if (next === undefined) return false
|
|
79
|
+
if (previous === undefined) return true
|
|
80
|
+
return previous.enabled !== next.enabled || previous.port !== next.port
|
|
81
|
+
}
|