多源聚合与本地数据主权:一个自托管的家庭网络看板

作者:

在

🇬🇧 English

现代家庭平均拥有 15–30 台联网设备——手机、电脑、智能音箱、摄像头、电视盒子、IoT 传感器。然而绝大多数家庭用户根本不知道谁真正在线,流量从哪条链路出去,以及某个陌生 IP 究竟属于哪台设备。

如果把一台路由器拆到只剩一张设备列表页面,它给出的信息通常只有 IP 和 MAC。把数据交给第三方云服务,则需要支付隐私税。把 Home Assistant 搬进家里,又意味着一套完整的硬件堆栈和持续维护的成本。

把这三条路都走完一遍之后,一个清晰的结论浮现出来:真正的痛点不是"没有工具",而是现有工具的聚合维度与隐私边界恰好错位。 有人用路由器自带界面,有人用 cloud-based 监控,有人硬上 Home Assistant——各自都有未被解决的盲区。lume-lan 的做法是把这三个盲区的缝隙填起来:多源聚合、自托管静态二进制、本地数据主权,用一条管线解决同一个问题。


现有方案的三个痛点

路由器 Web 界面:能看,但只能看一层

家用路由器的 Web 管理界面几乎是每个家庭用户的入口。它提供的能力非常固定——列出当前在线设备(IP / MAC / 信号强度),偶尔显示一下网关的 WAN 状态。功能扁平,没有任何历史趋势,也无法跨设备做关联分析。

它给出的只是一张"设备信息卡"式的静态快照,而真正有价值的是带时间序列的流量聚合视图(traffic trend)。路由器原生的 Web 界面只覆盖了前者——要呈现趋势,还得额外采集累计流量、做差分计算。

云监控方案:便捷,但数据出了家门

把网络数据交给第三方云服务是最省心的路径。打开 App 就能看到设备列表和流量曲线,随时随地可查。代价是:数据离开了本地网络,上传到了服务商的服务器。对于重视数据主权的用户来说,这是一笔必须支付的风险溢价。

这一取向也写进了项目本身:.env 里 HTPASSWD_FILE 的用意,就是让认证凭据只留在本地——从头就把"认证与数据隔离"当作一等公民,而不是事后补的补丁。

Home Assistant 等重量级方案:功能完整,门槛不低

Home Assistant、OpenWrt 插件、Netdata 之类的方案在功能上确实全面。但它们需要一个常驻的宿主系统(树莓派、NAS 或专用服务器),配置链路长,组件依赖复杂。对于只想看清"谁连了网、流量去向何方"的用户来说,这套方案的投入产出比未必划得来——除非你本来就有一台常驻主机,也愿意长期维护它。

lume-lan 的定位恰恰相反:它是一个 ~1.7 MB 的静态二进制——在 lume-core 的语言与运行时之上叠加 agent-httpd 与原生 SQLite,让同一个可执行文件既能服务静态站点和 JSON API,也能跑聊天与 agent。

不到两兆的可执行文件,覆盖路由器 API、DNS 日志、ARP 快照三个数据源,以容器形式部署,不需要 Node.js 运行时,不需要编译流水线——这正是"轻量级"的定义。

需要区分的是:~1.7 MB 指的是服务端产物——单个 C11 静态二进制。完整应用还包括宿主上的三个 Python 采集脚本、launchd 定时任务、Docker 运行环境,以及可选的路由器与 AdGuard Home 服务。实际部署成本取决于宿主机上已经具备哪些依赖(Python 3、Docker、系统 arp),而不是这个二进制的体积。


lume-lan 的不同思路

把三个痛点对齐之后,lume-lan 的架构选择就变得顺理成章。

多源聚合,而非单一接口

单一数据源是上述三个痛点共同的根源。路由器 API 看不到 DNS 查询,DNS 日志看不到流量趋势,ARP 表看不到设备厂商信息。把三者关联起来,能拼出一张比任何单一来源都更丰富的局域网视图;但三个来源的观测范围并不相同——ARP 只看邻居表、路由器 API 依赖具体型号、DNS 只覆盖走该解析器的流量——聚合结果仍然存在盲区,不等于一张「完整」的局域网地图。

三个脚本各司其职:

scripts/scan_lan.py        → arp -a 快照 → .data/devices.json
scripts/router_fetch.py    → 路由器会话 → .data/router.json
scripts/adguard_fetch.py   → DNS 查询   → .data/dns.json

