☰
1. 给 MosDNS 做了个实时统计面板:纯 Python 标准库、零依赖,能按设备看谁在查什么
2026/10/5 7:01:39 网站建设 项目流程

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

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

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

具体缺什么:

MosDNS 自带

缺什么

stats API 只有累加计数

没有明细、没有历史、重启就归零

日志里client恒为127.0.0.1

看不出是哪台设备查的(请求是本机 dnsmasq 转发进来的)

—

没有 MAC / 设备名

只有"现在"的数字

想看趋势?没有

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

所以就有了这个面板。


二、先看效果

📷【配图 1:主面板整页截图】图片文件:mosdns-dashboard-oss/docs/dashboard.png(1920×3662 整页) 在 CSDN 编辑器里直接把这张图拖进去就行

📷【配图 2:登录页】图片文件:mosdns-dashboard-oss/docs/login.png建议放在"HTTPS / 登录认证"那一节前面

说明:截图里的内网网段已替换成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(和 nginxssl_preread一个思路)

登录认证

PBKDF2 存密码,cookie 用 HMAC-SHA256 签名(HttpOnly / SameSite=Lax / Secure)

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

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


四、数据来源与架构

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

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


五、5 分钟跑起来

前置条件

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

安装

默认监听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 然后跳转)。但这样对使用者不友好——记错端口就访问不了。

所以做成了同一个端口:

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

自签证书:

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

📷【配图 2 建议放这里:登录页】


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

坑 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,我想给明文连接打个标记:

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

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

教训:重写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:

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

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

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

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

坑 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时面板对任何人开放。
  1. 不要把AUTH_LAN_FREE=1和反向代理一起用(见坑 6)。
  1. logs.db绝对不要提交到任何地方——里面是真实的域名查询历史。仓库的.gitignore已经排除它了。
  1. 面板本身不做访问控制之外的加固,暴露到公网请自己再加一层(防火墙 / Basic Auth / VPN)。

九、开源地址

平台

地址

GitHub

GitHub - 996kuku/mosdns-dashboard: Real-time DNS query dashboard for MosDNS on OpenWrt — per-device stats, upstream health, zero dependencies (pure Python 3 stdlib) · GitHub

Gitee(国内推荐)

https://gitee.com/yilunn996/mosdns-dashboard

AtomGit

mosdns-dashboard:MosDNS 统计面板:纯 Python 标准库实现,SSE 秒级推送,支持五区间统计、上游服务器表、设备级流量与域名反查。零依赖、单文件部署。 - AtomGit

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,或者评论区聊。觉得有用的话,去 Gitee / GitHub 点个 ⭐ 就是对我最大的鼓励。

本文同步发布于 997笔记(​​​​​​https://www.997wan.com)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询