给 MosDNS 做了个实时统计面板:纯 Python 标准库、零依赖,能按设备看谁在查什么

AI 摘要997笔记AI

针对MosDNS统计只有累计计数、日志客户端恒为127.0.0.1的问题,该面板用SQLite保存历史趋势,通过AdGuard querylog反查真实IP,并用iKuai租约补齐MAC和设备名,支持SSE实时推送、上游统计、单端口HTTPS与认证。纯Python标准库、零依赖、只读路由器,部署简单。

此内容由AI根据文章内容自动生成。

一、先说痛点:MosDNS 的统计太”粗”了

家里软路由跑的是 OpenWrt + MosDNS(sbwml/luci-app-mosdns),分流、去广告都挺好,但它的统计能力只有两样:

  • 一个 stats API,返回的是从启动到现在的累加计数
  • 一份日志文件,看个大概

具体缺什么:

MosDNS 自带 缺什么
stats API 只有累加计数 没有明细、没有历史、重启就归零
日志里 client 恒为 127.0.0.1 看不出是哪台设备查的(请求是本机 dnsmasq 转发进来的)
— 没有 MAC / 设备名
只有”现在”的数字 想看趋势?没有

第 2 条最要命——家里十几台设备,我想知道”到底是谁在疯狂查某个域名”,MosDNS 告诉我”都是 127.0.0.1 查的”。

所以就有了这个面板。


二、先看效果

MosDNS 实时统计面板整页效果:运行概览 KPI、查询趋势、TOP50 域名榜与设备级明细

说明:截图里的内网网段已替换成 192.168.1.x 示例值,其余是真实运行数据。


三、它到底补上了什么

能力 实现方式
历史与趋势 SQLite 落库,支持五个区间:实时(MosDNS 累计)/ 今天 / 近 7 天 / 近 30 天 / 自建库以来
真实客户端 IP 用 AdGuard Home 的 querylog 按 (时间, 域名, 查询类型) 反查,实测命中率 约 98.7%
MAC / 设备名 拉 iKuai(爱快)的 DHCP 租约表补上,再叠加一个手写 devices.conf 兜底
实时推送 SSE 长连接,约 2 秒一帧,指纹变了才广播(没人看时自动降到 10 秒一轮,不空转)
上游服务器表 清单的权威来源是路由器上的 /var/etc/mosdns.json(forward 插件的 upstreams),所以配了但一次没用过的备用上游也会列出来,再叠加成功率 / 平均耗时 / 总查询数
按模块”清除重计” 可以只重置 TOP 榜、或只重置统计,互不影响
8088 单端口同时收 HTTPS 和 HTTP accept 后 peek 首字节判断,明文请求自动 301 跳 HTTPS(和 nginx ssl_preread 一个思路)
登录认证 PBKDF2 存密码,cookie 用 HMAC-SHA256 签名(HttpOnly / SameSite=Lax / Secure)

最核心的一点:纯 Python 3 标准库,零第三方依赖。 不用 pip install 任何东西,git clone 下来改两行配置就能跑。

⚠️ 强调一下边界:本面板不参与解析、不改路由器任何配置,它只是读 MosDNS 的统计接口,然后把数据攒进本地 SQLite 展示出来。


四、数据来源与架构

数据来源与架构(对路由器只读)
┌─────────────────────────┐
│  路由器(MosDNS)        │  stats_collector API 127.0.0.1:9091
│  + AdGuard Home          │  querylog → 反查真实客户端 IP
│  + iKuai(可选)         │  DHCP 租约 → MAC / 设备名
└───────────┬─────────────┘
            │ SSH(只读,专用 key)
            ▼
┌─────────────────────────┐
│  面板主机(Python 3)    │  SQLite 落库 + SSE 推送
│  监听 :8088 (HTTPS)      │
└─────────────────────────┘

面板对路由器只做只读操作:SSH 过去读一个 JSON、拉一次 querylog、查一次邻居表,不写任何东西。

开销实测:单次 SSH 拉日志 148ms + AGH querylog 202ms,5 秒采集间隔下,路由器侧每分钟多耗约 2.8 秒 CPU(占单核 13.9%)。路由器是 8 核、常态 load 0.07,余量充足。


五、5 分钟跑起来

前置条件

  1. Python 3.9+(无需 pip 安装任何包)
  2. 到路由器的 SSH 免密登录,建议单独生成一把只读用途的 key
  3. MosDNS 开启 stats_collector API —— luci-app-mosdns 默认就开了,监听 127.0.0.1:9091

安装

一键安装(Gitee)
# 1. 放代码
git clone https://gitee.com/yilunn996/mosdns-dashboard /opt/mosdns-dashboard
cd /opt/mosdns-dashboard

# 2. 建配置(四个 .example 复制过去即可,AGH / iKuai / devices 不配也能跑)
cp auth.conf.example    auth.conf    && chmod 600 auth.conf
cp agh.conf.example     agh.conf     && chmod 600 agh.conf      # 可选
cp ikuai.conf.example   ikuai.conf   && chmod 600 ikuai.conf    # 可选
cp devices.conf.example devices.conf                            # 可选

