家庭服务器上的服务,大多只监听私有地址。这是好事:公网只留一个入口,服务不直接暴露。但同一个局域网里的设备也会被挡在外面——浏览器连 LAN_IP:PORT,内核找不到对应的 listener,直接拒绝。
公网入口当然能用,只是每个请求都要绕一圈:浏览器 → Cloudflare → Tunnel → VPS Nginx → WireGuard → 家用主机。对普通网页还能接受,对 dsh、Paseo 这种带长连接和流式输出的界面,延迟和抖动会非常明显。lan-bridge 就是为这个场景加的一层入口:只服务局域网,不改业务绑定,也不重启业务服务。
1. 问题:服务在私网,客户端在局域网
服务按“最小暴露”原则只绑私有地址。公网请求由 VPS 的 Nginx 经 WireGuard 转发进来,路径是通的;但同一个局域网里的笔记本、平板、手机直连主机的 LAN 地址时,目标端口上没有 listener,连接会被拒绝。
绕公网也不是不行,只是体验差:
- 每个请求都要经过 Cloudflare 边缘、Tunnel、VPS、WireGuard,往返次数多;
- 公网链路受运营商和隧道状态影响,延迟不稳定;
- dsh 的 WebSocket、Paseo 的流式输出会把这种抖动放大成“打字有延迟、滚动卡顿”。
所以需求很明确:在同一局域网内,用稳定的名字和原端口直连服务,不经过公网。
2. 约束:为什么不能直接改绑定
最直接的做法是让 dsh、Paseo 改绑 LAN 地址,但这条路走不通:
| 约束 | 后果 |
|---|---|
dsh / Paseo 的 --host 只接受一个具体地址,拒绝通配 | 不能同时绑 LAN 和 WireGuard 地址 |
家用宽带可能分配公网 IPv6,0.0.0.0 的暴露面不可控 | 不能图省事绑通配 |
| 家用路由器通常没有自定义 DNS / hosts | 不能靠改域名解析实现“同域名、局域网走直连” |
| 业务服务已经按私网地址部署,公网路径依赖它 | 不能为了局域网重绑所有服务、重启一遍 |
还有一个隐含约束:局域网入口不能成为新的公网入口。它只能监听 LAN 地址,不能监听 0.0.0.0,也不能监听 IPv6。
3. 设计:一层只服务局域网的 TCP 入口
方案是在业务服务前面加一层只监听 LAN 地址的 TCP 入口:
局域网浏览器 http://homebox.local:PORT │ ▼ lan-bridge(LAN_IP:PORT) │ TCP 原样转发 ▼ WG_IP:PORT(本机回环,不进隧道) │ ▼ dsh / Paseo / Immich / ...实现上是一层 nginx stream:
- 监听
${LAN_IP}:${PORT},地址由启动脚本写入环境文件,容器启动时渲染模板; - 转发到
WG_IP:${PORT},也就是主机自己的私有地址,端口不变; - 容器使用
network_mode: host,因为它必须精确绑当前 LAN 地址。Docker 的端口发布默认会绑0.0.0.0,还可能带 IPv6,这正是要避免的; - 只监听 IPv4 的 LAN 地址,不监听通配,不监听 IPv6。
公网路径保持不变:
浏览器 → Cloudflare → Tunnel → VPS Nginx → WireGuard → WG_IP:PORT → 服务两条路径在主机上汇合到同一个服务监听地址。业务服务不需要为两条路径做区分,它只看到发往自己监听地址的连接。
4. 三个关键事实
这个设计能成立,靠的是三个经常被忽略的细节。
4.1 私有地址是主机自己的地址
VPS 和家用主机在 WireGuard 私网里各有一个地址。主机访问自己的私网地址时,内核按本地投递处理,不会把包交给 wg0:
ip route get WG_IP# local WG_IP dev lo src WG_IP所以 lan-bridge → WG_IP 这一跳是回环,不加密、不出网卡、不到 VPS。只有 VPS 主动访问这个地址时,才真正走隧道。局域网直连之所以快,不只是因为少绕了公网,也因为这一跳根本没有出主机。
4.2 同一个端口绑不同 IP 不冲突
Linux 按“目的 IP + 目的端口”匹配 listener:
| 监听 | 结果 |
|---|---|
业务服务:WG_IP:PORT | 占用一个组合 |
lan-bridge:LAN_IP:PORT | 另一个组合,可以共存 |
任何进程:0.0.0.0:PORT | 与上面两个都重叠,冲突 |
因此 lan-bridge 可以保持原端口,业务服务不用改端口、不用重启。反过来说,如果 bridge 图省事绑了 0.0.0.0,就会和业务服务冲突,还会把端口暴露到所有网卡上——这也是它必须绑具体地址的原因。
4.3 mDNS 提供稳定名字
主机通过 avahi 发布 mDNS 名字,比如 homebox.local。这个解析不经过路由器 DNS,而是局域网组播:
- 换网后 IP 变了,名字不变,客户端重新解析即可;
- 不需要在每台设备写 hosts,也不需要买域名;
- 少数公司或公共 Wi-Fi 会屏蔽组播,这种网络下 mDNS 不可用,需要换方案。
mDNS 解决的是“名字稳定”,不解决“地址稳定”。地址变了,bridge 还是要重新绑,这就是下一节的对账。
5. 两条路径的对比
| 维度 | 局域网直连 | 公网入口 |
|---|---|---|
| 路径 | mDNS → lan-bridge → 本机回环 | Cloudflare → Tunnel → VPS → WireGuard |
| 延迟 | 毫秒级 | 数百毫秒级 |
| 传输 | 明文 HTTP | 边缘 TLS |
| 边缘认证 | 无(绕过 Access) | 按服务配置 Access + 应用认证 |
| 应用认证 | 必须保留 | 必须保留 |
| 依赖 | 局域网、mDNS、主机 | Cloudflare、Tunnel、VPS、WireGuard |
两条路径不是互相替代,而是分别覆盖“在家里”和“在外面”。公网入口负责可达性,局域网入口负责体验;安全责任都落回各服务自己。
6. 换网之后:地址对账
lan-bridge 绑的是“当前 LAN 地址”,不是固定 IP。换网、搬家、路由器重新分配地址之后,需要重新绑。
这个动作交给一个幂等脚本:
- 从默认路由取源地址:
ip -4 route get 1.1.1.1,取输出里的src; - 校验:必须是 IPv4,拒绝
0.0.0.0、127/8和 WireGuard 网段; - 和运行中容器里的
LAN_IP比较; - 相同且容器在运行 → 直接退出,不动 Docker;
- 不同 → 写
.env(权限 600)→ 只重建 lan-bridge 容器; - 等待端口可连接后退出。
再配一个每分钟触发一次的 systemd user timer,网络变化后最迟一分钟自动恢复。只有 lan-bridge 会重建,dsh、Paseo、Immich 这些业务服务不受影响。即使重建失败,影响的也只是局域网入口,公网路径仍然是好的。
UFW 按来源网段放行。换网段后要单独更新一次防火墙规则;这一步需要 sudo,不会由 timer 自动完成。手动指定地址也很简单:
# 显式指定时,把地址换成当前 LAN 地址LAN_IP=192.0.2.10 ./scripts/lan-bridge-up.sh7. 为什么用 stream 而不是 http
用 nginx 的 stream 而不是 http 反代,是为了“什么都不改”:
- TCP 字节原样转发,浏览器发的
Host头原样到达后端; - WebSocket 的
Upgrade自动透传,不需要额外配置; - 上传大小、流式输出不受 HTTP 层
client_max_body_size、缓冲和超时的影响; - 新增服务时只需要加一个
server块,不需要为每个服务调一遍代理参数。
代价是失去了 HTTP 层的认证、限流和路由能力。这些必须由服务自己负责;日志也只能记录 TCP 级的来源、端口、时长和字节数。对“只服务可信局域网”的定位来说,这个交换是合理的。
8. 安全边界
lan-bridge 的便利性来自“信任局域网”,所以边界必须说清楚:
- 只监听 LAN IPv4。 不做路由器端口转发,不把入口开到公网;公网仍然走 Cloudflare + Access。
- 传输是明文 HTTP。 Cookie、口令在局域网内可能被嗅探;只用于自己信任的网络。
- Access 不生效。 局域网直连不经过 Cloudflare,认证完全由各服务自己负责。dsh、Paseo 这种能执行 shell 的界面,尤其不能开到不可信网络。
- Host 白名单要同步。 dsh 的
/api有 Host/Origin 白名单,必须把 mDNS 名字加进去;用 IP 访问 API 可能返回 403。Paseo 的hostnames只写主机名,端口会被去掉。 - Cookie 作用域要检查。 同一主机不同端口共享 Cookie 作用域;新增服务时要注意
Set-Cookie名字是否冲突。 - 日志也是数据。 访问日志包含来源 IP、端口和时长,按敏感数据保护,不要随手贴出去。
如果当前网络不可信(公司 Wi-Fi、公共热点),不要启用这个入口。用 VPN 或公网入口更合适。
9. 不选其他方案的原因
| 方案 | 问题 |
|---|---|
每个服务改绑 ${LAN_IP} 并重启 | 改一处不够;换网要重启所有服务 |
| 业务服务直接改绑 LAN 地址 | dsh / Paseo 只能绑一个地址,公网路径会断 |
全部绑 0.0.0.0 | 与现有绑定冲突;公网 IPv6 暴露面不可控 |
| 本地 DNS + HTTPS 同域名 | 体验最好,但家用路由器固件通常不支持自定义 DNS |
| iptables DNAT 到私有地址 | 能做,但 SNAT 和回程规则难排查,不如显式入口直观 |
这些方案不是“不能用”,而是都不满足“不改业务、不重启、不暴露公网”这三个条件。
10. 故障排查与回滚
| 现象 | 原因 | 处理 |
|---|---|---|
| 502 | 请求走了系统或浏览器代理,代理不认识 .local | 加 --noproxy '*';检查 NO_PROXY 和浏览器代理忽略列表 |
| ping 通但连接超时/被拒 | UFW 仍只放行 WireGuard 来源 | 跑一次防火墙脚本;看 /var/log/ufw.log 里的 [UFW BLOCK] |
| 页面能开但 API 403 | 用 IP 访问 dsh,不在 Host 白名单 | 改用 mDNS 名字;或把该名字加进 --trusted-host |
| 连接被拒绝 | lan-bridge 还绑在旧地址 | 重跑对账脚本 |
| 容器起不来 | 新地址还没分配到网卡 | 等网络就绪,timer 会在下一分钟重试 |
| 名字解析不了 | 当前网络屏蔽 mDNS | 换网络;非 dsh 服务可临时用 IP |
验证时看三件事:
# 两个地址应该同时存在ss -ltn | grep ':PORT '
# 从局域网客户端验证,显式绕过代理curl --noproxy '*' -4 http://homebox.local:PORT/
# 证明局域网请求没有进隧道sudo tcpdump -ni wg0 port PORT回滚也很简单:停掉 timer 和 lan-bridge 容器,移除 dsh / Paseo 白名单里的 mDNS 名字即可。公网路径和业务容器都不受影响。
11. 小结
lan-bridge 的核心不是“再加一个 nginx”,而是把**“从哪来”和“到哪去”分开**:
- 业务服务继续只绑私网地址,不为局域网改变自己的暴露面;
- lan-bridge 只服务局域网,按当前 LAN 地址和原端口转发;
- 公网入口保持原样,继续由 Cloudflare、VPS 和 WireGuard 负责。
它的“特化”体现在几个很具体的选择上:只绑一个具体地址、只监听当前 LAN IPv4、保持原端口、用 TCP 透传而不是 HTTP 解析、用幂等脚本和 timer 做地址对账。代价是明文传输和绕过 Access,所以它只适用于可信局域网。
这套设计不追求“一套入口解决所有场景”。相反,它承认公网和局域网是两种不同的访问需求,然后用两层入口分别满足,边界反而更清楚。