dsh Web 局域网访问解密:从「0.0.0.0 被拒」到 TLS 反代打通全内网
dsh Web 局域网访问解密:从「0.0.0.0 被拒」到 TLS 反代打通全内网
DeepSeek Harness CLI(
dsh)安全地开放给任意局域网机器访问的一次完整排障、配置与二次坑复盘。
- 仓库地址:dsh-lan-access
Part I — 让 0.0.0.0 生效:配置层绕过守卫
1. 问题原因
运行 dsh web 想对外提供 Web UI 时,直接撞上硬错误:
$ dsh web --host 0.0.0.0
error: --host 0.0.0.0 is intentionally not supported yet for safety:
it would expose remote code execution to the network;
use 127.0.0.1 instead
dsh 是 DeepSeek 的 Harness CLI(版本 0.1.0-rc.6,npm 包 @deepseek-ai/dsh),把 web 当作 --profile web 的别名。其 Web 界面是能执行代码的 agent harness,所以开发者刻意禁掉了「绑定所有网卡」。但内网有同事/多台机器要访问,默认 127.0.0.1 只有本机能用。
目标:在不破坏安全设计的前提下,让任意局域网机器访问 Web UI。
2. 定位守卫代码
在安装包里找到报错魔数:
grep -rn "intentionally" node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-web-app/lib/
命中 dsh-web-app/lib/startup.js:
program.action(() => {
const options = program.opts();
if (options.host === "0.0.0.0")
program.error("error: --host 0.0.0.0 is intentionally not supported yet for safety: ...");
if (options.port !== void 0 && !/^\d+$/.test(options.port))
program.error(`error: --port must be a number, ...`);
ctx.provide(WEB_STARTUP_SERVICE, { ... });
});
一句话:守卫只检查「命令行参数」里有没有 0.0.0.0。这给配置层绕过留了门。
3. 关键发现:管道其实已为 0.0.0.0 铺好路
dsh-web-app/lib/index.js 里有个 resolveLanTrust(bindHost, extra):
function resolveLanTrust(bindHost, extra) {
const lanAddresses = bindHost === "0.0.0.0"
? Object.values(networkInterfaces()).flat()
.filter(iface => iface.family === "IPv4" && !iface.internal)
.map(iface => iface.address)
: [];
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] };
}
逻辑很妙:只要 webserver 绑定 0.0.0.0,就自动扫出本机所有非回环 IPv4,拼进 /api 信任栅栏。
- 不需要手工枚举每台机器;
- 局域网机器访问时
Host头 = 本机局域网 IP → 命中lanAddresses→ 放行。
整条链路上只有 startup.js 那张守卫是「最后一公里」拦路虎,其余全已实现。
3.1 安全栅栏(browser-trust fence)
dsh-client-connection 用 Host 头判定:
function isTrustedApiRequest(request, trustedHosts) {
const host = header(request.headers, "host");
const hostUrl = parseAuthority(host);
if (hostUrl === void 0) return false;
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
const origin = header(request.headers, "origin");
if (origin === void 0) return true;
return new URL(origin).host === hostUrl.host;
}
放行条件:Host 是回环 或 命中 trustedHosts;且浏览器标记同源。注释写得很克制:
Network reachability and authentication stay out of scope: binding policy belongs to the webserver config, and this fence is not an auth layer.
3.2 特权端点仍锁死回环
settings / credentials / 模型目录 / host.openPath / host.pickDirectory / agentPreset.remove 等硬编码要求回环(isTrustedApiRequest(request, [])),即使绑到局域网也读不到你的配置和密钥:
const PRIVILEGED_METHODS = new Set([
"agentPreset.remove", "host.pickDirectory", "host.openPath",
"settings.describe", "settings.openDocument", "settings.update",
"credentials.describe", // ...
]);
4. 解法推演
| 方案 | 可行性 | 结论 |
|---|---|---|
直接改 node_modules 里的 startup.js |
root 所有 + 无免密 sudo(EACCES);npm 重装即覆盖 |
❌ 不可行/不持久 |
dsh web --patch <file> 每次带补丁 |
可行但手动、易丢 | ⚠️ 仅临时验证 |
写个人 web profile 的 cordis.patch.yml |
框架设计的配置扩展点,随 ~/.dsh 持久 |
✅ 最终方案 |
dsh 配置分层合并,优先级自低向高:
内置 bundle 层(各 @deepseek-ai 包自带的 cordis.patch.yml)
↓ 按 id 覆盖
个人 profile 层(~/.dsh/profiles/web/cordis.patch.yml)
↓ 按 id 覆盖
--patch 命令行覆盖
且「补丁用新 config 整体替换目标行」;守卫只读命令行参数,不读配置 —— 两条数据通路互不相干,配置层驱动绑定既不触发守卫,也不削弱那段安全判断本身。
5. 正式配置
~/.dsh/profiles/web/cordis.patch.yml:
# ── LAN 服务 ──────────────────────────────────────────────────────────────
# 把 Web UI 绑到所有网卡,让任意局域网机器都能访问。
# 之后 dsh web 会在 http://<本机局域网IP>:3080 提供服务,/api 栅栏会自动
# 信任本机所有局域网 IP 字面量,局域网客户端即可驱动 agent。
#
# 安全注意:绑定 0.0.0.0 会把本机的 agent(远程代码执行)暴露给能到达本机
# 非回环地址的所有机器。settings / credentials / 模型目录 / 特权 host 端点
# 仍是 loopback-only,但 agent 本身已局域网可达。建议用防火墙收窄
# (如 ufw allow from <局域网网段> to any port 3080),用完即停。
- id: webserver
config:
host: 0.0.0.0
port: !!js ctx.webStartup.port ?? 3080
要点:host: 0.0.0.0 触发自动推导 LAN 信任;port 保留命令行覆盖能力;放在个人 profile 层随 ~/.dsh 持久。
6. 验证结果(实测)
| 请求 | Host 头 |
HTTP 状态 | 含义 |
|---|---|---|---|
GET / |
<LAN_IP>(局域网) |
200 | 首页正常 |
GET /api/foo |
<LAN_IP>(局域网) |
404 | 已穿栅栏,仅路由不存在 |
GET /api/foo |
8.8.8.8(未信任) |
403 | 被栅栏拦截 |
为什么「一份配置覆盖任意局域网机器」:局域网机器的浏览器访问 http://<LAN_IP>:3080,发出的 Host 头就是服务器自己的局域网 IP —— 所有内网机器共用一个 Host,只要该 IP 在 lanAddresses 里,整片内网一次性放行。
7. 使用方式
dsh web # 无额外 flag
# → dsh web: http://127.0.0.1:3080 (LAN: http://<LAN_IP>:3080)
dsh web --port 8080 # 换端口照旧
恢复默认(仅本机):把 cordis.patch.yml 改回空数组 []。
Part II — 第二个坑:明文 HTTP × 非回环 = 不安全上下文
8. 新症状
绑到 0.0.0.0 后局域网能打开了,但模型设置页报错:
加载提供方目录失败: crypto.randomUUID is not a function
表面怀疑是「npm 全局安装 vs npx 导致插件不全」——这个猜测不成立:
dsh web能启动、能 serve,说明整棵@deepseek-ai依赖树都解析成功;真有插件缺失,loader 会在启动时报Entry._init失败,根本起不来。- npm 全局装 与
npx @deepseek-ai/dsh只是包落盘位置不同,运行时解析同一份依赖树,插件配置来源一致。
9. 真正的根因(真实浏览器验证)
crypto.randomUUID是 Web Crypto API,且是 secure-context-only。它在浏览器端用于生成 RPC 请求 id(dsh-client-connection/lib/client.js、dsh-host-apiproxy/.../fetch/client.js里每个 RPC 都RpcId(crypto.randomUUID()))。- 浏览器判定「安全上下文」很严格:HTTPS,或 HTTP 且必须是 localhost/127.0.0.1。用局域网 IP(
<LAN_IP>)走明文 HTTP 访问 → 浏览器是不安全上下文 →crypto.randomUUID为undefined→ 一调用就抛错。
同一浏览器、同一服务实测:
| 源站 | isSecureContext |
typeof crypto.randomUUID |
|---|---|---|
http://127.0.0.1:3080 |
true | "function" ✓ |
http://<LAN_IP>:3080 |
false | "undefined" ✗ |
本质是 Part I 改动的连锁后果:绑 0.0.0.0 让非回环可访问,但浏览器对这些地址不再安全上下文,于是依赖 secure-context API 的功能全部挂 —— 不只是提供方目录,所有 RPC 都走 crypto.randomUUID 生成请求 id。
10. 解法:TLS 反代(纯内网)
核心就一条:让浏览器处于安全上下文 → 流量上 TLS。这里选的方案是纯局域网反代:
局域网机器 ──https──► TLS 反代 0.0.0.0:3443 ──http(原 Host 透传)──► dsh web 127.0.0.1:3080
关键实现细节(每一条都踩过/验证过):
- 必须透传原始
Host头,不能改成127.0.0.1—— 否则浏览器Origin=https://<LAN_IP>:3443与后端看到的 Host 不一致 →/api栅栏按跨站拒。透传后 Host=局域网 IP → 命中自动推导信任列表 → 放行。 - 必须转发 WebSocket
Upgrade:/api/events.mux、/api/events.host是 WS 下行通道,不转发实时事件就断。 - 自签证书:LAN IP 作 SAN,浏览器首次会警告「证书不受信任」,点「继续访问」或把证书装入信任库后即为安全上下文。
11. 验证结果(实测)
| 检测 | http://<LAN_IP>:3080(原,明文) |
https://<LAN_IP>:3443(反代+TLS) |
|---|---|---|
isSecureContext |
false | true |
crypto.randomUUID |
undefined | function ✓ |
GET / |
200 | 200 |
GET /api/foo |
404(栅栏放行,Host 透传正常) | |
未信任 Host /api/foo |
403(栅栏仍拦截) |
即使证书是自签的(浏览器接受后),isSecureContext 仍为 true、crypto.randomUUID 恢复 —— 纯内网方案 B 成立。
12. 落地文件
专门目录(本仓库):work/dsh-lan-access/
| 文件 | 作用 |
|---|---|
dsh-lan-tls-proxy.mjs |
自包含 TLS 反代:自动扫描局域网 IP 生成自签证书(SAN)、终止 TLS、默认「完整模式」(改写 Host→loopback + 剥 Origin,让特权端点在局域网可用)、转发 WebSocket Upgrade;TRUST_LOCAL=false 退回安全透传模式 |
systemd/dsh-web.service |
systemd --user 后端服务单元(绝对路径指到 nvm 的 dsh) |
systemd/dsh-lan-tls-proxy.service |
systemd --user 前端反代服务单元(依赖后端先起) |
systemd/install.sh |
一键安装:复制单元文件 + enable-linger + enable --now + 打印状态 |
systemd/uninstall.sh |
一键卸载:disable --now + 删除单元文件 + daemon-reload,零残留 |
dsh-lan-access-blog.md |
本文档 |
运行:
- dsh-lan-tls-proxy.mjs
node work/dsh-lan-access/dsh-lan-tls-proxy.mjs # 完整模式 :3443 -> :3080
node work/dsh-lan-access/dsh-lan-tls-proxy.mjs --port 4443 # 自定义前端端口
TRUST_LOCAL=false node work/dsh-lan-access/dsh-lan-tls-proxy.mjs # 安全透传模式
BACK_PORT=3090 FRONT_PORT=4443 node work/dsh-lan-access/dsh-lan-tls-proxy.mjs
- 局域网机器访问
https://<LAN_IP>:3443。证书信任两条路:一次性「继续访问」,或把cert.pem装进各机器信任库消除告警。
脚本首次运行会自动
openssl生成key.pem/cert.pem(SAN 覆盖 127.0.0.1、localhost、全部局域网 IP)。已存在则复用,不重复生成。
#!/usr/bin/env node
/**
* dsh LAN TLS reverse proxy — make the dsh Web UI usable from any LAN machine
* within a private network.
*
* Why this exists:
* The dsh Web UI (an agent harness = remote code execution) uses the Web
* Crypto API `crypto.randomUUID` to mint RPC request ids. That API is
* secure-context-only. Over plain HTTP to a non-loopback address (a LAN IP)
* the browser is an *insecure context*, so `crypto.randomUUID`
* is `undefined` and every RPC fails (surfacing as "加载提供方目录失败:
* crypto.randomUUID is not a function" in the models settings page).
*
* Fix within the LAN: terminate TLS in front of the backend so the browser
* is a secure context. This proxy does exactly that — HTTPS front on every
* interface -> plain-http backend on 127.0.0.1.
*
* FULL-ACCESS MODE (default): the proxy rewrites the request to look local so
* the /api fence passes for LAN clients:
* - Host -> 127.0.0.1 (the fence treats loopback as trusted)
* - strip Origin / Sec-Fetch-* headers (the browser's Origin
* (https://<lan-ip>:<port>) must NOT be forwarded, or the fence
* classifies the call as cross-site and 403s)
* SECURITY: this defeats the harness's loopback-only gate on PRIVILEGED
* methods (settings.describe, credentials.describe, ...). Those returns were
* deliberately kept loopback-only until real authentication exists. In full
* mode any machine that can reach this HTTPS port can read the deployment
* configuration and probe credential environment-variable names. Only use
* this on a trusted network, restrict with a firewall, and stop when done.
* Set TRUST_LOCAL=false to fall back to HOST-PASSTHROUGH (safe) mode, which
* keeps privileged endpoints loopback-only but breaks the models/provider
* page over the LAN.
*
* Requirements:
* - The dsh backend must accept the proxied connection on 127.0.0.1. Binding
* it to 0.0.0.0 (see ~/.dsh/profiles/web/cordis.patch.yml) works; binding
* it to 127.0.0.1 is the tighter choice since this proxy is the sole LAN
* entry point.
* - WebSocket upgrades MUST be forwarded: /api/events.mux and
* /api/events.host are the downlink event streams.
*
* Certificates:
* A self-signed cert (LAN IPs as Subject Alternative Names) is generated on
* first run. LAN browsers will show a one-time "untrusted" warning; click
* through, or install the CA/cert on each machine to remove the warning.
* Either way the connection is a secure context, so everything works.
*
* Usage:
* node dsh-lan-tls-proxy.mjs # full mode; :3443 -> :3080
* node dsh-lan-tls-proxy.mjs --port 4443 # custom front port
* TRUST_LOCAL=false node dsh-lan-tls-proxy.mjs # safe host-passthrough mode
* BACK_PORT=3090 FRONT_HOST=0.0.0.0 FRONT_PORT=4443 node dsh-lan-tls-proxy.mjs
*
* Environment:
* FRONT_HOST default 0.0.0.0 interface to bind the TLS listener on
* FRONT_PORT default 3443 TLS port LAN clients connect to
* BACK_HOST default 127.0.0.1 backend address
* BACK_PORT default 3080 backend dsh web port
*/
import { createServer } from "node:https";
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
import { request as httpRequest } from "node:http";
import { execFileSync } from "node:child_process";
import { networkInterfaces } from "node:os";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const FRONT_HOST = process.env.FRONT_HOST ?? "0.0.0.0";
const FRONT_PORT = Number(process.env.FRONT_PORT ?? 3443);
const BACK_HOST = process.env.BACK_HOST ?? "127.0.0.1";
const BACK_PORT = Number(process.env.BACK_PORT ?? 3080);
/** Full-access mode (default): rewrite Host->loopback + strip Origin so privileged endpoints work over the LAN. Set false for safe host-passthrough. */
const TRUST_LOCAL = process.env.TRUST_LOCAL !== "false";
const CERT_DIR = process.env.CERT_DIR ?? dirname(fileURLToPath(import.meta.url));
const KEY = join(CERT_DIR, "key.pem");
const CERT = join(CERT_DIR, "cert.pem");
/** Every LAN-reachable IPv4 literal (non-internal, non-loopback) -> SAN entries. */
function lanSANs() {
const ips = [];
for (const ifaces of Object.values(networkInterfaces())) {
for (const i of ifaces ?? []) {
if (i.family === "IPv4" && !i.internal) ips.push(i.address);
}
}
return ips;
}
function ensureCert() {
if (existsSync(KEY) && existsSync(CERT)) return;
mkdirSync(CERT_DIR, { recursive: true });
const ips = lanSANs();
const san = ["IP:127.0.0.1", "DNS:localhost", ...ips.map((ip) => `IP:${ip}`)].join(",");
// Temp-key generation: request a short-lived key for the signing step but keep
// the serving key durable. Use a single persistent key for serving.
execFileSync("openssl", [
"req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", KEY, "-out", CERT, "-days", "365",
"-subj", `/CN=${ips[0] ?? "localhost"}`,
"-addext", `subjectAltName=${san}`,
], { stdio: "ignore" });
console.log(`generated self-signed cert: ${CERT} (SAN: ${san})`);
}
ensureCert();
const tlsOpts = { key: readFileSync(KEY), cert: readFileSync(CERT) };
/**
* Build the backend request headers.
* - Full mode: rewrite Host to loopback and strip Origin / Sec-Fetch-* so the
* /api fence sees a local, same-origin-less request (privileged endpoints pass).
* - Safe mode: pass the original Host through (privileged endpoints stay
* loopback-only and 403 from the LAN).
*/
function backHeaders(req) {
const h = { ...req.headers, host: "127.0.0.1" };
if (TRUST_LOCAL) {
delete h.origin;
delete h["sec-fetch-site"];
delete h["sec-fetch-mode"];
delete h["sec-fetch-dest"];
delete h["sec-fetch-user"];
} else {
h.host = req.headers.host;
}
return h;
}
const server = createServer(tlsOpts, (req, res) => {
const proxy = httpRequest({
host: BACK_HOST,
port: BACK_PORT,
method: req.method,
path: req.url,
headers: backHeaders(req),
}, (pres) => {
res.writeHead(pres.statusCode, pres.headers);
pres.pipe(res);
});
proxy.on("error", (e) => { try { res.writeHead(502); res.end("proxy error: " + e.message); } catch {} });
req.pipe(proxy);
});
server.on("upgrade", (req, socket, head) => {
const proxy = httpRequest({
host: BACK_HOST, port: BACK_PORT,
method: req.method, path: req.url,
headers: backHeaders(req),
});
proxy.on("upgrade", (pres, psocket, phead) => {
try {
socket.write("HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\nConnection: Upgrade\r\n" +
`Sec-WebSocket-Accept: ${pres.headers["sec-websocket-accept"]}\r\n\r\n`);
psocket.write(phead);
psocket.pipe(socket); socket.pipe(psocket);
} catch { try { psocket.destroy(); } catch {} try { socket.destroy(); } catch {} }
});
proxy.on("error", () => { try { socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); } catch {} });
socket.pipe(proxy); proxy.write(head);
});
server.listen(FRONT_PORT, FRONT_HOST, () => {
console.log(`dsh LAN TLS proxy: https://${FRONT_HOST}:${FRONT_PORT} -> http://${BACK_HOST}:${BACK_PORT} [${TRUST_LOCAL ? "FULL-ACCESS: Host->loopback, privileged endpoints reachable from LAN" : "SAFE: host passthrough, privileged endpoints loopback-only"}]`);
console.log(`LAN clients open https://<this-host-lan-ip>:${FRONT_PORT} (Cert SANs: ${lanSANs().join(", ") || "none"})`);
});
Part III — 第三个坑:特权端点的 loopback-only 门
13. 新症状
HTTPS + 安全上下文的问题解决后,模型设置页又报新错:
加载提供方目录失败: transport failure for /api/settings.describe: HTTP 403
14. 根因:这不是「插件/提供方目录」自己的错
settings.describe 是 PRIVILEGED_METHODS 的一员,在 Part I 就见过:这批方法在 /api 栅栏之上再硬编码一遍 loopback-only(isTrustedApiRequest(request, []))。即使走 HTTPS 反代、Host 透传成局域网 IP,栅栏仍判它非回环 → 403。
实测三种来源:
| 请求路径 | Host |
结果 |
|---|---|---|
局域网透传(https://<LAN_IP>:3443) |
<LAN_IP> | 403 |
| 本机直连 | 127.0.0.1 | 415(已过特权门,仅缺请求体) |
反代改写 Host→127.0.0.1 + 剥 Origin |
127.0.0.1 | 200 |
「加载提供方目录」其实只是调用 settings.describe 来读当前模型/提供方配置,而它是特权方法 —— 所以这条错误是设计使然,不是配置缺失。
15. 解法:完整模式
-
若坚持局域网也能用提供方目录,唯一办法是让反代把请求打扮成本机来源:改写
Host → 127.0.0.1(栅栏视回环为信任)+ 剥掉Origin/Sec-Fetch-*(否则浏览器Origin=https://<lan-ip>:<port>与后端看到的Host不一致 → 按跨站 403)。 -
但是这会打破 dsh 的安全设计——
settings.describe、credentials.describe本意是「配置/密钥只对本机开放,等真正的认证层出现」。改写在局域网放开后,settings/credentials描述接口也向局域网客户端开放了(至少可读配置、探测凭据环境变量名)。这是由用户拍板的安全取舍(本文档对应实践选了「完整模式」)。
反代脚本实现在两者间切换:
function backHeaders(req) {
const h = { ...req.headers, host: "127.0.0.1" };
if (TRUST_LOCAL) { // 完整模式(默认)
delete h.origin; delete h["sec-fetch-site"];
delete h["sec-fetch-mode"]; delete h["sec-fetch-dest"]; delete h["sec-fetch-user"];
} else {
h.host = req.headers.host; // 安全透传模式
}
return h;
}
16. 验证(实测)
| 检测 | 安全透传(3443 你那份) | 完整模式(3444) |
|---|---|---|
GET / |
200 | 200 |
POST /api/settings.describe |
403 | 200 ✓ |
WS /api/events.mux 握手 |
101 | 101 ✓ |
完整模式下提供方目录所需的 settings.describe 已放行,报错解决;WebSocket 下行通道(events.mux/events.host)在完整模式下依然 101 正常。
Part IV — 固化为 systemd --user 常驻服务
17. 为什么会想到它 & 它解决什么
- 手动方式要分别起前端+后端两个进程,机器重启/进程崩溃/终端关掉都得手工再来。systemd
--user是「登录用户自己的常驻进程管理器」,不用 root,把前后端各声明成服务单元后它负责:开机/登录自启、崩溃自动重启、依赖编排、统一查状态。 - 安装/删除都很轻:装 = 复制单元文件 +
enable --now;删 =disable --now+ 删文件,零残留。配置就两个 ~10 行 INI 文件,已在此写好。
18. 单元文件内容
systemd/dsh-web.service(后端):
# dsh Web 后端 —— systemd --user 常驻服务(模板)
# 使用前请替换占位符:
# <NODE_BIN> = 你的 node 可执行目录(含 dsh 软链),例如
# /usr/local/nvm/versions/node/v22.17.0/bin
[Unit]
Description=dsh Web backend (server)
After=network-online.target
[Service]
Type=simple
# 关键:`dsh` 是 shebang `#!/usr/bin/env node` 的软链,systemd 不加载终端里的
# PATH,必须显式把 node 的 bin 放进 PATH,否则后端因找不到 node 退出 127。
Environment=PATH=<NODE_BIN>:/usr/local/bin:/usr/bin:/bin
ExecStart=<NODE_BIN>/dsh web
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
systemd/dsh-lan-tls-proxy.service(前端反代):
# dsh LAN TLS 反代 —— systemd --user 常驻服务(完整模式,模板)
# 依赖 dsh-web.service,保证后端先起。
# 使用前请替换占位符:
# <NODE_BIN> = 你的 node 可执行目录
# <PROXY_MJS> = 本仓库 dsh-lan-tls-proxy.mjs 的绝对路径
[Unit]
Description=dsh LAN TLS reverse proxy (full-access mode)
After=dsh-web.service
Requires=dsh-web.service
[Service]
Type=simple
ExecStart=<NODE_BIN>/node <PROXY_MJS>
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
要点:
ExecStart必须用绝对路径(nvm 的dsh/node),systemd 不加载终端里的 nvm PATH;- 后端必须补
Environment=PATH=…(含 nvm bin):dsh软链的 shebang 是#!/usr/bin/env node,systemd 服务环境 PATH 为空时找不到node→ 后端以 127(command not found) 退出。这是本次部署实测踩到的坑; - 前端单元
After+Requires后端,保证「后端先起」; Restart=on-failure崩溃自动拉起。
install.sh完整内容
#!/usr/bin/env bash
# 安装 dsh Web 后端 + LAN TLS 反代 为 systemd --user 常驻服务
set -euo pipefail
UIDR="$HOME/.config/systemd/user"
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "==> 复制单元文件到 $UIDR"
mkdir -p "$UIDR"
cp "$SRC/dsh-web.service" "$SRC/dsh-lan-tls-proxy.service" "$UIDR/"
echo "==> 生效开机自启(linger:无图形/SSH 会话也在开机拉起)"
loginctl enable-linger
echo "==> 注册并启动"
systemctl --user daemon-reload
systemctl --user enable --now dsh-web dsh-lan-tls-proxy
sleep 2
echo
echo "==> 状态"
systemctl --user status dsh-web dsh-lan-tls-proxy --no-pager || true
echo
echo "局域网访问: https://<本机局域网IP>:3443 (反代绑 0.0.0.0:3443 -> 127.0.0.1:3080)"
uninstall.sh
#!/usr/bin/env bash
# 卸载 dsh Web 后端 + LAN TLS 反代 的 systemd 服务(零残留)
set -euo pipefail
echo "==> 停止并取消自启"
systemctl --user disable --now dsh-lan-tls-proxy dsh-web || true
echo "==> 删除单元文件"
rm -f "$HOME/.config/systemd/user/dsh-web.service" \
"$HOME/.config/systemd/user/dsh-lan-tls-proxy.service"
echo "==> 刷新"
systemctl --user daemon-reload
echo "==> 已删除这两个服务。"
echo " 如需一并撤销「开机自启(linger)」(影响所有 --user 服务,可选):"
echo " loginctl disable-linger"
19. 安装(完整命令)
# 前提:先停掉手动起的后端(避免 3080 冲突)
pkill -f 'dsh web' # 停手动实例;确认无重要会话再执行
# 方式一:一键脚本
~/work/dsh-lan-access/systemd/install.sh
# 方式二:手动手把手(等同上面脚本)
mkdir -p ~/.config/systemd/user
cp ~/work/dsh-lan-access/systemd/dsh-web.service \
~/work/dsh-lan-access/systemd/dsh-lan-tls-proxy.service \
~/.config/systemd/user/
loginctl enable-linger # 开机自启(无需登录会话也拉起)
systemctl --user daemon-reload
systemctl --user enable --now dsh-web dsh-lan-tls-proxy
systemctl --user status dsh-web dsh-lan-tls-proxy --no-pager
loginctl enable-linger:让 --user 服务即使没有图形/SSH 登录会话也随开机拉起(常驻的关键);反向撤销loginctl disable-linger。- 装完局域网机器访问
https://<LAN_IP>:3443。
20. 日常管理命令
systemctl --user status dsh-web dsh-lan-tls-proxy # 查状态
systemctl --user restart dsh-web dsh-lan-tls-proxy # 重启
systemctl --user stop dsh-web dsh-lan-tls-proxy # 停(临时)
systemctl --user start dsh-web dsh-lan-tls-proxy # 再起
journalctl --user -u dsh-web -f # 看后端日志
journalctl --user -u dsh-lan-tls-proxy -f # 看反代日志
21. 卸载(完整命令,零残留)
# 方式一:一键脚本(推荐,等价下面的手动手把手)
~/work/dsh-lan-access/systemd/uninstall.sh
# 方式二:手动手把手
systemctl --user disable --now dsh-lan-tls-proxy dsh-web # 停止 + 取消自启
rm -f ~/.config/systemd/user/dsh-web.service \
~/.config/systemd/user/dsh-lan-tls-proxy.service # 删单元文件
systemctl --user daemon-reload # 刷新
# (可选) 撤销开机自启 —— 影响所有 --user 服务,只在要彻底恢复时才做
loginctl disable-linger
卸载不影响 /tmp、node_modules、dsh 配置或目录下的脚本/证书;进程、自启、单元文件全部移除,随时可回到手动启动或重新安装。
安全边界与运维建议(综合 Part I + II + III)
- agent = 远程代码执行。绑
0.0.0.0后,凡能到达本机非回环地址的机器都能驱动 agent 在本机执行命令。这是头号暴露面。 - 完整模式下配置/密钥的 loopback-only 门被打破:
settings.describe、credentials.describe等特权端点在局域网放行(由用户拍板)。这是「完整模式」与「安全透传」的本质区别;选择安全透传则这些仍保持本机独占。 - 防火墙收窄(推荐)——既然反代已成唯一局域网入口,可把后端收紧回
127.0.0.1,只让反代端口暴露:ufw allow from 192.168.0.0/16 to any port 3443 # 只放内网到反代 # 后端改回 127.0.0.1 可去掉局域网明文 HTTP 面 - 证书信任:自签证书用「接受一次告警」或「导入 CA」两种方式,都不影响 secure context 生效。
- 长期化:手写 Node 反代适用于验证/小型部署;长期可换 caddy(单文件配置、自动证书/WS),或把 Node 反代固化为
systemd --user常驻服务。 - 用完即停,或把后端绑定到具体局域网 IP 而非
0.0.0.0缩小暴露面。
复盘:几个值得记住的设计点
- 「安全拒绝」未必是死路:守卫实现常常只管某一个入口(这里命令行参数),配置/数据通路是另一条道。先读通数据流,再判断哪里是真闸门。
- 读上游注释:
resolveLanTrust、PRIVILEGED_METHODS、crypto.randomUUID is a Web API的注释已经把设计意图写清楚 —— 它只是被一张「还没准备好」的守卫临时挡着。 - 别改 node_modules,用框架的扩展点:
--profile+cordis.patch.yml分层合并在设计上就是给用户覆盖配置用的,持久、可评审、可回滚。 - 改动要连着看后果:让你「能访问」的改动(绑
0.0.0.0)会连带改变浏览器的安全上下文判定,进而触发「看起来跟插件无关」的新故障 —— 排查时优先怀疑自己上一次改动引入的变化。 - 安全是分层多道门,敲掉一道后面还有:
0.0.0.0守卫 → secure-context 判定 → 特权 loopback-only。每前进一步往往会暴露下一道。敲掉每一道前都要重新评估剩下的暴露面,尤其是手工改写请求头的「伪装成本机」类技巧,它直接影响配置/凭据这类真敏感数据 —— 这类取舍务必显式拍板、并在运维里持续承担。
更多推荐

所有评论(0)