dsh Web 局域网访问解密:从「0.0.0.0 被拒」到 TLS 反代打通全内网

DeepSeek Harness CLI(dsh)安全地开放给任意局域网机器访问的一次完整排障、配置与二次坑复盘。

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-connectionHost 头判定:

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.randomUUIDWeb Crypto API,且是 secure-context-only。它在浏览器端用于生成 RPC 请求 id(dsh-client-connection/lib/client.jsdsh-host-apiproxy/.../fetch/client.js 里每个 RPC 都 RpcId(crypto.randomUUID()))。
  • 浏览器判定「安全上下文」很严格:HTTPS,或 HTTP 且必须是 localhost/127.0.0.1。用局域网 IP(<LAN_IP>)走明文 HTTP 访问 → 浏览器是不安全上下文crypto.randomUUIDundefined → 一调用就抛错。
    同一浏览器、同一服务实测:
源站 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

关键实现细节(每一条都踩过/验证过):

  1. 必须透传原始 Host,不能改成 127.0.0.1 —— 否则浏览器 Origin=https://<LAN_IP>:3443 与后端看到的 Host 不一致 → /api 栅栏按跨站拒。透传后 Host=局域网 IP → 命中自动推导信任列表 → 放行。
  2. 必须转发 WebSocket Upgrade/api/events.mux/api/events.host 是 WS 下行通道,不转发实时事件就断。
  3. 自签证书: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.describePRIVILEGED_METHODS 的一员,在 Part I 就见过:这批方法在 /api 栅栏之上再硬编码一遍 loopback-onlyisTrustedApiRequest(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.describecredentials.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)

  1. agent = 远程代码执行。绑 0.0.0.0 后,凡能到达本机非回环地址的机器都能驱动 agent 在本机执行命令。这是头号暴露面。
  2. 完整模式下配置/密钥的 loopback-only 门被打破settings.describecredentials.describe 等特权端点在局域网放行(由用户拍板)。这是「完整模式」与「安全透传」的本质区别;选择安全透传则这些仍保持本机独占。
  3. 防火墙收窄(推荐)——既然反代已成唯一局域网入口,可把后端收紧回 127.0.0.1,只让反代端口暴露:
    ufw allow from 192.168.0.0/16 to any port 3443    # 只放内网到反代
    # 后端改回 127.0.0.1 可去掉局域网明文 HTTP 面
    
  4. 证书信任:自签证书用「接受一次告警」或「导入 CA」两种方式,都不影响 secure context 生效。
  5. 长期化:手写 Node 反代适用于验证/小型部署;长期可换 caddy(单文件配置、自动证书/WS),或把 Node 反代固化为 systemd --user 常驻服务。
  6. 用完即停,或把后端绑定到具体局域网 IP 而非 0.0.0.0 缩小暴露面。

复盘:几个值得记住的设计点

  1. 「安全拒绝」未必是死路:守卫实现常常只管某一个入口(这里命令行参数),配置/数据通路是另一条道。先读通数据流,再判断哪里是真闸门。
  2. 读上游注释resolveLanTrustPRIVILEGED_METHODScrypto.randomUUID is a Web API 的注释已经把设计意图写清楚 —— 它只是被一张「还没准备好」的守卫临时挡着。
  3. 别改 node_modules,用框架的扩展点--profile + cordis.patch.yml 分层合并在设计上就是给用户覆盖配置用的,持久、可评审、可回滚。
  4. 改动要连着看后果:让你「能访问」的改动(绑 0.0.0.0)会连带改变浏览器的安全上下文判定,进而触发「看起来跟插件无关」的新故障 —— 排查时优先怀疑自己上一次改动引入的变化。
  5. 安全是分层多道门,敲掉一道后面还有0.0.0.0 守卫 → secure-context 判定 → 特权 loopback-only。每前进一步往往会暴露下一道。敲掉每一道前都要重新评估剩下的暴露面,尤其是手工改写请求头的「伪装成本机」类技巧,它直接影响配置/凭据这类真敏感数据 —— 这类取舍务必显式拍板、并在运维里持续承担。
Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