commit ced05e06a34787590c69ad22a3b9c6e12ecf08f3 Author: Kaxi <1042864399@qq.com> Date: Sun Sep 27 03:49:12 2026 +0800 feat: dsh-lan-access — 局域网访问 dsh Web UI 用独立反向代理把 dsh Web UI 发布到局域网。官方 CLI 主动拒绝 `dsh web --host 0.0.0.0`(会把宿主机远程代码执行暴露到网络), 因此本插件让官方服务器保持只绑回环,另开监听并转发。 宿主端(index.js) - 在 0.0.0.0:3082 监听,把 HTTP 与 WebSocket 转发到 127.0.0.1: - 上游 Host/Origin 重写为回环,使官方 /api 信任围栏「通过」而不是被绕过 - 不改动 Set-Cookie:浏览器按请求 URL 建立 host-only cookie,转发层无需干预 - 监听端 DNS 重绑定防护;targetHost 仅接受回环地址(不会变成开放代理) - /api/dsh-lan-access/summary 复用官方 connection.admit() 做围栏与浏览器认证 - 启动横幅打印带 launch token 的局域网链接 - 端口被占用时只告警不抛出,并在面板上报 listening:false,不展示不可用的二维码 - ownsHostCompat(默认关):仅为非回环页面声明 ownsHost,恢复官方设置界面 客户端(src/ 经 scripts/build-client.mjs 生成 client.js) - Settings → 局域网访问:局域网地址列表、复制/打开、选中地址的二维码 - 零依赖二维码编码器(byte 模式 / ECC M / version 1–10) 组合层 - 只 insert 一行,不覆盖任何 shipped row:停用本插件只会移除局域网监听, 回环 Web UI 不受影响 验证 - 宿主转发契约 28/28;客户端契约 25/25 - 二维码编码器与 npm qrcode 参考实现在全版本 × 全 8 掩码下逐模块比对 240/240 一致 - 端到端:局域网 token 首访 303 并铸 cookie → 应用 200;无凭证 401; WebSocket 升级 101,且与直连回环逐项行为一致 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce20120 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# `client.js` is generated from `src/` by `scripts/build-client.mjs`; no ignore. +node_modules/ +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d0bb7f8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 sutong + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..f7ba2c1 --- /dev/null +++ b/README.en.md @@ -0,0 +1,119 @@ +# @sutong/dsh-lan-access + +Publish the dsh Web UI on your local network with a **standalone reverse proxy**: it listens on `0.0.0.0:`, forwards HTTP and WebSocket to the loopback web server, and **keeps the official launch-token login**. + +[简体中文](README.md) | **English** + +## Why a plugin is needed + +`dsh web --host 0.0.0.0` is refused by the shipped CLI on purpose (`@deepseek-ai/dsh-web-app/lib/startup.js`: it would expose this machine's remote code execution to the network). The official web server therefore always binds loopback, so LAN access has to be a second listener that forwards to it. + +## How it differs from dsh-lan-proxy + +| | This plugin | dsh-lan-proxy | +|---|---|---| +| Default login | **the launch token is required** (the link from the banner or the QR code) | `injectToken: true` — anyone who can reach the port gets in untokened | +| Upstream `Host` | rewritten to loopback, so the official fence passes | rewritten to loopback | +| `Set-Cookie` | left untouched (a browser binds a host-only cookie to the **request URL**, so the forwarder needs no rewrite) | paired with token injection and a 401 replay | +| WebSocket | handshake forwarded byte-for-byte; `permessage-deflate` negotiates end to end and **the proxy decompresses nothing** | compression bridge (adds a decompression surface) | +| Shipped rows | **overrides none**, so a failure or a manual disable cannot affect the loopback UI | inserts its own row plus a compatibility injection | + +> **Port collision**: dsh-lan-proxy claims `3081` by default, so this plugin defaults to `3082`. They can coexist, but running both exposes the UI twice — keep one. + +## Install + +```sh +dsh plugin --profile web add /path/to/dsh-lan-access +``` + +Then restart `dsh web` once. The terminal prints the tokenized LAN links. + +## Configuration + +Set the row's `config` (Host Profile → Plugins → this plugin → Configure, or edit `cordis.patch.yml`). + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | `true` | Master switch; off means no listener | +| `host` | `0.0.0.0` | Listen address | +| `port` | `3082` | Listen port (avoids dsh-lan-proxy's 3081) | +| `targetHost` | `127.0.0.1` | Upstream host. **Loopback only** — anything else is refused at startup, so this can never become an open proxy | +| `targetPort` | `0` | Upstream port; `0` follows the live `webServer` port | +| `printBanner` | `true` | Print the LAN links at startup | + +## Usage + +1. **Startup banner**: + + ``` + dsh-lan-access: 0.0.0.0:3082 → http://127.0.0.1:3080 (LAN listener) + dsh-lan-access: http://192.168.1.5:3082/?token=XXXXXXXX + ``` + +2. **Web UI panel**: Settings → **LAN Access** (the plugin owns this settings page). It lists every LAN address with copy/open actions and renders a **QR code** for the address you select, so a phone can scan straight in. + +The `token` is used only on the first visit: the server mints a 30-day cookie and 302s to the clean `./`, so **the token never stays in the address bar or history**. That device then enters by cookie. + +## Security model + +- **Authentication is not relaxed.** No token injection, no fence bypass: a LAN device must hold the link from the banner or the QR code. That is stricter than trusting the whole network. +- **The official fence stays in force.** `/api` accepts only loopback or declared authorities, and `Origin` must match `Host`; the proxy rewrites the upstream `Host`/`Origin` to loopback so those checks *pass* rather than being bypassed. +- **DNS-rebinding guard**: the listener accepts only `localhost`, loopback, the LAN IP literals it publishes, or its own listen address. Hostnames are dropped. +- **Upstream locked to loopback**, so it cannot be turned into an open forward proxy. +- **No extra decompression surface**: no response or frame compression in the middle. +- **The panel's data route** (`/api/dsh-lan-access/summary`) is mounted on the official web server and **reuses the official `connection.admit()`** — the same fence and browser authentication — so only an authenticated page can read the token. +- **⚠️ Blast radius**: a device holding that link has **full dsh control, including command execution on this host**. Enable it only on a trusted network, and disable or uninstall it when you are done. Every link in the list is a key. + +## Settings screens on a LAN page (`ownsHostCompat`) + +DSH restricts the durable settings surface to loopback pages: the client derives `isLoopback` +from `location.hostname` (`dsh-client-connection/lib/client.js`), so a non-loopback page gets +`persistence = "memory"` (`dsh-client-ui-settings/lib/client.js`), the settings mirror never +answers with a `view`, and the Models/provider page reports +**settings are unavailable in this browser** — while the General page's "open config file" +action disappears. + +**This is unrelated to forwarding correctness**: every LAN exposure method hits it, and +`127.0.0.1:3080` is unaffected. There is no matching server-side gate (the `/api` fence plus the +launch token and cookie are the whole authorization surface), so this plugin exposes a switch: + +| Key | Default | Meaning | +|---|---|---| +| `ownsHostCompat` | `false` | Declare `ownsHost` for **non-loopback pages**, restoring the official settings screens (a loopback page is untouched) | + +This profile sets it to `true`. The tradeoff: that page can then read and write server settings, +credentials included. Since a device holding the tokenized link already has full control +(command execution on this host), this is not a new class of capability — but it does step past +the shipped loopback isolation. Set it to `false` to restore that posture and change settings +locally through `127.0.0.1:3080` (or an `ssh -L 3080:127.0.0.1:3080 ` tunnel). + +## Troubleshooting + +| Symptom | Cause and fix | +|---|---| +| `LAN listener unavailable ... EADDRINUSE` | The port is taken (commonly dsh-lan-proxy on 3081). Change `port`, or disable the other plugin. The panel shows the same error instead of a dead QR code. | +| A LAN device gets 401 | It opened the bare address without a token and has no cookie yet. Use the `?token=` link from the banner or the panel. | +| A bookmark stops working after a restart | The launch token changes on every boot. Devices that already logged in keep their cookie; otherwise use a fresh link. | +| The panel reports no non-internal IPv4 address | This machine currently has no usable LAN IPv4, only loopback. | +| Scanning does nothing | Some cameras read dark-background codes poorly; the panel always draws the code on a white card. Raise the screen brightness. | + +## Development + +```sh +node scripts/build-client.mjs # generates client.js from src/panel.js + src/qrcode.js +node scripts/smoke.mjs # host forwarding contract (HTTP/WS/Host/Origin/cookie/bind failure) +node scripts/test-qr.mjs # module-by-module comparison against the npm qrcode reference +node scripts/test-client.mjs # browser contract (slot registration, render, route agreement) +``` + +`client.js` is generated: edit `src/` and rebuild. + +The QR encoder (`src/qrcode.js`) is a dependency-free implementation (byte mode, ECC M, versions 1–10) verified module-by-module against the npm `qrcode` reference across every version and every mask (240/240 identical). + +## Rollback + +Disable or uninstall the plugin from the Plugins page. Because it **overrides no shipped row**, disabling it only removes the LAN listener; loopback `dsh web` is unaffected. + +## License + +MIT diff --git a/README.md b/README.md new file mode 100644 index 0000000..15320e5 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# @sutong/dsh-lan-access + +局域网访问 dsh Web UI:一个**独立反向代理**,在 `0.0.0.0:` 监听,把 HTTP 与 WebSocket 转发到回环 web 服务器,并**保留官方 launch token 登录**。 + +**简体中文** | [English](README.en.md) + +## 为什么需要插件 + +`dsh web --host 0.0.0.0` 被官方 CLI 主动拒绝(`@deepseek-ai/dsh-web-app/lib/startup.js`:会把这台机器的远程代码执行能力暴露到网络)。所以官方 web 服务器始终只绑回环,局域网访问必须由插件在**另一个端口**上另开监听并转发。 + +## 与 dsh-lan-proxy 的区别 + +社区插件 `@wingsky-1/dsh-lan-proxy` 也做转发,但设计取向不同: + +| | 本插件 | dsh-lan-proxy | +|---|---|---| +| 默认登录 | **必须持有 launch token**(横幅/二维码里的链接) | `injectToken: true`,局域网内任何设备免 token 直入 | +| 上游 `Host` | 重写为回环(官方围栏通过) | 重写为回环 | +| `Set-Cookie` | 不改动(浏览器按**请求 URL** 建立 host-only cookie,转发侧无需干预) | 需配合 token 注入与 401 重放 | +| WebSocket | 握手按字节转发,`permessage-deflate` 端到端协商,**代理不解压任何帧** | 压缩桥接(存在解压放大面) | +| 官方行覆盖 | **不覆盖任何 shipped row**,失败/禁用不会影响回环 UI | 插入自身行 + 兼容注入 | + +> **端口冲突**:dsh-lan-proxy 默认占用 `3081`,因此本插件默认用 `3082`。两者可共存,但同时启用等于把 UI 暴露两次;建议只留一个。 + +## 安装 + +```sh +dsh plugin --profile web add /path/to/dsh-lan-access +``` + +安装后重启一次 `dsh web`: + +```sh +# 重启后终端会打印带 token 的局域网链接 +``` + +或在本仓库内用本地目录安装(Profile → Plugins → 本地安装)。 + +## 配置 + +配置写在插件行的 `config` 里(Host Profile → Plugins → 本插件 → Configure,或直接改 `cordis.patch.yml`)。 + +| 键 | 默认 | 说明 | +|---|---|---| +| `enabled` | `true` | 总开关;关闭后不再监听 | +| `host` | `0.0.0.0` | 监听地址 | +| `port` | `3082` | 监听端口(避开 dsh-lan-proxy 的 3081) | +| `targetHost` | `127.0.0.1` | 上游主机。**仅接受回环地址**,否则插件拒绝启动(防止变成开放转发/SSRF) | +| `targetPort` | `0` | 上游端口;`0` = 跟随 `webServer` 实际端口 | +| `printBanner` | `true` | 启动时打印局域网链接 | + +## 使用 + +1. **终端横幅**(启动时): + + ``` + dsh-lan-access: 0.0.0.0:3082 → http://127.0.0.1:3080 (LAN listener) + dsh-lan-access: http://192.168.1.5:3082/?token=XXXXXXXX + ``` + +2. **Web UI 面板**:Settings → **局域网访问**(插件自带的设置页)。列出全部局域网地址、可复制、可直接打开,并为你选中的地址渲染**二维码**,手机扫一下即可进入。 + +链接里的 `token` 只在首次访问用到:服务器校验后会铸一个 30 天有效的 cookie 并 302 跳到干净的 `./`,**token 不会留在地址栏或历史记录里**。之后该设备靠 cookie 直接进入。 + +## 安全模型 + +- **认证不放松**:不注入 token、不绕过围栏。局域网设备必须拿到横幅或二维码里的链接。这比"信任整个局域网"更保守。 +- **官方围栏照旧生效**:`/api` 只接受回环或已声明的 authority,且 `Origin` 必须与 `Host` 同源;本插件把上游 `Host`/`Origin` 重写为回环,因此这些检查全部通过而不是被绕过。 +- **DNS 重绑定防护**:监听器只接受 `localhost`、回环地址、本插件公布的局域网 IP 字面量,或它自己的监听地址;域名一律断开。 +- **上游锁定回环**:`targetHost` 非回环直接拒绝启动,避免开放代理。 +- **无额外解压面**:中间不做响应/帧压缩,也不解压 WebSocket 帧。 +- **面板数据路由**:`/api/dsh-lan-access/summary` 挂在官方 web 服务器上,并**复用官方 `connection.admit()`**(同一套围栏 + 浏览器认证)——只有已登录页面能读到 token。 +- **⚠️ 风险面**:拿到该链接的设备拥有**完整 dsh 控制权,包括在宿主机执行命令**。请只在可信局域网启用;不使用时卸载或关闭插件。列表里的每一条链接都是一把钥匙。 + +## 局域网页面上的设置界面(ownsHostCompat) + +DSH 官方把「持久化设置」限制在回环页面:客户端由 `location.hostname` 推导 `isLoopback` +(`dsh-client-connection/lib/client.js`),非回环页面得到 `persistence = "memory"` +(`dsh-client-ui-settings/lib/client.js`),于是设置镜像永远没有 `view`,模型/提供商页会报 +**settings are unavailable in this browser**,General 页的「打开配置文件」也会消失。 + +**这与转发是否正确无关**:任何局域网暴露方式都会遇到,用 `127.0.0.1:3080` 打开则一切正常。 +服务端也**没有**对应的回环门禁(`/api` 围栏 + token/cookie 才是授权面),所以本插件提供一个开关: + +| 键 | 默认 | 说明 | +|---|---|---| +| `ownsHostCompat` | `false` | 为**非回环页面**声明 `ownsHost`,恢复官方设置界面(回环页面不受影响) | + +本 profile 已设为 `true`。取舍:该页面因此可读写服务器设置(含凭据)。考虑到持有带 token +链接的设备本就拥有完整控制权(含在宿主机执行命令),这不算新增一类能力,但确实绕过了官方的 +回环隔离 —— 设为 `false` 即回到官方姿态,设置请在本机通过 `127.0.0.1:3080`(或 +`ssh -L 3080:127.0.0.1:3080 <主机>` 隧道)修改。 + +## 排障 + +| 现象 | 原因与处理 | +|---|---| +| 终端出现 `LAN listener unavailable ... EADDRINUSE` | 端口被占用(常见:dsh-lan-proxy 占着 3081)。改 `port`,或停用另一个插件。面板会同时显示该错误,不会展示不可用的二维码。 | +| 局域网设备打开后 401 | 用的是不带 token 的裸地址,且该设备还没有 cookie。改用横幅/面板里带 `?token=` 的链接。 | +| 重启后旧书签失效 | launch token 每次启动都会变。已登录过的设备靠 cookie 仍然有效;否则重新用新链接进入。 | +| 面板显示"未找到非内部 IPv4 地址" | 这台机器当前没有可用的局域网 IPv4(只有回环)。 | +| 扫码无反应 | 部分机型对深色背景上的二维码识别差;面板里的二维码固定渲染在白底卡片上。适当调高屏幕亮度。 | + +## 开发 + +```sh +node scripts/build-client.mjs # 由 src/panel.js + src/qrcode.js 生成 client.js +node scripts/smoke.mjs # 宿主端转发契约(HTTP/WS/Host/Origin/cookie/绑定失败) +node scripts/test-qr.mjs # 与 npm qrcode 参考实现逐模块比对 +node scripts/test-client.mjs # 浏览器端契约(槽位注册、渲染、路由一致) +``` + +`client.js` 是生成产物,请改 `src/` 后重新构建。 + +二维码编码器(`src/qrcode.js`)是零依赖自实现(byte 模式 / ECC M / version 1–10),与 npm `qrcode` 参考实现做了全版本 × 全掩码的逐模块比对(240/240 一致)。 + +## 回退 + +在 Plugins 页停用或卸载本插件即可;因为本插件**不覆盖任何官方行**,停用只会让局域网监听消失,回环 `dsh web` 不受影响。 + +## 许可 + +MIT diff --git a/client.js b/client.js new file mode 100644 index 0000000..15349c8 --- /dev/null +++ b/client.js @@ -0,0 +1,877 @@ +/** + * @sutong/dsh-lan-access — browser half (generated by scripts/build-client.mjs). + * + * Do not edit this file: change src/panel.js or src/qrcode.js and rebuild. + */ +window.__ModuleLoader__.load({ + id: "@sutong/dsh-lan-access", + factory: function (require) { + "use strict"; + var React = require("react"); + + // ── inlined src/qrcode.js ──────────────────────────────────────────────── + var __qrcode = (function () { + /** + * A dependency-free QR Code encoder (byte mode, error-correction level M, + * versions 1–10) used to render a scannable link to the LAN address. + * + * This is a from-scratch implementation of ISO/IEC 18004 for the one mode the + * panel needs; it carries no runtime dependency into the browser artifact. + * Level M recovers about 15% of the symbol, which is the usual choice for a + * code read off a screen. + * + * Verified against the reference `qrcode` npm package: `node scripts/test-qr.mjs` + * compares every module of every version and mask. + * + * @module @sutong/dsh-lan-access/qrcode + */ + + /** Error-correction level indicator bits (`M` = 0b00) and the format-info prefix. */ + const EC_LEVEL_BITS = 0b00 + + /** Reed-Solomon block layout per version at level M: [ecCodewordsPerBlock, [[blocks, dataCodewords], …]]. */ + const BLOCKS_M = { + 1: [10, [[1, 16]]], + 2: [16, [[1, 28]]], + 3: [26, [[1, 44]]], + 4: [18, [[2, 32]]], + 5: [24, [[2, 43]]], + 6: [16, [[4, 27]]], + 7: [18, [[4, 31]]], + 8: [22, [[2, 38], [2, 39]]], + 9: [22, [[3, 36], [2, 37]]], + 10: [26, [[4, 43], [1, 44]]], + } + + /** Alignment-pattern centre coordinates per version. */ + const ALIGN_CENTERS = { + 1: [], + 2: [6, 18], + 3: [6, 22], + 4: [6, 26], + 5: [6, 30], + 6: [6, 34], + 7: [6, 22, 38], + 8: [6, 24, 42], + 9: [6, 26, 46], + 10: [6, 28, 50], + } + + /** Largest version this encoder supports. */ + const MAX_VERSION = 10 + + // ── GF(256) arithmetic ────────────────────────────────────────────────────── + + const GF_EXP = new Uint8Array(512) + const GF_LOG = new Uint8Array(256) + { + let x = 1 + for (let i = 0; i < 255; i += 1) { + GF_EXP[i] = x + GF_LOG[x] = i + x <<= 1 + if (x & 0x100) x ^= 0x11d + } + for (let i = 255; i < 512; i += 1) GF_EXP[i] = GF_EXP[i - 255] + } + + /** + * Multiply two field elements. + * @param a - first element. + * @param b - second element. + * @returns the product in GF(256). + */ + function gfMul(a, b) { + if (a === 0 || b === 0) return 0 + return GF_EXP[GF_LOG[a] + GF_LOG[b]] + } + + /** + * Build the Reed-Solomon generator polynomial for `degree` error codewords. + * @param degree - number of error-correction codewords. + * @returns coefficients, highest power first, leading coefficient 1. + */ + function rsGenerator(degree) { + let poly = [1] + for (let i = 0; i < degree; i += 1) { + const factor = GF_EXP[i] + const next = new Array(poly.length + 1).fill(0) + for (let j = 0; j < poly.length; j += 1) { + next[j] ^= poly[j] + next[j + 1] ^= gfMul(poly[j], factor) + } + poly = next + } + return poly + } + + /** + * Compute the error-correction codewords for one data block. + * @param data - data codewords. + * @param ecCount - number of error codewords to append. + * @returns the error codewords. + */ + function rsEncode(data, ecCount) { + const generator = rsGenerator(ecCount) + const buffer = new Uint8Array(data.length + ecCount) + buffer.set(data, 0) + for (let i = 0; i < data.length; i += 1) { + const coefficient = buffer[i] + if (coefficient === 0) continue + for (let j = 1; j < generator.length; j += 1) { + buffer[i + j] ^= gfMul(generator[j], coefficient) + } + } + return buffer.slice(data.length) + } + + // ── bit stream ────────────────────────────────────────────────────────────── + + /** Append the low `count` bits of `value`, most significant first. */ + function appendBits(bits, value, count) { + for (let i = count - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1) + } + + /** + * The smallest version whose data capacity holds `byteLength`. + * @param byteLength - payload length in bytes. + * @returns the version, or `undefined` when the payload is too large. + */ + function versionFor(byteLength) { + for (let version = 1; version <= MAX_VERSION; version += 1) { + const [, groups] = BLOCKS_M[version] + const dataCodewords = groups.reduce((sum, [blocks, per]) => sum + blocks * per, 0) + const lengthBits = version >= 10 ? 16 : 8 + const capacity = Math.floor((dataCodewords * 8 - 4 - lengthBits) / 8) + if (byteLength <= capacity) return version + } + return undefined + } + + /** + * Encode the payload into the final interleaved codeword sequence. + * @param bytes - UTF-8 payload bytes. + * @param version - resolved symbol version. + * @returns data codewords followed by error codewords, interleaved per block. + */ + function codewordsFor(bytes, version) { + const [ecPerBlock, groups] = BLOCKS_M[version] + const dataCodewords = groups.reduce((sum, [blocks, per]) => sum + blocks * per, 0) + + const bits = [] + appendBits(bits, 0b0100, 4) + appendBits(bits, bytes.length, version >= 10 ? 16 : 8) + for (const byte of bytes) appendBits(bits, byte, 8) + // Terminator, then pad to a codeword boundary. + const capacityBits = dataCodewords * 8 + const terminator = Math.min(4, capacityBits - bits.length) + appendBits(bits, 0, terminator) + while (bits.length % 8 !== 0) bits.push(0) + + const stream = new Uint8Array(dataCodewords) + for (let i = 0; i < bits.length; i += 8) { + let byte = 0 + for (let j = 0; j < 8; j += 1) byte = (byte << 1) | bits[i + j] + stream[i / 8] = byte + } + // Alternating pad codewords fill the remainder. + const PAD = [0xec, 0x11] + for (let i = bits.length / 8, k = 0; i < dataCodewords; i += 1, k += 1) stream[i] = PAD[k % 2] + + // Split into blocks, append each block's error codewords, then interleave. + const dataBlocks = [] + const ecBlocks = [] + let offset = 0 + for (const [blocks, per] of groups) { + for (let b = 0; b < blocks; b += 1) { + const chunk = stream.slice(offset, offset + per) + offset += per + dataBlocks.push(chunk) + ecBlocks.push(rsEncode(chunk, ecPerBlock)) + } + } + + const out = [] + const maxData = Math.max(...dataBlocks.map((block) => block.length)) + for (let i = 0; i < maxData; i += 1) { + for (const block of dataBlocks) if (i < block.length) out.push(block[i]) + } + for (let i = 0; i < ecPerBlock; i += 1) { + for (const block of ecBlocks) out.push(block[i]) + } + return Uint8Array.from(out) + } + + // ── matrix construction and masking ───────────────────────────────────────── + + /** The eight data-mask predicates; index is the mask pattern. */ + const MASKS = [ + (row, col) => (row + col) % 2 === 0, + (row) => row % 2 === 0, + (_row, col) => col % 3 === 0, + (row, col) => (row + col) % 3 === 0, + (row, col) => (Math.floor(row / 2) + Math.floor(col / 3)) % 2 === 0, + (row, col) => ((row * col) % 2) + ((row * col) % 3) === 0, + (row, col) => (((row * col) % 2) + ((row * col) % 3)) % 2 === 0, + (row, col) => (((row * col) % 3) + ((row + col) % 2)) % 2 === 0, + ] + + /** BCH(15,5) generator used by the format information. */ + const FORMAT_GENERATOR = 0x537 + + /** BCH(18,6) generator used by the version information. */ + const VERSION_GENERATOR = 0x1f25 + + /** + * Compute the masked format-information word. + * @param mask - mask pattern index. + * @returns the 15-bit word, already XORed with `0x5412`. + */ + function formatBits(mask) { + const data = (EC_LEVEL_BITS << 3) | mask + let value = data << 10 + for (let i = 14; i >= 10; i -= 1) { + if ((value >>> i) & 1) value ^= FORMAT_GENERATOR << (i - 10) + } + return (((data << 10) | value) ^ 0x5412) & 0x7fff + } + + /** + * Compute the version-information word for versions 7 and above. + * @param version - symbol version. + * @returns the 18-bit word. + */ + function versionBits(version) { + let value = version << 12 + for (let i = 17; i >= 12; i -= 1) { + if ((value >>> i) & 1) value ^= VERSION_GENERATOR << (i - 12) + } + return ((version << 12) | value) & 0x3ffff + } + + /** + * Draw the fixed function patterns and reserve their modules. + * @param size - symbol side length in modules. + * @param version - symbol version. + * @returns `{ modules, reserved }` as row-major byte grids. + */ + function newMatrix(size, version) { + const modules = new Uint8Array(size * size) + const reserved = new Uint8Array(size * size) + const set = (row, col, dark) => { + modules[row * size + col] = dark ? 1 : 0 + reserved[row * size + col] = 1 + } + + // Finder patterns with separators, at three corners. + for (const [top, left] of [[0, 0], [0, size - 7], [size - 7, 0]]) { + for (let r = -1; r <= 7; r += 1) { + for (let c = -1; c <= 7; c += 1) { + const row = top + r + const col = left + c + if (row < 0 || row >= size || col < 0 || col >= size) continue + const inRing = r >= 0 && r <= 6 && c >= 0 && c <= 6 + const dark = inRing && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)) + set(row, col, dark) + } + } + } + + // Timing patterns: dark on even coordinates, alternating with the finders. + for (let i = 8; i < size - 8; i += 1) { + set(6, i, i % 2 === 0) + set(i, 6, i % 2 === 0) + } + + // Alignment patterns, skipping the three finder corners. + const centers = ALIGN_CENTERS[version] + for (const row of centers) { + for (const col of centers) { + const nearFinder = + (row <= 8 && col <= 8) || (row <= 8 && col >= size - 9) || (row >= size - 9 && col <= 8) + if (nearFinder) continue + for (let r = -2; r <= 2; r += 1) { + for (let c = -2; c <= 2; c += 1) { + const dark = Math.max(Math.abs(r), Math.abs(c)) !== 1 + set(row + r, col + c, dark) + } + } + } + } + + // The always-dark module beside the lower-left finder. + set(size - 8, 8, true) + + // Reserve the format-information areas (values are written after masking). + for (let i = 0; i < 9; i += 1) { + if (i !== 6) { + set(8, i, false) + set(i, 8, false) + } + } + // The horizontal copy carries 8 modules; the vertical copy carries 7, because + // the eighth slot of column 8 is the always-dark module below. + for (let i = 0; i < 8; i += 1) set(8, size - 1 - i, false) + for (let i = 0; i < 7; i += 1) set(size - 1 - i, 8, false) + + // Reserve the version-information areas for versions 7 and above. + if (version >= 7) { + for (let i = 0; i < 18; i += 1) { + set(Math.floor(i / 3), (i % 3) + size - 11, false) + set((i % 3) + size - 11, Math.floor(i / 3), false) + } + } + return { size, modules, reserved } + } + + /** + * Write the data and error codewords into the matrix in the zigzag order. + * @param matrix - `{ size, modules, reserved }`. + * @param codewords - interleaved codewords. + * @param mask - mask pattern index. + */ + function placeData(matrix, codewords, mask) { + const { size, modules, reserved } = matrix + const maskFn = MASKS[mask] + let bitIndex = 7 + let byteIndex = 0 + let row = size - 1 + let direction = -1 + + for (let col = size - 1; col > 0; col -= 2) { + if (col === 6) col -= 1 + for (;;) { + for (let c = 0; c < 2; c += 1) { + const at = row * size + (col - c) + if (reserved[at]) continue + let dark = false + if (byteIndex < codewords.length) dark = ((codewords[byteIndex] >>> bitIndex) & 1) === 1 + if (maskFn(row, col - c)) dark = !dark + modules[at] = dark ? 1 : 0 + reserved[at] = 1 + bitIndex -= 1 + if (bitIndex === -1) { + byteIndex += 1 + bitIndex = 7 + } + } + row += direction + if (row < 0 || row >= size) { + row -= direction + direction = -direction + break + } + } + } + } + + /** + * Measure one masked matrix with the four standard penalty rules. + * @param size - symbol side length. + * @param modules - row-major module grid. + * @returns the penalty score; lower is better. + */ + function penalty(size, modules) { + const at = (row, col) => modules[row * size + col] + let score = 0 + + // Rule 1: runs of five or more same-coloured modules in a line. + for (let i = 0; i < size; i += 1) { + for (const line of [0, 1]) { + let run = 1 + let previous = line === 0 ? at(i, 0) : at(0, i) + for (let j = 1; j < size; j += 1) { + const current = line === 0 ? at(i, j) : at(j, i) + if (current === previous) { + run += 1 + } else { + if (run >= 5) score += 3 + (run - 5) + previous = current + run = 1 + } + } + if (run >= 5) score += 3 + (run - 5) + } + } + + // Rule 2: every 2x2 block of one colour. + for (let row = 0; row < size - 1; row += 1) { + for (let col = 0; col < size - 1; col += 1) { + const value = at(row, col) + if (value === at(row, col + 1) && value === at(row + 1, col) && value === at(row + 1, col + 1)) score += 3 + } + } + + // Rule 3: the finder-like 1:1:3:1:1 patterns with a four-module quiet side. + const first = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0] + const second = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1] + const matches = (get, start) => { + let one = true + let two = true + for (let k = 0; k < 11; k += 1) { + const value = get(start + k) + if (value !== first[k]) one = false + if (value !== second[k]) two = false + } + return (one ? 1 : 0) + (two ? 1 : 0) + } + for (let i = 0; i < size; i += 1) { + for (let j = 0; j + 11 <= size; j += 1) { + score += 40 * matches((k) => at(i, k), j) + score += 40 * matches((k) => at(k, i), j) + } + } + + // Rule 4: deviation of the dark-module proportion from 50%. + let dark = 0 + for (let i = 0; i < size; i += 1) dark += modules[i] + const percent = (dark * 100) / (size * size) + score += Math.floor(Math.abs(percent - 50) / 5) * 10 + return score + } + + /** + * Write the format information (and version information for v7+) around the + * fixed patterns. + * @param matrix - `{ size, modules }`. + * @param version - symbol version. + * @param mask - chosen mask pattern index. + */ + function placeHeaders(matrix, version, mask) { + const { size, modules } = matrix + const set = (row, col, dark) => { + modules[row * size + col] = dark ? 1 : 0 + } + + const format = formatBits(mask) + for (let i = 0; i < 15; i += 1) { + const dark = ((format >>> i) & 1) === 1 + if (i < 6) set(i, 8, dark) + else if (i < 8) set(i + 1, 8, dark) + else set(size - 15 + i, 8, dark) + + if (i < 8) set(8, size - 1 - i, dark) + else if (i < 9) set(8, 15 - i, dark) + else set(8, 15 - i - 1, dark) + } + + if (version >= 7) { + const bits = versionBits(version) + for (let i = 0; i < 18; i += 1) { + const dark = ((bits >>> i) & 1) === 1 + set(Math.floor(i / 3), (i % 3) + size - 11, dark) + set((i % 3) + size - 11, Math.floor(i / 3), dark) + } + } + } + + /** + * Encode text as a QR Code symbol. + * @param text - the payload; UTF-8 encoded. + * @param options - optional `version` and `mask` overrides (mainly for tests). + * @returns `{ size, version, mask, modules }` where `modules` is row-major, 1 = dark. + * @throws {RangeError} when the payload exceeds version 10 at level M. + */ + function encodeQR(text, options = {}) { + const bytes = new TextEncoder().encode(String(text)) + const version = options.version ?? versionFor(bytes.length) + if (version === undefined || version > MAX_VERSION) { + throw new RangeError(`qrcode: payload of ${bytes.length} bytes exceeds version ${MAX_VERSION} at level M`) + } + + const codewords = codewordsFor(bytes, version) + const size = version * 4 + 17 + + let best + if (options.mask !== undefined) { + best = options.mask + } else { + let bestScore = Number.POSITIVE_INFINITY + for (let mask = 0; mask < 8; mask += 1) { + const matrix = newMatrix(size, version) + placeData(matrix, codewords, mask) + placeHeaders(matrix, version, mask) + const score = penalty(size, matrix.modules) + if (score < bestScore) { + bestScore = score + best = mask + } + } + } + + const matrix = newMatrix(size, version) + placeData(matrix, codewords, best) + placeHeaders(matrix, version, best) + return { size, version, mask: best, modules: matrix.modules } + } + return { encodeQR: encodeQR, MAX_VERSION: MAX_VERSION }; + })(); + var encodeQR = __qrcode.encodeQR; + + // ── inlined src/panel.js ───────────────────────────────────────────────── + /** + * Client half of @sutong/dsh-lan-access: a Plugins-settings tab that shows the + * LAN addresses the host listener publishes, with a scannable QR code for the + * address you pick. + * + * This file is bundled into the single browser artifact `client.js` by + * `scripts/build-client.mjs`; it is never loaded directly by the browser. + * + * @module @sutong/dsh-lan-access/panel + */ + + + + /** Locale namespace for this plugin's visible text. */ + const NS = 'dsh-lan-access' + + /** Host route (behind the official `/api` fence and browser authentication). */ + const SUMMARY_URL = '/api/dsh-lan-access/summary' + + /** + * The seat this panel occupies: its own entry in the Settings navigation. + * Registering here (rather than inside the Plugins section's tab strip) makes + * the page reachable directly from Settings; `dsh-opencode-go` sets the same + * precedent for a plugin-owned settings page. + */ + const SLOT = 'settings.section' + + /** Nav/section key for this panel. */ + const SECTION_ID = 'lan-access' + + /** Simplified-Chinese dictionary; the key source. */ + const zh = { + title: '局域网访问', + description: '通过独立反向代理把本机 dsh Web UI 发布到局域网,保留官方 launch token 登录。', + loading: '正在读取局域网地址…', + failed: '无法读取局域网信息:{message}', + forward: '转发到', + addresses: '局域网地址', + noAddresses: '未找到非内部 IPv4 地址,局域网内暂时无法访问。', + scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。', + copy: '复制', + copied: '已复制', + open: '打开', + warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。', + disabled: '该 LAN 监听器在此 profile 中已被配置为关闭。', + notListening: '监听器未能绑定 {target}:{message}', + qr: '{address} 的二维码', + } + + /** English dictionary; must cover every key above. */ + const en = { + title: 'LAN Access', + description: 'Publishes this machine’s dsh Web UI on the LAN through a standalone reverse proxy while keeping the official launch-token login.', + loading: 'Reading LAN addresses…', + failed: 'Could not read LAN information: {message}', + forward: 'forwards to', + addresses: 'LAN addresses', + noAddresses: 'No non-internal IPv4 address was found, so nothing is reachable from the LAN yet.', + scanHint: 'Scan the QR code with another device to open it (the link already carries the login token).', + copy: 'Copy', + copied: 'Copied', + open: 'Open', + warning: 'That link carries the launch token and grants full dsh control, shell included. Share it only on a trusted network.', + disabled: 'The LAN listener is configured off in this profile.', + notListening: 'The listener could not bind {target}: {message}', + qr: 'QR code for {address}', + } + + /** Substitute `{name}` placeholders. */ + function interpolate(template, params) { + if (params === undefined) return template + return template.replaceAll(/\{(\w+)\}/g, (match, key) => (key in params ? String(params[key]) : match)) + } + + /** + * Bind the locale service, falling back to the Chinese dictionary when the + * service is absent or its contract drifted. + * @param locale - the live `locale` service, when present. + * @returns a translate function taking a key and optional `{name}` parameters. + */ + function makeTranslator(locale) { + if (locale !== undefined && typeof locale.bind === 'function') { + try { + const bound = locale.bind(NS) + if (typeof bound === 'function') return (key, params) => interpolate(bound(key), params) + } catch { + /* fall through to the local dictionary */ + } + } + return (key, params) => interpolate(zh[key] ?? key, params) + } + + /** + * The active translator. `apply` replaces it once the locale service is bound + * and re-binds it on every locale change, so components read it at render time. + */ + let translate = (key, params) => interpolate(zh[key] ?? key, params) + + /** Stylesheet, scoped under `dla-` and expressed only in theme tokens. */ + const CSS = ` + .dla-root { display: flex; flex-direction: column; gap: 16px; padding: 4px 2px 24px; color: var(--dsw-alias-label-primary); } + .dla-head { display: flex; flex-direction: column; gap: 6px; } + .dla-title { font-size: 15px; font-weight: 600; } + .dla-desc { font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } + .dla-facts { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 12px; color: var(--dsw-alias-label-secondary); } + .dla-fact b { color: var(--dsw-alias-label-primary); font-weight: 500; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + .dla-body { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; } + .dla-list { display: flex; flex-direction: column; gap: 8px; flex: 1 1 320px; min-width: 280px; } + .dla-section { font-size: 12px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; color: var(--dsw-alias-label-secondary); } + .dla-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 8px 10px; background: var(--dsw-alias-bg-layer-1); } + .dla-row[data-selected="true"] { border-color: var(--dsw-alias-brand-primary); } + .dla-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } + .dla-ip { font-size: 12px; color: var(--dsw-alias-label-secondary); } + .dla-url { font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .dla-btn { flex: none; font: inherit; font-size: 12px; line-height: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); cursor: pointer; } + .dla-btn:hover { border-color: var(--dsw-alias-border-l2); } + .dla-btn[data-primary="true"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); } + .dla-qr { display: flex; flex-direction: column; gap: 8px; align-items: center; } + .dla-qr-card { padding: 10px; border-radius: 10px; background: #ffffff; border: 1px solid var(--dsw-alias-border-l1); } + .dla-qr-cap { font-size: 12px; color: var(--dsw-alias-label-secondary); max-width: 200px; text-align: center; } + .dla-hint { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } + .dla-warn { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-state-warn-primary); } + .dla-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); } + ` + + /** + * Render a QR matrix as a crisp SVG. + * @param symbol - `{ size, modules }` from the encoder. + * @param label - accessible label. + * @returns a React element. + */ + function qrElement(React, symbol, label, caption) { + const h = React.createElement + const { size, modules } = symbol + let path = '' + for (let row = 0; row < size; row += 1) { + for (let col = 0; col < size; col += 1) { + if (modules[row * size + col]) path += `M${col} ${row}h1v1h-1z` + } + } + return h( + 'div', + { className: 'dla-qr' }, + h( + 'div', + { className: 'dla-qr-card' }, + h( + 'svg', + { + width: 168, + height: 168, + viewBox: `0 0 ${size} ${size}`, + role: 'img', + 'aria-label': label, + shapeRendering: 'crispEdges', + }, + h('rect', { width: size, height: size, fill: '#ffffff' }), + h('path', { d: path, fill: '#000000' }), + ), + ), + caption === undefined ? null : h('div', { className: 'dla-qr-cap' }, caption), + ) + } + + /** + * The Plugins-settings tab: read the host summary and present the LAN links. + * @param props - slot props; this seat supplies none. + * @returns the panel element. + */ + function Panel() { + const h = React.createElement + const [state, setState] = React.useState({ status: 'loading' }) + const [selected, setSelected] = React.useState(0) + const [copied, setCopied] = React.useState('') + + React.useEffect(() => { + let live = true + fetch(SUMMARY_URL, { headers: { accept: 'application/json' } }) + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${String(response.status)}`) + return response.json() + }) + .then((data) => { + if (live) setState({ status: 'ready', data }) + }) + .catch((error) => { + if (live) setState({ status: 'error', message: String(error?.message ?? error) }) + }) + return () => { + live = false + } + }, []) + + const t = translate + const children = [h('style', { key: 'style' }, CSS)] + + if (state.status === 'loading') { + children.push(h('div', { className: 'dla-hint', key: 'loading' }, t('loading'))) + return h('div', { className: 'dla-root' }, children) + } + if (state.status === 'error') { + children.push(h('div', { className: 'dla-error', key: 'error' }, t('failed', { message: state.message }))) + return h('div', { className: 'dla-root' }, children) + } + + const { data } = state + const lan = Array.isArray(data.lan) ? data.lan : [] + const active = lan[Math.min(selected, Math.max(lan.length - 1, 0))] + + children.push( + h( + 'div', + { className: 'dla-head', key: 'head' }, + h('div', { className: 'dla-title' }, t('title')), + h('div', { className: 'dla-desc' }, t('description')), + h( + 'div', + { className: 'dla-facts' }, + h('span', null, `${data.listen.host}:${String(data.listen.port)}`, ' ', t('forward'), ' ', h('b', null, `${data.target.host}:${String(data.target.port)}`)), + ), + ), + ) + + if (data.enabled === false) { + children.push(h('div', { className: 'dla-warn', key: 'disabled' }, t('disabled'))) + } + if (data.listening === false) { + // Never advertise an address that is not actually bound. + children.push( + h( + 'div', + { className: 'dla-error', key: 'not-listening' }, + t('notListening', { + target: `${data.listen.host}:${String(data.listen.port)}`, + message: data.bindError ?? 'unknown error', + }), + ), + ) + } + + if (lan.length === 0) { + children.push(h('div', { className: 'dla-hint', key: 'none' }, t('noAddresses'))) + return h('div', { className: 'dla-root' }, children) + } + + const copy = (url) => { + const done = () => { + setCopied(url) + globalThis.setTimeout(() => setCopied(''), 1500) + } + if (globalThis.navigator?.clipboard?.writeText !== undefined) { + globalThis.navigator.clipboard.writeText(url).then(done, done) + } else { + done() + } + } + + const rows = lan.map((entry, index) => + h( + 'div', + { + className: 'dla-row', + key: entry.address, + 'data-selected': String(index === selected), + onClick: () => setSelected(index), + }, + h( + 'div', + { className: 'dla-row-main' }, + h('div', { className: 'dla-ip' }, entry.address), + h('div', { className: 'dla-url', title: entry.tokenUrl }, entry.tokenUrl), + ), + h( + 'button', + { + type: 'button', + className: 'dla-btn', + onClick: (event) => { + event.stopPropagation() + copy(entry.tokenUrl) + }, + }, + copied === entry.tokenUrl ? t('copied') : t('copy'), + ), + h( + 'a', + { className: 'dla-btn', href: entry.tokenUrl, target: '_blank', rel: 'noreferrer', onClick: (event) => event.stopPropagation() }, + t('open'), + ), + ), + ) + + let symbol + try { + symbol = encodeQR(active.tokenUrl) + } catch { + symbol = undefined + } + + children.push( + h( + 'div', + { className: 'dla-body', key: 'body' }, + h( + 'div', + { className: 'dla-list' }, + h('div', { className: 'dla-section' }, t('addresses')), + ...rows, + h('div', { className: 'dla-hint' }, t('scanHint')), + ), + symbol === undefined ? null : qrElement(React, symbol, t('qr', { address: active.address }), active.address), + ), + h('div', { className: 'dla-warn', key: 'warn' }, t('warning')), + ) + return h('div', { className: 'dla-root' }, children) + } + + /** Services this client half reads. */ + const inject = ['slots', 'locale'] + + /** + * Register the locale dictionaries and the Plugins-settings tab. + * @param ctx - the restricted client context. + */ + function apply(ctx) { + const locale = ctx.get('locale') + if (locale !== undefined) { + if (typeof locale.register === 'function') { + try { + const dispose = locale.register(NS, { zh, en }) + if (typeof dispose === 'function') ctx.effect(() => dispose, 'dsh-lan-access: locale dictionaries') + } catch (error) { + console.error('dsh-lan-access: locale registration failed', error) + } + } + translate = makeTranslator(locale) + if (typeof locale.subscribe === 'function') { + const unsubscribe = locale.subscribe(() => { + translate = makeTranslator(locale) + }) + if (typeof unsubscribe === 'function') ctx.effect(() => unsubscribe, 'dsh-lan-access: locale subscription') + } + } + + const slots = ctx.get('slots') + if (slots === undefined) { + console.error('dsh-lan-access: the slots service is unavailable; the LAN panel is not mounted') + return + } + const t = makeTranslator(locale) + slots.inject(SLOT, () => + slots.register( + { + name: SLOT, + id: SECTION_ID, + order: 50, + label: () => t('title'), + }, + Panel, + ), + ) + } + + return { inject: inject, apply: apply }; + }, +}); diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..991e626 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,25 @@ +# @sutong/dsh-lan-access — bundle patch. +# +# This patch only INSERTS one row and overrides no shipped row. That is a +# deliberate design property: the LAN listener is a separate socket that +# forwards to the loopback web server, so nothing in the official composition +# (webserver bind, connection trust fence, launch token) has to change, and a +# failure or a manual disable of this row cannot break the loopback Web UI. +# +# The row is an ordinary Cordis plugin activated by package name. +- insert: + - id: dsh-lan-access + name: '@sutong/dsh-lan-access' + config: + # The official client derives its `isLoopback` topology fact from + # `location.hostname`, and the durable settings surface is loopback-only + # by that rule: on a LAN page the settings mirror stays in `memory` mode, + # so the Models/provider pages fail with "settings are unavailable in + # this browser" and the config-file action disappears. + # + # Declaring `ownsHost` for non-loopback pages restores those screens. + # A loopback page is left untouched, and the Host/Origin fence plus the + # launch-token login are unchanged, so this does not widen server-side + # authorization — but that settings UI reads and writes server settings, + # credentials included. Set this to false to keep the shipped posture. + ownsHostCompat: true diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..a08face --- /dev/null +++ b/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..f46d250 --- /dev/null +++ b/index.js @@ -0,0 +1,431 @@ +/** + * @sutong/dsh-lan-access — host half. + * + * Publishes the loopback dsh Web UI on the LAN through a standalone reverse + * proxy. `dsh web --host 0.0.0.0` is refused by the shipped CLI on purpose + * (it would expose remote code execution to the network), so this plugin keeps + * the official server on loopback and adds a second listener that forwards to + * it. The official browser-trust fence and the launch-token login stay in + * force; nothing about the composed application is overridden. + * + * @module @sutong/dsh-lan-access + */ + +import { createServer, request as httpRequest } from 'node:http' +import { connect as netConnect, isIPv4 } from 'node:net' +import { networkInterfaces } from 'node:os' +import z from '@deepseek-ai/schemastery' + +/** Stable Cordis plugin name. */ +export const name = 'dsh-lan-access' + +/** Services required before the LAN listener resolves its upstream. */ +export const inject = ['webServer'] + +/** + * Configuration for the LAN listener. Every value also lives in the profile's + * `cordis.patch.yml` row config, so it survives plugin upgrades. + */ +export const Config = z.object({ + /** Whether the LAN listener runs at all. @default true */ + enabled: z.boolean().default(true), + /** Listener address; `0.0.0.0` publishes on every interface. @default '0.0.0.0' */ + host: z.string().default('0.0.0.0'), + /** + * Listener port. The default avoids 3081, which the community + * `dsh-lan-proxy` plugin also claims by default, so the two can coexist. + * @default 3082 + */ + port: z.number().default(3082), + /** + * Upstream host. Only a loopback authority is accepted, so the plugin can + * never become an open forward proxy (SSRF guard). @default '127.0.0.1' + */ + targetHost: z.string().default('127.0.0.1'), + /** + * Upstream port, or 0 to follow the live `webServer` port. @default 0 + */ + targetPort: z.number().default(0), + /** Print the LAN URLs (with launch token) at startup. @default true */ + printBanner: z.boolean().default(true), + /** + * Declare the page-side topology fact `ownsHost` for non-loopback pages. + * + * The official client derives `isLoopback` from `location.hostname`, and the + * durable settings surface is loopback-only by that rule: on a LAN page the + * settings mirror stays in `memory` mode, so the Models/provider pages fail + * with "settings are unavailable in this browser" and the config-file action + * disappears. The Host/Origin fence and the launch-token login are unaffected + * by this fact, so enabling it restores the settings UI rather than granting + * new server-side authority — but that UI reads and writes server settings, + * credentials included. Keep it off unless every device on this network is + * trusted. @default false + */ + ownsHostCompat: z.boolean().default(false), +}) + +/** Exact route the Web UI panel reads its LAN facts from. */ +export const SUMMARY_PATH = '/api/dsh-lan-access/summary' + +/** Connection-owned hop-by-hop headers this proxy never forwards. */ +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]) + +/** Names that always mean the local machine. */ +const LOOPBACK_NAMES = new Set(['localhost', '::1', '0:0:0:0:0:0:0:1']) + +/** + * Whether a host names the local loopback authority. + * @param host - a bare hostname or IP literal, without a port. + * @returns true for localhost, IPv6 loopback, or any 127/8 address. + */ +function isLoopbackHost(host) { + if (typeof host !== 'string' || host === '') return false + const bare = host.toLowerCase().replaceAll('[', '').replaceAll(']', '') + if (LOOPBACK_NAMES.has(bare)) return true + return isIPv4(bare) && bare.startsWith('127.') +} + +/** + * Strip the port from an HTTP `Host` / authority value. + * @param authority - `host`, `host:port`, or `[v6]:port`. + * @returns the bare host, or the input when it cannot be split. + */ +function hostnameOf(authority) { + if (typeof authority !== 'string') return '' + const value = authority.trim() + if (value.startsWith('[')) { + const end = value.indexOf(']') + return end === -1 ? value : value.slice(1, end) + } + const colon = value.indexOf(':') + return colon === -1 ? value : value.slice(0, colon) +} + +/** + * Every non-internal IPv4 address this machine currently holds. These are the + * addresses a peer on the same network can reach. + * @returns addresses in interface order, link-local autoconf excluded. + */ +function lanIPv4Addresses() { + const found = [] + for (const entries of Object.values(networkInterfaces())) { + for (const entry of entries ?? []) { + if (entry.family !== 'IPv4' || entry.internal) continue + if (entry.address.startsWith('169.254.')) continue + found.push(entry.address) + } + } + return found +} + +/** + * Rewrite one outbound request's headers for the loopback upstream. + * + * `Host` must name a loopback authority because the official `/api` fence + * accepts only loopback or declared authorities, and the browser cookie is + * authority-bound. `Origin` must then match the rewritten `Host`, or the fence + * rejects an otherwise same-origin request. The browser keeps its own cookie + * jar keyed by the address it actually requested, so cookies need no rewriting + * on the way back. + * @param headers - incoming request headers. + * @param authority - the upstream `host:port` to present. + * @param preserve - lowercase names to keep even though they are hop-by-hop (the WebSocket handshake). + * @returns a new header record safe to send upstream. + */ +function upstreamHeaders(headers, authority, preserve = EMPTY_PRESERVE) { + const out = {} + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue + const lower = key.toLowerCase() + if (HOP_BY_HOP.has(lower) && !preserve.has(lower)) continue + out[key] = value + } + out.host = authority + if (typeof out.origin === 'string') out.origin = `http://${authority}` + return out +} + +/** + * Hop-by-hop names the WebSocket handshake itself must keep: stripping them + * would downgrade the forwarded request to a plain GET, so the upstream would + * answer 200 with a body instead of switching protocols. + */ +const UPGRADE_PRESERVE = new Set(['connection', 'upgrade']) + +/** Shared empty set, so the common path allocates nothing. */ +const EMPTY_PRESERVE = new Set() + +/** + * Forward one upgraded (WebSocket) socket to the upstream server. + * + * The handshake is re-emitted byte-for-byte with only `Host`/`Origin` + * rewritten, so `permessage-deflate` and every other extension negotiate + * end-to-end between the browser and the upstream — this proxy never + * decompresses a frame. + * @param req - the incoming upgrade request. + * @param clientSocket - the browser-side socket, owned on return. + * @param head - bytes already read past the handshake. + * @param options - `authority` plus the upstream `host`/`port` to dial. + */ +function forwardUpgrade(req, clientSocket, head, options) { + const { authority, host, port } = options + const headers = upstreamHeaders(req.headers, authority, UPGRADE_PRESERVE) + let raw = `${req.method ?? 'GET'} ${req.url ?? '/'} HTTP/1.1\r\n` + for (const [key, value] of Object.entries(headers)) { + for (const item of Array.isArray(value) ? value : [value]) raw += `${key}: ${item}\r\n` + } + raw += '\r\n' + + const upstream = netConnect(port, host, () => { + upstream.write(raw) + if (head !== undefined && head.length > 0) upstream.write(head) + upstream.pipe(clientSocket) + clientSocket.pipe(upstream) + }) + const bail = () => { + upstream.destroy() + clientSocket.destroy() + } + upstream.on('error', bail) + clientSocket.on('error', bail) +} + +/** + * Head script that declares `ownsHost` for a non-loopback page only. + * + * It stays inert on a loopback page, where the official client already treats + * the browser as local, and mutates nothing but the one topology flag. + */ +export const OWNS_HOST_COMPAT_SCRIPT = [ + '(function () {', + ' try {', + ' var host = globalThis.location && globalThis.location.hostname;', + ' if (!host) return;', + ' if (host === "localhost" || host === "[::1]" || host.indexOf("127.") === 0) return;', + ' var transport = globalThis.__DSH_TRANSPORT__ || {};', + ' transport.ownsHost = true;', + ' globalThis.__DSH_TRANSPORT__ = transport;', + ' } catch (error) { /* leave the page topology untouched */ }', + '})();', +].join('\n') + +/** + * Insert an inline classic script immediately before ``, so it runs + * before the deferred module bundle reads `__DSH_TRANSPORT__`. + * @param html - the raw index document. + * @param text - script body; must not contain `${text}` + const at = html.indexOf('') + return at === -1 ? `${tag}${html}` : `${html.slice(0, at)}${tag}${html.slice(at)}` +} + +/** + * Mount the LAN listener, the panel's data route, and the startup banner. + * @param ctx - plugin context carrying the web server and connection services. + * @param config - validated {@link Config}. + */ +export function apply(ctx, config) { + const settings = { ...config } + + if (settings.enabled === false) return + if (!isLoopbackHost(settings.targetHost)) { + console.warn( + `dsh-lan-access: refusing targetHost ${JSON.stringify(settings.targetHost)}; only a loopback upstream is allowed (this plugin is not an open proxy)`, + ) + return + } + if (!Number.isInteger(settings.port) || settings.port <= 0 || settings.port > 65535) { + console.warn(`dsh-lan-access: refusing invalid port ${String(settings.port)}`) + return + } + + /** Resolve the live upstream port; `targetPort: 0` follows the web server. */ + const upstreamPort = () => { + if (Number.isInteger(settings.targetPort) && settings.targetPort > 0) return settings.targetPort + const live = ctx.get('webServer')?.port + return typeof live === 'number' && live > 0 ? live : 3080 + } + const upstreamAuthority = () => `${settings.targetHost}:${String(upstreamPort())}` + + /** LAN facts for the banner and the Web UI panel. */ + const summary = () => { + const connection = ctx.get('connection') + const port = settings.port + return { + enabled: true, + listening: listened, + ...(bindError === undefined ? {} : { bindError }), + listen: { host: settings.host, port }, + target: { host: settings.targetHost, port: upstreamPort() }, + lan: lanIPv4Addresses().map((address) => { + const url = `http://${address}:${String(port)}/` + return { + address, + url, + tokenUrl: connection === undefined ? url : connection.authenticatedUrl(url), + } + }), + } + } + + let listened = false + let announced = false + /** Set when the listener could not bind; surfaced to the panel. */ + let bindError + const announce = (connection) => { + if (announced || !listened || settings.printBanner === false) return + announced = true + console.log( + `dsh-lan-access: 0.0.0.0:${String(settings.port)} → http://${upstreamAuthority()} (LAN listener)`, + ) + const addresses = lanIPv4Addresses() + if (addresses.length === 0) { + console.log('dsh-lan-access: no non-internal IPv4 address found; no LAN URL to print') + } + for (const address of addresses) { + const url = `http://${address}:${String(settings.port)}/` + const tokenUrl = connection === undefined ? url : connection.authenticatedUrl(url) + console.log(`dsh-lan-access: ${tokenUrl}`) + } + console.log( + 'dsh-lan-access: that link carries the launch token and grants full dsh control (shell included) — keep it on a trusted network', + ) + } + + // The browser URL the panel is served from may be the loopback one, so the + // data route is reachable both directly and through the listener. + ctx.inject(['webServer', 'connection'], (routeCtx) => { + routeCtx.effect( + () => + routeCtx.webServer.register({ + kind: 'exact', + path: SUMMARY_PATH, + handler: (req, res) => { + const admission = routeCtx.connection.admit(req) + if ('rejection' in admission) { + res.writeHead(admission.rejection, { 'content-type': 'text/plain; charset=utf-8' }) + res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden') + return + } + const body = JSON.stringify(summary()) + res.writeHead(200, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'content-length': Buffer.byteLength(body), + }) + res.end(body) + }, + }), + 'dsh-lan-access: LAN summary route', + ) + }) + + if (settings.ownsHostCompat) { + ctx.inject(['webServer'], (compatCtx) => { + compatCtx.effect( + () => compatCtx.webServer.tapIndex((html) => withHeadScript(html, OWNS_HOST_COMPAT_SCRIPT)), + 'dsh-lan-access: ownsHost compat index tap', + ) + }) + } + + const server = createServer((req, res) => { + const authority = upstreamAuthority() + const upstream = httpRequest( + { + host: settings.targetHost, + port: upstreamPort(), + method: req.method, + path: req.url, + headers: upstreamHeaders(req.headers, authority), + }, + (up) => { + const out = {} + for (const [key, value] of Object.entries(up.headers)) { + if (value === undefined) continue + if (HOP_BY_HOP.has(key.toLowerCase())) continue + out[key] = value + } + res.writeHead(up.statusCode ?? 502, out) + up.pipe(res) + }, + ) + upstream.on('error', (error) => { + if (!res.headersSent) { + res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }) + } + res.end(`dsh-lan-access: upstream error: ${error.message}`) + }) + req.pipe(upstream) + }) + + server.on('upgrade', (req, socket, head) => { + const authority = upstreamAuthority() + const host = hostnameOf(req.headers.host) + const isLocal = + host === '' || + host === settings.host || + isLoopbackHost(host) || + lanIPv4Addresses().includes(host) + if (!isLocal) { + // DNS-rebinding guard: only address-literal/localhost authorities may + // reach the upstream, so a hostile name cannot point here. + socket.destroy() + return + } + forwardUpgrade(req, socket, head, { + authority, + host: settings.targetHost, + port: upstreamPort(), + }) + }) + + server.on('error', (error) => { + bindError = error.message + const hint = + error.code === 'EADDRINUSE' + ? ' — another process or plugin already holds that port (the community dsh-lan-proxy plugin defaults to 3081)' + : '' + console.warn( + `dsh-lan-access: LAN listener unavailable on ${settings.host}:${String(settings.port)} because ${error.message}${hint}`, + ) + }) + + ctx.effect( + () => () => { + server.closeAllConnections?.() + server.close() + }, + 'dsh-lan-access: LAN listener', + ) + + server.listen(settings.port, settings.host, () => { + listened = true + announce(ctx.get('connection')) + }) + + ctx.inject(['connection'], (connectionCtx) => { + announce(connectionCtx.connection) + }) +} + +/** Test hooks; production never mutates them. */ +export const internals = { + isLoopbackHost, + hostnameOf, + lanIPv4Addresses, + upstreamHeaders, + withHeadScript, +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..54cbb84 --- /dev/null +++ b/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "LAN Access", + "description": "Publish the dsh Web UI on your local network with a standalone reverse proxy while keeping the official launch-token login." + } +} diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..d3491b8 --- /dev/null +++ b/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "局域网访问", + "description": "用独立反向代理把 dsh Web UI 发布到局域网,同时保留官方 launch token 登录。" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cc78cf9 --- /dev/null +++ b/package.json @@ -0,0 +1,71 @@ +{ + "name": "@sutong/dsh-lan-access", + "version": "0.1.0", + "description": "Expose the dsh Web UI on the LAN through a standalone reverse proxy that preserves the official launch token — HTTP and WebSocket forwarding, no Host rewrite of your trust boundary.", + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js", + "./client": "./client.js", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "icon": "./icon.svg", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "catalog": { + "category": "integration", + "summary": { + "en": "Access the dsh web UI over the LAN: a standalone reverse proxy forwards HTTP and WebSocket to the loopback server while keeping the official launch-token login.", + "zh": "局域网访问 dsh web UI:独立反向代理转发 HTTP 与 WebSocket 到回环服务,保留官方 launch token 登录" + }, + "capabilities": [ + "connection", + "ui-slots" + ] + }, + "client": { + "platform": "web" + } + }, + "peerDependencies": { + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependenciesMeta": { + "@deepseek-ai/schemastery": { + "optional": true + } + }, + "files": [ + "index.js", + "client.js", + "cordis.patch.yml", + "src", + "scripts", + "locale/*.json", + "icon.svg", + "README.md", + "README.en.md", + "LICENSE" + ], + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build:client": "node scripts/build-client.mjs", + "test": "node scripts/smoke.mjs && node scripts/test-qr.mjs && node scripts/test-client.mjs" + }, + "keywords": [ + "dsh", + "deepseek-harness", + "dsh-plugin", + "plugin", + "lan", + "proxy", + "reverse-proxy", + "qr" + ], + "license": "MIT" +} diff --git a/scripts/build-client.mjs b/scripts/build-client.mjs new file mode 100644 index 0000000..6edfdb1 --- /dev/null +++ b/scripts/build-client.mjs @@ -0,0 +1,60 @@ +/** + * Emit the single browser artifact `client.js` from `src/panel.js` and + * `src/qrcode.js`. + * + * A bundle's client half is served as one self-contained module; it cannot + * import a sibling file or a package dependency at runtime. This script inlines + * both sources into the `window.__ModuleLoader__.load` shell, which is the same + * shape the reference plugins ship. + * + * Run: `node scripts/build-client.mjs` + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = join(here, '..') + +/** Read a source file and turn its ESM exports into plain declarations. */ +function inline(relativePath) { + const source = readFileSync(join(root, relativePath), 'utf8') + return source + .replace(/^import .*$/gm, '') + .replace(/^export (?=(?:function|const|let|class)\b)/gm, '') + .trim() +} + +const qrcode = inline('src/qrcode.js') +const panel = inline('src/panel.js') + +const artifact = `/** + * @sutong/dsh-lan-access — browser half (generated by scripts/build-client.mjs). + * + * Do not edit this file: change src/panel.js or src/qrcode.js and rebuild. + */ +window.__ModuleLoader__.load({ + id: "@sutong/dsh-lan-access", + factory: function (require) { + "use strict"; + var React = require("react"); + + // ── inlined src/qrcode.js ──────────────────────────────────────────────── + var __qrcode = (function () { +${qrcode.replace(/^/gm, ' ')} + return { encodeQR: encodeQR, MAX_VERSION: MAX_VERSION }; + })(); + var encodeQR = __qrcode.encodeQR; + + // ── inlined src/panel.js ───────────────────────────────────────────────── +${panel.replace(/^/gm, ' ')} + + return { inject: inject, apply: apply }; + }, +}); +` + +const out = join(root, 'client.js') +writeFileSync(out, artifact) +console.log(`wrote ${out} (${artifact.length} bytes)`) diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..94870ab --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,256 @@ +/** + * Standalone smoke test for the LAN listener. It mounts the plugin against a + * fake Cordis context, drives a real upstream HTTP + WebSocket server, and + * asserts the forwarding contract. Run: `node scripts/smoke.mjs`. + */ + +import { createServer } from 'node:http' +import { connect as netConnect } from 'node:net' +import vm from 'node:vm' +import { apply, internals, OWNS_HOST_COMPAT_SCRIPT } from '../index.js' + +const results = [] +function check(label, condition, detail) { + results.push({ label, ok: Boolean(condition), detail }) + console.log(`${condition ? 'PASS' : 'FAIL'} ${label}${detail === undefined || condition ? '' : ` — ${detail}`}`) +} + +/** Bind a throwaway server on an OS-assigned loopback port. */ +function listen(server, host = '127.0.0.1') { + return new Promise((resolve) => { + server.listen(0, host, () => resolve(server.address().port)) + }) +} + +const seen = [] +const upstream = createServer((req, res) => { + seen.push({ url: req.url, host: req.headers.host, origin: req.headers.origin, cookie: req.headers.cookie }) + res.writeHead(200, { + 'content-type': 'application/json', + 'set-cookie': 'dsh-auth-test=abc123; Max-Age=60; Path=/; HttpOnly; SameSite=Strict', + 'x-upstream': 'yes', + }) + res.end(JSON.stringify({ url: req.url, host: req.headers.host, origin: req.headers.origin ?? null })) +}) + +// DSH serves HTTP and WebSocket on the same port, so the upstream carries both. +upstream.on('upgrade', (req, socket) => { + seen.push({ upgrade: true, url: req.url, host: req.headers.host, origin: req.headers.origin }) + socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n') + socket.write('hello-from-upstream') +}) + +const upstreamPort = await listen(upstream) + +// ── fake Cordis context ───────────────────────────────────────────────────── +// Cordis exposes injected services both as `ctx.` and through `ctx.get`. +const routes = [] +let admitted = 0 +const connection = { + admit: () => { + admitted += 1 + return { peer: {} } + }, + authenticatedUrl: (base) => `${base}?token=TESTTOKEN`, +} +const webServer = { + port: upstreamPort, + register: (route) => { + routes.push(route) + return () => {} + }, +} +const ctx = { + webServer, + connection, + get(name) { + if (name === 'connection') return connection + if (name === 'webServer') return webServer + return undefined + }, + inject(deps, callback) { + if (deps.every((d) => ctx.get(d) !== undefined)) callback(ctx) + return () => {} + }, + effect(callback) { + callback() + return () => {} + }, +} + +const LAN_PORT = 39_991 +apply(ctx, { + enabled: true, + host: '127.0.0.1', + port: LAN_PORT, + targetHost: '127.0.0.1', + targetPort: upstreamPort, + printBanner: false, +}) + +// Wait for the listener to bind. +await new Promise((resolve) => setTimeout(resolve, 250)) + +/** Issue a request through the LAN listener. */ +function viaLan(path, headers = {}) { + return new Promise((resolve, reject) => { + const req = netConnect(LAN_PORT, '127.0.0.1', () => { + req.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${LAN_PORT}\r\nOrigin: http://127.0.0.1:${LAN_PORT}\r\nCookie: dsh-auth-test=abc123\r\nConnection: close\r\n\r\n`) + }) + let raw = '' + req.on('data', (chunk) => { raw += chunk }) + req.on('end', () => resolve(raw)) + req.on('error', reject) + }) +} + +const response = await viaLan('/api/remote.mux') +check('LAN listener returns the upstream status', response.startsWith('HTTP/1.1 200'), response.split('\r\n')[0]) +check('upstream sees a loopback Host', seen[0]?.host === `127.0.0.1:${upstreamPort}`, seen[0]?.host) +check('upstream sees a matching loopback Origin', seen[0]?.origin === `http://127.0.0.1:${upstreamPort}`, seen[0]?.origin) +check('upstream receives the browser cookie', seen[0]?.cookie === 'dsh-auth-test=abc123', seen[0]?.cookie) +check('Set-Cookie is forwarded untouched', /set-cookie: dsh-auth-test=abc123/i.test(response), 'no set-cookie') +check('custom upstream header survives', /x-upstream: yes/i.test(response)) + +// ── WebSocket upgrade ─────────────────────────────────────────────────────── +const wsRaw = await new Promise((resolve, reject) => { + const socket = netConnect(LAN_PORT, '127.0.0.1', () => { + socket.write( + `GET /api/remote.mux HTTP/1.1\r\nHost: 127.0.0.1:${LAN_PORT}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nOrigin: http://127.0.0.1:${LAN_PORT}\r\n\r\n`, + ) + }) + let raw = '' + socket.on('data', (chunk) => { raw += chunk }) + socket.on('close', () => resolve(raw)) + socket.on('error', reject) + setTimeout(() => { socket.destroy(); resolve(raw) }, 900) +}) +const upgrade = seen.find((entry) => entry.upgrade) +check('WebSocket upgrade reached the upstream', upgrade !== undefined) +check('upgraded request kept a loopback Host', upgrade?.host === `127.0.0.1:${upstreamPort}`, upgrade?.host) +check('upgrade response flows back to the client', /hello-from-upstream/.test(wsRaw)) + +// ── the panel data route ──────────────────────────────────────────────────── +const route = routes.find((entry) => entry.path === '/api/dsh-lan-access/summary') +check('summary route registered as exact', route?.kind === 'exact', route?.kind) +let body = '' +const fakeRes = { + writeHead(status, headers) { this.status = status; this.headers = headers }, + end(value) { body = value ?? '' }, +} +route?.handler({ headers: { host: `127.0.0.1:${upstreamPort}` } }, fakeRes) +check('summary route requires admission', admitted === 1, `admit calls: ${admitted}`) +check('summary reports the listen port', JSON.parse(body).listen.port === LAN_PORT, body) +check('summary carries a tokenized LAN url', /token=TESTTOKEN/.test(body), body) + +// ── pure helpers ──────────────────────────────────────────────────────────── +check('isLoopbackHost accepts 127.0.0.1', internals.isLoopbackHost('127.0.0.1') === true) +check('isLoopbackHost accepts ::1', internals.isLoopbackHost('::1') === true) +check('isLoopbackHost rejects 192.168.1.5', internals.isLoopbackHost('192.168.1.5') === false) +check('hostnameOf strips the port', internals.hostnameOf('192.168.1.5:3081') === '192.168.1.5') +check('upstreamHeaders drops hop-by-hop', internals.upstreamHeaders({ connection: 'keep-alive', 'x-a': '1' }, 'h:1').connection === undefined) + +// ── bind failure is reported, never thrown ────────────────────────────────── +const blocker = createServer(() => {}) +const blockerPort = await listen(blocker) +const routes2 = [] +const connection2 = { admit: () => ({ peer: {} }), authenticatedUrl: (base) => `${base}?token=T` } +const webServer2 = { port: upstreamPort, register: (route) => { routes2.push(route); return () => {} } } +const ctx2 = { + webServer: webServer2, + connection: connection2, + get: (name) => (name === 'connection' ? connection2 : name === 'webServer' ? webServer2 : undefined), + inject: (deps, callback) => { + if (deps.every((d) => ctx2.get(d) !== undefined)) callback(ctx2) + return () => {} + }, + effect: (callback) => { callback(); return () => {} }, +} +apply(ctx2, { + enabled: true, + host: '127.0.0.1', + port: blockerPort, + targetHost: '127.0.0.1', + targetPort: upstreamPort, + printBanner: false, +}) +await new Promise((resolve) => setTimeout(resolve, 250)) +const route2 = routes2.find((entry) => entry.path === '/api/dsh-lan-access/summary') +let body2 = '' +route2?.handler({ headers: { host: `127.0.0.1:${upstreamPort}` } }, { + writeHead(status) { this.status = status }, + end(value) { body2 = value ?? '' }, +}) +const payload2 = JSON.parse(body2) +check('taken port is reported as listening:false', payload2.listening === false, body2) +check('taken port carries the bind error', String(payload2.bindError ?? '').includes('EADDRINUSE'), payload2.bindError) +blocker.close() + +// ── ownsHost compat is opt-in and page-conditional ────────────────────────── +/** + * Mount the plugin with a given config and return the registered index taps. + * @param config - extra config over merged onto a loopback test binding. + * @returns the tap functions the plugin registered. + */ +let compatPort = 39_990 +async function tapsFor(config) { + compatPort += 1 + const taps = [] + const records = [] + const c = { + get: () => undefined, + inject: (deps, callback) => { + callback({ + webServer: { + port: upstreamPort, + register: (route) => { records.push(route); return () => {} }, + tapIndex: (transform) => { taps.push(transform); return () => {} }, + }, + effect: (callback2) => { callback2(); return () => {} }, + }) + return () => {} + }, + effect: (callback2) => { callback2(); return () => {} }, + } + apply(c, { + enabled: true, + host: '127.0.0.1', + port: compatPort, + targetHost: '127.0.0.1', + targetPort: upstreamPort, + printBanner: false, + ...config, + }) + await new Promise((resolve) => setTimeout(resolve, 120)) + return taps +} + +const offTaps = await tapsFor({ ownsHostCompat: false }) +check('ownsHost compat is off by default', offTaps.length === 0, `taps: ${offTaps.length}`) + +const onTaps = await tapsFor({ ownsHostCompat: true }) +check('ownsHost compat registers one index tap', onTaps.length === 1, `taps: ${onTaps.length}`) +const html = 't' +const tapped = onTaps[0]?.(html) ?? html +const scriptAt = tapped.indexOf('ownsHost = true') +const bundleAt = tapped.indexOf('x.js') +check('compat script lands in the head', scriptAt !== -1 && tapped.indexOf('') > scriptAt, tapped.slice(0, 80)) +check('compat script precedes the app bundle', scriptAt !== -1 && scriptAt < bundleAt) + +/** Run the compat script against a stubbed page location. */ +function runCompat(hostname) { + const sandbox = { globalThis: undefined, location: hostname === undefined ? undefined : { hostname } } + sandbox.globalThis = sandbox + vm.createContext(sandbox) + vm.runInContext(OWNS_HOST_COMPAT_SCRIPT, sandbox) + return sandbox.__DSH_TRANSPORT__ +} +check('compat forges ownsHost on a LAN page', runCompat('192.168.1.5')?.ownsHost === true) +check('compat leaves a loopback page untouched', runCompat('127.0.0.1') === undefined) +check('compat leaves localhost untouched', runCompat('localhost') === undefined) +check('compat survives a missing location', runCompat(undefined) === undefined) + +upstream.close() +const failed = results.filter((entry) => !entry.ok) +console.log(`\n${results.length - failed.length}/${results.length} checks passed`) +process.exit(failed.length === 0 ? 0 : 1) diff --git a/scripts/test-client.mjs b/scripts/test-client.mjs new file mode 100644 index 0000000..9c0c0fd --- /dev/null +++ b/scripts/test-client.mjs @@ -0,0 +1,166 @@ +/** + * Contract test for the generated `client.js`. + * + * Loads the artifact in a sandbox with a stub `window.__ModuleLoader__` and a + * stub React, then checks the slot registration, the localization wiring, the + * loading render, the ready render, and that the route the panel fetches is the + * one the host half exports. + * + * Run: `node scripts/test-client.mjs` + */ + +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import vm from 'node:vm' +import { SUMMARY_PATH } from '../index.js' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const source = readFileSync(join(root, 'client.js'), 'utf8') + +const results = [] +function check(label, condition, detail) { + results.push({ label, ok: Boolean(condition) }) + console.log(`${condition ? 'PASS' : 'FAIL'} ${label}${condition || detail === undefined ? '' : ` — ${detail}`}`) +} + +// ── load the artifact the way the browser kernel does ─────────────────────── +let captured +const sandbox = { + window: { __ModuleLoader__: { load: (options) => { captured = options } } }, + console, + TextEncoder, + setTimeout, + navigator: {}, +} +vm.createContext(sandbox) +vm.runInContext(source, sandbox) + +check('artifact registers exactly once', captured !== undefined) +check('module id equals the package name', captured?.id === '@sutong/dsh-lan-access', captured?.id) +check('factory is a function', typeof captured?.factory === 'function') + +// ── stub React ────────────────────────────────────────────────────────────── +let useStateCalls = 0 +let stateFixture +const React = { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }), + useState: (initial) => { + useStateCalls += 1 + if (useStateCalls === 1 && stateFixture !== undefined) return [stateFixture, () => {}] + return [typeof initial === 'function' ? initial() : initial, () => {}] + }, + useEffect: () => {}, +} +const requireStub = (name) => { + if (name === 'react') return React + throw new Error(`unexpected require(${JSON.stringify(name)})`) +} + +const plugin = captured.factory(requireStub) +check('plugin exposes an inject list', Array.isArray(plugin?.inject), JSON.stringify(plugin?.inject)) +check('plugin injects slots and locale', plugin.inject?.includes('slots') && plugin.inject?.includes('locale')) +check('plugin exposes apply', typeof plugin?.apply === 'function') + +// ── drive apply against a fake client context ─────────────────────────────── +let dictionaries +const registrations = [] +let injectedOwner +const slots = { + inject(owner, callback) { + injectedOwner = owner + callback() + return () => {} + }, + register(options, component) { + registrations.push({ options, component }) + return () => {} + }, +} +const locale = { + register(ns, dicts) { + dictionaries = { ns, dicts } + return () => {} + }, + bind: () => (key) => dictionaries?.dicts.zh?.[key] ?? key, + subscribe: () => () => {}, +} +const effects = [] +const ctx = { + get: (name) => (name === 'slots' ? slots : name === 'locale' ? locale : undefined), + effect: (callback, label) => { + effects.push(label) + const disposer = callback() + return () => { + if (typeof disposer === 'function') disposer() + } + }, +} +plugin.apply(ctx) + +check('locale namespace registered', dictionaries?.ns === 'dsh-lan-access', dictionaries?.ns) +check('locale carries zh and en', dictionaries?.dicts?.zh !== undefined && dictionaries?.dicts?.en !== undefined) +check( + 'zh and en cover the same keys', + JSON.stringify(Object.keys(dictionaries.dicts.zh).sort()) === JSON.stringify(Object.keys(dictionaries.dicts.en).sort()), + 'dictionary key sets differ', +) +check('registered into the Settings nav seat', injectedOwner === 'settings.section', injectedOwner) +check('exactly one slot registration', registrations.length === 1, `count ${registrations.length}`) +check('registration id is the section key', registrations[0]?.options?.id === 'lan-access', registrations[0]?.options?.id) +check('registration names its slot', registrations[0]?.options?.name === 'settings.section') +check('label thunk projects localized text', registrations[0]?.options?.label?.() === '局域网访问', registrations[0]?.options?.label?.()) + +// ── loading render ────────────────────────────────────────────────────────── +useStateCalls = 0 +const loading = registrations[0].component({}) +check('loading render returns an element', loading?.type === 'div' && loading.props.className === 'dla-root') +check('loading render mentions reading', JSON.stringify(loading.children).includes('正在读取')) + +// ── ready render with the host's payload shape ────────────────────────────── +stateFixture = { + status: 'ready', + data: { + enabled: true, + listen: { host: '0.0.0.0', port: 3081 }, + target: { host: '127.0.0.1', port: 3080 }, + lan: [ + { address: '192.168.1.5', url: 'http://192.168.1.5:3081/', tokenUrl: 'http://192.168.1.5:3081/?token=abc' }, + { address: '10.0.0.7', url: 'http://10.0.0.7:3081/', tokenUrl: 'http://10.0.0.7:3081/?token=abc' }, + ], + }, +} +useStateCalls = 0 +const ready = registrations[0].component({}) +const readyJson = JSON.stringify(ready) +check('ready render returns the panel', ready?.type === 'div' && ready.props.className === 'dla-root') +check('ready render lists both addresses', readyJson.includes('192.168.1.5') && readyJson.includes('10.0.0.7')) +check('ready render embeds a QR svg', readyJson.includes('"svg"')) +check('ready render shows the token url', readyJson.includes('token=abc')) +check('ready render warns about the token', readyJson.includes('launch token')) +check('qr caption targets the selected address', readyJson.includes('192.168.1.5 的二维码')) + +// ── a failed bind must be visible, not dressed up as a live QR ────────────── +stateFixture = { + status: 'ready', + data: { + enabled: true, + listening: false, + bindError: 'listen EADDRINUSE: address already in use 0.0.0.0:3081', + listen: { host: '0.0.0.0', port: 3081 }, + target: { host: '127.0.0.1', port: 3080 }, + lan: [{ address: '192.168.1.5', url: 'http://192.168.1.5:3081/', tokenUrl: 'http://192.168.1.5:3081/?token=abc' }], + }, +} +useStateCalls = 0 +const failed = registrations[0].component({}) +const failedJson = JSON.stringify(failed) +check('bind failure is surfaced in the panel', failedJson.includes('EADDRINUSE')) +check('bind failure names the target', failedJson.includes('0.0.0.0:3081')) + +// ── the two halves agree on the route ─────────────────────────────────────── +check('client fetches the host route', source.includes(SUMMARY_PATH), SUMMARY_PATH) + +const failures = results.filter((entry) => !entry.ok) +console.log(`\n${results.length - failures.length}/${results.length} checks passed`) +process.exit(failures.length === 0 ? 0 : 1) diff --git a/scripts/test-qr.mjs b/scripts/test-qr.mjs new file mode 100644 index 0000000..d8bede5 --- /dev/null +++ b/scripts/test-qr.mjs @@ -0,0 +1,140 @@ +/** + * Cross-check `src/qrcode.js` against the reference `qrcode` npm package. + * + * The reference is only a test oracle — it is never a runtime dependency. Run + * `node scripts/test-qr.mjs`; set `QR_ORACLE_DIR` to the directory holding the + * installed `qrcode` package (default `/tmp/qr-oracle`). + */ + +import { createRequire } from 'node:module' +import { encodeQR } from '../src/qrcode.js' + +const oracleDir = process.env.QR_ORACLE_DIR ?? '/tmp/qr-oracle' +const requireFromOracle = createRequire(`${oracleDir}/package.json`) +let oracle +try { + oracle = requireFromOracle('qrcode') +} catch (error) { + console.error(`cannot load the qrcode oracle from ${oracleDir}: ${error.message}`) + process.exit(2) +} + +/** Byte-mode capacity at error-correction level M, independent of the encoder under test. */ +const CAPACITY_M = { 1: 14, 2: 26, 3: 42, 4: 62, 5: 84, 6: 106, 7: 122, 8: 152, 9: 180, 10: 213 } + +/** Deterministic pseudo-random byte source. */ +function makeRandom(seed) { + let state = seed >>> 0 + return () => { + state = (state * 1_664_525 + 1_013_904_223) >>> 0 + return state + } +} + +/** + * Deterministic payload of exactly `length` bytes. + * + * A lowercase letter is always present on purpose: the reference implementation + * auto-selects the most compact mode, and QR alphanumeric mode covers only + * digits, uppercase, space and `$%*+-./:`. This encoder implements byte mode + * (what a URL needs), so the payload must rule numeric and alphanumeric modes + * out to make the two comparable. + */ +function payload(length, seed) { + const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~:/?#[]@!$&()*+,;=%' + const random = makeRandom(seed) + let text = '' + while (text.length < length) text += alphabet[random() % alphabet.length] + text = text.slice(0, length) + if (!/[a-z]/.test(text)) text = `${text.slice(0, -1)}a` + return text +} + +let comparisons = 0 +let failures = 0 +const failureSamples = [] + +for (let version = 1; version <= 10; version += 1) { + for (const length of [1, Math.floor(CAPACITY_M[version] / 2), CAPACITY_M[version]]) { + const text = payload(length, version * 1000 + length) + for (let mask = 0; mask < 8; mask += 1) { + const mine = encodeQR(text, { version, mask }) + const reference = oracle.create(text, { + errorCorrectionLevel: 'M', + version, + maskPattern: mask, + }) + comparisons += 1 + + const size = reference.modules.size + const expected = reference.modules.data + let mismatch = -1 + if (mine.size !== size) { + mismatch = -2 + } else { + for (let i = 0; i < expected.length; i += 1) { + if ((mine.modules[i] ? 1 : 0) !== (expected[i] ? 1 : 0)) { + mismatch = i + break + } + } + } + if (mismatch !== -1) { + failures += 1 + if (failureSamples.length < 5) { + failureSamples.push({ version, length, mask, mismatch }) + } + } + } + } +} + +console.log(`forced version+mask comparisons: ${comparisons - failures}/${comparisons} identical`) + +// The format-information and mask choice must match too: compare the chosen +// mask for payloads where both implementations score penalties the same way. +let maskAgreements = 0 +let maskCases = 0 +for (let version = 1; version <= 10; version += 1) { + const text = payload(Math.min(20, CAPACITY_M[version]), version * 7 + 3) + const mine = encodeQR(text, { version }) + const reference = oracle.create(text, { errorCorrectionLevel: 'M', version }) + maskCases += 1 + if (mine.mask === reference.maskPattern) maskAgreements += 1 + else console.log(` mask differs at v${version}: mine=${mine.mask} reference=${reference.maskPattern}`) +} +console.log(`auto-mask agreement: ${maskAgreements}/${maskCases}`) + +// UTF-8. The reference re-segments mixed text into Byte+Alphanumeric runs for +// compactness; this encoder deliberately emits one Byte segment (there is no +// correctness difference, only size). Compare strictly against a payload the +// reference also encodes as a single Byte segment. +const byteOnly = '局域网_def~令牌~abc' +const byteOnlyAuto = encodeQR(byteOnly) +const byteOnlyReference = oracle.create(byteOnly, { + errorCorrectionLevel: 'M', + version: byteOnlyAuto.version, + maskPattern: byteOnlyAuto.mask, +}) +const byteOnlySame = + byteOnlyAuto.size === byteOnlyReference.modules.size && + byteOnlyAuto.modules.every((value, index) => (value ? 1 : 0) === (byteOnlyReference.modules.data[index] ? 1 : 0)) +console.log( + `utf8 single-byte-segment (v${byteOnlyAuto.version}, mask ${byteOnlyAuto.mask}) byte-identical: ${byteOnlySame}`, +) +if (byteOnlyReference.segments.length !== 1) console.log(' note: reference did not emit one segment') + +// A realistic URL is where the reference re-segments. Report the divergence +// rather than treating a valid-but-larger symbol as a failure. +const url = 'http://192.168.1.5:3081/?token=aBcD1234EfGh5678' +const urlSymbol = encodeQR(url) +const urlReference = oracle.create(url, { errorCorrectionLevel: 'M' }) +console.log( + `realistic URL fits v${urlSymbol.version} (size ${urlSymbol.size}); reference used v${urlReference.version} in ${urlReference.segments.length} segment(s)`, +) +const urlStructurallySound = urlSymbol.size === urlSymbol.version * 4 + 17 && urlSymbol.modules.length === urlSymbol.size * urlSymbol.size + +const ok = failures === 0 && byteOnlySame && urlStructurallySound +if (failureSamples.length > 0) console.log('failure samples:', JSON.stringify(failureSamples)) +console.log(ok ? '\nQR encoder matches the reference implementation' : '\nQR encoder MISMATCH') +process.exit(ok ? 0 : 1) diff --git a/src/panel.js b/src/panel.js new file mode 100644 index 0000000..e84b2d8 --- /dev/null +++ b/src/panel.js @@ -0,0 +1,362 @@ +/** + * Client half of @sutong/dsh-lan-access: a Plugins-settings tab that shows the + * LAN addresses the host listener publishes, with a scannable QR code for the + * address you pick. + * + * This file is bundled into the single browser artifact `client.js` by + * `scripts/build-client.mjs`; it is never loaded directly by the browser. + * + * @module @sutong/dsh-lan-access/panel + */ + +import { encodeQR } from './qrcode.js' + +/** Locale namespace for this plugin's visible text. */ +const NS = 'dsh-lan-access' + +/** Host route (behind the official `/api` fence and browser authentication). */ +const SUMMARY_URL = '/api/dsh-lan-access/summary' + +/** + * The seat this panel occupies: its own entry in the Settings navigation. + * Registering here (rather than inside the Plugins section's tab strip) makes + * the page reachable directly from Settings; `dsh-opencode-go` sets the same + * precedent for a plugin-owned settings page. + */ +const SLOT = 'settings.section' + +/** Nav/section key for this panel. */ +const SECTION_ID = 'lan-access' + +/** Simplified-Chinese dictionary; the key source. */ +const zh = { + title: '局域网访问', + description: '通过独立反向代理把本机 dsh Web UI 发布到局域网,保留官方 launch token 登录。', + loading: '正在读取局域网地址…', + failed: '无法读取局域网信息:{message}', + forward: '转发到', + addresses: '局域网地址', + noAddresses: '未找到非内部 IPv4 地址,局域网内暂时无法访问。', + scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。', + copy: '复制', + copied: '已复制', + open: '打开', + warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。', + disabled: '该 LAN 监听器在此 profile 中已被配置为关闭。', + notListening: '监听器未能绑定 {target}:{message}', + qr: '{address} 的二维码', +} + +/** English dictionary; must cover every key above. */ +const en = { + title: 'LAN Access', + description: 'Publishes this machine’s dsh Web UI on the LAN through a standalone reverse proxy while keeping the official launch-token login.', + loading: 'Reading LAN addresses…', + failed: 'Could not read LAN information: {message}', + forward: 'forwards to', + addresses: 'LAN addresses', + noAddresses: 'No non-internal IPv4 address was found, so nothing is reachable from the LAN yet.', + scanHint: 'Scan the QR code with another device to open it (the link already carries the login token).', + copy: 'Copy', + copied: 'Copied', + open: 'Open', + warning: 'That link carries the launch token and grants full dsh control, shell included. Share it only on a trusted network.', + disabled: 'The LAN listener is configured off in this profile.', + notListening: 'The listener could not bind {target}: {message}', + qr: 'QR code for {address}', +} + +/** Substitute `{name}` placeholders. */ +function interpolate(template, params) { + if (params === undefined) return template + return template.replaceAll(/\{(\w+)\}/g, (match, key) => (key in params ? String(params[key]) : match)) +} + +/** + * Bind the locale service, falling back to the Chinese dictionary when the + * service is absent or its contract drifted. + * @param locale - the live `locale` service, when present. + * @returns a translate function taking a key and optional `{name}` parameters. + */ +function makeTranslator(locale) { + if (locale !== undefined && typeof locale.bind === 'function') { + try { + const bound = locale.bind(NS) + if (typeof bound === 'function') return (key, params) => interpolate(bound(key), params) + } catch { + /* fall through to the local dictionary */ + } + } + return (key, params) => interpolate(zh[key] ?? key, params) +} + +/** + * The active translator. `apply` replaces it once the locale service is bound + * and re-binds it on every locale change, so components read it at render time. + */ +let translate = (key, params) => interpolate(zh[key] ?? key, params) + +/** Stylesheet, scoped under `dla-` and expressed only in theme tokens. */ +const CSS = ` +.dla-root { display: flex; flex-direction: column; gap: 16px; padding: 4px 2px 24px; color: var(--dsw-alias-label-primary); } +.dla-head { display: flex; flex-direction: column; gap: 6px; } +.dla-title { font-size: 15px; font-weight: 600; } +.dla-desc { font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } +.dla-facts { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 12px; color: var(--dsw-alias-label-secondary); } +.dla-fact b { color: var(--dsw-alias-label-primary); font-weight: 500; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.dla-body { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; } +.dla-list { display: flex; flex-direction: column; gap: 8px; flex: 1 1 320px; min-width: 280px; } +.dla-section { font-size: 12px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; color: var(--dsw-alias-label-secondary); } +.dla-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 8px 10px; background: var(--dsw-alias-bg-layer-1); } +.dla-row[data-selected="true"] { border-color: var(--dsw-alias-brand-primary); } +.dla-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.dla-ip { font-size: 12px; color: var(--dsw-alias-label-secondary); } +.dla-url { font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dla-btn { flex: none; font: inherit; font-size: 12px; line-height: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); cursor: pointer; } +.dla-btn:hover { border-color: var(--dsw-alias-border-l2); } +.dla-btn[data-primary="true"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); } +.dla-qr { display: flex; flex-direction: column; gap: 8px; align-items: center; } +.dla-qr-card { padding: 10px; border-radius: 10px; background: #ffffff; border: 1px solid var(--dsw-alias-border-l1); } +.dla-qr-cap { font-size: 12px; color: var(--dsw-alias-label-secondary); max-width: 200px; text-align: center; } +.dla-hint { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); } +.dla-warn { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-state-warn-primary); } +.dla-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); } +` + +/** + * Render a QR matrix as a crisp SVG. + * @param symbol - `{ size, modules }` from the encoder. + * @param label - accessible label. + * @returns a React element. + */ +function qrElement(React, symbol, label, caption) { + const h = React.createElement + const { size, modules } = symbol + let path = '' + for (let row = 0; row < size; row += 1) { + for (let col = 0; col < size; col += 1) { + if (modules[row * size + col]) path += `M${col} ${row}h1v1h-1z` + } + } + return h( + 'div', + { className: 'dla-qr' }, + h( + 'div', + { className: 'dla-qr-card' }, + h( + 'svg', + { + width: 168, + height: 168, + viewBox: `0 0 ${size} ${size}`, + role: 'img', + 'aria-label': label, + shapeRendering: 'crispEdges', + }, + h('rect', { width: size, height: size, fill: '#ffffff' }), + h('path', { d: path, fill: '#000000' }), + ), + ), + caption === undefined ? null : h('div', { className: 'dla-qr-cap' }, caption), + ) +} + +/** + * The Plugins-settings tab: read the host summary and present the LAN links. + * @param props - slot props; this seat supplies none. + * @returns the panel element. + */ +function Panel() { + const h = React.createElement + const [state, setState] = React.useState({ status: 'loading' }) + const [selected, setSelected] = React.useState(0) + const [copied, setCopied] = React.useState('') + + React.useEffect(() => { + let live = true + fetch(SUMMARY_URL, { headers: { accept: 'application/json' } }) + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${String(response.status)}`) + return response.json() + }) + .then((data) => { + if (live) setState({ status: 'ready', data }) + }) + .catch((error) => { + if (live) setState({ status: 'error', message: String(error?.message ?? error) }) + }) + return () => { + live = false + } + }, []) + + const t = translate + const children = [h('style', { key: 'style' }, CSS)] + + if (state.status === 'loading') { + children.push(h('div', { className: 'dla-hint', key: 'loading' }, t('loading'))) + return h('div', { className: 'dla-root' }, children) + } + if (state.status === 'error') { + children.push(h('div', { className: 'dla-error', key: 'error' }, t('failed', { message: state.message }))) + return h('div', { className: 'dla-root' }, children) + } + + const { data } = state + const lan = Array.isArray(data.lan) ? data.lan : [] + const active = lan[Math.min(selected, Math.max(lan.length - 1, 0))] + + children.push( + h( + 'div', + { className: 'dla-head', key: 'head' }, + h('div', { className: 'dla-title' }, t('title')), + h('div', { className: 'dla-desc' }, t('description')), + h( + 'div', + { className: 'dla-facts' }, + h('span', null, `${data.listen.host}:${String(data.listen.port)}`, ' ', t('forward'), ' ', h('b', null, `${data.target.host}:${String(data.target.port)}`)), + ), + ), + ) + + if (data.enabled === false) { + children.push(h('div', { className: 'dla-warn', key: 'disabled' }, t('disabled'))) + } + if (data.listening === false) { + // Never advertise an address that is not actually bound. + children.push( + h( + 'div', + { className: 'dla-error', key: 'not-listening' }, + t('notListening', { + target: `${data.listen.host}:${String(data.listen.port)}`, + message: data.bindError ?? 'unknown error', + }), + ), + ) + } + + if (lan.length === 0) { + children.push(h('div', { className: 'dla-hint', key: 'none' }, t('noAddresses'))) + return h('div', { className: 'dla-root' }, children) + } + + const copy = (url) => { + const done = () => { + setCopied(url) + globalThis.setTimeout(() => setCopied(''), 1500) + } + if (globalThis.navigator?.clipboard?.writeText !== undefined) { + globalThis.navigator.clipboard.writeText(url).then(done, done) + } else { + done() + } + } + + const rows = lan.map((entry, index) => + h( + 'div', + { + className: 'dla-row', + key: entry.address, + 'data-selected': String(index === selected), + onClick: () => setSelected(index), + }, + h( + 'div', + { className: 'dla-row-main' }, + h('div', { className: 'dla-ip' }, entry.address), + h('div', { className: 'dla-url', title: entry.tokenUrl }, entry.tokenUrl), + ), + h( + 'button', + { + type: 'button', + className: 'dla-btn', + onClick: (event) => { + event.stopPropagation() + copy(entry.tokenUrl) + }, + }, + copied === entry.tokenUrl ? t('copied') : t('copy'), + ), + h( + 'a', + { className: 'dla-btn', href: entry.tokenUrl, target: '_blank', rel: 'noreferrer', onClick: (event) => event.stopPropagation() }, + t('open'), + ), + ), + ) + + let symbol + try { + symbol = encodeQR(active.tokenUrl) + } catch { + symbol = undefined + } + + children.push( + h( + 'div', + { className: 'dla-body', key: 'body' }, + h( + 'div', + { className: 'dla-list' }, + h('div', { className: 'dla-section' }, t('addresses')), + ...rows, + h('div', { className: 'dla-hint' }, t('scanHint')), + ), + symbol === undefined ? null : qrElement(React, symbol, t('qr', { address: active.address }), active.address), + ), + h('div', { className: 'dla-warn', key: 'warn' }, t('warning')), + ) + return h('div', { className: 'dla-root' }, children) +} + +/** Services this client half reads. */ +export const inject = ['slots', 'locale'] + +/** + * Register the locale dictionaries and the Plugins-settings tab. + * @param ctx - the restricted client context. + */ +export function apply(ctx) { + const locale = ctx.get('locale') + if (locale !== undefined) { + if (typeof locale.register === 'function') { + try { + const dispose = locale.register(NS, { zh, en }) + if (typeof dispose === 'function') ctx.effect(() => dispose, 'dsh-lan-access: locale dictionaries') + } catch (error) { + console.error('dsh-lan-access: locale registration failed', error) + } + } + translate = makeTranslator(locale) + if (typeof locale.subscribe === 'function') { + const unsubscribe = locale.subscribe(() => { + translate = makeTranslator(locale) + }) + if (typeof unsubscribe === 'function') ctx.effect(() => unsubscribe, 'dsh-lan-access: locale subscription') + } + } + + const slots = ctx.get('slots') + if (slots === undefined) { + console.error('dsh-lan-access: the slots service is unavailable; the LAN panel is not mounted') + return + } + const t = makeTranslator(locale) + slots.inject(SLOT, () => + slots.register( + { + name: SLOT, + id: SECTION_ID, + order: 50, + label: () => t('title'), + }, + Panel, + ), + ) +} diff --git a/src/qrcode.js b/src/qrcode.js new file mode 100644 index 0000000..28734de --- /dev/null +++ b/src/qrcode.js @@ -0,0 +1,493 @@ +/** + * A dependency-free QR Code encoder (byte mode, error-correction level M, + * versions 1–10) used to render a scannable link to the LAN address. + * + * This is a from-scratch implementation of ISO/IEC 18004 for the one mode the + * panel needs; it carries no runtime dependency into the browser artifact. + * Level M recovers about 15% of the symbol, which is the usual choice for a + * code read off a screen. + * + * Verified against the reference `qrcode` npm package: `node scripts/test-qr.mjs` + * compares every module of every version and mask. + * + * @module @sutong/dsh-lan-access/qrcode + */ + +/** Error-correction level indicator bits (`M` = 0b00) and the format-info prefix. */ +const EC_LEVEL_BITS = 0b00 + +/** Reed-Solomon block layout per version at level M: [ecCodewordsPerBlock, [[blocks, dataCodewords], …]]. */ +const BLOCKS_M = { + 1: [10, [[1, 16]]], + 2: [16, [[1, 28]]], + 3: [26, [[1, 44]]], + 4: [18, [[2, 32]]], + 5: [24, [[2, 43]]], + 6: [16, [[4, 27]]], + 7: [18, [[4, 31]]], + 8: [22, [[2, 38], [2, 39]]], + 9: [22, [[3, 36], [2, 37]]], + 10: [26, [[4, 43], [1, 44]]], +} + +/** Alignment-pattern centre coordinates per version. */ +const ALIGN_CENTERS = { + 1: [], + 2: [6, 18], + 3: [6, 22], + 4: [6, 26], + 5: [6, 30], + 6: [6, 34], + 7: [6, 22, 38], + 8: [6, 24, 42], + 9: [6, 26, 46], + 10: [6, 28, 50], +} + +/** Largest version this encoder supports. */ +export const MAX_VERSION = 10 + +// ── GF(256) arithmetic ────────────────────────────────────────────────────── + +const GF_EXP = new Uint8Array(512) +const GF_LOG = new Uint8Array(256) +{ + let x = 1 + for (let i = 0; i < 255; i += 1) { + GF_EXP[i] = x + GF_LOG[x] = i + x <<= 1 + if (x & 0x100) x ^= 0x11d + } + for (let i = 255; i < 512; i += 1) GF_EXP[i] = GF_EXP[i - 255] +} + +/** + * Multiply two field elements. + * @param a - first element. + * @param b - second element. + * @returns the product in GF(256). + */ +function gfMul(a, b) { + if (a === 0 || b === 0) return 0 + return GF_EXP[GF_LOG[a] + GF_LOG[b]] +} + +/** + * Build the Reed-Solomon generator polynomial for `degree` error codewords. + * @param degree - number of error-correction codewords. + * @returns coefficients, highest power first, leading coefficient 1. + */ +function rsGenerator(degree) { + let poly = [1] + for (let i = 0; i < degree; i += 1) { + const factor = GF_EXP[i] + const next = new Array(poly.length + 1).fill(0) + for (let j = 0; j < poly.length; j += 1) { + next[j] ^= poly[j] + next[j + 1] ^= gfMul(poly[j], factor) + } + poly = next + } + return poly +} + +/** + * Compute the error-correction codewords for one data block. + * @param data - data codewords. + * @param ecCount - number of error codewords to append. + * @returns the error codewords. + */ +function rsEncode(data, ecCount) { + const generator = rsGenerator(ecCount) + const buffer = new Uint8Array(data.length + ecCount) + buffer.set(data, 0) + for (let i = 0; i < data.length; i += 1) { + const coefficient = buffer[i] + if (coefficient === 0) continue + for (let j = 1; j < generator.length; j += 1) { + buffer[i + j] ^= gfMul(generator[j], coefficient) + } + } + return buffer.slice(data.length) +} + +// ── bit stream ────────────────────────────────────────────────────────────── + +/** Append the low `count` bits of `value`, most significant first. */ +function appendBits(bits, value, count) { + for (let i = count - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1) +} + +/** + * The smallest version whose data capacity holds `byteLength`. + * @param byteLength - payload length in bytes. + * @returns the version, or `undefined` when the payload is too large. + */ +function versionFor(byteLength) { + for (let version = 1; version <= MAX_VERSION; version += 1) { + const [, groups] = BLOCKS_M[version] + const dataCodewords = groups.reduce((sum, [blocks, per]) => sum + blocks * per, 0) + const lengthBits = version >= 10 ? 16 : 8 + const capacity = Math.floor((dataCodewords * 8 - 4 - lengthBits) / 8) + if (byteLength <= capacity) return version + } + return undefined +} + +/** + * Encode the payload into the final interleaved codeword sequence. + * @param bytes - UTF-8 payload bytes. + * @param version - resolved symbol version. + * @returns data codewords followed by error codewords, interleaved per block. + */ +function codewordsFor(bytes, version) { + const [ecPerBlock, groups] = BLOCKS_M[version] + const dataCodewords = groups.reduce((sum, [blocks, per]) => sum + blocks * per, 0) + + const bits = [] + appendBits(bits, 0b0100, 4) + appendBits(bits, bytes.length, version >= 10 ? 16 : 8) + for (const byte of bytes) appendBits(bits, byte, 8) + // Terminator, then pad to a codeword boundary. + const capacityBits = dataCodewords * 8 + const terminator = Math.min(4, capacityBits - bits.length) + appendBits(bits, 0, terminator) + while (bits.length % 8 !== 0) bits.push(0) + + const stream = new Uint8Array(dataCodewords) + for (let i = 0; i < bits.length; i += 8) { + let byte = 0 + for (let j = 0; j < 8; j += 1) byte = (byte << 1) | bits[i + j] + stream[i / 8] = byte + } + // Alternating pad codewords fill the remainder. + const PAD = [0xec, 0x11] + for (let i = bits.length / 8, k = 0; i < dataCodewords; i += 1, k += 1) stream[i] = PAD[k % 2] + + // Split into blocks, append each block's error codewords, then interleave. + const dataBlocks = [] + const ecBlocks = [] + let offset = 0 + for (const [blocks, per] of groups) { + for (let b = 0; b < blocks; b += 1) { + const chunk = stream.slice(offset, offset + per) + offset += per + dataBlocks.push(chunk) + ecBlocks.push(rsEncode(chunk, ecPerBlock)) + } + } + + const out = [] + const maxData = Math.max(...dataBlocks.map((block) => block.length)) + for (let i = 0; i < maxData; i += 1) { + for (const block of dataBlocks) if (i < block.length) out.push(block[i]) + } + for (let i = 0; i < ecPerBlock; i += 1) { + for (const block of ecBlocks) out.push(block[i]) + } + return Uint8Array.from(out) +} + +// ── matrix construction and masking ───────────────────────────────────────── + +/** The eight data-mask predicates; index is the mask pattern. */ +const MASKS = [ + (row, col) => (row + col) % 2 === 0, + (row) => row % 2 === 0, + (_row, col) => col % 3 === 0, + (row, col) => (row + col) % 3 === 0, + (row, col) => (Math.floor(row / 2) + Math.floor(col / 3)) % 2 === 0, + (row, col) => ((row * col) % 2) + ((row * col) % 3) === 0, + (row, col) => (((row * col) % 2) + ((row * col) % 3)) % 2 === 0, + (row, col) => (((row * col) % 3) + ((row + col) % 2)) % 2 === 0, +] + +/** BCH(15,5) generator used by the format information. */ +const FORMAT_GENERATOR = 0x537 + +/** BCH(18,6) generator used by the version information. */ +const VERSION_GENERATOR = 0x1f25 + +/** + * Compute the masked format-information word. + * @param mask - mask pattern index. + * @returns the 15-bit word, already XORed with `0x5412`. + */ +function formatBits(mask) { + const data = (EC_LEVEL_BITS << 3) | mask + let value = data << 10 + for (let i = 14; i >= 10; i -= 1) { + if ((value >>> i) & 1) value ^= FORMAT_GENERATOR << (i - 10) + } + return (((data << 10) | value) ^ 0x5412) & 0x7fff +} + +/** + * Compute the version-information word for versions 7 and above. + * @param version - symbol version. + * @returns the 18-bit word. + */ +function versionBits(version) { + let value = version << 12 + for (let i = 17; i >= 12; i -= 1) { + if ((value >>> i) & 1) value ^= VERSION_GENERATOR << (i - 12) + } + return ((version << 12) | value) & 0x3ffff +} + +/** + * Draw the fixed function patterns and reserve their modules. + * @param size - symbol side length in modules. + * @param version - symbol version. + * @returns `{ modules, reserved }` as row-major byte grids. + */ +function newMatrix(size, version) { + const modules = new Uint8Array(size * size) + const reserved = new Uint8Array(size * size) + const set = (row, col, dark) => { + modules[row * size + col] = dark ? 1 : 0 + reserved[row * size + col] = 1 + } + + // Finder patterns with separators, at three corners. + for (const [top, left] of [[0, 0], [0, size - 7], [size - 7, 0]]) { + for (let r = -1; r <= 7; r += 1) { + for (let c = -1; c <= 7; c += 1) { + const row = top + r + const col = left + c + if (row < 0 || row >= size || col < 0 || col >= size) continue + const inRing = r >= 0 && r <= 6 && c >= 0 && c <= 6 + const dark = inRing && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)) + set(row, col, dark) + } + } + } + + // Timing patterns: dark on even coordinates, alternating with the finders. + for (let i = 8; i < size - 8; i += 1) { + set(6, i, i % 2 === 0) + set(i, 6, i % 2 === 0) + } + + // Alignment patterns, skipping the three finder corners. + const centers = ALIGN_CENTERS[version] + for (const row of centers) { + for (const col of centers) { + const nearFinder = + (row <= 8 && col <= 8) || (row <= 8 && col >= size - 9) || (row >= size - 9 && col <= 8) + if (nearFinder) continue + for (let r = -2; r <= 2; r += 1) { + for (let c = -2; c <= 2; c += 1) { + const dark = Math.max(Math.abs(r), Math.abs(c)) !== 1 + set(row + r, col + c, dark) + } + } + } + } + + // The always-dark module beside the lower-left finder. + set(size - 8, 8, true) + + // Reserve the format-information areas (values are written after masking). + for (let i = 0; i < 9; i += 1) { + if (i !== 6) { + set(8, i, false) + set(i, 8, false) + } + } + // The horizontal copy carries 8 modules; the vertical copy carries 7, because + // the eighth slot of column 8 is the always-dark module below. + for (let i = 0; i < 8; i += 1) set(8, size - 1 - i, false) + for (let i = 0; i < 7; i += 1) set(size - 1 - i, 8, false) + + // Reserve the version-information areas for versions 7 and above. + if (version >= 7) { + for (let i = 0; i < 18; i += 1) { + set(Math.floor(i / 3), (i % 3) + size - 11, false) + set((i % 3) + size - 11, Math.floor(i / 3), false) + } + } + return { size, modules, reserved } +} + +/** + * Write the data and error codewords into the matrix in the zigzag order. + * @param matrix - `{ size, modules, reserved }`. + * @param codewords - interleaved codewords. + * @param mask - mask pattern index. + */ +function placeData(matrix, codewords, mask) { + const { size, modules, reserved } = matrix + const maskFn = MASKS[mask] + let bitIndex = 7 + let byteIndex = 0 + let row = size - 1 + let direction = -1 + + for (let col = size - 1; col > 0; col -= 2) { + if (col === 6) col -= 1 + for (;;) { + for (let c = 0; c < 2; c += 1) { + const at = row * size + (col - c) + if (reserved[at]) continue + let dark = false + if (byteIndex < codewords.length) dark = ((codewords[byteIndex] >>> bitIndex) & 1) === 1 + if (maskFn(row, col - c)) dark = !dark + modules[at] = dark ? 1 : 0 + reserved[at] = 1 + bitIndex -= 1 + if (bitIndex === -1) { + byteIndex += 1 + bitIndex = 7 + } + } + row += direction + if (row < 0 || row >= size) { + row -= direction + direction = -direction + break + } + } + } +} + +/** + * Measure one masked matrix with the four standard penalty rules. + * @param size - symbol side length. + * @param modules - row-major module grid. + * @returns the penalty score; lower is better. + */ +function penalty(size, modules) { + const at = (row, col) => modules[row * size + col] + let score = 0 + + // Rule 1: runs of five or more same-coloured modules in a line. + for (let i = 0; i < size; i += 1) { + for (const line of [0, 1]) { + let run = 1 + let previous = line === 0 ? at(i, 0) : at(0, i) + for (let j = 1; j < size; j += 1) { + const current = line === 0 ? at(i, j) : at(j, i) + if (current === previous) { + run += 1 + } else { + if (run >= 5) score += 3 + (run - 5) + previous = current + run = 1 + } + } + if (run >= 5) score += 3 + (run - 5) + } + } + + // Rule 2: every 2x2 block of one colour. + for (let row = 0; row < size - 1; row += 1) { + for (let col = 0; col < size - 1; col += 1) { + const value = at(row, col) + if (value === at(row, col + 1) && value === at(row + 1, col) && value === at(row + 1, col + 1)) score += 3 + } + } + + // Rule 3: the finder-like 1:1:3:1:1 patterns with a four-module quiet side. + const first = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0] + const second = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1] + const matches = (get, start) => { + let one = true + let two = true + for (let k = 0; k < 11; k += 1) { + const value = get(start + k) + if (value !== first[k]) one = false + if (value !== second[k]) two = false + } + return (one ? 1 : 0) + (two ? 1 : 0) + } + for (let i = 0; i < size; i += 1) { + for (let j = 0; j + 11 <= size; j += 1) { + score += 40 * matches((k) => at(i, k), j) + score += 40 * matches((k) => at(k, i), j) + } + } + + // Rule 4: deviation of the dark-module proportion from 50%. + let dark = 0 + for (let i = 0; i < size; i += 1) dark += modules[i] + const percent = (dark * 100) / (size * size) + score += Math.floor(Math.abs(percent - 50) / 5) * 10 + return score +} + +/** + * Write the format information (and version information for v7+) around the + * fixed patterns. + * @param matrix - `{ size, modules }`. + * @param version - symbol version. + * @param mask - chosen mask pattern index. + */ +function placeHeaders(matrix, version, mask) { + const { size, modules } = matrix + const set = (row, col, dark) => { + modules[row * size + col] = dark ? 1 : 0 + } + + const format = formatBits(mask) + for (let i = 0; i < 15; i += 1) { + const dark = ((format >>> i) & 1) === 1 + if (i < 6) set(i, 8, dark) + else if (i < 8) set(i + 1, 8, dark) + else set(size - 15 + i, 8, dark) + + if (i < 8) set(8, size - 1 - i, dark) + else if (i < 9) set(8, 15 - i, dark) + else set(8, 15 - i - 1, dark) + } + + if (version >= 7) { + const bits = versionBits(version) + for (let i = 0; i < 18; i += 1) { + const dark = ((bits >>> i) & 1) === 1 + set(Math.floor(i / 3), (i % 3) + size - 11, dark) + set((i % 3) + size - 11, Math.floor(i / 3), dark) + } + } +} + +/** + * Encode text as a QR Code symbol. + * @param text - the payload; UTF-8 encoded. + * @param options - optional `version` and `mask` overrides (mainly for tests). + * @returns `{ size, version, mask, modules }` where `modules` is row-major, 1 = dark. + * @throws {RangeError} when the payload exceeds version 10 at level M. + */ +export function encodeQR(text, options = {}) { + const bytes = new TextEncoder().encode(String(text)) + const version = options.version ?? versionFor(bytes.length) + if (version === undefined || version > MAX_VERSION) { + throw new RangeError(`qrcode: payload of ${bytes.length} bytes exceeds version ${MAX_VERSION} at level M`) + } + + const codewords = codewordsFor(bytes, version) + const size = version * 4 + 17 + + let best + if (options.mask !== undefined) { + best = options.mask + } else { + let bestScore = Number.POSITIVE_INFINITY + for (let mask = 0; mask < 8; mask += 1) { + const matrix = newMatrix(size, version) + placeData(matrix, codewords, mask) + placeHeaders(matrix, version, mask) + const score = penalty(size, matrix.modules) + if (score < bestScore) { + bestScore = score + best = mask + } + } + } + + const matrix = newMatrix(size, version) + placeData(matrix, codewords, best) + placeHeaders(matrix, version, best) + return { size, version, mask: best, modules: matrix.modules } +}