针对MosDNS统计只有累计计数、日志客户端恒为127.0.0.1的问题,该面板用SQLite保存历史趋势,通过AdGuard querylog反查真实IP,并用iKuai租约补齐MAC和设备名,支持SSE实时推送、上游统计、单端口HTTPS与认证。纯Python标准库、零依赖、只读路由器,部署简单。
一、先说痛点:MosDNS 的统计太”粗”了
家里软路由跑的是 OpenWrt + MosDNS(sbwml/luci-app-mosdns),分流、去广告都挺好,但它的统计能力只有两样:
- 一个
statsAPI,返回的是从启动到现在的累加计数 - 一份日志文件,看个大概
具体缺什么:
| MosDNS 自带 | 缺什么 |
|---|---|
| stats API 只有累加计数 | 没有明细、没有历史、重启就归零 |
日志里 client 恒为 127.0.0.1 |
看不出是哪台设备查的(请求是本机 dnsmasq 转发进来的) |
| — | 没有 MAC / 设备名 |
| 只有”现在”的数字 | 想看趋势?没有 |
第 2 条最要命——家里十几台设备,我想知道”到底是谁在疯狂查某个域名”,MosDNS 告诉我”都是 127.0.0.1 查的”。
所以就有了这个面板。
二、先看效果

说明:截图里的内网网段已替换成
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 分钟跑起来
前置条件
- Python 3.9+(无需 pip 安装任何包)
- 到路由器的 SSH 免密登录,建议单独生成一把只读用途的 key
- MosDNS 开启 stats_collector API ——
luci-app-mosdns默认就开了,监听127.0.0.1:9091
安装
# 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 首字节
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 教程):
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():
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:
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 关掉,内外网一律要登录。
八、部署到公网前必读的安全提醒
- 默认要登录。
auth.conf缺失或AUTH_ENABLE != 1时面板对任何人开放。 - 不要把
AUTH_LAN_FREE=1和反向代理一起用(见坑 6)。 logs.db绝对不要提交到任何地方——里面是真实的域名查询历史。仓库的.gitignore已经排除它了。- 面板本身不做访问控制之外的加固,暴露到公网请自己再加一层(防火墙 / Basic Auth / VPN)。
九、开源地址
| 平台 | 地址 |
|---|---|
| GitHub | github.com/996kuku/mosdns-dashboard |
| Gitee(国内推荐) | https://gitee.com/yilunn996/mosdns-dashboard |
| AtomGit | atomgit.com/G_CKI/mosdns-dashboard |
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 做了个实时统计面板:纯 Python 标准库、零依赖,能按设备看谁在查什么
- 文章永久网址:https://www.997wan.com/mosdns-realtime-dashboard/
- 本网站的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系站长 997笔记 进行删除处理。
- 本站一切资源不代表本站立场,并不代表本站赞同其观点和对其真实性负责。
- 本站一律禁止以任何方式发布或转载任何违法的相关信息,访客发现请向站长举报
- 本站资源大多存储在云盘,如发现链接失效,请联系我们我们会第一时间更新。
- 如无特别声明本文即为原创文章仅代表个人观点,版权归《997笔记》所有,欢迎转载,转载请保留原文链接。