dsh-mobile 0.1.0-alpha.6 → 0.1.0-alpha.8
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 +8 -6
- package/README.zh.md +8 -6
- package/SECURITY.md +2 -1
- package/lib/cli.js +4 -4
- package/lib/client.js +2 -1
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +8 -0
- package/lib/index.mjs +209 -28
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -61,12 +61,12 @@ pnpm dsh plugin --profile web exec dsh-mobile setup
|
|
|
61
61
|
pnpm dsh --profile web
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
Both paths install the public npm package into stock DSH without patching its source. Setup remembers the selected LAN interface, creates one local CA, stores plugin data under `$DSH_HOME/mobile-access/`, and on Windows requests administrator approval for
|
|
64
|
+
Both paths install the public npm package into stock DSH without patching its source. Setup remembers the selected LAN interface, creates one local CA, stores plugin data under `$DSH_HOME/mobile-access/`, and on Windows requests administrator approval once for TCP/UDP rules limited to Windows' dynamic `LocalSubnet`. Use `--no-firewall` only when those rules are managed separately. While DSH is running, the plugin follows DHCP, Wi-Fi, and phone-hotspot address changes on that interface, rebinds the gateway, and signs a matching server certificate from the same CA. Paired devices and the Android CA trust remain valid.
|
|
65
65
|
|
|
66
|
-
1.
|
|
67
|
-
2.
|
|
68
|
-
3.
|
|
69
|
-
4.
|
|
66
|
+
1. Open the Mobile card in the lower-left corner of the desktop DSH UI.
|
|
67
|
+
2. Start access and select **Create pairing key**. The fingerprint-bound key is copied to the clipboard; the same card also shows a complete browser link.
|
|
68
|
+
3. Open the Android app, select **Scan**, then select the discovered DSH. Paste the key and connect.
|
|
69
|
+
4. On first connection, confirm the fingerprint-verified DSH Mobile CA in Android's system screen. The app continues automatically; no certificate file transfer is required. Alternatively, install the CA manually and open the browser link directly on the phone.
|
|
70
70
|
|
|
71
71
|
The key and browser link expire after two minutes and work once. After pairing, Android stores a revocable device credential encrypted by Android Keystore. The app can rediscover the same DSH installation on the default port after its LAN address changes and renew its short Web session without another key. Browser profiles still pair separately.
|
|
72
72
|
|
|
@@ -79,7 +79,9 @@ The two access modes are equivalent:
|
|
|
79
79
|
- Android app: a thin system WebView shell without browser address and tab bars.
|
|
80
80
|
- Mobile browser: open the setup origin directly; no app installation is required.
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
While Mobile Access is running, DSH publishes a small DNS-SD/mDNS service and a periodic UDP announcement. Each contains only the computer name, HTTPS origin, port, protocol version, and stable installation identifier. Android listens to both, also sends the original UDP query, and keeps HTTPS `/24` probing as a last compatibility fallback. Results are merged by installation identifier, so a new Wi-Fi or hotspot address updates the existing device instead of requiring another pairing. No pairing key, CA, device token, Cookie, or DSH data is discoverable. The first screen contains only Scan and the discovered DSH list; the pairing-key field appears after selecting one result.
|
|
83
|
+
|
|
84
|
+
After the user selects a result and enters its fingerprint-bound pairing key, the app fetches the public CA from that selected HTTPS origin without sending the key or any credential. It installs the CA only if the certificate is self-signed and its SHA-256 fingerprint exactly matches the key and discovered installation identifier. Only then does normal platform-validated HTTPS pairing begin.
|
|
83
85
|
|
|
84
86
|
## Customize the mobile Web UI
|
|
85
87
|
|
package/README.zh.md
CHANGED
|
@@ -61,12 +61,12 @@ pnpm dsh plugin --profile web exec dsh-mobile setup
|
|
|
61
61
|
pnpm dsh --profile web
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会记住所选局域网网卡、生成一份稳定的本地 CA,并把插件数据保存到 `$DSH_HOME/mobile-access/`。Windows
|
|
64
|
+
两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会记住所选局域网网卡、生成一份稳定的本地 CA,并把插件数据保存到 `$DSH_HOME/mobile-access/`。Windows 只在首次设置时请求管理员批准两条受动态 `LocalSubnet` 限制的 TCP/UDP 规则;只有自行管理规则时才使用 `--no-firewall`。DSH 运行期间,如果该网卡因 DHCP、切换 Wi-Fi 或手机热点而改变地址,插件会自动重绑网关,并用同一 CA 签发匹配的新服务器证书;已有设备配对和 Android CA 信任不会失效。
|
|
65
65
|
|
|
66
|
-
1.
|
|
67
|
-
2.
|
|
68
|
-
3.
|
|
69
|
-
4.
|
|
66
|
+
1. 在电脑 DSH 左下角打开“移动端”卡片。
|
|
67
|
+
2. 确认服务已开启,点击“生成配对密钥”。绑定 CA 指纹的密钥会复制到剪贴板,同一卡片也会显示完整的浏览器链接。
|
|
68
|
+
3. 打开 Android App,点击“扫描”并选择发现的 DSH,再粘贴密钥连接。
|
|
69
|
+
4. 首次连接时,在 Android 系统页面确认 App 已核对指纹的 DSH Mobile CA;确认后自动继续,无需传输证书文件。手机浏览器访问仍可手动安装 CA 后打开完整链接。
|
|
70
70
|
|
|
71
71
|
密钥和浏览器链接两分钟内有效且只能使用一次。配对后,Android 会用 Android Keystore 加密保存可随时撤销的设备凭据。局域网 IP 变化后,App 能在默认端口重新找到同一个 DSH,并自动续期短期 Web 会话,不需要新密钥。浏览器仍需单独配对。
|
|
72
72
|
|
|
@@ -79,7 +79,9 @@ pnpm dsh --profile web
|
|
|
79
79
|
- Android App:使用系统 WebView 打开同一 DSH 页面,没有浏览器地址栏和标签栏。
|
|
80
80
|
- 手机浏览器:直接访问向导显示的 `https://局域网地址:端口`,无需安装 App。
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
“移动访问”运行时,DSH 会同时发布小型 DNS-SD/mDNS 服务和周期性 UDP 公告。二者只包含电脑名称、HTTPS 地址、端口、协议版本和稳定安装标识。Android 同时监听两种公告,也保留原有的主动 UDP 查询,最后再以 HTTPS `/24` 网段探测兼容特殊热点。结果按安装标识合并,因此切换 Wi-Fi、热点或 DHCP 地址后只会更新同一设备的 IP,不需要重新配对。发现阶段不会传输配对密钥、CA、设备令牌、Cookie 或任何 DSH 数据。首页仍然只有扫描按钮和设备列表,点击设备后才显示密钥输入框。
|
|
83
|
+
|
|
84
|
+
用户选中设备并输入带指纹的配对密钥后,App 才从所选 HTTPS 地址获取公开 CA;这个引导请求不会携带密钥或任何凭据。只有证书为自签 CA,且 SHA-256 指纹同时匹配密钥和发现到的安装标识时,App 才会交给 Android 系统确认安装。随后配对请求始终使用 Android 正常校验的 HTTPS。
|
|
83
85
|
|
|
84
86
|
## 自定义移动端 Web UI
|
|
85
87
|
|
package/SECURITY.md
CHANGED
|
@@ -16,7 +16,8 @@ The maintainer will acknowledge a complete report within seven days. Publication
|
|
|
16
16
|
|
|
17
17
|
- Keep the ordinary DSH Web listener on loopback.
|
|
18
18
|
- Expose only the plugin-owned HTTPS listener to the LAN.
|
|
19
|
-
-
|
|
19
|
+
- DNS-SD/mDNS, periodic UDP announcements, active UDP query replies, and HTTPS discovery return only the device name, public HTTPS origin, port, protocol version, and stable non-secret installation identifier. Discovery never returns the CA, a pairing key, a device token, Cookies, credentials, or private configuration.
|
|
20
|
+
- Only after a user selects a device and enters the fingerprint-bound pairing key may Android fetch the public CA from that exact HTTPS origin. The bootstrap GET sends no key or credential. Android offers the certificate to the system installer only after validating that it is a self-signed CA whose SHA-256 fingerprint equals both the key and discovered installation identifier.
|
|
20
21
|
- Use a certificate trusted by every client platform; never instruct a WebView to ignore TLS errors.
|
|
21
22
|
- Keep pairing closed except during a short local onboarding action.
|
|
22
23
|
- Revoke a lost device immediately and rotate the device registry if credential theft is suspected.
|
package/lib/cli.js
CHANGED
|
@@ -229,14 +229,14 @@ async function runElevatedPowerShell(script) {
|
|
|
229
229
|
launch
|
|
230
230
|
], { windowsHide: true });
|
|
231
231
|
}
|
|
232
|
-
async function configureWindowsFirewall(port
|
|
232
|
+
async function configureWindowsFirewall(port) {
|
|
233
233
|
if (process.platform !== "win32") return;
|
|
234
234
|
const script = [
|
|
235
235
|
"$ErrorActionPreference = 'Stop'",
|
|
236
236
|
`Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
237
237
|
`Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
|
|
238
|
-
`New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress
|
|
239
|
-
`New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress
|
|
238
|
+
`New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
|
|
239
|
+
`New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`
|
|
240
240
|
].join("; ");
|
|
241
241
|
console.log("Windows will request administrator approval for two LAN-only firewall rules.");
|
|
242
242
|
await runElevatedPowerShell(script);
|
|
@@ -293,7 +293,7 @@ async function setup(args) {
|
|
|
293
293
|
chmod(keyFile, 384),
|
|
294
294
|
chmod(androidCertificate, 384)
|
|
295
295
|
]);
|
|
296
|
-
if (options.configureFirewall) await configureWindowsFirewall(options.port
|
|
296
|
+
if (options.configureFirewall) await configureWindowsFirewall(options.port);
|
|
297
297
|
const customCss = join(directory, "mobile.css");
|
|
298
298
|
try {
|
|
299
299
|
await readFile(customCss);
|
package/lib/client.js
CHANGED
|
@@ -176,7 +176,8 @@ window.__ModuleLoader__.load({
|
|
|
176
176
|
}).then(async (data) => {
|
|
177
177
|
const url = typeof data.pairUrl === "string" ? data.pairUrl : "";
|
|
178
178
|
const token = typeof data.token === "string" ? data.token : "";
|
|
179
|
-
const
|
|
179
|
+
const appKey = typeof data.appKey === "string" ? data.appKey : "";
|
|
180
|
+
const copied = appKey !== "" ? appKey : token !== "" ? token : url;
|
|
180
181
|
if (copied !== "" && navigator.clipboard !== void 0) await navigator.clipboard.writeText(copied);
|
|
181
182
|
status.textContent = copied === "" ? zh ? "无法生成密钥" : "Could not create key" : zh ? `配对密钥已复制:${copied}${url === "" ? "" : `\n手机浏览器链接:${url}`}` : `Pairing key copied: ${copied}${url === "" ? "" : `\nMobile browser link: ${url}`}`;
|
|
182
183
|
}, (error) => {
|
package/lib/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":["createElement"],"sources":["../src/client.ts"],"sourcesContent":["import { createElement } from 'react'\n\ninterface ClientContext {\n effect(effect: () => void | (() => void), label?: string): void\n slots: {\n inject(key: string, callback: () => (() => void)): () => void\n register(options: { name: string; id: string }, component: (props: { wide: boolean }) => unknown): () => void\n }\n}\n\ninterface MobileExtensionContext {\n readonly document: Document\n readonly request: (path: string, init?: RequestInit) => Promise<Response>\n readonly root: HTMLElement\n readonly window: Window\n}\n\ntype MobileExtensionMount = (context: MobileExtensionContext) => void | (() => void)\n\ninterface MobileExtensionRegistry {\n register(mount: MobileExtensionMount): void\n}\n\ndeclare global {\n interface Window {\n dshMobile?: MobileExtensionRegistry\n }\n}\n\nconst MOBILE_QUERY = '(max-width: 720px)'\n\nconst BASE_STYLES = `\n@media ${MOBILE_QUERY} {\n :root {\n --dsh-mobile-accent: #2563eb;\n --dsh-mobile-font-scale: 1;\n --dsh-mobile-radius: 14px;\n }\n html { font-size: calc(16px * var(--dsh-mobile-font-scale)); }\n body { overscroll-behavior: none; }\n button, [role='button'], input, textarea, select { min-height: 44px; }\n input, textarea, select { font-size: 16px !important; }\n [data-sidebar-collapsed] {\n padding-top: env(safe-area-inset-top);\n padding-right: env(safe-area-inset-right);\n padding-bottom: env(safe-area-inset-bottom);\n padding-left: env(safe-area-inset-left);\n }\n [data-shell-overlay] { inset: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }\n * { -webkit-tap-highlight-color: transparent; }\n}\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }\n}\n.dsh-mobile-control {\n position: fixed;\n left: 68px;\n bottom: 12px;\n z-index: 90;\n color: #172554;\n font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n}\n.dsh-mobile-control button {\n min-width: 44px;\n min-height: 44px;\n border: 1px solid #bfdbfe;\n border-radius: 12px;\n background: #fff;\n color: #1e3a8a;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger {\n width: 100%;\n min-width: 44px;\n min-height: 44px;\n padding: 0 12px;\n border: 0;\n border-radius: 10px;\n background: transparent;\n color: inherit;\n font: inherit;\n font-weight: 700;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger:hover { background: rgb(37 99 235 / 8%); }\n.dsh-mobile-control__panel {\n width: min(320px, calc(100vw - 24px));\n margin-bottom: 8px;\n padding: 16px;\n border: 1px solid #dbeafe;\n border-radius: 16px;\n background: #fff;\n box-shadow: 0 10px 30px rgb(15 23 42 / 12%);\n}\n.dsh-mobile-control__title { margin: 0 0 4px; font-size: 16px; font-weight: 800; }\n.dsh-mobile-control__status { margin: 0 0 14px; color: #475569; overflow-wrap: anywhere; }\n.dsh-mobile-control__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n.dsh-mobile-control__actions button[data-primary='true'] { background: #2563eb; border-color: #2563eb; color: #fff; }\n.dsh-mobile-control[hidden], .dsh-mobile-control__panel[hidden] { display: none; }\n`\n\nfunction isLoopbackHost(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'\n}\n\nfunction element<K extends keyof HTMLElementTagNameMap>(name: K, className?: string): HTMLElementTagNameMap[K] {\n const node = document.createElement(name)\n if (className !== undefined) node.className = className\n return node\n}\n\nasync function requestJson(path: string, init?: RequestInit): Promise<Record<string, unknown>> {\n const response = await fetch(path, {\n ...init,\n headers: { 'content-type': 'application/json', ...init?.headers },\n })\n const body = await response.json() as Record<string, unknown>\n if (!response.ok) {\n const code = typeof body.error === 'string' ? body.error : `HTTP ${String(response.status)}`\n throw new Error(controlErrorMessage(code))\n }\n return body\n}\n\nfunction controlErrorMessage(code: string): string {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const messages: Record<string, readonly [string, string]> = {\n network_address_changed: ['局域网地址已变化,请重启 DSH 后重试。', 'The LAN address changed. Restart DSH and try again.'],\n network_interface_unavailable: ['保存的局域网网卡未连接,请连接网络后重启 DSH。', 'The saved LAN interface is disconnected. Connect it and restart DSH.'],\n listen_port_in_use: ['移动访问端口已被占用。', 'The mobile-access port is already in use.'],\n }\n const message = messages[code]\n return message === undefined ? code : message[zh ? 0 : 1]\n}\n\nfunction mobileExtensionRequest(path: string, init: RequestInit = {}): Promise<Response> {\n const target = new URL(path, location.href)\n if (target.origin !== location.origin) throw new TypeError('mobile extension requests must stay on the DSH origin')\n const headers = new Headers(init.headers)\n const method = (init.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && !headers.has('x-dsh-mobile-csrf')) {\n const csrf = document.cookie.split(';').map(entry => entry.trim())\n .find(entry => entry.startsWith('dsh_ma_csrf='))?.slice('dsh_ma_csrf='.length)\n if (csrf !== undefined) headers.set('x-dsh-mobile-csrf', csrf)\n }\n return fetch(target, {\n ...init,\n cache: 'no-store',\n credentials: 'same-origin',\n headers,\n redirect: 'error',\n })\n}\n\nfunction installControl(): { remove: () => void; toggle: () => void } {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const root = element('div', 'dsh-mobile-control')\n const panel = element('section', 'dsh-mobile-control__panel')\n panel.hidden = true\n const title = element('h2', 'dsh-mobile-control__title')\n title.textContent = zh ? '移动访问' : 'Mobile access'\n const status = element('p', 'dsh-mobile-control__status')\n status.textContent = zh ? '正在读取状态…' : 'Reading status…'\n const actions = element('div', 'dsh-mobile-control__actions')\n const toggle = element('button')\n toggle.dataset.primary = 'true'\n const pair = element('button')\n pair.textContent = zh ? '生成配对密钥' : 'Create pairing key'\n actions.append(toggle, pair)\n panel.append(title, status, actions)\n root.append(panel)\n document.body.append(root)\n\n let running = false\n const render = (data: Record<string, unknown>): void => {\n running = data.running === true\n const origin = typeof data.origin === 'string' ? data.origin : undefined\n status.textContent = running\n ? (zh ? `已开启:${origin ?? ''}` : `Running: ${origin ?? ''}`)\n : (zh ? '已关闭。DSH 仍只在本机可用。' : 'Stopped. DSH remains local-only.')\n toggle.textContent = running ? (zh ? '关闭' : 'Stop') : (zh ? '开启' : 'Start')\n pair.disabled = !running\n }\n const refresh = async (): Promise<void> => {\n try { render(await requestJson('/api/mobile-access/control')) }\n catch (error) { status.textContent = error instanceof Error ? error.message : String(error) }\n }\n toggle.addEventListener('click', () => {\n toggle.disabled = true\n void requestJson('/api/mobile-access/control', {\n method: 'POST',\n body: JSON.stringify({ running: !running }),\n }).then(render, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { toggle.disabled = false })\n })\n pair.addEventListener('click', () => {\n pair.disabled = true\n void requestJson('/api/mobile-access/pairing/open', {\n method: 'POST',\n body: '{}',\n }).then(async (data) => {\n const url = typeof data.pairUrl === 'string' ? data.pairUrl : ''\n const token = typeof data.token === 'string' ? data.token : ''\n const copied = token !== '' ? token : url\n if (copied !== '' && navigator.clipboard !== undefined) await navigator.clipboard.writeText(copied)\n status.textContent = copied === '' ? (zh ? '无法生成密钥' : 'Could not create key')\n : (zh\n ? `配对密钥已复制:${copied}${url === '' ? '' : `\\n手机浏览器链接:${url}`}`\n : `Pairing key copied: ${copied}${url === '' ? '' : `\\nMobile browser link: ${url}`}`)\n }, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { pair.disabled = !running })\n })\n void refresh()\n return {\n remove: () => { root.remove() },\n toggle: () => { panel.hidden = !panel.hidden },\n }\n}\n\nfunction installCustomExtension(): () => void {\n let activeRoot: HTMLElement | undefined\n let activeDispose: (() => void) | undefined\n let observedSource = ''\n let loading = false\n let pendingMount: MobileExtensionMount | undefined\n const previousRegistry = window.dshMobile\n const registry: MobileExtensionRegistry = Object.freeze({\n register: (mount: MobileExtensionMount): void => {\n if (typeof mount !== 'function') throw new TypeError('dshMobile.register requires a mount function')\n pendingMount = mount\n },\n })\n const registeredMount = (): MobileExtensionMount | undefined => pendingMount\n window.dshMobile = registry\n\n const refresh = async (): Promise<void> => {\n if (loading || document.visibilityState === 'hidden') return\n loading = true\n try {\n const response = await fetch('/mobile-access/custom.js', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/javascript')) return\n const source = await response.text()\n if (source === observedSource) return\n observedSource = source\n pendingMount = undefined\n const script = document.createElement('script')\n script.textContent = `${source}\\n//# sourceURL=dsh-mobile-custom.js`\n document.head.append(script)\n script.remove()\n const mount = registeredMount()\n if (mount === undefined) return\n\n const nextRoot = document.createElement('div')\n nextRoot.dataset.dshMobileExtension = 'true'\n document.body.append(nextRoot)\n try {\n const nextDispose = mount(Object.freeze({\n document,\n request: mobileExtensionRequest,\n root: nextRoot,\n window,\n }))\n if (nextDispose !== undefined && typeof nextDispose !== 'function') {\n throw new TypeError('the mobile extension mount function must return a disposer or undefined')\n }\n activeDispose?.()\n activeRoot?.remove()\n activeRoot = nextRoot\n activeDispose = typeof nextDispose === 'function' ? nextDispose : undefined\n } catch (error) {\n nextRoot.remove()\n console.error('DSH Mobile custom extension failed to mount', error)\n }\n } catch {\n // A transient LAN disconnect keeps the last successfully mounted extension.\n } finally {\n loading = false\n }\n }\n void refresh()\n const timer = setInterval(() => { void refresh() }, 1_000)\n return () => {\n clearInterval(timer)\n activeDispose?.()\n activeRoot?.remove()\n if (previousRegistry === undefined) delete window.dshMobile\n else window.dshMobile = previousRegistry\n }\n}\n\n/** Install the responsive mobile layer and the loopback-only control card. */\nexport function apply(ctx: ClientContext): void {\n ctx.effect(() => {\n const style = document.createElement('style')\n style.dataset.plugin = 'dsh-mobile'\n style.textContent = BASE_STYLES\n document.head.append(style)\n\n const loopback = isLoopbackHost(location.hostname)\n const custom = loopback ? undefined : document.createElement('style')\n let customRefresh: ReturnType<typeof setInterval> | undefined\n let customLoading = false\n if (custom !== undefined) {\n custom.dataset.plugin = 'dsh-mobile'\n document.head.append(custom)\n const refreshCustomCss = async (): Promise<void> => {\n if (customLoading || document.visibilityState === 'hidden') return\n customLoading = true\n try {\n const response = await fetch('/mobile-access/custom.css', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/css')) return\n const css = await response.text()\n if (custom.textContent !== css) custom.textContent = css\n } catch {\n // A transient LAN disconnect keeps the last successfully applied stylesheet.\n } finally {\n customLoading = false\n }\n }\n void refreshCustomCss()\n customRefresh = setInterval(() => { void refreshCustomCss() }, 1_000)\n }\n const removeCustomExtension = loopback ? undefined : installCustomExtension()\n const control = loopback ? installControl() : undefined\n const disposeSlot = control === undefined ? undefined : ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({\n name: 'sidebar.footer.action',\n id: 'dsh-mobile',\n }, ({ wide }) => createElement('button', {\n className: 'dsh-mobile-control__trigger',\n type: 'button',\n title: controlLabel(),\n onClick: control.toggle,\n }, wide ? controlLabel() : 'M')))\n return () => {\n if (customRefresh !== undefined) clearInterval(customRefresh)\n disposeSlot?.()\n control?.remove()\n removeCustomExtension?.()\n custom?.remove()\n style.remove()\n }\n }, 'dsh-mobile: responsive UI and local control')\n}\n\n/** Client face has no service prerequisites. */\nexport const inject: readonly string[] = ['slots']\n\nfunction controlLabel(): string {\n return navigator.language.toLowerCase().startsWith('zh') ? '移动端' : 'Mobile access'\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.js","names":["createElement"],"sources":["../src/client.ts"],"sourcesContent":["import { createElement } from 'react'\n\ninterface ClientContext {\n effect(effect: () => void | (() => void), label?: string): void\n slots: {\n inject(key: string, callback: () => (() => void)): () => void\n register(options: { name: string; id: string }, component: (props: { wide: boolean }) => unknown): () => void\n }\n}\n\ninterface MobileExtensionContext {\n readonly document: Document\n readonly request: (path: string, init?: RequestInit) => Promise<Response>\n readonly root: HTMLElement\n readonly window: Window\n}\n\ntype MobileExtensionMount = (context: MobileExtensionContext) => void | (() => void)\n\ninterface MobileExtensionRegistry {\n register(mount: MobileExtensionMount): void\n}\n\ndeclare global {\n interface Window {\n dshMobile?: MobileExtensionRegistry\n }\n}\n\nconst MOBILE_QUERY = '(max-width: 720px)'\n\nconst BASE_STYLES = `\n@media ${MOBILE_QUERY} {\n :root {\n --dsh-mobile-accent: #2563eb;\n --dsh-mobile-font-scale: 1;\n --dsh-mobile-radius: 14px;\n }\n html { font-size: calc(16px * var(--dsh-mobile-font-scale)); }\n body { overscroll-behavior: none; }\n button, [role='button'], input, textarea, select { min-height: 44px; }\n input, textarea, select { font-size: 16px !important; }\n [data-sidebar-collapsed] {\n padding-top: env(safe-area-inset-top);\n padding-right: env(safe-area-inset-right);\n padding-bottom: env(safe-area-inset-bottom);\n padding-left: env(safe-area-inset-left);\n }\n [data-shell-overlay] { inset: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }\n * { -webkit-tap-highlight-color: transparent; }\n}\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }\n}\n.dsh-mobile-control {\n position: fixed;\n left: 68px;\n bottom: 12px;\n z-index: 90;\n color: #172554;\n font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n}\n.dsh-mobile-control button {\n min-width: 44px;\n min-height: 44px;\n border: 1px solid #bfdbfe;\n border-radius: 12px;\n background: #fff;\n color: #1e3a8a;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger {\n width: 100%;\n min-width: 44px;\n min-height: 44px;\n padding: 0 12px;\n border: 0;\n border-radius: 10px;\n background: transparent;\n color: inherit;\n font: inherit;\n font-weight: 700;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger:hover { background: rgb(37 99 235 / 8%); }\n.dsh-mobile-control__panel {\n width: min(320px, calc(100vw - 24px));\n margin-bottom: 8px;\n padding: 16px;\n border: 1px solid #dbeafe;\n border-radius: 16px;\n background: #fff;\n box-shadow: 0 10px 30px rgb(15 23 42 / 12%);\n}\n.dsh-mobile-control__title { margin: 0 0 4px; font-size: 16px; font-weight: 800; }\n.dsh-mobile-control__status { margin: 0 0 14px; color: #475569; overflow-wrap: anywhere; }\n.dsh-mobile-control__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n.dsh-mobile-control__actions button[data-primary='true'] { background: #2563eb; border-color: #2563eb; color: #fff; }\n.dsh-mobile-control[hidden], .dsh-mobile-control__panel[hidden] { display: none; }\n`\n\nfunction isLoopbackHost(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'\n}\n\nfunction element<K extends keyof HTMLElementTagNameMap>(name: K, className?: string): HTMLElementTagNameMap[K] {\n const node = document.createElement(name)\n if (className !== undefined) node.className = className\n return node\n}\n\nasync function requestJson(path: string, init?: RequestInit): Promise<Record<string, unknown>> {\n const response = await fetch(path, {\n ...init,\n headers: { 'content-type': 'application/json', ...init?.headers },\n })\n const body = await response.json() as Record<string, unknown>\n if (!response.ok) {\n const code = typeof body.error === 'string' ? body.error : `HTTP ${String(response.status)}`\n throw new Error(controlErrorMessage(code))\n }\n return body\n}\n\nfunction controlErrorMessage(code: string): string {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const messages: Record<string, readonly [string, string]> = {\n network_address_changed: ['局域网地址已变化,请重启 DSH 后重试。', 'The LAN address changed. Restart DSH and try again.'],\n network_interface_unavailable: ['保存的局域网网卡未连接,请连接网络后重启 DSH。', 'The saved LAN interface is disconnected. Connect it and restart DSH.'],\n listen_port_in_use: ['移动访问端口已被占用。', 'The mobile-access port is already in use.'],\n }\n const message = messages[code]\n return message === undefined ? code : message[zh ? 0 : 1]\n}\n\nfunction mobileExtensionRequest(path: string, init: RequestInit = {}): Promise<Response> {\n const target = new URL(path, location.href)\n if (target.origin !== location.origin) throw new TypeError('mobile extension requests must stay on the DSH origin')\n const headers = new Headers(init.headers)\n const method = (init.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && !headers.has('x-dsh-mobile-csrf')) {\n const csrf = document.cookie.split(';').map(entry => entry.trim())\n .find(entry => entry.startsWith('dsh_ma_csrf='))?.slice('dsh_ma_csrf='.length)\n if (csrf !== undefined) headers.set('x-dsh-mobile-csrf', csrf)\n }\n return fetch(target, {\n ...init,\n cache: 'no-store',\n credentials: 'same-origin',\n headers,\n redirect: 'error',\n })\n}\n\nfunction installControl(): { remove: () => void; toggle: () => void } {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const root = element('div', 'dsh-mobile-control')\n const panel = element('section', 'dsh-mobile-control__panel')\n panel.hidden = true\n const title = element('h2', 'dsh-mobile-control__title')\n title.textContent = zh ? '移动访问' : 'Mobile access'\n const status = element('p', 'dsh-mobile-control__status')\n status.textContent = zh ? '正在读取状态…' : 'Reading status…'\n const actions = element('div', 'dsh-mobile-control__actions')\n const toggle = element('button')\n toggle.dataset.primary = 'true'\n const pair = element('button')\n pair.textContent = zh ? '生成配对密钥' : 'Create pairing key'\n actions.append(toggle, pair)\n panel.append(title, status, actions)\n root.append(panel)\n document.body.append(root)\n\n let running = false\n const render = (data: Record<string, unknown>): void => {\n running = data.running === true\n const origin = typeof data.origin === 'string' ? data.origin : undefined\n status.textContent = running\n ? (zh ? `已开启:${origin ?? ''}` : `Running: ${origin ?? ''}`)\n : (zh ? '已关闭。DSH 仍只在本机可用。' : 'Stopped. DSH remains local-only.')\n toggle.textContent = running ? (zh ? '关闭' : 'Stop') : (zh ? '开启' : 'Start')\n pair.disabled = !running\n }\n const refresh = async (): Promise<void> => {\n try { render(await requestJson('/api/mobile-access/control')) }\n catch (error) { status.textContent = error instanceof Error ? error.message : String(error) }\n }\n toggle.addEventListener('click', () => {\n toggle.disabled = true\n void requestJson('/api/mobile-access/control', {\n method: 'POST',\n body: JSON.stringify({ running: !running }),\n }).then(render, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { toggle.disabled = false })\n })\n pair.addEventListener('click', () => {\n pair.disabled = true\n void requestJson('/api/mobile-access/pairing/open', {\n method: 'POST',\n body: '{}',\n }).then(async (data) => {\n const url = typeof data.pairUrl === 'string' ? data.pairUrl : ''\n const token = typeof data.token === 'string' ? data.token : ''\n const appKey = typeof data.appKey === 'string' ? data.appKey : ''\n const copied = appKey !== '' ? appKey : token !== '' ? token : url\n if (copied !== '' && navigator.clipboard !== undefined) await navigator.clipboard.writeText(copied)\n status.textContent = copied === '' ? (zh ? '无法生成密钥' : 'Could not create key')\n : (zh\n ? `配对密钥已复制:${copied}${url === '' ? '' : `\\n手机浏览器链接:${url}`}`\n : `Pairing key copied: ${copied}${url === '' ? '' : `\\nMobile browser link: ${url}`}`)\n }, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { pair.disabled = !running })\n })\n void refresh()\n return {\n remove: () => { root.remove() },\n toggle: () => { panel.hidden = !panel.hidden },\n }\n}\n\nfunction installCustomExtension(): () => void {\n let activeRoot: HTMLElement | undefined\n let activeDispose: (() => void) | undefined\n let observedSource = ''\n let loading = false\n let pendingMount: MobileExtensionMount | undefined\n const previousRegistry = window.dshMobile\n const registry: MobileExtensionRegistry = Object.freeze({\n register: (mount: MobileExtensionMount): void => {\n if (typeof mount !== 'function') throw new TypeError('dshMobile.register requires a mount function')\n pendingMount = mount\n },\n })\n const registeredMount = (): MobileExtensionMount | undefined => pendingMount\n window.dshMobile = registry\n\n const refresh = async (): Promise<void> => {\n if (loading || document.visibilityState === 'hidden') return\n loading = true\n try {\n const response = await fetch('/mobile-access/custom.js', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/javascript')) return\n const source = await response.text()\n if (source === observedSource) return\n observedSource = source\n pendingMount = undefined\n const script = document.createElement('script')\n script.textContent = `${source}\\n//# sourceURL=dsh-mobile-custom.js`\n document.head.append(script)\n script.remove()\n const mount = registeredMount()\n if (mount === undefined) return\n\n const nextRoot = document.createElement('div')\n nextRoot.dataset.dshMobileExtension = 'true'\n document.body.append(nextRoot)\n try {\n const nextDispose = mount(Object.freeze({\n document,\n request: mobileExtensionRequest,\n root: nextRoot,\n window,\n }))\n if (nextDispose !== undefined && typeof nextDispose !== 'function') {\n throw new TypeError('the mobile extension mount function must return a disposer or undefined')\n }\n activeDispose?.()\n activeRoot?.remove()\n activeRoot = nextRoot\n activeDispose = typeof nextDispose === 'function' ? nextDispose : undefined\n } catch (error) {\n nextRoot.remove()\n console.error('DSH Mobile custom extension failed to mount', error)\n }\n } catch {\n // A transient LAN disconnect keeps the last successfully mounted extension.\n } finally {\n loading = false\n }\n }\n void refresh()\n const timer = setInterval(() => { void refresh() }, 1_000)\n return () => {\n clearInterval(timer)\n activeDispose?.()\n activeRoot?.remove()\n if (previousRegistry === undefined) delete window.dshMobile\n else window.dshMobile = previousRegistry\n }\n}\n\n/** Install the responsive mobile layer and the loopback-only control card. */\nexport function apply(ctx: ClientContext): void {\n ctx.effect(() => {\n const style = document.createElement('style')\n style.dataset.plugin = 'dsh-mobile'\n style.textContent = BASE_STYLES\n document.head.append(style)\n\n const loopback = isLoopbackHost(location.hostname)\n const custom = loopback ? undefined : document.createElement('style')\n let customRefresh: ReturnType<typeof setInterval> | undefined\n let customLoading = false\n if (custom !== undefined) {\n custom.dataset.plugin = 'dsh-mobile'\n document.head.append(custom)\n const refreshCustomCss = async (): Promise<void> => {\n if (customLoading || document.visibilityState === 'hidden') return\n customLoading = true\n try {\n const response = await fetch('/mobile-access/custom.css', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/css')) return\n const css = await response.text()\n if (custom.textContent !== css) custom.textContent = css\n } catch {\n // A transient LAN disconnect keeps the last successfully applied stylesheet.\n } finally {\n customLoading = false\n }\n }\n void refreshCustomCss()\n customRefresh = setInterval(() => { void refreshCustomCss() }, 1_000)\n }\n const removeCustomExtension = loopback ? undefined : installCustomExtension()\n const control = loopback ? installControl() : undefined\n const disposeSlot = control === undefined ? undefined : ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({\n name: 'sidebar.footer.action',\n id: 'dsh-mobile',\n }, ({ wide }) => createElement('button', {\n className: 'dsh-mobile-control__trigger',\n type: 'button',\n title: controlLabel(),\n onClick: control.toggle,\n }, wide ? controlLabel() : 'M')))\n return () => {\n if (customRefresh !== undefined) clearInterval(customRefresh)\n disposeSlot?.()\n control?.remove()\n removeCustomExtension?.()\n custom?.remove()\n style.remove()\n }\n }, 'dsh-mobile: responsive UI and local control')\n}\n\n/** Client face has no service prerequisites. */\nexport const inject: readonly string[] = ['slots']\n\nfunction controlLabel(): string {\n return navigator.language.toLowerCase().startsWith('zh') ? '移动端' : 'Mobile access'\n}\n"],"mappings":"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"}
|
package/lib/index.d.mts
CHANGED
|
@@ -241,6 +241,8 @@ interface PluginConfig {
|
|
|
241
241
|
customScriptFile?: string;
|
|
242
242
|
/** Stable public discovery identifier; it is not an authentication secret. */
|
|
243
243
|
instanceId?: string;
|
|
244
|
+
/** Managed CA certificate offered to the Android installer after fingerprint binding. */
|
|
245
|
+
pairingCaFile?: string;
|
|
244
246
|
/** First-run state used only while the control file does not exist. */
|
|
245
247
|
initiallyEnabled: boolean;
|
|
246
248
|
tls?: {
|
|
@@ -274,6 +276,7 @@ interface ResolvedGatewayConfig {
|
|
|
274
276
|
readonly customCssFile: string;
|
|
275
277
|
readonly customScriptFile: string;
|
|
276
278
|
readonly instanceId: string;
|
|
279
|
+
readonly pairingCaFile?: string;
|
|
277
280
|
readonly tls: TlsConfig;
|
|
278
281
|
readonly pairingTtlMs: number;
|
|
279
282
|
readonly deviceTtlMs: number;
|
|
@@ -353,6 +356,9 @@ declare class MobileAccessGateway {
|
|
|
353
356
|
private policy;
|
|
354
357
|
private server;
|
|
355
358
|
private discoverySocket;
|
|
359
|
+
private discoveryTimer;
|
|
360
|
+
private bonjour;
|
|
361
|
+
private pairingCaCertificate;
|
|
356
362
|
private listenerPort;
|
|
357
363
|
private readonly connectedSockets;
|
|
358
364
|
private readonly activeRequests;
|
|
@@ -367,7 +373,9 @@ declare class MobileAccessGateway {
|
|
|
367
373
|
/** Initialize durable state, validate TLS, and bind the externally reachable listener. */
|
|
368
374
|
start(): Promise<void>;
|
|
369
375
|
private startDiscovery;
|
|
376
|
+
private discoveryAnnouncement;
|
|
370
377
|
private closeFailedStart;
|
|
378
|
+
private closeBonjour;
|
|
371
379
|
/** Actual bound address, available after start and safe for loopback status output. */
|
|
372
380
|
address(): {
|
|
373
381
|
host: string;
|
package/lib/index.mjs
CHANGED
|
@@ -4,11 +4,12 @@ import z from "@deepseek-ai/schemastery";
|
|
|
4
4
|
import { connect, isIP } from "node:net";
|
|
5
5
|
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
6
|
import { createSocket } from "node:dgram";
|
|
7
|
+
import { hostname, networkInterfaces } from "node:os";
|
|
7
8
|
import { createServer, request } from "node:http";
|
|
8
9
|
import { createServer as createServer$1 } from "node:https";
|
|
9
10
|
import { Transform } from "node:stream";
|
|
10
11
|
import { pipeline } from "node:stream/promises";
|
|
11
|
-
import
|
|
12
|
+
import Bonjour from "bonjour-service";
|
|
12
13
|
import { generate } from "selfsigned";
|
|
13
14
|
//#region src/access.ts
|
|
14
15
|
/** Stable error categories converted to deliberately terse HTTP responses. */
|
|
@@ -545,6 +546,7 @@ const Config = z.object({
|
|
|
545
546
|
customCssFile: z.string().hidden(),
|
|
546
547
|
customScriptFile: z.string().hidden(),
|
|
547
548
|
instanceId: z.string().hidden(),
|
|
549
|
+
pairingCaFile: z.string().hidden(),
|
|
548
550
|
initiallyEnabled: z.boolean().hidden().required(),
|
|
549
551
|
tls: z.object({
|
|
550
552
|
mode: z.union([z.const("provided"), z.const("disabled")]),
|
|
@@ -669,6 +671,7 @@ function parseGatewayConfig(raw) {
|
|
|
669
671
|
instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
|
|
670
672
|
throw new Error("instanceId must be a lowercase SHA-256 value");
|
|
671
673
|
})(),
|
|
674
|
+
...value.pairingCaFile === void 0 ? {} : { pairingCaFile: absoluteFile(value.pairingCaFile, "pairingCaFile") },
|
|
672
675
|
tls,
|
|
673
676
|
pairingTtlMs: integer(value.pairingTtlMs, "pairingTtlMs", 12e4, 1e4, 6e5),
|
|
674
677
|
deviceTtlMs,
|
|
@@ -687,6 +690,60 @@ function parseGatewayConfig(raw) {
|
|
|
687
690
|
}
|
|
688
691
|
//#endregion
|
|
689
692
|
//#region src/control.ts
|
|
693
|
+
/** Keeps one runtime aligned with a changing network selection. */
|
|
694
|
+
var FollowingMobileAccessRuntime = class {
|
|
695
|
+
select;
|
|
696
|
+
onRefreshError;
|
|
697
|
+
runtime;
|
|
698
|
+
key;
|
|
699
|
+
queue = Promise.resolve();
|
|
700
|
+
closed = false;
|
|
701
|
+
timer;
|
|
702
|
+
constructor(select, onRefreshError) {
|
|
703
|
+
this.select = select;
|
|
704
|
+
this.onRefreshError = onRefreshError;
|
|
705
|
+
}
|
|
706
|
+
/** Start the current selection and optionally poll for later changes. */
|
|
707
|
+
async initialize(refreshIntervalMs) {
|
|
708
|
+
await this.refresh();
|
|
709
|
+
if (refreshIntervalMs === void 0) return;
|
|
710
|
+
this.timer = setInterval(() => {
|
|
711
|
+
this.refresh().catch(this.onRefreshError);
|
|
712
|
+
}, refreshIntervalMs);
|
|
713
|
+
this.timer.unref();
|
|
714
|
+
}
|
|
715
|
+
/** Reconcile the active runtime with the latest selection. */
|
|
716
|
+
refresh() {
|
|
717
|
+
return this.enqueue(async () => {
|
|
718
|
+
if (this.closed) return;
|
|
719
|
+
const selection = await this.select();
|
|
720
|
+
if (this.closed || this.runtime !== void 0 && this.key === selection.key) return;
|
|
721
|
+
const previous = this.runtime;
|
|
722
|
+
this.runtime = void 0;
|
|
723
|
+
this.key = void 0;
|
|
724
|
+
if (previous !== void 0) await previous.close();
|
|
725
|
+
this.runtime = await selection.start();
|
|
726
|
+
this.key = selection.key;
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
/** Stop polling and close the most recently selected runtime. */
|
|
730
|
+
close() {
|
|
731
|
+
if (this.closed) return this.queue;
|
|
732
|
+
this.closed = true;
|
|
733
|
+
if (this.timer !== void 0) clearInterval(this.timer);
|
|
734
|
+
return this.enqueue(async () => {
|
|
735
|
+
const runtime = this.runtime;
|
|
736
|
+
this.runtime = void 0;
|
|
737
|
+
this.key = void 0;
|
|
738
|
+
if (runtime !== void 0) await runtime.close();
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
enqueue(operation) {
|
|
742
|
+
const run = this.queue.then(operation, operation);
|
|
743
|
+
this.queue = run.then(() => {}, () => {});
|
|
744
|
+
return run;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
690
747
|
/** Validate control state loaded across the filesystem boundary. */
|
|
691
748
|
function parseMobileAccessControlState(value) {
|
|
692
749
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile-access control state must be an object");
|
|
@@ -1021,6 +1078,9 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
|
|
|
1021
1078
|
const MAX_CONTROL_BODY_BYTES = 16384;
|
|
1022
1079
|
const MAX_HEADER_BYTES = 16384;
|
|
1023
1080
|
const DISCOVERY_QUERY = Buffer.from("DSH_MOBILE_DISCOVER_V1", "ascii");
|
|
1081
|
+
const DISCOVERY_PROTOCOL = 1;
|
|
1082
|
+
const DISCOVERY_INTERVAL_MS = 3e3;
|
|
1083
|
+
const MDNS_SERVICE_TYPE = "dsh-mobile";
|
|
1024
1084
|
const PAIR_PAGE = `<!doctype html>
|
|
1025
1085
|
<html lang="en">
|
|
1026
1086
|
<meta charset="utf-8">
|
|
@@ -1287,6 +1347,29 @@ function mapError(error) {
|
|
|
1287
1347
|
if (error instanceof AccessError) return new HttpError(error.status, error.code);
|
|
1288
1348
|
return new HttpError(500, "internal_error");
|
|
1289
1349
|
}
|
|
1350
|
+
function discoveryDeviceName() {
|
|
1351
|
+
const value = hostname().trim().replaceAll(/[\u0000-\u001f\u007f]/gu, "");
|
|
1352
|
+
return (value === "" ? "DeepSeek Harness" : value).slice(0, 63);
|
|
1353
|
+
}
|
|
1354
|
+
function discoveryMdnsHost(instanceId) {
|
|
1355
|
+
const label = hostname().toLowerCase().replaceAll(/[^a-z0-9-]/gu, "-").replaceAll(/^-+|-+$/gu, "").slice(0, 40);
|
|
1356
|
+
return `${label === "" ? "dsh" : label}-${instanceId.slice(0, 8)}.local`;
|
|
1357
|
+
}
|
|
1358
|
+
function discoveryBroadcastTargets(cidrs) {
|
|
1359
|
+
const targets = /* @__PURE__ */ new Set(["255.255.255.255"]);
|
|
1360
|
+
for (const cidr of cidrs) {
|
|
1361
|
+
if (cidr.bits !== 32 || cidr.prefix >= 32) continue;
|
|
1362
|
+
const hostBits = BigInt(32 - cidr.prefix);
|
|
1363
|
+
const broadcast = cidr.network | (1n << hostBits) - 1n;
|
|
1364
|
+
targets.add([
|
|
1365
|
+
24n,
|
|
1366
|
+
16n,
|
|
1367
|
+
8n,
|
|
1368
|
+
0n
|
|
1369
|
+
].map((shift) => Number(broadcast >> shift & 255n)).join("."));
|
|
1370
|
+
}
|
|
1371
|
+
return [...targets];
|
|
1372
|
+
}
|
|
1290
1373
|
/** Authenticated TLS edge in front of the ordinary loopback-only DSH Web server. */
|
|
1291
1374
|
var MobileAccessGateway = class {
|
|
1292
1375
|
config;
|
|
@@ -1295,6 +1378,9 @@ var MobileAccessGateway = class {
|
|
|
1295
1378
|
policy;
|
|
1296
1379
|
server;
|
|
1297
1380
|
discoverySocket;
|
|
1381
|
+
discoveryTimer;
|
|
1382
|
+
bonjour;
|
|
1383
|
+
pairingCaCertificate;
|
|
1298
1384
|
listenerPort;
|
|
1299
1385
|
connectedSockets = /* @__PURE__ */ new Set();
|
|
1300
1386
|
activeRequests = /* @__PURE__ */ new Map();
|
|
@@ -1329,6 +1415,12 @@ var MobileAccessGateway = class {
|
|
|
1329
1415
|
this.started = true;
|
|
1330
1416
|
await this.access.initialize();
|
|
1331
1417
|
try {
|
|
1418
|
+
if (this.config.pairingCaFile !== void 0) {
|
|
1419
|
+
const certificate = new X509Certificate(await readFile(this.config.pairingCaFile));
|
|
1420
|
+
const fingerprint = certificate.fingerprint256.replaceAll(":", "").toLowerCase();
|
|
1421
|
+
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey) || fingerprint !== this.config.instanceId) throw new Error("pairingCaFile must be the self-signed CA identified by instanceId");
|
|
1422
|
+
this.pairingCaCertificate = certificate.raw.toString("base64");
|
|
1423
|
+
}
|
|
1332
1424
|
const handler = (request, response) => {
|
|
1333
1425
|
this.handleExternalRequest(request, response).catch((error) => {
|
|
1334
1426
|
const mapped = mapError(error);
|
|
@@ -1388,14 +1480,10 @@ var MobileAccessGateway = class {
|
|
|
1388
1480
|
async startDiscovery(port) {
|
|
1389
1481
|
const socket = createSocket("udp4");
|
|
1390
1482
|
this.discoverySocket = socket;
|
|
1483
|
+
const announcement = this.discoveryAnnouncement(port);
|
|
1391
1484
|
socket.on("message", (message, remote) => {
|
|
1392
1485
|
if (this.closing || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
|
|
1393
|
-
|
|
1394
|
-
protocol: 1,
|
|
1395
|
-
instanceId: this.config.instanceId,
|
|
1396
|
-
origin: this.address().origin
|
|
1397
|
-
}), "utf8");
|
|
1398
|
-
socket.send(response, remote.port, remote.address, () => void 0);
|
|
1486
|
+
socket.send(announcement, remote.port, remote.address, () => void 0);
|
|
1399
1487
|
});
|
|
1400
1488
|
await new Promise((resolve, reject) => {
|
|
1401
1489
|
const failed = (error) => {
|
|
@@ -1404,11 +1492,47 @@ var MobileAccessGateway = class {
|
|
|
1404
1492
|
socket.once("error", failed);
|
|
1405
1493
|
socket.bind(port, "0.0.0.0", () => {
|
|
1406
1494
|
socket.off("error", failed);
|
|
1495
|
+
socket.setBroadcast(true);
|
|
1407
1496
|
resolve();
|
|
1408
1497
|
});
|
|
1409
1498
|
});
|
|
1499
|
+
const announce = () => {
|
|
1500
|
+
for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) socket.send(announcement, port, target, () => void 0);
|
|
1501
|
+
};
|
|
1502
|
+
announce();
|
|
1503
|
+
this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
|
|
1504
|
+
this.discoveryTimer.unref();
|
|
1505
|
+
const deviceName = discoveryDeviceName();
|
|
1506
|
+
const bonjour = new Bonjour({ disableIPv6: true });
|
|
1507
|
+
this.bonjour = bonjour;
|
|
1508
|
+
bonjour.publish({
|
|
1509
|
+
name: `${deviceName} (${this.config.instanceId.slice(0, 8)})`,
|
|
1510
|
+
type: MDNS_SERVICE_TYPE,
|
|
1511
|
+
protocol: "tcp",
|
|
1512
|
+
port,
|
|
1513
|
+
host: discoveryMdnsHost(this.config.instanceId),
|
|
1514
|
+
disableIPv6: true,
|
|
1515
|
+
txt: {
|
|
1516
|
+
deviceName,
|
|
1517
|
+
origin: this.address().origin,
|
|
1518
|
+
instanceId: this.config.instanceId,
|
|
1519
|
+
protocol: String(DISCOVERY_PROTOCOL)
|
|
1520
|
+
}
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
discoveryAnnouncement(port) {
|
|
1524
|
+
return Buffer.from(JSON.stringify({
|
|
1525
|
+
deviceName: discoveryDeviceName(),
|
|
1526
|
+
origin: this.address().origin,
|
|
1527
|
+
port,
|
|
1528
|
+
protocol: DISCOVERY_PROTOCOL,
|
|
1529
|
+
instanceId: this.config.instanceId
|
|
1530
|
+
}), "utf8");
|
|
1410
1531
|
}
|
|
1411
1532
|
async closeFailedStart() {
|
|
1533
|
+
if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
|
|
1534
|
+
this.discoveryTimer = void 0;
|
|
1535
|
+
await this.closeBonjour();
|
|
1412
1536
|
this.discoverySocket?.close();
|
|
1413
1537
|
this.discoverySocket = void 0;
|
|
1414
1538
|
for (const socket of this.connectedSockets) socket.destroy();
|
|
@@ -1419,6 +1543,16 @@ var MobileAccessGateway = class {
|
|
|
1419
1543
|
});
|
|
1420
1544
|
await this.access.close();
|
|
1421
1545
|
}
|
|
1546
|
+
async closeBonjour() {
|
|
1547
|
+
const bonjour = this.bonjour;
|
|
1548
|
+
this.bonjour = void 0;
|
|
1549
|
+
if (bonjour === void 0) return;
|
|
1550
|
+
await new Promise((resolve) => {
|
|
1551
|
+
bonjour.unpublishAll(() => {
|
|
1552
|
+
bonjour.destroy(() => resolve());
|
|
1553
|
+
});
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1422
1556
|
/** Actual bound address, available after start and safe for loopback status output. */
|
|
1423
1557
|
address() {
|
|
1424
1558
|
if (this.listenerPort === void 0 || this.policy === void 0) throw new Error("gateway is not listening");
|
|
@@ -1558,12 +1692,26 @@ var MobileAccessGateway = class {
|
|
|
1558
1692
|
}
|
|
1559
1693
|
if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/discovery`) {
|
|
1560
1694
|
sendJson(response, 200, {
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
port: this.address().port
|
|
1695
|
+
deviceName: discoveryDeviceName(),
|
|
1696
|
+
origin: this.address().origin,
|
|
1697
|
+
port: this.address().port,
|
|
1698
|
+
protocol: DISCOVERY_PROTOCOL,
|
|
1699
|
+
instanceId: this.config.instanceId
|
|
1564
1700
|
}, this.tlsEnabled);
|
|
1565
1701
|
return;
|
|
1566
1702
|
}
|
|
1703
|
+
if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/ca.cer`) {
|
|
1704
|
+
if (this.pairingCaCertificate === void 0) throw new HttpError(404, "not_found");
|
|
1705
|
+
const body = Buffer.from(this.pairingCaCertificate, "base64");
|
|
1706
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
1707
|
+
response.writeHead(200, {
|
|
1708
|
+
"Content-Type": "application/pkix-cert",
|
|
1709
|
+
"Content-Length": body.length,
|
|
1710
|
+
"Cache-Control": "no-store"
|
|
1711
|
+
});
|
|
1712
|
+
response.end(body);
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1567
1715
|
if (target.search === "" && request.method === "GET" && (target.decodedPathname === `/mobile-access/pair` || target.decodedPathname === `/mobile-access/pair.js`)) {
|
|
1568
1716
|
if (!this.access.pairingStatus().open) throw new HttpError(404, "not_found");
|
|
1569
1717
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
@@ -1913,6 +2061,7 @@ var MobileAccessGateway = class {
|
|
|
1913
2061
|
const opened = await this.access.openPairing(body.ttlMs);
|
|
1914
2062
|
sendJson(response, 201, {
|
|
1915
2063
|
...opened,
|
|
2064
|
+
appKey: `dsh1.${this.config.instanceId}.${opened.token}`,
|
|
1916
2065
|
pairUrl: `${this.address().origin}/mobile-access/pair#token=${opened.token}`
|
|
1917
2066
|
}, false);
|
|
1918
2067
|
return;
|
|
@@ -1955,6 +2104,9 @@ var MobileAccessGateway = class {
|
|
|
1955
2104
|
websocket.upstream.destroy();
|
|
1956
2105
|
}
|
|
1957
2106
|
for (const socket of this.connectedSockets) socket.destroy();
|
|
2107
|
+
if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
|
|
2108
|
+
this.discoveryTimer = void 0;
|
|
2109
|
+
await this.closeBonjour();
|
|
1958
2110
|
const discoverySocket = this.discoverySocket;
|
|
1959
2111
|
this.discoverySocket = void 0;
|
|
1960
2112
|
if (discoverySocket !== void 0) await new Promise((resolve) => {
|
|
@@ -2263,6 +2415,7 @@ async function materializeManagedSetup(setup, table) {
|
|
|
2263
2415
|
upstreamOrigin: setup.upstreamOrigin,
|
|
2264
2416
|
allowedCidrs: [network.cidr],
|
|
2265
2417
|
instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
|
|
2418
|
+
pairingCaFile: setup.tls.caCertFile,
|
|
2266
2419
|
tls: {
|
|
2267
2420
|
mode: "provided",
|
|
2268
2421
|
certFile: setup.tls.certFile,
|
|
@@ -2293,16 +2446,28 @@ const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
|
2293
2446
|
"publicAuthorities",
|
|
2294
2447
|
"allowedCidrs",
|
|
2295
2448
|
"instanceId",
|
|
2449
|
+
"pairingCaFile",
|
|
2296
2450
|
"tls"
|
|
2297
2451
|
]);
|
|
2452
|
+
function withoutSetupKeys(config) {
|
|
2453
|
+
const merged = { ...config };
|
|
2454
|
+
for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
|
|
2455
|
+
return merged;
|
|
2456
|
+
}
|
|
2298
2457
|
async function loadSetup(config) {
|
|
2299
|
-
if (config.setupFile === void 0) return
|
|
2458
|
+
if (config.setupFile === void 0) return {
|
|
2459
|
+
kind: "fixed",
|
|
2460
|
+
config
|
|
2461
|
+
};
|
|
2300
2462
|
if (!isAbsolute(config.setupFile)) throw new Error("setupFile must be an absolute file path");
|
|
2301
2463
|
let source;
|
|
2302
2464
|
try {
|
|
2303
2465
|
source = await readFile(resolve(config.setupFile), "utf8");
|
|
2304
2466
|
} catch (error) {
|
|
2305
|
-
if (error.code === "ENOENT") return
|
|
2467
|
+
if (error.code === "ENOENT") return {
|
|
2468
|
+
kind: "fixed",
|
|
2469
|
+
config
|
|
2470
|
+
};
|
|
2306
2471
|
throw error;
|
|
2307
2472
|
}
|
|
2308
2473
|
let parsed;
|
|
@@ -2313,29 +2478,27 @@ async function loadSetup(config) {
|
|
|
2313
2478
|
}
|
|
2314
2479
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("mobile setup file must be an object");
|
|
2315
2480
|
const record = parsed;
|
|
2316
|
-
if (record.version === 2) {
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
...merged,
|
|
2322
|
-
...setup
|
|
2323
|
-
};
|
|
2324
|
-
}
|
|
2481
|
+
if (record.version === 2) return {
|
|
2482
|
+
kind: "managed",
|
|
2483
|
+
config: withoutSetupKeys(config),
|
|
2484
|
+
setup: parseManagedSetup(record)
|
|
2485
|
+
};
|
|
2325
2486
|
if (record.version !== 1 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || !SETUP_KEYS.has(key))) throw new Error("mobile setup file has an unsupported format");
|
|
2326
2487
|
const { version: _version, ...setup } = record;
|
|
2327
|
-
const merged = { ...config };
|
|
2328
|
-
for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
|
|
2329
2488
|
return {
|
|
2330
|
-
|
|
2331
|
-
|
|
2489
|
+
kind: "fixed",
|
|
2490
|
+
config: {
|
|
2491
|
+
...withoutSetupKeys(config),
|
|
2492
|
+
...setup
|
|
2493
|
+
}
|
|
2332
2494
|
};
|
|
2333
2495
|
}
|
|
2334
2496
|
/** Mount the resident control route and its optional authenticated LAN gateway. */
|
|
2335
2497
|
async function apply(ctx, config) {
|
|
2336
|
-
const
|
|
2498
|
+
const loaded = await loadSetup(config);
|
|
2337
2499
|
let gateway;
|
|
2338
|
-
const
|
|
2500
|
+
const startGateway = async (candidateConfig) => {
|
|
2501
|
+
const resolved = parseGatewayConfig(candidateConfig);
|
|
2339
2502
|
const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices));
|
|
2340
2503
|
await candidate.start();
|
|
2341
2504
|
gateway = candidate;
|
|
@@ -2343,7 +2506,25 @@ async function apply(ctx, config) {
|
|
|
2343
2506
|
if (gateway === candidate) gateway = void 0;
|
|
2344
2507
|
await candidate.close();
|
|
2345
2508
|
} };
|
|
2346
|
-
}
|
|
2509
|
+
};
|
|
2510
|
+
const startRuntime = async () => {
|
|
2511
|
+
if (loaded.kind === "fixed") return startGateway(loaded.config);
|
|
2512
|
+
const following = new FollowingMobileAccessRuntime(async () => {
|
|
2513
|
+
const network = selectLanNetwork(void 0, loaded.setup.networkInterface);
|
|
2514
|
+
return {
|
|
2515
|
+
key: `${network.name}\0${network.address}\0${network.cidr}`,
|
|
2516
|
+
start: async () => startGateway({
|
|
2517
|
+
...loaded.config,
|
|
2518
|
+
...await materializeManagedSetup(loaded.setup)
|
|
2519
|
+
})
|
|
2520
|
+
};
|
|
2521
|
+
}, (error) => {
|
|
2522
|
+
process.emitWarning(`DSH Mobile could not follow the current LAN address: ${error instanceof Error ? error.message : String(error)}`, { code: "DSH_MOBILE_NETWORK_REFRESH" });
|
|
2523
|
+
});
|
|
2524
|
+
await following.initialize(2e3);
|
|
2525
|
+
return following;
|
|
2526
|
+
};
|
|
2527
|
+
const controller = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), startRuntime);
|
|
2347
2528
|
const adminRoute = {
|
|
2348
2529
|
kind: "prefix",
|
|
2349
2530
|
path: LOCAL_ADMIN_PREFIX,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Authenticated Android and mobile-browser access for the stock DeepSeek Harness Web UI",
|
|
6
6
|
"type": "module",
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
},
|
|
101
101
|
"dependencies": {
|
|
102
102
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
103
|
+
"bonjour-service": "^1.4.4",
|
|
103
104
|
"selfsigned": "^5.5.0"
|
|
104
105
|
},
|
|
105
106
|
"devDependencies": {
|