scan_lan.py 的核心逻辑是从系统路由表提取网关 IP,再用 arp -a 解析当前 ARP 表,过滤掉 169.254 link-local、广播 MAC、虚拟接口(Docker bridge、VPN tun 等)的噪声条目,最终写入 .data/devices.json:

def is_noise(ip: str, mac: str, iface: str = "") -> bool:
    if ip.startswith("169.254."):
        return True
    mm = mac.lower()
    if mm in ("", "ff:ff:ff:ff:ff:ff", "00:00:00:00:00:00"):
        return True
    if iface and iface.startswith("bridge"):
        return True
    ...

router_fetch.py 逆向了 HiLink CAT 路由器的登录协议——免密获取设备型号和 SN(/api/system/deviceinfo),再通过 SHA256 路径完成登录,最后拉取 HostInfo 拿到设备列表和累计流量:

class CatSession:
    def __init__(self, base: str):
        self.base = base
        self.cookies = {}

    def _req(self, method: str, path: str, body: bytes = None,
             timeout: int = 15) -> tuple:
        url = self.base + path
        req = urllib.request.Request(
            url, data=body, method=method,
            headers={
                "Content-Type": "application/json; charset=utf-8",
                "_ResponseFormat": "JSON",
                "X-Requested-With": "XMLHttpRequest",
                ...
            },
        )
        ...

adguard_fetch.py 负责读取 AdGuard Home 的 DNS 查询日志(新版 API 路径为 /control/querylog),按客户端 IP 聚合近 1 小时内的域名访问,输出 Top10 排行和每台设备的拦截统计:

WIN = 3600          # 「近 1h」聚合窗口(秒)
TOPD = 15           # 每客户端域名 Top N
TOTAL_TOP = 10      # 全家域名排行 Top N

自托管静态二进制:无需运行时,容器内只服务不构建

架构上的分工划分得很清楚:

┌─ Host (macOS) ───────────────────────────────────────────┐
│  launchd 60s refresh loop                                │
│   scripts/scan_lan.py        arp -a → .data/devices.json │
│   scripts/router_fetch.py    router session → .data/router.json │
│   scripts/adguard_fetch.py   DNS queries → .data/dns.json │
│  (arp scanning needs the host NIC — host only, not container) │
└───────────────────┬───────────────────────────────────────┘
                    │ .data/ snapshots mounted into the container (ro)
┌─ Docker container (lume-lan, port 10091) ─────────────────┐
│  Lume server (lan.lume, binds 0.0.0.0:8091, docroot www)  │
│   /api/devices  /api/router  /api/dns … (reads .data)     │
└──────────────────────────────────────────────────────────┘

宿主机负责数据采集,容器只负责读取快照并对外提供 HTTP API。Dockerfile 中没有 Node.js、没有 build step、没有 npm install,只有一个已编译好的 C11 静态二进制。容器侧的部署复杂度因此被压得很低;不过完整链路仍依赖宿主上已经跑通的采集脚本与定时任务(launchd),二者缺一不可。

本地数据主权:.data/ 完全本地,.env gitignored

安全设计贯穿了整个架构。.data/ 目录存放所有快照数据,.env 存放路由器密码和 AdGuard 凭证,两者都在 .gitignore 中:

.data/aliases.json      # local naming layer ({ip: name}), gitignored
.runtime/htpasswd       # Basic Auth 密码文件, gitignored

HTPASSWD_FILE 的设计意图很明确——Basic Auth 的密码存储在本地,不与路由器密码混用,也不传入环境变量明文。

不过要说明的是:.data/ 本地保存,只意味着数据默认不主动外传,并不等于它绝不会离开这台机器。一旦接入外部 DNS 上游、云端 LLM 或远程访问服务,数据就有了对外的流向,需要逐项确认。

auth-pass:
    @mkdir -p .runtime
    @if [ -z "$(PASSWORD)" ]; then \
      PASS="$$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 20)"; \
    else PASS="$(PASSWORD)"; fi; \
    /usr/sbin/htpasswd -c -b -B -C 12 .runtime/htpasswd admin "$$PASS" && \
    chmod 600 .runtime/htpasswd

架构对比下的工程取舍

把架构讲清楚之后,真正有价值的是理解每一项选择背后的权衡。

数据源的独立性

三个脚本之间没有任何依赖关系。scan_lan.py 失败不会影响 router_fetch.py,adguard_fetch.py 挂了也不会让看板直接崩溃——它自己的降级逻辑清楚地体现了这一点:

"""AdGuard 未运行/不通: 保留旧快照并标记 reachable=false(UI 显示「未连接」), 不崩。"""

