dsh-service-portal

dsh-service-portal

一个随机域名 + 一个随机口令,把本机的服务按需对外开放。

English · 安装 · 工作原理 · 服务清单 · 更新日志 · 问题反馈

npm version npm downloads MIT license GitHub stars

**dsh-service-portal(对外服务门户)** 是一个 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) 插件。 它把**任意本机服务**按需暴露到外网:`cloudflared` 建一个**随机域名**,一道**口令登录** (只需口令,没有账号)守住入口,设置页一键开关并显示连接与口令。 它最初长在 `ld-rl-poker` 里(为了把训练配置台/模型查看台放到外网),但门户本身与德州扑克无关—— **任何本地服务**都能用同一套「一个域名 + 一个口令 + 按需挂载」。 ## 功能特性 - **装好即用,不用先点开关** —— 插件加载后门户自动起来(**只开内网**,不会自动把本机 服务挂到公网)。打开设置页就能看到内网地址和二维码,扫一下就进。 外网暴露必须由你显式点「开启公网访问」。 - **设置页就是 dsh-pocket 那个形态** —— 局域网、公网两个区块,各自带开关 + 二维码 + 地址 + 访问密码行 + 高级(手动选地址)。 - **节点管理就在插件设置里**(插件页 → 该插件 → 配置)—— 添加节点(名称 / 对外路径 / 本机端口 / 备注)、 **开放 / 屏蔽**(路由闸门)、编辑、删除、以及每个节点一个「访问」。 **不用改配置文件、不用重启**。 - **只管路由,不管启停** —— 「屏蔽」只表示门户不再把这条路由转过去, **不会**停止上游程序;上游由你自己的方式启动(插件不猜它的启动命令)。 - **自带门户网页(只读)** —— 扫码打开的是**测试页 + 引导页**: 列出已发布的节点,每个节点有「访问」(打开它)和「测试」 (**就地**探一次通不通并把结论写在卡片里,不用跳走): - 「通了:HTTP 200」= 门户转发成功且上游有响应; - 「不通:HTTP 502 … 请用它自己的方式启动那个程序」= 上游没起; - 「上游有响应,但它自己返回了 HTTP 502」= 上游在线、是它内部出错。 最后这条必须和上一条**分开说** —— 状态码一样,把上游自己的错报成 「没启动」会让人去启动一个本来就在跑的程序。 **没有任何设置功能** —— 要改配置请到 DSH 插件页里该插件自己的配置里。 - **内网 + 外网两种访问** —— 同一张页面,两条入口: - 内网(`http://192.168.x.x:8899`)默认**免密**,同一 WiFi 下手机/平板直接打开。 可开「局域网访问密码」,也可以直接关掉整个局域网访问; - 外网(`https://<随机>.trycloudflare.com`)**必须口令**,人在外面也能用。 - 两个开关**改完立即生效**,不用重启门户(重启会换掉隧道域名,代价太大)。 - **本机访问永远免密** —— 否则你在电脑上连自己的门户都进不去。 - **手机扫码即用** —— 设置页与门户页都显示二维码(内联 SVG,不依赖外部请求), 手机扫一下就到;页面按窄屏自适应。 - **只需口令,不要账号** —— 自定义登录页(只有口令输入框),不弹浏览器原生 「账号 + 密码」框;口令可**每次随机**或**你自己设固定值**。 - **登录限速** —— 单 IP 连续失败 5 次锁 60 秒,另有全局阈值防分布式撞码。 - **服务状态一目了然** —— 每个服务显示 已启用/未启用/存活探测失败,未启用的不可点; 健康检查是**真探测**(TCP 连上游端口),不是标记位。 - **控制检验**(本仓库硬纪律)—— 26 个 Python 控制检验脚本 + 8 个端到端脚本 + 83 项 Node 检验;**依赖缺失必须「跳过并返回 2」,不得静默通过**。 ## 界面 门户页(扫码打开的那个页面,只读:测试 + 引导):左边手机窄屏、中间深色、 右边宽屏。截图由 `tools/shoot_portal.py` 用**真门户进程 + 无头 Chrome** 实拍, 不是设计稿。 | 窄屏(浅色) | 窄屏(深色) | 宽屏 | | :---: | :---: | :---: | | 门户页 窄屏 浅色 | 门户页 窄屏 深色 | 门户页 宽屏 | ## 工作原理 ``` ┌─────────── 门户进程(一个端口,默认 8899)───────────┐ 内网 http://192.168.x.x:8899 ──┤ │ ├──▶ 按 Host 分类:loopback / lan / public │ 外网 https://<随机>.trycloudflare.com ─┘ │ │ ├─ / → 门户页(只读:测试 + 引导) ├─ /_auth/login → 口令登录(外网) ├─ /_api/* → 门户 API(受 DSH 插件设置调用) └─ /<路由路径>/… → 转发到对应服务 ``` **认证强度按来源区分**(照搬 dsh-pocket 的模型,见[致谢](#致谢)): | Host 分类 | 判定 | 门户页 / 服务 | | :--- | :--- | :--- | | `loopback` | `localhost`、`127.x`、`::1` | 免密 | | `lan` | RFC1918 私网、CGNAT(100.64/10)、`.local`、单标签名 | 免密 | | `public` | **其余一切**(含你自建域名) | **必须口令** | > 兜底方向是**安全的一侧**:认不出的 Host 一律当公网。反着写(只把 `*.trycloudflare.com` > 当外网、其余当内网)会让人挂个自己的域名就绕过认证——dsh-pocket 踩过这个坑 > (它的 issue #66),本项目直接按 fail-closed 实现。 ## 安装 ### 方式一:npm(发布后) ```powershell $dsh = "$env:LOCALAPPDATA\Programs\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd" & $dsh plugin --profile desktop add dsh-service-portal ``` ### 方式二:link 本地插件(开发中) ```powershell $dsh = "$env:LOCALAPPDATA\Programs\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd" & $dsh plugin --profile desktop add "link:D:/DshProject/dsh-service-portal" ``` 装完**需要重启 DSH**(插件在宿主进程里加载)。 > 若旧 profile 仍装有已删除的子插件 `dsh-poker-viewer`,先移除再装根插件: > `& $dsh plugin --profile desktop remove dsh-poker-viewer`。 ## 配置(DSH 设置页) | 字段 | 默认 | 说明 | | :--- | :--- | :--- | | `infraPath` | 本仓库根 | 基础设施仓库:含 `tools/` 与 `runs/`(隧道/代理/凭据) | | `repoPath` | **空** | 宿主仓库根:被开放的服务本体在这里。**必须自己填** | | `pythonPath` | 空 | 拉服务用的解释器;留空则自动选 `.venv313` → `.runtime` → PATH | | `passwordMode` | `random` | 口令形态:`random` 每次开启自动生成 / `fixed` 用固定口令 | | `fixedPassword` | 空 | `passwordMode = fixed` 时使用,至少 12 字符(保存时即校验) | | `portalPort` | `8899` | 门户端口。只接受 1–65535 的整数,非法值回落默认 | | `autoStart` | `true` | 插件加载后自动启动门户(**只开内网**,不建隧道) | | `lanAuthEnabled` | `false` | 内网访问是否要口令(`false` = 免密) | | `webEnabled` | `true` | 是否挂载 `/service-portal/api` 路由 | 所有字段都是 `.volatile()`:改设置即时生效,不需要重挂插件。 「局域网访问密码」「局域网访问」两个开关走门户的 `/_api/settings`, **改完立即生效且立即落盘**,不受「运行中不可改」限制。 **口令形态是安全相关设置**:口令模式与固定口令在门户**运行中**不可改(先「关闭」再改), 避免出现「界面显示的口令」与「实际生效的口令」不一致。 ### 为什么 `autoStart` 默认开、但只开内网 对齐 dsh-pocket 的「装好即用」:装了插件打开设置页就该看到内网地址和二维码, 而不是一片空白等着你点按钮。但**外网暴露是另一回事** —— 那是把本机服务放到公网上, 必须由你显式决定(点「开启公网访问」),不能因为装了插件就自动发生。 ### 口令的两种形态 | | `random`(默认) | `fixed` | | :--- | :--- | :--- | | 谁生成 | 门户每次开启自动生成 16 字符随机口令 | 你自己设定(≥12 字符) | | 何时变 | 每次点「开启」都换 | 只有你改它才变 | | 泄漏后果 | 只在本次开启期间有效,重启即作废 | **长期有效**,直到你改口令 | | 适用 | 分享给临时协作者、不想记口令 | 常用设备,省去每次复制口令 | | 安全代价 | 需重新分发口令 | 口令一旦外泄就是长期敞口 | > 固定口令**不会**进入进程命令行(那会让本机任意进程通过 `Win32_Process` 读到它): > 插件把它写进 `runs/` 下的临时文件并以 `--password-file` 传路径,启动器读完即删。 ## HTTP 接口 挂在宿主 webServer 上(`prefix` 路由,路径 `/service-portal/api`): | 方法 | 路径 | 作用 | | :--- | :--- | :--- | | GET | `/service-portal/api/status` | 当前状态:是否运行、模式、地址、口令、口令形态、端点列表 | | GET | `/service-portal/api` | 等同 `/status` | | GET | `/service-portal/api/settings` | 口令形态设置(模式 + 固定口令 + 最小长度) | | POST | `/service-portal/api/settings` | 保存口令形态(body:`{"passwordMode":"fixed","fixedPassword":"..."}`) | | POST | `/service-portal/api/start` | 启动(body 可选 `{"singleDomain": true}`,默认单域名+路径) | | POST | `/service-portal/api/stop` | 停止 | 未知子路径返回 **404**(不拿 status 兜底——拼错的路径不许「看起来能用」)。 ## 在插件设置里管理节点 在 DSH 里打开 **设置 → 插件 → 找到 dsh-service-portal → 配置**, 就在那里管理要对外发布的路由(注册在插件页的 `plugins.bundle.config` / `plugins.row.config` 槽位,与 workbuddy 同一形态;**不会**占用宿主设置的左侧导航)。 每个节点一条卡片: | 操作 | 说明 | | :--- | :--- | | **添加节点** | 填 名称 / 对外路径(如 `/ollama`)/ 本机端口(如 `127.0.0.1:11434`)/ 备注 | | **访问** | 点了直接在新标签打开该路由(`/<对外路径>/`)。屏蔽时**置灰不可点** | | **开放 · 屏蔽** | **路由闸门**:开放 = 门户把请求转过去;屏蔽 = 不转(该路由**立即 404**)。**不启停上游程序** | | **编辑 / 删除** | 改对外路径、上游、备注;删除只从注册表移除 | | **状态** | 绿=开放中,灰=已屏蔽;上游没响应时另给一行提示(**不影响**路由开放与否) | > **「屏蔽」不是"停服务"** —— 上游程序照跑不误。本插件不负责启动/停止上游 > (那需要知道它的启动命令、工作目录、就绪判定,是一整套配置,且容易做出 > "以为在管、其实没管住"的假象)。上游请用它自己的方式启动。 改动**立即落盘**(`runs/portal_registry.json`)并生效,不用重启门户、不用重建隧道。 **门户页面是只读的**(扫码后的落地页):列出已发布的节点,每个节点两个动作 —— 「访问」(打开它)与「测试」(就地探一次,把通/不通与原因写在卡片里)。 那是**测试页 + 引导页**,没有任何设置功能。 「测试」的三种结论(真机用 Chrome 点过,三种都对且各不相同): | 上游状态 | 页面结论 | | :--- | :--- | | 活着 | 通了:HTTP 200 —— 门户转发成功且上游有响应 | | 没启动 | 不通:HTTP 502 —— 门户转发过去了但上游没响应,**请用它自己的方式启动** | | 在线但自己 502 | 上游有响应,但它自己返回了 HTTP 502 —— 上游在线,请看它自己的日志 | > 最后两条状态码都是 502,只能靠正文区分(门户自己发的那条固定含 > `Bad Gateway` 与 `上游`)。分开说是必须的:把上游自己的错报成「没启动」, > 用户会去启动一个本来就在跑的程序。契约由 `tools/check_portal_probe.py` 钉住。 节点字段: | 字段 | 必填 | 说明 | | :--- | :--- | :--- | | `key` | ✓ | 唯一标识,`[a-zA-Z0-9_-]{1,32}`,不能与保留字冲突。**从对外路径自动派生**(`/rltrain` → `rltrain`),界面不必填 | | `name` | ✓ | 显示名(≤64 字符,页面会 HTML 转义) | | `mount` | ✓ | 对外路径,以 `/` 开头;不能与其他开放项重复、不能占用 `/_auth` `/_api` | | `target` | ✓ | 本机上游 `host:port` | | `enabled` | | **路由是否开放**(开放 / 屏蔽) | | `script` / `cwd` / `autoStart` | | **已废弃**:老数据仍能读,但插件不再拿它们去启动任何进程 | > 命令行也能直接改(等同设置页操作): > `POST /_api/services`、`POST /_api/services//open|block|delete|update` > (`enable` / `disable` 作为老别名仍可用)。 ## 工具(也可脱离插件直接用) | 文件 | 作用 | | :--- | :--- | | `tools/portal_run.py` | **门户启动器**:起门户页 + 内网/外网双入口(**不管上游进程**) | | `tools/portal_proxy.py` | 按 Host 分类认证的反向代理(内网免密 / 外网口令) | | `tools/portal_console.py` | 门户页(只读:测试 + 引导)+ 二维码(纯 Python,无依赖) | | `tools/portal_registry.py` | 服务注册表(运行时增删改查 + 原子落盘) | | `tools/portal_login.py` | 口令登录 + 签名会话 cookie | | `tools/start_public_gui.py` | 旧版启动器(静态清单 + 单/多域名两种形态,仍可用) | | `tools/tunnel_auth.py` | 旧版认证代理(口令登录 + 多挂载点路径分流) | ```powershell python -X utf8 tools/portal_run.py # 起门户(内网 + 外网隧道) python -X utf8 tools/portal_run.py --lan-only # 只开内网 python -X utf8 tools/portal_run.py --port 8899 # 指定端口 python -X utf8 tools/portal_run.py --status / --stop ``` ## 控制检验 | 检查 | 项数 | 拦的 bug | | :--- | ---: | :--- | | `tools/check_portal_parity.py` | 126 | 设置页三处对标:缺右上角求 Star 区块、局域网口令与公网共用、**「自动选地址」只是显示不生效**;未启动状态逻辑;排版(字号刻度、只引用真实主题令牌、动作与说明分层、警示块去重);公网密码自定义与警告提示;**节点开关必须是"路由闸门"而不是启停上游**、门户页只读无管理入口 | | `tools/check_portal_stop_scope.py` | 13 | **`--stop` 顺手杀掉同端口上别人的进程**(用户正在跑的门户被检验脚本干掉);停止后口令/候选被整份删掉 | | `tools/check_portal_shutdown.py` | 6 | 门户自退接口:局域网/公网来源必须被拒(不能从手机把门户关掉)、GET 不生效、**端口松了进程没退** | | `tools/check_plugin_stop.py` | 6 | 设置页「停止门户」真能停:**宿主派生进程杀不掉门户却报成功**(沙箱/作业隔离),改走"请门户自己退" | | `tools/check_portal_settings_forward.py` | 7 | 设置页局域网三项(总开关 / 口令开关 / 手动选地址)被宿主**静默忽略**:返回 ok:true、toast 说改了,门户其实一点没变 | | `tools/check_public_pin.py` | 18 | 公网密码自定义:**新密码真能登录、旧密码真的作废**(拿会话 cookie 当判据)、重启后仍在;安全负例:不能用局域网口令当公网密码、太短/空白被拒 | | `tools/check_portal_service_api.py` | 13 | 节点接口:门户侧增删、**开放/屏蔽(open/block)**、`enable/disable` 老别名仍可用、**`restart` 被明确拒绝**;**反例**——门户没跑时转发必须返回 ok:false(上一轮的"静默忽略却弹成功"就是栽在这) | | `tools/check_portal_add_route.py` | 12 | 添加节点走**宿主 HTTP 路由**的端到端:真 `createHandler` + 真门户 + 真磁盘;**开放/屏蔽**、反例(`restart` 必须被拒、target 非法必须 400 + 明确 error) | | `tools/check_gate_does_not_stop_upstream.py` | 17 | **「屏蔽」不得停止上游进程**(用户最关心的一条):起真上游进程,验屏蔽后 PID 仍活、绕过门户直连仍通、门户不再转发(404);删除节点也不动上游 | | `tools/check_portal_status_running_services.py` | 6 | **添加节点后设置页看不到**:宿主原来从不去问活门户要服务清单(判据漏了 services),修后能读回;反例——门户没跑时必须空数组,不许编造 | | `tools/check_shutdown_self_exit.py` | 8 | **第二次点「停止」失败**:`serve_forever` 先起、关闭钩子晚装,那个窗口里 `/_api/shutdown` 回 503「门户还没就绪」,宿主退到 `--stop` 兜底而兜底在沙箱里杀不掉(`stoppedBy=stop-command`)。含源码顺序断言 + 实测「端口一在听就能自退」 | | `tools/check_portal_lan_pin.py` | 23 | 局域网口令刷新/自定义;**公网拿局域网口令能进来**(安全边界)、候选列表没落盘 | | `tools/check_portal_form.py` | 8 | **控制台表单提交被当成坏 JSON**(页面上点「添加服务」永远失败)、表单没有 key 字段导致必然失败 | | `tools/check_portal_tunnel.py` | 11 | **门户已在跑时「开启公网访问」什么都不做**(幂等检查把建隧道也跳过了)、「关闭公网」把整个门户停掉 | | `tools/check_portal_bind.py` | 7 | **只绑 127.0.0.1**(手机永远连不上)、Windows `SO_REUSEADDR` 重复绑定(流量随机分流)、端口被占不换端口 | | `tools/check_portal_websocket.py` | 8 | 升级头被过滤(握手失败)、101 响应没原样回传、握手后没有双向管道(实时数据不刷新) | | `tools/check_portal_integration.py` | — | **畸形 Host 被判成内网**(`garbage value` 竟免密)、控制台链接双斜杠、局域网总开关只改界面不改认证 | | `tools/check_portal_registry.py` | 88 | 路由重复/非法、key 冲突、**改完不落盘**(重启就丢)、坏文件崩溃 | | `tools/check_portal_console.py` | 41 | 未启用服务可点、服务名里塞 `