# 3. 设登录密码(会写入 auth.conf 的 AUTH_HASH / AUTH_SECRET)
./tools/dashboard-setpass

# 4. SSH key(面板要用它连路由器)
ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519_mosdns -N ''
ssh-copy-id -i /root/.ssh/id_ed25519_mosdns root@<路由器IP>

# 5. 装服务
cp deploy/mosdns-dashboard.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now mosdns-dashboard

默认监听 0.0.0.0:8088。没配证书时会自动回退成明文 HTTP(日志里会明确写一句)。

环境变量速查

变量 默认值 说明
DASH_HOST 0.0.0.0 监听地址
DASH_PORT 8088 监听端口
DASH_TLS_CERT /opt/mosdns-dashboard/ssl/server.pem TLS 证书链
DASH_TLS_KEY /opt/mosdns-dashboard/ssl/server.key TLS 私钥
MOSDNS_HOST 192.168.1.1 路由器 IP
MOSDNS_USER root SSH 用户名
MOSDNS_SSH_KEY /root/.ssh/id_ed25519_mosdns SSH 私钥路径
INGEST_INTERVAL 5 采集间隔(秒)
PUSH_INTERVAL 2 有人看时的 SSE 采样间隔(秒)
PUSH_IDLE_INTERVAL 10 没人看时的采样间隔(秒)
UP_CFG_TTL 60 上游清单配置缓存(秒)

配置文件速查

文件 必需 作用 缺失时
auth.conf 否(但强烈建议) 登录认证 不启用认证,任何人可看
agh.conf 否 反查真实客户端 IP 客户端列全是 127.0.0.1
ikuai.conf 否 MAC / 设备名 没有 MAC 列
devices.conf 否 IP → 设备名手写映射 无

六、HTTPS:单端口同时收 HTTPS 和 HTTP

这个设计我觉得挺值得说一下。

一般做法是开两个端口(比如 8088 收 HTTPS、8089 收 HTTP 然后跳转)。但这样对使用者不友好——记错端口就访问不了。

所以做成了同一个端口:

accept 之后 peek 首字节
# accept 之后 peek 首字节
first = conn.recv(1, MSG_PEEK)
if first == b'x16':        # 0x16 = TLS Handshake
    conn = ssl_ctx.wrap_socket(conn, server_side=True)
else:
    conn._plain_http = True  # 标记为明文,稍后回 301

直接访问 http://host:8088/ 也会被 301 带到 HTTPS。

自签证书(内网 HTTPS 全套做法见 PVE 内部 CA 教程):

生成自签证书(10 年)
mkdir -p /opt/mosdns-dashboard/ssl
openssl req -x509 -newkey rsa:2048 -nodes 
  -keyout /opt/mosdns-dashboard/ssl/server.key 
  -out    /opt/mosdns-dashboard/ssl/server.pem 
  -days 3650 -subj "/CN=mosdns-dashboard"

有正式证书(Let’s Encrypt 等)就用 DASH_TLS_CERT / DASH_TLS_KEY 指过去。


七、踩坑记录(这节是我最想写的)

坑 1:TLS 必须”按连接” wrap,不能包监听 socket

一开始我想省事,直接在监听 socket 上套一层 ssl_ctx.wrap_socket(listen_sock)。结果明文 HTTP 请求直接握手失败,浏览器直接报 SSL 协议错误(Firefox 是 SSL_ERROR_RX_RECORD_TOO_LONG)——压根不会给你跳转的机会。

正确做法是 accept 之后对那条连接单独 wrap。

坑 2:socket.socket 没有 __dict__,挂不上自定义属性

承接坑 1,我想给明文连接打个标记:

❌ 这样写会崩
conn._plain_http = True   # ❌ AttributeError: 'socket' object has no attribute '_plain_http'

Python 的 socket.socket 是 C 扩展类型,没有 __dict__。而且这不是”报个错就完事”——它发生在 accept 循环里,异常逃出去会让进程崩,systemd 再把它拉起来,然后再次崩,形成崩溃重启循环。

解法是包一层子类 + detach():

✅ 包一层子类再 detach
class _PlainSocket(socket.socket):
    pass

def accept():
    conn, addr = listen_sock.accept()
    family, typ, proto = conn.family, conn.type, conn.proto   # 必须在 detach 前取
    plain = _PlainSocket(family, typ, proto, fileno=conn.detach())
    return plain, addr

教训:重写 accept() 这类底层方法时,一定要确认异常不会逃出循环。

坑 3:实现 HTTP HEAD 不能”把 self.wfile 换成黑洞”

有个探测工具用 HEAD 请求探活,结果我的服务一直返回 501。补 HEAD 时我第一反应是:HEAD 不就是要”只回响应头不回 body”吗,那把 wfile 换成个黑洞对象不就行了?