dns_watch.py 还有一个独特的设计:它会把已发现客户端写入 .runtime/dns_watch_seen.json,重启后不会重复打印,避免噪声:

SEEN = ROOT / ".runtime" / "dns_watch_seen.json"
prev = {}
if SEEN.exists():
    try:
        prev = json.loads(SEEN.read_text())
    except Exception:
        prev = {}

这本质上是一个快照隔离机制——每个数据源都有自己的生命周期,互不干扰。

流量差分的边界

设备的 delta_1h(以及趋势曲线)来自路由器累计计数器的两点之差,这里有两个边界值得说清楚,且一个是已实现、一个是已知缺口:

  • 计数器重置 / 路由器重启(已处理):差值为负时取 max(0, …) 收敛为 0(scripts/router_fetch.py 的 apply_traffic_deltas),所以不会画出负流量或反向跳变。代价是:重置发生后、基线点跨过重置时刻之前(约 10 分钟),该设备的增量会被暂时低估为 0。
  • 设备身份变化(尚未实现):流量历史按 IP 索引。一旦 DHCP 重新分配 IP,同一个 IP 下的新旧设备计数器会被混算,产生偏差。当前版本没有按 MAC 做身份绑定,这是已知的边界条件。

(另外,在路由器登录冷却 / stale 态下,脚本不往趋势历史追加旧累计值,避免出现假的"流量=0"平线。)

部署边界

架构图清楚地展示了两个层次:

  • 宿主机:跑脚本,拿权限,扫 ARP
  • 容器:只读挂载 .data/,出 HTTP API,不涉及任何系统调用

scan_lan.py 中有一个细节值得注意——虚拟接口过滤:

def _is_virtual_iface(name: str) -> bool:
    n = (name or "").lower()
    return n.startswith(("utun", "tun", "tap", "ppp", "ipsec", "awdl",
                         "bridge", "docker", "veth", "wg", "tailscale"))

这是为了应对 macOS 上 Docker 和 VPN 会瞬时接管默认路由的场景——如果不加过滤,网关会被错误地识别为 74.125.250.129 这类虚拟接口地址,整个扫描网段都会跑偏。

安全现状:已做的措施与剩余风险

整套方案做了几层安全措施,但它们各自只解决对应层次的问题,并不等于整体安全:

  1. 网络层:ADGUARD_HOST=127.0.0.1,AdGuard 只监听本机,不从外部暴露
  2. 应用层:路由器 API 通过 Cookie 会话保护,登录态与 ARP 快照完全隔离
  3. 服务层:Lume server 的 Basic Auth 使用本地 htpasswd 文件,密码不与路由器凭证复用

需要明确的是剩余风险:

  • Basic Auth 不是完整的安全边界。它只负责认证,不加密信道;若通过明文 HTTP 在不可信网络中访问,凭据仍有被截获的风险。公网部署还需要 HTTPS、防火墙与访问控制。
  • 只读挂载不是容器没有其他风险。.data/ 以 :ro 挂载能降低数据被篡改的风险,但不解决服务暴露面、依赖漏洞或进程权限问题。
  • 本地存储不等于数据绝不外传(见上文)——.data/ 默认不外传,但接入外部服务后需重新评估数据流向。
Authentication: Basic Auth + local htpasswd + router password isolation

一句话概括:这些措施降低了风险,但把"安全设计"写成"当前的安全措施与剩余风险",比直接宣称"安全"更贴近实际。


各自的适用场景

回到最初的问题:有没有一种轻量、本地、能聚合多源数据的方案?答案取决于用户的需求层次。

路由器 Web 界面适合一个场景:你只想知道"谁连了网"。这是一个一次性问题的解决方式,信息维度单一,但部署成本为零。

云监控方案适合另一个场景:你愿意用隐私换便捷。手机 App 随时可查,多地点访问,维护省心,但数据始终不在自己手里。

lume-lan 对应的是第三个场景:重视数据主权、需要多维度监控的进阶用户。它不是一个"最好"的方案,而是一个"最贴合某些约束条件"的方案——数据不出门、部署不复杂、信息覆盖面超过单一接口。

三层价值:从交付物到语言验证,再到未来方向

把它的价值分三层来看,比把它当成"某个路由器的家庭面板"更接近实际:

  • 当前交付:家庭网络数据聚合与本地看板——ARP + 路由器 + DNS 三源关联,容器内自托管、数据留在本地。
  • Lume 的验证价值:用自建的 .lume DSL 与轻量服务端承载一个真实应用——它有真实的操作系统依赖(arp / ping / 路由表)、网络数据采集与安全边界,而不是一门语言的 Demo。这也是它能反过来检验运行时能力的原因。
  • 未来方向(尚未实现):设备发现、局域网内消息投递、Agent 之间的协作——这是与"观测现有网络"不同的层次,需要单独设计身份认证、配对授权与消息协议。当前项目只做观测与聚合,不具备设备间通信能力。

