一个随机域名 + 一个随机口令,把本机的服务按需对外开放。
English · 安装 · 工作原理 · 服务清单 · 更新日志 · 问题反馈
**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/