大错特错。 Python 3 的 end_headers() 是把 header 缓冲写进 self.wfile 的,你把 wfile 换掉,连响应头一起吞了,客户端拿到的是 EOF。

现象特别迷惑人:curl 报 SSL unexpected eof,前面挂了 nginx 反代的话直接 502。

正确做法是加一个标记位,路由照常复用 do_GET()(这样 Content-Type / Content-Length 和 GET 完全一致),只是在 end_headers() 之后不写 body:

do_HEAD 正确写法
def do_HEAD(self):
    p = urlparse(self.path).path.rstrip("/") or "/"
    if p in self.HEAD_BLOCK:          # /api/stream、/api/clear、/api/logout
        self.send_response(405)        # 这三个有副作用,HEAD 直接拒
        self.send_header("Allow", "GET")
        ...
        return
    self._head_only = True
    try:
        self.do_GET()
    finally:
        self._head_only = False

顺带一提,测试 HEAD 要用 curl -I,用 curl -X HEAD 会报 unexpected eof——这是 curl 自己的行为(它去读 body),不是服务的问题。

坑 4:SSE 经过反向代理会被”憋住”

面板前面挂了反向代理(我这边是 nginx + frp 的链路),SSE 推不出去,前端一直等。

原因是 nginx 默认会缓冲响应。SSE 的响应必须显式关掉缓冲:

响应头加这一行
self.send_header("X-Accel-Buffering", "no")

坑 5:「清除重计」点了完全没反应

这是最难受的一个 bug。用户反馈”点了清除,数字跳一下又回去了”。

排查了半天,最后发现是两个出口口径不一致:

  • 推送线程算”数据指纹”用的是减过基线的值
  • 而 /api/stream 首帧取的是没减基线的原始数据

前端拿到归零数据、刚更新完界面,紧接着被下一帧原始累计值整个覆盖回去了。

教训:凡是给前端的快照出口,必须走同一套变换。光读代码发现不了——必须把两个出口的真实返回值拉出来对比。

坑 6:反向代理 +「内网免登录」= 对全网放行

面板有个 AUTH_LAN_FREE 选项,意思是”内网来的就不必登录”。

但”内网”是靠 socket 远端地址判定的。一旦前面挂了 nginx / frp,服务端看到的来源 IP 恒为 127.0.0.1——于是”内网免登录”变成了对全网免登录。

而面板里是什么?是你家所有设备的完整上网记录。

要按来源放行,必须先让代理层把真实 IP 传进来(proxy_protocol 或 X-Forwarded-For),并且验证过它真的生效再说。我这边就踩了这个坑,香港入口拿不到真实客户端 IP,最后干脆把 AUTH_LAN_FREE 关掉,内外网一律要登录。


八、部署到公网前必读的安全提醒

  1. 默认要登录。auth.conf 缺失或 AUTH_ENABLE != 1 时面板对任何人开放。
  2. 不要把 AUTH_LAN_FREE=1 和反向代理一起用(见坑 6)。
  3. logs.db 绝对不要提交到任何地方——里面是真实的域名查询历史。仓库的 .gitignore 已经排除它了。
  4. 面板本身不做访问控制之外的加固,暴露到公网请自己再加一层(防火墙 / Basic Auth / VPN)。

九、开源地址

MIT 协议,随便用、随便改。

几个可能对你有用的细节:

  • 零依赖:只用 Python 标准库,不用担心某天某个包不维护了
  • 网站图标不进仓库(版权归各自站点),部署后按需抓取:./tools/fetch-logos.py -n 200 -j 16,抓不到就退化成首字母色块,不影响使用
  • 改了路由器 MosDNS 配置,面板最多 60 秒就能看到(UP_CFG_TTL),不用重启

十、常见问题

Q:客户端那一列全是 127.0.0.1? MosDNS 日志里的 client 确实是本机(dnsmasq 转进来的)。要真实 IP 必须配 agh.conf,让面板用 AGH 的 querylog 去反查。

Q:上游服务器表里某个上游一直是 0? 清单来自路由器的 MosDNS 配置,所以”配了没用过”的上游也会列出来(这是故意的)。统计来自本地日志库,只有真的发出过查询才有数。

Q:环境里没有 AdGuard Home / 爱快能用吗? 能。这两个都是可选的,缺了只是拿不到真实客户端 IP 和 MAC / 设备名,核心统计照常。


最后

这个面板最初只是我为了解决”到底是谁在查这个域名”这个小问题写的,后来一点点加成了现在这样。

如果你也在用 MosDNS,欢迎试试;有问题直接提 Issue,或者评论区聊。想要从零手把手部署一份独立面板的,可以看姊妹篇《MosDNS 实时统计面板从零搭建》。觉得有用的话,去 Gitee / GitHub 点个 ⭐ 就是对我最大的鼓励。

上一篇 MosDNS 实时统计面板从零搭建:SSE 秒级推送 + 五区间统计 + 设备级流量
下一篇 用 Nginx 访问日志自建网站统计面板