快速上手与验证

不必只看架构图,下面是最短的跑通路径,以及可以直接核对的输出(详见仓库 README)。

启动:

make auth-pass                 # 设置看板 Basic Auth 密码 → .runtime/htpasswd
make docker-build              # 首次:构建镜像(宿主预下载 Lume 静态二进制到 vendor/)
make docker-up                 # → http://localhost:10091/
make scan && make router-fetch # 手动补一轮采集(日常由 launchd 每 60s 自动跑)

多源关联:同一台设备在三个数据源里以 IP 为键被拼到一起(下列 IP/MAC 已脱敏)。

.data/devices.json——宿主 ARP 快照,回答"谁在线":

{ "ip": "192.168.1.42", "mac": "aa:bb:cc:dd:ee:42", "iface": "en0", "live": true }

.data/router.json——路由器登录态,回答"它是什么、跑了多少流量":

{ "ip": "192.168.1.42", "hostname": "LivingRoom-TV", "vendor": "Xiaomi",
  "online": true, "freq": "5GHz", "rssi": -58, "delta_1h": 734003200 }

.data/dns.json——AdGuard 近 1h、按客户端 IP 聚合,回答"它访问了什么":

"192.168.1.42": { "total": 214, "blocked": 12,
                  "top": [ { "domain": "example-cdn.com", "count": 41 } ] }

前端按 IP 合并三者:ARP 给出在线存在,路由器补上设备名/厂商/流量/信号,DNS 补上域名访问——三者都指向 192.168.1.42 时,这台设备才从"一个陌生 IP"变成可读的一张卡片。

降级可验证:停掉 AdGuard 后,adguard_fetch.py 不崩——它在快照上标记 reachable=false,附上 error 与 stale_since,并把上一轮的 clients / top_domains 原样保留(不新建空对象);界面据此把"域名访问"显示为"未连接",其余数据源照常刷新:

{ "reachable": false, "error": "URLError: ...", "stale_since": 1792331063 }

这条路径正对应上文"数据源的独立性"——任一来源断开都只影响它自己那一块,看板不会整体崩溃。


源码导航

源码已开源,默认分支 main:https://github.com/erishen/lume-lan


lume-lan 的"lume"是什么?它和流行的静态网站生成器 lume 是什么关系?

lume-lan 使用的 lume 是自建的 C11 Web 引擎(.lume DSL + HTTP/SSE/SSR + SQLite),与 Deno 静态站生成器 lumeland/lume 以及 CSS3D 工具库 lume/lume 完全无关,命名碰撞纯属巧合。

为什么 ARP 扫描必须跑在宿主机,不能放进容器?

ARP 表是操作系统网络栈的状态,容器通过 NAT 桥接上网时拿不到宿主机的物理网卡 ARP 信息。scan_lan.py 需要通过 arp -a 和 ifconfig 直接与宿主机网络栈交互,因此必须在外层运行。

路由器密码泄露的风险如何控制?

.env 中的 ROUTER_USER 和 ROUTER_PASS 被 gitignore 排除,不会进入版本控制;容器内的 .runtime/htpasswd 存储 Web UI 的 Basic Auth 密码,与路由器凭证完全隔离,即使 Web 界面被未授权访问也不会导致路由器后台泄露。

AdGuard Home 没有安装或 API 不通时会怎样?

adguard_fetch.py 会将 reachable 标记为 false 并保留上次快照,前端显示”未连接”状态,不会中断其他数据源的采集,也不会导致服务崩溃。

LAN_SWEEP=1 的作用是什么?与默认模式有什么区别?

默认模式只解析 arp -a 的现成表,速度快但只能看到近期通信过的设备;开启 LAN_SWEEP=1 后会在网关所在 /24 网段对 253 个 IP 并发 ping,能捞出”在线但 ARP 表里还没”的设备,速度较慢(约 10–30 秒)。

.data/aliases.json 和路由器里的设备名有什么优先级关系?

显示名称优先级为:本地别名(.data/aliases.json,无需登录)> 路由器备注(编辑客户端管理中的 ActualName)> 设备自报名(HostName)> 厂商信息。前两层来自认证的路由器会话,本地别名层无需登录,适合路由器从未设置名称的情况。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号