dsh-service-portal
dsh-service-portal
一个随机域名 + 一个随机口令,把本机的服务按需对外开放。
English ·
安装 ·
工作原理 ·
服务清单 ·
更新日志 ·
问题反馈
dsh-service-portal(对外服务门户) 是一个 DeepSeek Harness (DSH) 插件。
它把任意本机服务按需暴露到外网: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(发布后)
$dsh = "$env:LOCALAPPDATA\Programs\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd"
& $dsh plugin --profile desktop add dsh-service-portal
方式二:link 本地插件(开发中)
$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/<key>/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 | 旧版认证代理(口令登录 + 多挂载点路径分流) |
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 |
端到端(真起门户进程,不是单元级)
| 脚本 | 项数 | 覆盖 |
|---|
test/acceptance_portal.py | 30 | 页面增删改开关服务、路由改动生效、内网免密/外网口令、二维码/手机端/限速/健康检查真实性、三分类 fail-closed、依赖缺失返回 2 |
test/test_autostart.py | 4 | 插件加载后门户自动跑起来(「装好即用」)、只开内网、内网地址可用 |
test/test_lan_address.py | 16 | 网卡评分选地址(物理网卡优先、198.18.x 降权)、手动选地址立即生效、非法地址被拒、覆盖值落盘 |
test/test_lan_toggle.py | 10 | 内网口令开关立即生效(不重启门户)、本机永远免密、公网不受影响 |
test/test_lan_switch.py | 6 | 局域网访问总开关:关掉后内网要求口令、本机不受影响、重开恢复 |
test/test_public_link.py | 12 | 真实公网链路:域名可达、公网首页是登录页、未登录拿不到 API 数据、内网免密、畸形 Host fail-closed |
test/test_settings_data.py | 10 | 设置页数据链:状态字段、二维码生成、二维码只给本门户地址(防图床) |
test/test_host_buttons.py | 10 | 走插件宿主 API(设置页按钮真正调用的路径):开启→状态→重复开启→停止;停止走的是"门户自退";收尾恢复用户原本在跑的门户 |
依赖缺失必须「跳过并返回 2」,不得静默通过 —— 这是本仓库的硬纪律。
检验不许动用户正在跑的门户:脚本一律用临时端口 + 临时 PORTAL_RUNS_DIR,
不碰用户真实的 runs/(那里面存着局域网口令和你注册的节点)。
这条是踩出来的:一句裸的 portal_run.py --stop 曾把用户的门户干掉,
用户看到的是「二维码和访问地址一起消失」。
$node = "$env:LOCALAPPDATA\Programs\DeepSeek Harness\resources\runtime\primary-runtime\dependencies\node\bin\node.exe"
& $node --test test/host.test.mjs # 55 项宿主端控制检验
& $node --test test/client.test.mjs # 28 项客户端分发契约检验
# Python 控制检验
python -X utf8 tools\check_portal_bind.py
python -X utf8 tools\check_portal_websocket.py
python -X utf8 tools\check_portal_integration.py
python -X utf8 tools\check_portal_registry.py
python -X utf8 tools\check_portal_console.py
python -X utf8 tools\check_portal_proxy.py
python -X utf8 tools\check_portal_auth.py
python -X utf8 tools\check_portal_stop_scope.py
python -X utf8 tools\check_portal_shutdown.py
python -X utf8 tools\check_plugin_stop.py
python -X utf8 tools\check_tunnel_auth.py
python -X utf8 tools\check_tunnel_domain.py
python -X utf8 tools\check_plugin.py
# 端到端(会真起门户进程,几十秒)
python -X utf8 test\acceptance_portal.py
python -X utf8 test\test_autostart.py
python -X utf8 test\test_lan_address.py
python -X utf8 test\test_lan_toggle.py
python -X utf8 test\test_lan_switch.py
python -X utf8 test\test_settings_data.py
python -X utf8 test\test_host_buttons.py # 需要 DSH 在跑;宿主代码过期会跳过并返回 2
改样式时怎么看效果(人工看图,不是自动检验)
tools/render_panel_preview.mjs 把设置面板真渲染出来(直接 require
lib/client.js,最小 React 替身 + 假 fetch),用无头 Chrome 截图肉眼核对:
node tools\render_panel_preview.mjs # 打印地址
# 然后访问 /?s=running|public|off[&dark=1] 截图
它是人工工具,不进控制检验(要人看图,没法自动判过),而且有已知局限:
替身 React 的文本排版不真实(截图里标题与副标题会"看起来压在一起"),
只适合看结构顺序、颜色、胶囊形状、层级差别。局限写在文件头。
它现在能反映真实布局了(0.8.4 修的):替身工厂原来把 className 当成
字面量属性设上去(className="..."),而外观在 0.8.4 起改由注入的 CSS 类承担,
于是预览会画出一个和真实渲染完全不同的页面(两栏不并排、hover 没有)。
这种"预览骗人"比没有预览更糟,已改成映射 className → class。
看门户页(访问页)的效果
tools/shoot_portal.py 在临时端口用当前代码起一个真门户(真 HTTP、
真状态文件、真注册表),再用无头 Chrome 截三张图(窄屏 / 窄屏深色 / 宽屏):
python -X utf8 tools\shoot_portal.py # 输出到 runs/shots/
为什么不直接截你线上那个 8899:那个进程里是改动前的 portal_console 模块
(Python 一 import 就固定),要看新页必须重启你的门户 —— 而重启会换掉
cloudflared 隧道域名(你发出去的地址就废了)。所以另起一个临时的来看。
外观:一份注入的 CSS,而不是内联 style
面板的外观(底色、描边、圆角、尺寸、hover、焦点环、窄屏媒体查询)都在
client/index.jsx 顶部的 CARD_CSS 里,模块加载时注入一个 <style>
(照 workbuddy 的做法,按 data-plugin-css 查重做幂等)。
为什么不用内联 style:内联的优先级高于类选择器,连 :hover 也压不过它 ——
内联一旦写了底色,类里的 hover 就成了死代码。而"可点击元素要有按压反馈""键盘
焦点要可见""窄屏要塌单栏"这三条都只能靠 :hover / :focus-visible / @media
实现。所以按钮原语的 style 对象是空的,颜色与尺寸一律走 .dsp-* 类
(类名统一 dsp- 前缀,避免与宿主或其他插件撞车)。
安全边界(必须如实理解)
- 口令是明文存在
runs/public_gui_credentials.json 并明文显示在插件设置页。
- 拿到口令 == 能改对外发布的路由。持有效会话者可以调用
/api/services*
增删节点、开放或屏蔽路由。但它不能启停你的上游程序 —— 本插件已不做启停
(所以拿口令的人无法借这里去停 5090 上的训练)。对 ld-rl-poker 而言,
风险面从"能启停训练"缩小到"能改发布出去的路由"。
- 局域网免密时,同 WiFi 上的人也能读改节点(
/_api/services 不需要口令)。
这是"内网免密"体验的代价;不想承担就开「局域网访问密码」或关掉局域网访问。
节点管理搬到插件设置后,日常操作走宿主转发(127.0.0.1),不必再开着控制台页面。
- 有些本地服务自身没有认证(两个 GUI、Ollama 等),挂出去就等于公开——
门户这层口令是它们唯一的关卡。
- 本插件不会碰 dsh-pocket 自己的隧道(目标 3081);
--stop 只停自己记录的进程。
- 登录后的会话是签名 cookie(HttpOnly,默认 12 小时到期):不必每次请求都带口令,
但这也意味着在这段时间内,那个浏览器就等于已授权。会话密钥随门户进程随机生成,
关掉门户即全体登出。
- 选固定口令就要接受它的代价:门户重启后口令不变,一次外泄就是长期敞口。
只想临时分享就保持默认的
random。
- 门户跑在本机
127.0.0.1 上,口令校验也发生在这一层;不要把入口端口直接暴露到公网,
否则认证层会被绕过(cloudflared 隧道才是唯一的对外通道)。
测试
$node = "$env:LOCALAPPDATA\Programs\DeepSeek Harness\resources\runtime\primary-runtime\dependencies\node\bin\node.exe"
& $node --test test/host.test.mjs # 55 项宿主端控制检验
& $node --test test/client.test.mjs # 28 项客户端分发契约检验
# 端到端验收:真起门户进程,逐条验证「内网免密/外网口令/页面管理服务/限速/健康检查」
python -X utf8 test\acceptance_portal.py # 30 项
# 认证层与路由层的控制检验(纯标准库)
python -X utf8 tools\check_portal_auth.py # 35 项:登录页/会话/认证前置
python -X utf8 tools\check_tunnel_auth.py # 35 项:路由/前缀剥离/多挂载/502
与同族插件的关系
dsh-service-portal 是 LaoDing 插件家族的一员(家族品牌图标与 dsh-connect-workbuddy /
dsh-connect-trae 等一致)。家族成员共享:
- 设置卡片外观与
row.* 文案约定;
- 手写 CommonJS 客户端分发格式(
window.__ModuleLoader__.load),零构建依赖;
- 「启动器,不是第二份实现」的复用纪律:隧道/认证等已完成并验收的实现不在插件里重写。
历史上门户功能曾是专用子插件 dsh-poker-viewer(ld-rl-poker 外网开关);2026-10-15
已将其功能吸收进根插件成为通用门户能力(服务清单驱动,不绑定任何具体服务),
子插件目录已删除。
未做
- 固定自有域名:需要 named tunnel + ingress。现在每次启动都是随机 trycloudflare 域名。
- cloudflared 自动下载:本项目复用本机已有的那份(dsh-pocket 会多镜像源下载)。
- 服务健康检查定时刷新:现在是页面刷新时探测存活,没有后台定时器。
致谢
本项目的门户形态(门户网页、内网+外网双入口、按 Host 分类的认证强度)明确参照了:
「把 DeepSeek Harness 装进你的口袋:一个包、一个设置页,手机扫码即同步访问电脑上的
DSH(局域网 + 公网,实时同屏)。」 —— GPL-2.0
我们从它学到的设计(逐条对应到本项目的实现):
| 它的做法 | 我们的实现 |
|---|
classifyHost() 把 Host 分成 loopback / lan / public,按类别决定认证强度 | tools/portal_proxy.py::classify_host(),同一套三分类 |
| fail-closed:认不出的 Host 一律当公网(它曾因反着写被绕过,issue #66) | 同样 fail-closed,并有 20 项分类控制检验(含「自建域名必须判为公网」) |
| 公网始终要密码;局域网可开关;loopback 免密 | 内网免密、外网必须口令(用户选定) |
| 登录限速:单 IP 5 次失败锁 60s + 全局阈值 | RateLimiter,同样的阈值策略 |
cf-connecting-ip 只在源地址是 loopback 时信任 | client_ip(),同一条判定(防伪造绕过限速) |
| 自定义登录页(不弹浏览器原生认证框) | tools/portal_login.py |
| 手机扫码即用(二维码 + 移动端适配) | tools/portal_console.py(纯 Python 生成 SVG 二维码,不引入依赖) |
同时说明清楚:本项目没有复制 dsh-pocket 的任何代码。
- 它是 JavaScript(
lib/proxy.mjs / lib/tunnel.mjs / client/),本项目的门户是独立的
Python 实现(tools/portal_*.py),两侧语言、结构与命名均不同;
- 双方暴露的 HTTP 接口、口令存储格式、配置文件格式都不兼容;
- 我们借用的是设计判断(尤其是 fail-closed 的兜底方向、限速阈值、IP 信任边界这类
「用血换来的结论」),实现是从零写的。
因此本项目的 MIT 许可不受其 GPL-2.0 传染:GPL 约束的是衍生作品的代码分发,而
「参考设计思路、独立实现」不构成衍生作品。我们仍在此明确致谢并标注其许可,
一是尊重原作者,二是让读者能自己判断。
如果将来要在本项目里直接搬运它的代码,那就必须整体转为 GPL-2.0 ——
请勿在未确认的情况下混入其源码片段。
其他
完整的第三方清单与许可要求见 THIRD_PARTY_NOTICES.md。
许可证
MIT,Copyright (c) 2026 LaoDing。第三方致谢见 THIRD_PARTY_NOTICES.md。