feat: 发布 0.6.21,社区共享库改为项目自带社区主站,修复 0.6.20 共享不可用;自检补 IPv6 / FastTrack

- 社区主站(SHARE_HUB=true),默认地址 https://rosdpi.suyijun.top:8443;实例只需出站访问,不需要公网 IP
- 令牌注册、按来源网络(IPv4 /24、IPv6 /56 加盐哈希)计票,≥2 个网络一致且领先才发布
- ETag/304、本地缓存、限频、压缩炸弹防护;主站投票进程内计入,不再经 NAT 回环
- 修复 0.6.20:部署量接口 500、共享任务从未运行、开关保存不上;删除 WebDAV / 旧中转代码
- 接入自检:缺 IPv6 嗅探规则提示并给命令;FastTrack 已限定 connection-bytes 时不再误报
- 重算历史分类请求超时放宽到 10 分钟;新增 GET 接口烟雾测试与主站端到端测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Su YijunandClaude Opus 5.5 committed 2026-10-25 17:30:26 +08:00
1 parent f16448d4cb
commit b0fcb7d20e
22 files changed
+1054 -403

No files matched your search

+6 -5
View File
@@ -75,8 +75,9 @@ LAN_CIDRS=192.168.88.0/24
# RETENTION_EVENT_DAYS=180 # 审计事件、操作日志
# 以上留存天数也可以在网页 系统设置 → 日志与留存 里改,网页优先
# ---------- 社区共享库(debug16) ----------
# 把本网络实测的「远端 IP → 应用」匿名投票上报给社区中转,并拉取社区库;只含远端 IP 和应用 ID,
# 不含本机终端、域名记录、自定义应用,私网地址剔除。总开关在网页 系统设置 → 外部数据源(默认打开)。
# SHARE_URL=https://<社区中转地址> # 留空不参与;中转可以是任何一个开启了「共享中转」的实例
# SHARE_TOKEN= # 中转设置了 SHARE_TOKEN 时才需要
# ---------- 社区共享库 ----------
# 把本网络实测的「远端 IP / 域名 → 应用」匿名投票汇总到项目自带的社区主站,并拉取社区库;不含本机终端、
# 访问记录、自定义应用,私网地址剔除。开关在网页 系统设置 → 外部数据源(默认打开)。
# SHARE_HUB_URL=https://rosdpi.suyijun.top:8443 # 社区主站(内置,一般不用改);自建私有社区时改成自己的主站
# SHARE_HUB=false # 只有主站自己设为 true
# SHARE_VERIFY_TLS=true # 私有主站用自签证书时才设 false
+25
View File
@@ -1,5 +1,30 @@
# Changelog
## [0.6.21] - 2026-10-25
社区共享库重做:项目自带社区主站,不再依赖 WebDAV / 第三方存储(debug13~22 复查)
- **修复 0.6.20 的共享整体不可用**:
- 设置页打开就报 500:部署量接口调用了已删除的函数;
- 共享任务一次都没跑过:调用了已删除的 `sharing_on`,启动时报错被吞掉;
- 共享开关保存不上:前端没把开关发给后端。
- **社区主站**(debug22 改为项目内实现):主站和普通实例是同一套程序,主站部署设 `SHARE_HUB=true`,地址内置为 `https://rosdpi.suyijun.top:8443`(`SHARE_HUB_URL` 可改成自建的私有社区)。
- 普通实例每 30 分钟心跳、每 6 小时附带一次匿名投票,拉社区库时没变化主站回 304;社区库缓存在本地,主站暂时不可达时继续可用。
- 主站自己的投票在进程内计入,不再通过公网地址访问自己(旧中转在 NAT 回环不通时永远同步不上)。
- 设置页只保留“参与社区共享”开关,显示主站地址、连接状态和失败原因、社区库规模、全球已部署 / 在线 / 离线;主站还显示来源网络数,反向代理没传客户端 IP 时提示。
- **防冒充、防刷票**:
- 每个实例注册时领取令牌,别人无法冒充它或覆盖它的投票;
- 按**来源网络**计票(IPv4 /24、IPv6 /56,只存加盐哈希、不存来源 IP),至少 2 个不同网络的实例投给同一应用、并且票数领先才发布——同一网络伪造再多实例也只算一票;
- 同一网络每天限注册 5 个实例,投票每小时最多收一次,请求体和解压后大小都有上限(防压缩炸弹);
- 应用白名单、域名格式、私网地址剔除照旧;主站证书恢复校验(`SHARE_VERIFY_TLS`,私有社区用自签证书时才关)。
- **不需要公网 IP**:心跳、上报、拉库都是实例主动连主站。主站目前只有 IPv6 地址:Linux 一键安装直接可用;Docker 默认网桥没有 IPv6 出口,`docker-compose.yml` 末尾给了开启方法(Docker 27+),连不上时设置页直接提示原因。
- 删除 WebDAV 共享和 0.6.15~0.6.19 的中转代码。旧中转留下的无令牌心跳不计入部署量,各实例升级后会重新注册。
- **接入自检**:
- 路由器有 IPv6 公网地址、嗅探规则却只有 IPv4 的一份时提示,并给出 `/ipv6 firewall mangle` 的 4 条命令;
- 读取 `/ip/firewall/filter`,FastTrack 规则已经限定 connection-bytes 时不再误报,没限定时直接给出修改命令。主站点实测:FastTrack 开着、SNI 只解析到几十个握手,“国内网络”占 21%,主要原因就在这里。
- 重算历史分类的请求超时从 20 秒放宽到 10 分钟(审计报告 P1-3:实测 80 秒,前端会误报失败)。
- 新增接口烟雾测试(所有无路径参数的 GET 接口都不能返回 500),以及主站 / 客户端端到端测试。
## [0.6.20] - 2026-10-25
社区共享库改用 WebDAV(NAS 自带),取消中转模式(debug22)
+4 -1
View File
@@ -36,6 +36,7 @@ services:
## What's new
- **0.6.21**: 社区共享库重做——项目自带社区主站,不再需要 WebDAV / 第三方存储:令牌注册、按来源网络计票(≥2 个不同网络一致才发布,防伪造实例刷票)、ETag 增量拉取、本地缓存;修复 0.6.20 部署量接口 500、共享任务从未运行、开关保存不上。接入自检新增 IPv6 嗅探规则检查,FastTrack 已限定 connection-bytes 时不再误报,并直接给出修改命令。Community library rebuilt around a built-in hub (no WebDAV/third-party storage): per-instance tokens, votes counted per source network, ETag pulls, local cache; fixes the 0.6.20 deployments 500 and the never-running share job. Self-check now flags missing IPv6 sniff rules.
- **0.6.12 / 0.6.11 / 0.6.10 / 0.6.9 / 0.6.8**: 自学习与共享系列。**本地自学习库**:本网络实测的「IP → 应用」(IPv6 按 /64 前缀聚合)与「域名 → 应用」长期记忆,每小时重建,DNS 缓存过期、DoH、裸 IP 流量都能按记忆归类;**社区共享库**:各部署实例把实测结论匿名共享(中转聚合、至少 2 个实例见过才发布),本地实测优先、社区只补空白;**全球部署量**:实例心跳上报,中转公开已部署 / 在线 / 离线数量;共享总开关默认打开;**一键自更新**:Linux 二进制部署在网页上检测并一键升级(自动下载、替换、重启服务);**Linux 单文件二进制部署**:`install.sh` 一键安装 systemd 服务(SQLite 零配置起步);修复落库卡死(代理长域名撑爆 access_log.remote 列导致每分钟落库失败、在线终端归零);组播 / 广播流量不再计入统计;17 个 DPI 大类配色互异。
- **0.6.7**: fixes for the 0.6.5 / 0.6.6 history reclassification and rule auto-registration. Reclassification now only touches days before today and only upgrades vague rows (HTTPS / Chinese websites / unknown / CDN) to a specific app — it no longer downgrades apps identified live by IP range, port or ASN. New upstream blackmatrix7 directories are downloaded first and then assigned by a vote of the existing libraries. The manual dark theme now uses the 17 distinct group colors, and the AdGuard Home password is masked in the operation log.
- **0.6.6**: AdGuard Home's newer query-log format (`{"data": [...]}`) is now read (0.6.5 always got 0 answers); new upstream rule directories / lists are registered automatically; history reclassification looks up the DNS cache for IP-only rows and runs automatically after library updates.
@@ -119,7 +120,9 @@ Open `http://<host>:8080`. The setup wizard has two steps. 打开网页后按两
| `ADMIN_PASSWORD` | empty | initial password for `admin` |
| `ROS_URL` / `ROS_USER` / `ROS_PASSWORD` | empty | RouterOS REST + read-only user |
| `SINGBOX_URL` / `MOSDNS_URL` / `ADGUARD_URL` | empty | external sources (Clash API, MosDNS audit, AdGuard Home query log) |
| `SHARE_URL` / `SHARE_TOKEN` | empty | community library relay (anonymous IP→app / domain→app votes) |
| `SHARE_HUB_URL` | `https://rosdpi.suyijun.top:8443` | community hub (built in); point it at your own hub for a private community |
| `SHARE_HUB` | `false` | run this instance as the community hub (only the hub sets `true`) |
| `SHARE_VERIFY_TLS` | `true` | verify the hub certificate; set `false` only for a private hub with a self-signed cert |
| `TZ` | `Asia/Shanghai` | statistics time zone (weeks start Monday) |
Full list: https://github.com/suyijun8182/ros-dpi#环境变量
+6 -5
View File
@@ -42,7 +42,7 @@ RouterOS ◀─REST 只读(可选)── 主机名、IP↔MAC、WAN 口速
- **域名 → 应用规则库,四层叠加**:
1. 公开库:内置手工规则库 + [v2fly](https://github.com/v2fly/domain-list-community)(约 2500 应用)+ [blackmatrix7](https://github.com/blackmatrix7/ios_rule_script)(自动登记上游新目录)+ Hagezi / UT1 分类列表 + 国内域名列表 / 官方 IP 段 / 国内 IP 段兜底;
2. **本地自学习库**:本网络实测的「IP → 应用」与「域名 → 应用」长期记忆——DNS 缓存过期、DoH、裸 IP 流量都能按记忆归类,每小时自动重建;
3. **社区共享库**:各部署实例把实测结论匿名共享(至少 2 个实例见过才发布),本地没有结论时兜底;
3. **社区共享库**:各部署实例把实测结论匿名汇总到项目自带的社区主站(至少 2 个不同网络的实例一致才发布),本地没有结论时兜底;
4. 以上都没有时,才归到 HTTPS / 国内网络这类笼统分类。
- 终端用 MAC 区分,IP 变化不影响统计。
@@ -186,7 +186,7 @@ EOF
1. **公开库**(自动更新):内置手工库 + v2fly(每天自检更新)+ blackmatrix7(每周,上游新目录自动登记:先下载、再由现有识别库投票定归属)+ Hagezi / UT1 分类列表 + ASN(iptoasn)+ 国内域名列表 / 官方 IP 段 / 国内 IP 段兜底。
2. **本地自学习库**:本网络实测的「IP → 应用」(IPv6 按 /64 前缀聚合)与「域名 → 应用」长期记忆,每小时从数据库重建;DNS 缓存过期、DoH、裸 IP 流量都能按记忆归类。每次历史重算自动纠正它。
3. **社区共享库**:各部署实例把实测结论匿名共享——只含远端 IP / 域名、应用 ID 和字节票数,不含本机终端、域名记录与自定义应用,私网地址剔除;中转聚合**至少 2 个实例见过**的映射才发布。系统设置 → 外部数据源 填中转地址即参与(总开关默认打开),也可把自己的实例开成中转(公网可达)。中转的 `/api/share/deployments` 公开显示全球已部署 / 在线 / 离线数量。
3. **社区共享库**:项目自带社区主站,不依赖任何第三方存储。各实例每 30 分钟向主站心跳、每 6 小时上报一次匿名投票——只含远端 IP / 域名、应用 ID 和字节票数,不含本机终端、访问记录与自定义应用,私网地址剔除;主站按**来源网络**计票(IPv4 /24、IPv6 /56,只存加盐哈希),**至少 2 个不同网络的实例投给同一应用且票数领先**才发布,一个人伪造多个实例也只算一票;每个实例有主站发的令牌,别人无法冒充。社区库缓存在本地,主站暂时不可达时继续可用。各实例**不需要公网 IP**(都是主动连主站),但要能访问主站:主站目前只有 IPv6 地址,Linux 一键安装直接用主机网络即可;Docker 默认网桥没有 IPv6 出口,需按 `docker-compose.yml` 末尾的说明开启(Docker 27+)。系统设置 → 外部数据源 可看连接状态、社区库规模与全球已部署 / 在线 / 离线数量;开关默认打开,关闭后不再上报也不再使用社区库。自建私有社区:一台实例设 `SHARE_HUB=true`,其它实例把 `SHARE_HUB_URL` 指向它(主站要能被各实例访问,建议 HTTPS)。
4. 以上都没有时,才归到 HTTPS / 国内网络这类笼统分类。
识别库有变化或过了零点,自动把今天以前的历史流量按新规则重算(只升级不降级);识别规则页也可手动执行。世界地图使用 [Natural Earth](https://www.naturalearthdata.com/)(公有领域)简化数据。
@@ -204,8 +204,9 @@ EOF
| `SINGBOX_URL` / `SINGBOX_SECRET` / `PROXY_IPS` | 空 | 旁路代理 sing-box 的 Clash API(也可在网页填写,网页优先) |
| `MOSDNS_URL` | 空 | MosDNS 审计 API 地址 |
| `ADGUARD_URL` / `ADGUARD_USERNAME` / `ADGUARD_PASSWORD` | 空 | 自有 DNS 服务器 AdGuard Home 的查询日志(双层 NAT 场景) |
| `SHARE_URL` | 空 | 社区共享库中转地址(debug16):上报本网络实测、拉取社区库;留空不参与 |
| `SHARE_TOKEN` | 空 | 中转的上报令牌(中转设置了才需要) |
| `SHARE_HUB_URL` | `https://rosdpi.suyijun.top:8443` | 社区主站地址(内置,一般不用改);自建私有社区时改成自己的主站 |
| `SHARE_HUB` | `false` | 本实例作为社区主站(只有主站部署设为 `true`) |
| `SHARE_VERIFY_TLS` | `true` | 校验主站证书;私有社区用自签证书时才设 `false` |
| `FAKEIP_CIDRS` | `198.18.0.0/15,fc00::/18` | FakeIP 地址段 |
| `ASN_DB` / `CHINA_DB` / `IPLIST_DB` / `BM7_DB` / `HAGEZI_DB` / `UT1_DB` / `OUI_DB` | `true` | 各识别库自动下载开关;没有外网时设 `false` |
| `WAN_INTERFACE` | 自动 | WAN 接口名,默认按默认路由识别 |
@@ -252,7 +253,7 @@ backend/.venv/bin/python scripts/simulate.py --minutes 5
- **Flows**: live connections (sing-box connections show their policy group and outbound node), top destinations with a flow map from your location to each country, and proxy routing per sing-box policy group and node.
- **Family members / Employees**: pick the Home or Business scenario under **Settings → Scenario & network**; group each person's devices and see their traffic, online time, top apps and behaviour tags for the last 7 days.
- **Audit**: access log (CSV export), audit rules (app, domain, time window, traffic, large upload, rogue router, new device) with 14 push-notification channels, daily / weekly reports, operation log, TTL-based shared-access check, and a RouterOS control-command generator.
- **Self-learning & community library**: mappings observed on YOUR network (IP → app, domain → app) are kept as long-term memory and classify traffic even after DNS caches expire, with DoH clients, or for bare-IP flows; deployments can share anonymous votes through a relay, and the relay publishes mappings seen by at least two instances plus a global deployment counter.
- **Self-learning & community library**: mappings observed on YOUR network (IP → app, domain → app) are kept as long-term memory and classify traffic even after DNS caches expire, with DoH clients, or for bare-IP flows; deployments share anonymous votes through the built-in community hub (no third-party storage); a mapping is published only when instances on at least two different networks agree, and the hub shows global deployed / online / offline counts. Instances need no public IP — they only connect out to the hub.
- **Self-update** (Linux binary install): a one-line installer sets up a systemd service with SQLite out of the box; new versions are detected automatically and applied with one click from the UI — no SSH needed.
- Chinese and English UI, light and dark themes, works on phones.
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.6.20"
__version__ = "0.6.21"
+4 -3
View File
@@ -42,9 +42,10 @@ class Settings(BaseSettings):
adguard_username: str = "" # AdGuard Home 登录用户名;和密码都留空表示免认证(只在内网监听时)
adguard_password: str = ""
share_enabled: bool = True # 共享识别库总开关(debug16 第 3 条,默认打开):关掉后不心跳、不上报、不拉取
webdav_url: str = "" # 社区共享库的 WebDAV 地址(debug22,NAS 自带):如 http://192.168.8.10:5005/ros-dpi
webdav_user: str = "" # WebDAV 账号;和密码都留空表示免认证
webdav_password: str = ""
# 社区共享库(debug16→debug22 重做):项目自带的社区主站,不依赖第三方存储
share_hub_url: str = "https://rosdpi.suyijun.top:8443" # 社区主站地址;自建私有社区时改成自己的主站
share_hub: bool = False # 本实例就是社区主站(只有主站部署设为 true):接收上报、发布社区库与全球部署量
share_verify_tls: bool = True # 校验主站证书;私有社区用自签证书时才设 false
oui_db: bool = True # 自动下载 IEEE 的 MAC 厂商库(OUI,识别终端类型用,每 30 天更新)
asn_db: bool = True # 自动下载 iptoasn 的 IP→ASN 库(公有领域,每天更新)
china_db: bool = True # 自动下载国内域名列表(dnsmasq-china-list)与国内 IP 段(每天更新)
+1 -1
View File
@@ -19,7 +19,7 @@ import httpx
from app.config import get_settings
KEYS = ("singbox_url", "singbox_secret", "proxy_ips", "mosdns_url", "adguard_url", "adguard_username",
"adguard_password", "webdav_url", "webdav_user", "webdav_password")
"adguard_password")
def effective(db_values: dict) -> dict:
+16 -14
View File
@@ -17,6 +17,7 @@ from app.models import Setting
from app.ros_poller import poller
from app import audit # noqa: F401 挂上审计(engine.audit)
from app.routers import admin, auth, setup, stats
from app.routers import share as share_router
from app.routers import audit as audit_router
from app.routers import compliance as compliance_router
from app.routers import people as people_router
@@ -48,7 +49,7 @@ def _on_db_ready():
(0.6.0 / 0.6.1 的主域名回填出错时,后面的外部数据源配置、自定义规则都没加载,外部数据源显示“未启用”)"""
ensure_admin() # 建管理员失败要报出来(安装向导靠这个提示)
for name, fn in (("load_state", engine.load_state), ("apply_settings", _apply_saved),
("local_db", _refresh_local), ("share_lib", _share_cycle)):
("local_db", _refresh_local), ("share_lib", _share_startup)):
try:
fn()
except Exception as e: # noqa: BLE001
@@ -76,23 +77,24 @@ def _auto_update_on() -> bool:
def _share_cycle():
"""社区共享库(debug22):心跳(部署量统计)+ 投票共享,走 NAS 的 WebDAV。开关关闭或未配置地址时跳过"""
"""社区共享库:普通实例向社区主站心跳、上报、拉库;主站在进程内聚合(见 app/share.py)"""
if not database.is_configured():
return
from app import share
with database.session() as db:
if not share.sharing_on(db):
return
saved = {k: (db.get(Setting, k).value if db.get(Setting, k) else None) for k in integrations.KEYS}
cfg = integrations.effective(saved)
url = cfg.get("webdav_url") or ""
if not url:
return
with database.session() as db:
res = share.webdav_cycle(engine, db, url, cfg.get("webdav_user") or "", cfg.get("webdav_password") or "")
res = share.cycle(engine, db)
db.commit()
print(f"[share] 共享库已同步:已部署 {res['deployed']} 台(在线 {res['online']}),"
f"社区库 {res['community_ips'] + res['community_domains']} 条", flush=True)
if res:
print(f"[share] 社区共享已同步{'(本实例是社区主站)' if share.is_hub() else ''}:已部署 {res['total']} 台"
f"(在线 {res['online']}),社区库 {res['community_ips'] + res['community_domains']} 条", flush=True)
def _share_startup():
"""启动时先用上次缓存的社区库,再在后台跑第一轮同步(不挡住启动)"""
from app import share
if not share.is_hub():
share.load_cached_library()
share.kick(_safe(_share_cycle, "share"))
def _auto_update_rules():
@@ -210,7 +212,7 @@ async def _unhandled(request: Request, exc: Exception):
for r in (setup.router, auth.router, stats.router, admin.router, audit_router.router, people_router.router,
compliance_router.router):
compliance_router.router, share_router.router):
app.include_router(r)
+6 -2
View File
@@ -227,13 +227,17 @@ class ShareEntry(Base):
class ShareDeployment(Base):
"""社区部署心跳(debug16):实例每小时上报一次存在,中转聚合出 全球已部署 / 在线 / 离线。只含版本号,无任何网络数据"""
"""社区主站上的实例登记(debug16 / 0.6.21 重做):注册时发令牌,每次上报更新心跳。
全球 已部署 / 在线 / 离线 从这里统计;只含版本号和来源网络的哈希,不存来源 IP"""
__tablename__ = "share_deployments"
instance = Column(String(36), primary_key=True)
version = Column(String(16))
first_seen = Column(BigInteger, nullable=False)
last_seen = Column(BigInteger, nullable=False)
public_url = Column(String(255)) # 中转实例的公开地址(部署量里的可用中转列表用)
public_url = Column(String(255)) # 0.6.15~0.6.19 的中转地址,已停用
token_hash = Column(String(64)) # 实例令牌的 SHA-256(令牌本身只在实例那边)
net = Column(String(16)) # 来源网络(IPv4 /24、IPv6 /56)的加盐哈希:防一人伪造多个实例刷票
last_votes = Column(BigInteger) # 最近一次带投票的上报时间
class ShareDomain(Base):
+28 -3
View File
@@ -9,6 +9,16 @@ import httpx
from app.config import get_settings
def _global_v6(addr: str | None) -> bool:
"""RouterOS /ipv6/address 的 address(带前缀长度)是不是公网地址"""
import ipaddress
try:
a = ipaddress.ip_interface(addr or "").ip
except ValueError:
return False
return a.version == 6 and a.is_global
class RosPoller:
def __init__(self, engine):
self.engine = engine
@@ -32,6 +42,8 @@ class RosPoller:
self.samples: deque = deque(maxlen=1440)
self.mangle: list[dict] = [] # 接入自检用:RouterOS 的 mangle 规则(只读)
self.ip_settings: dict = {}
self.filter_rules: list[dict] | None = None # /ip/firewall/filter(看 FastTrack 规则有没有限定 connection-bytes)
self.has_ipv6 = False # 路由器上有没有 IPv6 公网地址
self._sample_prev: dict[str, tuple[float, int, int]] = {}
@property
@@ -85,12 +97,16 @@ class RosPoller:
extra = {k: self._opt(c, path) for k, path in (
("pools", "/ip/pool"), ("servers", "/ip/dhcp-server"), ("dhcpc", "/ip/dhcp-client"),
("pppoe", "/interface/pppoe-client"), ("mangle", "/ip/firewall/mangle"),
("mangle6", "/ipv6/firewall/mangle"), ("ipset", "/ip/settings"))}
("mangle6", "/ipv6/firewall/mangle"), ("ipset", "/ip/settings"),
("filter", "/ip/firewall/filter"))}
self._apply(ifaces, arp, leases, addrs, routes, nb6, addrs6)
self._apply_status(ifaces, addrs, leases, routes, extra)
self.mangle = [{**r, "_family": "ipv4"} for r in extra["mangle"] or []] + \
[{**r, "_family": "ipv6"} for r in extra["mangle6"] or []]
self.ip_settings = extra["ipset"] if isinstance(extra["ipset"], dict) else {}
self.filter_rules = extra["filter"] if isinstance(extra["filter"], list) else None
self.has_ipv6 = any(_global_v6(a.get("address")) and a.get("disabled") != "true"
for a in addrs6 or [])
# 全部成功后再更新展示用的状态,失败时保留上一次的结果
self.identity = ident.get("name", "") if isinstance(ident, dict) else ""
self.resource = resource if isinstance(resource, dict) else {}
@@ -280,10 +296,19 @@ class RosPoller:
issues.append("no_sni")
elif not any(x["kind"] == "sni" and x["protocol"] == "udp" for x in rules):
issues.append("no_quic")
if self.ip_settings.get("ipv4-fasttrack-active") == "true":
# 有 IPv6 公网地址却只有 IPv4 的嗅探规则:走 IPv6 的 DNS 应答和握手全都看不到(国内运营商默认下发 IPv6)
if self.has_ipv6 and any(x["family"] == "ipv4" for x in rules) and not any(x["family"] == "ipv6" and not x["disabled"] for x in rules):
issues.append("no_ipv6")
fasttrack = self.ip_settings.get("ipv4-fasttrack-active") == "true"
# FastTrack 规则已经加了 connection-bytes(连接开头几 KB 不走 FastTrack)时握手照样镜像得到,不算问题;
# 读不到 filter 表(权限不够时是空列表)时按旧办法提示
ft_rules = [r for r in (self.filter_rules or []) if r.get("action") == "fasttrack-connection"
and r.get("disabled") != "true"]
ft_limited = bool(ft_rules) and all(r.get("connection-bytes") for r in ft_rules)
if fasttrack and not ft_limited:
issues.append("fasttrack")
return {"checked": True, "port": port, "target": ref[0] if ref else None, "rules": rules, "issues": issues,
"fasttrack": self.ip_settings.get("ipv4-fasttrack-active") == "true"}
"fasttrack": fasttrack, "fasttrack_limited": fasttrack and ft_limited}
def status(self) -> dict:
r = self.resource if isinstance(self.resource, dict) else {}
+6 -7
View File
@@ -132,9 +132,6 @@ class SettingsIn(BaseModel):
adguard_url: str | None = Field(None, max_length=255)
adguard_username: str | None = Field(None, max_length=255)
adguard_password: str | None = Field(None, max_length=255)
webdav_url: str | None = Field(None, max_length=255)
webdav_user: str | None = Field(None, max_length=255)
webdav_password: str | None = Field(None, max_length=255)
rules_auto_update: bool | None = None
share_enabled: bool | None = None
@@ -179,8 +176,6 @@ def _integrations(db):
"mosdns_url": cfg["mosdns_url"],
"adguard_url": cfg["adguard_url"], "adguard_username": cfg["adguard_username"],
"has_adguard_password": bool(cfg["adguard_password"]),
"webdav_url": cfg["webdav_url"], "webdav_user": cfg["webdav_user"],
"has_webdav_password": bool(cfg["webdav_password"]),
"proxy_auto": integrations.singbox.host_ip()}
@@ -229,6 +224,9 @@ def put_settings(req: SettingsIn, request: Request, user: str = Depends(current_
_put(db, "rules_auto_update", "1" if req.rules_auto_update else "0")
if req.share_enabled is not None:
_put(db, "share_enabled", "1" if req.share_enabled else "0")
db.commit()
from app import share
share.kick(share.run_once)
if req.wan_interface is not None:
_put(db, "wan_interface", req.wan_interface.strip())
poller.wan_override = req.wan_interface.strip()
@@ -311,8 +309,9 @@ def library(db: Session = Depends(get_db)):
@router.get("/system/deployments")
def system_deployments(db: Session = Depends(get_db)):
"""全球部署量 + 可用中转列表(debug19)"""
return deployments_with_relays(db)
"""社区共享状态 + 全球部署量(设置页 → 外部数据源 → 社区共享库)"""
from app import share
return share.status(db)
@router.get("/system/update")
+89
View File
@@ -0,0 +1,89 @@
"""社区主站的公开端点(0.6.21 重做,见 app/share.py)。只有 SHARE_HUB=true 的实例开放,其它实例一律 404。
- POST /api/share/v1/register 首次注册,领取实例 ID 和令牌
- POST /api/share/v1/report 心跳 + 投票(Authorization: Bearer <实例ID>:<令牌>,可 gzip)
- GET /api/share/v1/library 社区库(ETag / 304,支持 gzip)
- GET /api/share/v1/stats 全球部署量:已部署 / 在线 / 离线(所有人可见)
"""
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from starlette.concurrency import run_in_threadpool
from app import database, share
router = APIRouter(prefix=share.API, tags=["share"])
def _need_hub():
if not share.is_hub():
raise HTTPException(404, "本实例不是社区主站")
if not database.is_configured():
raise HTTPException(503, "社区主站的数据库还没就绪")
def _source(request: Request) -> str:
return share.client_ip(request.client.host if request.client else "", request.headers.get("x-forwarded-for"),
request.headers.get("x-real-ip"))
def _run(fn):
try:
return fn()
except share.ShareError as e:
raise HTTPException(e.status, e.detail) from None
@router.post("/register")
async def register(request: Request):
_need_hub()
if int(request.headers.get("content-length") or 0) > share.MAX_BODY:
raise HTTPException(413, "请求内容太大")
raw = await request.body()
ip = _source(request)
def work():
body = share.inflate(raw, request.headers.get("content-encoding"))
with database.session() as db:
iid, token = share.register(db, share.net_of(db, ip), str(body.get("version") or ""))
db.commit()
return {"instance": iid, "token": token}
return await run_in_threadpool(_run, work)
@router.post("/report")
async def report(request: Request):
_need_hub()
if int(request.headers.get("content-length") or 0) > share.MAX_BODY:
raise HTTPException(413, "上报内容太大")
raw = await request.body()
ip = _source(request)
def work():
from app.engine import engine
body = share.inflate(raw, request.headers.get("content-encoding"))
with database.session() as db:
row = share.authenticate(db, request.headers.get("authorization"))
res = share.report(db, row, share.net_of(db, ip), body, engine.classifier)
db.commit()
return res
return await run_in_threadpool(_run, work)
@router.get("/library")
def library(request: Request):
_need_hub()
with database.session() as db:
lib = share.library(db)
headers = {"ETag": lib["etag"], "Cache-Control": "no-cache"}
if request.headers.get("if-none-match") == lib["etag"]:
return Response(status_code=304, headers=headers)
if "gzip" in (request.headers.get("accept-encoding") or "").lower():
return Response(lib["gzip"], media_type="application/json", headers={**headers, "Content-Encoding": "gzip"})
return Response(lib["json"], media_type="application/json", headers=headers)
@router.get("/stats")
def stats():
_need_hub()
with database.session() as db:
return JSONResponse(share.stats(db), headers={"Access-Control-Allow-Origin": "*"})
+518 -191
View File
@@ -1,48 +1,76 @@
"""社区共享库(debug16→debug22):通过 **WebDAV 文件夹**(飞牛 / 群晖等 NAS 自带)共享本地自学习库。
"""社区共享库(debug16 → 0.6.21 重做):各部署实例互相共享本地自学习库的实测结论,项目自带社区主站,不依赖第三方存储。
架构(无中心服务、无中转):
- 每个实例把各自的匿名投票写到 WebDAV 上的两个文件(文件名是随机 UUID,不含任何可识别信息):
deployments/<uuid>.json {"version", "ts"} —— 部署心跳(全球部署量统计)
votes/<uuid>.json {"entries": [IP→应用], "domains": [域名→应用]}
- 每 30 分钟:PUT 自己的两个文件(WebDAV 天然按文件去重,不会互相覆盖),然后列出文件夹里
所有其它实例的文件、全部拉下来在本地聚合——**至少 2 个不同实例见过的映射**才进入社区层。
- 本地实测优先:社区条目只补本地没有结论的 IP / 域名。
架构:
- 社区主站:和普通实例是同一套程序,部署时设 SHARE_HUB=true(只有主站这样设)。它接收各实例的上报,
聚合后发布社区库与全球部署量(/api/share/v1/*,见 routers/share.py)。主站自己的投票在进程内直接计入,
不经过网络(之前的中转要通过公网地址访问自己,NAT 回环不通时永远同步不上)。
- 普通实例:默认参与共享,主站地址内置(SHARE_HUB_URL 可改成自建的私有社区)。每 30 分钟一轮:
首次注册领取令牌 → 上报心跳(每 6 小时附带一次投票)→ 拉取社区库(没变化时主站回 304)→ 拉取部署量。
社区库缓存在 DATA_DIR/share_community.json,主站暂时不可达时继续用上次的版本。
隐私与防污染:
- 投票内容只有 (远端 IP / 域名, 应用 ID, 字节票数);不含本机终端、域名记录、自定义应用;
- 私网 / 保留 / 组播地址剔除;IPv6 按 /64 前缀聚合(中国环境以 IPv6 公网为主);
- 应用白名单:只共享所有实例都认识的应用,自定义应用和编造的 ID 拒收;
- 单文件限量,防止单实例撑爆共享文件夹。
隐私:
- 投票只有 (远端 IP / 域名, 应用 ID, 字节票数);不含本机终端、访问记录、自定义应用;
- 私网 / 保留 / 组播地址剔除;IPv6 按 /64 前缀聚合;
- 主站不保存来源 IP,只保存来源网络(IPv4 /24、IPv6 /56)的加盐哈希。
安全:WebDAV 地址建议用 HTTPS 或仅内网可达;账号密码保存在本实例数据库里,不上报。
防污染:
- 每个实例有主站发的令牌,别人不能冒充或覆盖它的投票;同一来源网络每天限注册次数;投票每小时最多收一次;
- 一条映射要有 ≥2 个**不同来源网络**的实例投给同一个应用,并且票数多于其它应用,才进社区库——
一个人伪造再多实例 ID,来自同一个网络也只算一票;
- 应用白名单(自定义应用、编造的 ID 拒收)、域名格式校验、单实例条数上限、字节票数上限。
"""
import gzip
import hashlib
import hmac
import ipaddress
import json
import os
import re
import secrets
import threading
import time
import urllib.parse
import uuid
from collections import defaultdict
import httpx
from sqlalchemy import select
from sqlalchemy import delete, func, select
from app.classify.local import _is_ip, _v6key
from app.models import HostDomainDay
from app.config import get_settings
from app.models import HostDomainDay, Setting, ShareDeployment, ShareDomain, ShareEntry
MAX_UPLOAD = 50_000 # 单个实例投票文件的条数上限
MAX_COMMUNITY = 200_000 # 社区聚合后的条数上限
PUBLISH_FILES = 2 # 至少几个不同实例的投票文件见过才进社区层
DEPLOY_ONLINE = 7200 # 心跳 2 小时内算在线
API = "/api/share/v1"
MAX_UPLOAD = 50_000 # 单次上报 IP / 域名各自的条数上限
MAX_COMMUNITY = 200_000 # 社区库 IP / 域名各自的条数上限
PUBLISH_NETS = 2 # 至少几个不同来源网络投给同一应用才发布
DEPLOY_ONLINE = 7200 # 2 小时内有心跳算在线
DEPLOY_KEEP = 90 * 86400 # 90 天没心跳的实例不再计入部署量
ENTRY_TTL = 30 * 86400 # 30 天没再上报投票的实例,投票作废
VOTES_EVERY = 6 * 3600 # 客户端每 6 小时附带一次投票(心跳每轮都发)
VOTES_MIN_GAP = 3600 # 主站对同一实例的投票每小时最多收一次
REGISTER_PER_NET = 5 # 同一来源网络每天最多注册几个实例
MAX_BODY = 8 * 1024 * 1024 # 上报请求体上限(压缩后)
MAX_INFLATED = 48 * 1024 * 1024 # 解压后上限,防压缩炸弹
LIB_REBUILD_GAP = 60 # 社区库最快每分钟重建一次
MAX_BYTES = 10 ** 15 # 单条字节票数上限
HUB_NET = "hub" # 主站自己的投票的来源网络标记
# 域名格式(debug20:格式错误的域名条目直接丢弃)
# 域名格式:格式错误的域名条目直接丢弃
_DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$")
MAX_BYTES = 10 ** 15
class ShareError(Exception):
def __init__(self, status: int, detail: str):
super().__init__(detail)
self.status, self.detail = status, detail
# ---------- 共用 ----------
_sync_lock = threading.Lock()
_sync = {"ts": 0, "ok": None, "error": "", "community_ips": 0, "community_domains": 0,
"deployed": 0, "online": 0, "offline": 0}
"total": None, "online": None, "offline": None}
_client = {"votes_ts": 0, "etag": ""}
def sync_state() -> dict:
@@ -50,19 +78,38 @@ def sync_state() -> dict:
return dict(_sync)
def instance_id(db) -> str:
"""本实例的匿名 ID:第一次生成后存在 settings 表(不上报任何可识别信息)"""
from app.models import Setting
row = db.get(Setting, "share_instance_id")
if row is not None and row.value:
return row.value
iid = str(uuid.uuid4())
row = db.get(Setting, "share_instance_id")
def _set_sync(**kw):
with _sync_lock:
_sync.update(kw)
def is_hub() -> bool:
return bool(get_settings().share_hub)
def hub_url() -> str:
return (get_settings().share_hub_url or "").strip().rstrip("/")
def sharing_on(db) -> bool:
"""共享识别库的总开关(debug16 第 3 条):默认打开;关闭后不心跳、不上报、不拉取(主站不受影响,照常服务其它实例)"""
row = db.get(Setting, "share_enabled")
if row is not None and row.value is not None:
return row.value == "1"
return bool(get_settings().share_enabled)
def _setting(db, key: str) -> str:
row = db.get(Setting, key)
return row.value if row is not None and row.value else ""
def _put_setting(db, key: str, value: str):
row = db.get(Setting, key)
if row is None:
db.add(Setting(key="share_instance_id", value=iid))
db.add(Setting(key=key, value=value))
else:
row.value = iid
return iid
row.value = value
def _is_shareable_app(classifier, app: str) -> bool:
@@ -80,166 +127,446 @@ def _shareable_ip(ip: str) -> bool:
return not (a.is_private or a.is_reserved or a.is_multicast or a.is_loopback or a.is_link_local)
def export_entries(engine, db) -> list[dict]:
"""本地自学习库的 IP 条目:host_domain_day 里以 IP 记录的行,按字节数取最多的应用。
IPv4 按精确地址;IPv6 按 /64 前缀聚合(中国环境以 IPv6 公网为主,精确地址不稳定)"""
def export_entries(engine, db) -> tuple[list[dict], list[dict]]:
"""本地自学习库整理成投票:host_domain_day 里的行按字节数取最多的应用。
IP 条目 IPv4 按精确地址、IPv6 按 /64 前缀(精确地址在不同网络间几乎不重复,凑不满投票);域名条目按精确域名"""
classifier = engine.classifier
votes: dict = defaultdict(dict)
rows = db.execute(select(HostDomainDay.domain, HostDomainDay.app, HostDomainDay.bytes)).all()
for dom, app, b in rows:
if not _shareable_ip(dom) or not _is_shareable_app(classifier, app):
continue
key = _v6key(dom) or dom
per = votes[key]
per[app] = per.get(app, 0) + int(b or 0)
out = [{"ip": ip, "app": max(per, key=per.get), "bytes": sum(per.values())}
for ip, per in votes.items()]
out.sort(key=lambda x: -x["bytes"])
doms = export_domains(engine, db)
return out[:MAX_UPLOAD], doms
def export_domains(engine, db) -> list[dict]:
"""本地自学习库的域名条目(debug17):非 IP 的域名行,按字节数取最多的应用,精确域名共享"""
classifier = engine.classifier
votes: dict = defaultdict(dict)
rows = db.execute(select(HostDomainDay.domain, HostDomainDay.app, HostDomainDay.bytes)).all()
for dom, app, b in rows:
if _is_ip(dom) or not _is_shareable_app(classifier, app):
continue
per = votes[dom]
per[app] = per.get(app, 0) + int(b or 0)
out = [{"domain": d, "app": max(p, key=p.get), "bytes": sum(p.values())}
for d, p in votes.items()]
out.sort(key=lambda x: -x["bytes"])
return out[:MAX_UPLOAD]
# ---------- WebDAV ----------
def _dav_url(base: str, path: str) -> str:
return base.rstrip("/") + "/" + "/".join(urllib.parse.quote(x) for x in path.split("/"))
def _dav_put(httpx, base: str, path: str, data, auth) -> bool:
"""PUT 文件;目标文件夹不存在(409)时逐级 MKCOL 后重试一次"""
url = _dav_url(base, path)
r = httpx.put(url, content=data, auth=auth, timeout=30.0, verify=False)
if r.status_code in (200, 201, 204):
return True
if r.status_code == 409: # 上级文件夹不存在:逐级创建
parts = path.split("/")
for i in range(1, len(parts)):
httpx.request("MKCOL", _dav_url(base, "/".join(parts[:i])), auth=auth,
timeout=30.0, verify=False)
r = httpx.put(url, content=data, auth=auth, timeout=30.0, verify=False)
return r.status_code in (200, 201, 204)
return False
def _dav_list(httpx, base: str, folder: str, auth) -> list[str]:
"""PROPFIND 列出文件夹里的 .json 文件,返回 folder/name 相对路径(和 _dav_get 拼接一致)"""
r = httpx.request("PROPFIND", _dav_url(base, folder), headers={"Depth": "1"}, auth=auth,
timeout=30.0, verify=False)
if r.status_code not in (200, 207):
return []
hrefs = re.findall(r"<(?:[\w]+:)?href>\s*([^<]+)\s*</(?:[\w]+:)?href>", r.text, re.IGNORECASE)
base_path = urllib.parse.quote(_dav_url(base, "").split("//", 1)[-1].split("/", 1)[-1])
out = []
for h in hrefs:
name = urllib.parse.unquote(h.split("?")[0].rstrip("/")).rsplit("/", 1)[-1]
if name.endswith(".json") and name != folder:
out.append(f"{folder}/{name}")
return out
def _dav_get(httpx, base: str, path: str, auth):
r = httpx.get(_dav_url(base, path), auth=auth, timeout=30.0, verify=False)
if r.status_code != 200:
return None
try:
return json.loads(r.text)
except Exception: # noqa: BLE001
return None
def webdav_cycle(engine, db, base: str, user: str, password: str) -> dict:
"""一轮 WebDAV 共享(每 30 分钟):写自己的心跳与投票文件 → 读其它实例的文件 → 本地聚合"""
from app import __version__
auth = (user, password) if user or password else None
iid = instance_id(db)
now = int(time.time())
# 1) 心跳(部署量统计:只有版本号和时间)
ok_put_dep = _dav_put(httpx, base, f"deployments/{iid}.json",
json.dumps({"version": __version__, "ts": now}).encode(), auth)
# 2) 投票(本网络实测)
entries, domains = export_entries(engine, db)
ok_put_votes = _dav_put(httpx, base, f"votes/{iid}.json",
json.dumps({"version": __version__, "ts": now,
"entries": entries, "domains": domains},
ensure_ascii=False).encode(), auth)
# 3) 读其它实例的文件,本地聚合(自己写不进去时留给下轮,不空算)
other_dep, other_votes = [], []
if ok_put_dep and ok_put_votes:
for kind, out in (("deployments", other_dep), ("votes", other_votes)):
for path in _dav_list(httpx, base, kind, auth):
if path == f"{kind}/{iid}.json":
continue
data = _dav_get(httpx, base, path, auth)
if isinstance(data, dict):
out.append(data)
# 部署量:心跳文件数 = 已部署;ts 在 2 小时内 = 在线
deployed, online = len(other_dep) + (1 if ok_put_dep else 0), 0
for d in other_dep:
if int(d.get("ts", 0) or 0) >= now - DEPLOY_ONLINE:
online += 1
if ok_put_dep:
online += 1
# 社区层:出现在 ≥2 个不同投票文件里的映射(自己的文件算一票)。votes[k] = {app: (实例数, 总字节)}
def _vote(bucket, key, app, weight):
per = bucket[key]
n, b = per.get(app, (0, 0))
per[app] = (n + 1, b + weight)
ip_votes: dict = defaultdict(dict)
dom_votes: dict = defaultdict(dict)
if ok_put_votes:
for e in entries:
_vote(ip_votes, e["ip"], e["app"], e["bytes"])
for e in domains:
_vote(dom_votes, e["domain"], e["app"], e["bytes"])
for d in other_votes:
for e in d.get("entries") or []:
ip, app, b = str(e.get("ip", "")), str(e.get("app", "")), int(e.get("bytes", 0) or 0)
if _shareable_ip(ip):
_vote(ip_votes, ip, app, b)
for e in d.get("domains") or []:
dom_, app, b = str(e.get("domain", "")).lower(), str(e.get("app", "")), int(e.get("bytes", 0) or 0)
if dom_ and not _is_ip(dom_):
_vote(dom_votes, dom_, app, b)
rows = db.execute(select(HostDomainDay.domain, HostDomainDay.app, HostDomainDay.bytes)).all()
for dom, app, b in rows:
if not dom or not _is_shareable_app(classifier, app):
continue
if _is_ip(dom):
if not _shareable_ip(dom):
continue
per = ip_votes[_v6key(dom) or dom]
else:
per = dom_votes[dom.lower().rstrip(".")]
per[app] = per.get(app, 0) + int(b or 0)
def top(bucket, cap):
out = {}
for key, per in sorted(bucket.items(), key=lambda kv: -sum(v[1] for v in kv[1].values()))[:cap]:
if any(n >= PUBLISH_FILES for n, _b in per.values()): # 至少 2 个实例见过
out[key] = max(per, key=lambda a: per[a][1])
return out
def pack(votes, field):
out = [{field: k, "app": max(p, key=p.get), "bytes": sum(p.values())} for k, p in votes.items()]
out.sort(key=lambda x: -x["bytes"])
return out[:MAX_UPLOAD]
return pack(ip_votes, "ip"), pack(dom_votes, "domain")
community_ips = top(ip_votes, MAX_COMMUNITY)
community_domains = top(dom_votes, MAX_COMMUNITY)
def apply_library(data: dict) -> tuple[int, int]:
"""把社区库并入本地自学习库的社区层(本地实测优先,社区只补空白)。返回 (IP 条数, 域名条数)"""
from app.classify.local import local
local.load_community(community_ips, community_domains)
ips, doms = {}, {}
for e in (data or {}).get("entries") or []:
if not isinstance(e, dict):
continue
ip, app = str(e.get("ip", "")), str(e.get("app", ""))
if app and len(app) <= 48 and _shareable_ip(ip[:-3] if ip.endswith("/64") else ip):
ips[ip] = app
for e in (data or {}).get("domains") or []:
if not isinstance(e, dict):
continue
dom, app = str(e.get("domain", "")).lower().rstrip("."), str(e.get("app", ""))
if app and len(app) <= 48 and _DOMAIN_RE.match(dom):
doms[dom] = app
local.load_community(ips, doms)
return len(ips), len(doms)
ok = ok_put_dep and ok_put_votes
error = "" if ok else "WebDAV 写入失败:检查地址 / 账号密码 / 文件夹权限"
with _sync_lock:
_sync.update(ts=now, ok=ok, error=error, deployed=deployed, online=online,
offline=deployed - online,
community_ips=len(community_ips), community_domains=len(community_domains))
return {"deployed": deployed, "online": online, "offline": deployed - online,
"community_ips": len(community_ips), "community_domains": len(community_domains),
"uploaded": bool(ok_put_dep and ok_put_votes)}
def _cache_path() -> str:
return os.path.join(os.environ.get("DATA_DIR", "data"), "share_community.json")
def load_cached_library():
"""启动时先用上次拉到的社区库,不用等第一轮同步;主站暂时不可达时也一直有数据"""
try:
with open(_cache_path(), encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
return
n_ip, n_dom = apply_library(data)
_client["etag"] = str(data.get("etag") or "")
_set_sync(community_ips=n_ip, community_domains=n_dom)
def _save_cache(data: dict, etag: str):
try:
os.makedirs(os.path.dirname(_cache_path()) or ".", exist_ok=True)
tmp = _cache_path() + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({**data, "etag": etag}, f, ensure_ascii=False)
os.replace(tmp, _cache_path())
except OSError as e:
print(f"[share] 社区库缓存写入失败:{e}", flush=True)
# ---------- 社区主站 ----------
def net_of(db, ip: str) -> str:
"""来源网络的加盐哈希(IPv4 /24、IPv6 /56):同一网络里伪造多个实例也只算一票;主站不保存来源 IP 本身。
IPv6 按 /56:国内家庭宽带一般分到 /56 或 /60,按 /48 会把几百户人家算成同一个网络"""
try:
a = ipaddress.ip_address(ip)
except ValueError:
return "unknown"
net = ipaddress.ip_network(f"{a}/{24 if a.version == 4 else 56}", strict=False)
salt = _setting(db, "share_salt")
if not salt:
salt = secrets.token_hex(16)
_put_setting(db, "share_salt", salt)
return hashlib.sha256(f"{salt}|{net}".encode()).hexdigest()[:16]
def client_ip(peer: str, forwarded: str | None, real_ip: str | None) -> str:
"""主站看到的来源地址。直接暴露在公网时用 TCP 对端;对端是内网(前面有反向代理)时,取代理追加在
X-Forwarded-For 最右边的公网地址——直连的人伪造这个头没用,因为那时对端是公网地址、头会被忽略"""
try:
p = ipaddress.ip_address(peer)
behind_proxy = p.is_private or p.is_loopback
except ValueError:
behind_proxy = False
if not behind_proxy:
return peer
for cand in reversed([x.strip() for x in (forwarded or "").split(",") if x.strip()]):
if _shareable_ip(cand):
return cand
if real_ip and _shareable_ip(real_ip.strip()):
return real_ip.strip()
return peer
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def register(db, net: str, version: str) -> tuple[str, str]:
"""主站:登记一个新实例并发放令牌(调用方提交事务)。同一来源网络每天限 REGISTER_PER_NET 个"""
now = int(time.time())
recent = db.scalar(select(func.count()).select_from(ShareDeployment)
.where(ShareDeployment.net == net, ShareDeployment.first_seen >= now - 86400)) or 0
if net != HUB_NET and recent >= REGISTER_PER_NET:
raise ShareError(429, "注册太频繁,请明天再试")
iid, token = str(uuid.uuid4()), secrets.token_urlsafe(32)
db.add(ShareDeployment(instance=iid, version=version[:16], first_seen=now, last_seen=now,
token_hash=_hash_token(token), net=net))
return iid, token
def authenticate(db, auth_header: str | None) -> ShareDeployment:
"""主站:校验 Authorization: Bearer <实例ID>:<令牌>"""
raw = (auth_header or "").removeprefix("Bearer ").strip()
iid, _, token = raw.partition(":")
row = db.get(ShareDeployment, iid[:36]) if iid and token else None
if row is None or not row.token_hash or not hmac.compare_digest(row.token_hash, _hash_token(token)):
raise ShareError(401, "实例未注册或令牌无效")
return row
def _clamp_bytes(v) -> int:
try:
return min(max(int(v or 0), 0), MAX_BYTES)
except (TypeError, ValueError):
return 0
def report(db, row: ShareDeployment, net: str, body: dict, classifier) -> dict:
"""主站:记录一次上报(调用方提交事务)。心跳每次都记;带了投票时整份替换该实例的旧投票。
应用白名单:只收主站也认识的内置应用,自定义应用和编造的 ID 拒收"""
now = int(time.time())
row.last_seen, row.net = now, net
row.version = str(body.get("version") or "")[:16]
entries, domains = body.get("entries"), body.get("domains")
if not isinstance(entries, list) and not isinstance(domains, list):
return {"ok": True, "accepted": 0}
if row.last_votes and now - row.last_votes < VOTES_MIN_GAP:
return {"ok": True, "accepted": 0, "skipped": "投票每小时最多收一次"}
ips: dict = {}
for e in (entries or [])[:MAX_UPLOAD]:
if not isinstance(e, dict):
continue
ip, app = str(e.get("ip", "")), str(e.get("app", ""))
base = ip[:-3] if ip.endswith("/64") else ip
if not _shareable_ip(base) or not _is_shareable_app(classifier, app):
continue
ips[_v6key(base) or base] = (app, _clamp_bytes(e.get("bytes")))
doms: dict = {}
for e in (domains or [])[:MAX_UPLOAD]:
if not isinstance(e, dict):
continue
dom, app = str(e.get("domain", "")).lower().rstrip("."), str(e.get("app", ""))
if not _DOMAIN_RE.match(dom) or not _is_shareable_app(classifier, app):
continue
doms[dom] = (app, _clamp_bytes(e.get("bytes")))
db.execute(delete(ShareEntry).where(ShareEntry.reporter == row.instance))
db.execute(delete(ShareDomain).where(ShareDomain.reporter == row.instance))
db.add_all([ShareEntry(ip=k, app=a, reporter=row.instance, bytes=b, ts=now) for k, (a, b) in ips.items()])
db.add_all([ShareDomain(domain=k, app=a, reporter=row.instance, bytes=b, ts=now) for k, (a, b) in doms.items()])
row.last_votes = now
_lib["dirty"] = True
return {"ok": True, "accepted": len(ips) + len(doms)}
def purge(db):
"""主站:清掉 90 天没心跳的实例、30 天没更新的投票(调用方提交事务)"""
now = int(time.time())
db.execute(delete(ShareDeployment).where(ShareDeployment.last_seen < now - DEPLOY_KEEP))
n = db.execute(delete(ShareEntry).where(ShareEntry.ts < now - ENTRY_TTL)).rowcount or 0
n += db.execute(delete(ShareDomain).where(ShareDomain.ts < now - ENTRY_TTL)).rowcount or 0
if n:
_lib["dirty"] = True
def _aggregate(rows, nets: dict) -> dict:
"""按来源网络计票:每个键选投票网络最多的应用(平票看字节),网络数 ≥ PUBLISH_NETS 且多于第二名才发布"""
votes: dict = defaultdict(lambda: defaultdict(lambda: [set(), 0]))
for key, app, reporter, b in rows:
net = nets.get(reporter)
if net is None: # 已被清理的实例、0.6.20 以前的旧中转留下的投票不算
continue
v = votes[key][app]
v[0].add(net)
v[1] += int(b or 0)
out = []
for key, per in votes.items():
ranked = sorted(per.items(), key=lambda kv: (len(kv[1][0]), kv[1][1]), reverse=True)
top_app, (top_nets, top_bytes) = ranked[0]
runner = len(ranked[1][1][0]) if len(ranked) > 1 else 0
if len(top_nets) >= PUBLISH_NETS and len(top_nets) > runner:
out.append((top_bytes, key, top_app))
out.sort(reverse=True)
return {key: app for _b, key, app in out[:MAX_COMMUNITY]}
def build_library(db) -> dict:
nets = {i: n for i, n in db.execute(select(ShareDeployment.instance, ShareDeployment.net)).all() if n}
ips = _aggregate(db.execute(select(ShareEntry.ip, ShareEntry.app, ShareEntry.reporter, ShareEntry.bytes)).all(), nets)
doms = _aggregate(db.execute(select(ShareDomain.domain, ShareDomain.app, ShareDomain.reporter,
ShareDomain.bytes)).all(), nets)
return {"version": 1, "generated": int(time.time()),
"entries": [{"ip": k, "app": v} for k, v in sorted(ips.items())],
"domains": [{"domain": k, "app": v} for k, v in sorted(doms.items())]}
_lib_lock = threading.Lock()
_lib = {"dirty": True, "ts": 0, "etag": "", "json": b"", "gzip": b"", "data": None}
def library(db) -> dict:
"""主站:当前社区库(有新投票时最快每分钟重建一次),返回 {etag, json, gzip, data}"""
with _lib_lock:
if _lib["data"] is None or (_lib["dirty"] and time.time() - _lib["ts"] >= LIB_REBUILD_GAP):
data = build_library(db)
raw = json.dumps({k: data[k] for k in ("version", "entries", "domains")}, ensure_ascii=False,
separators=(",", ":")).encode()
body = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode()
_lib.update(dirty=False, ts=time.time(), etag='"' + hashlib.sha256(raw).hexdigest()[:32] + '"',
json=body, gzip=gzip.compress(body, 6), data=data)
return dict(_lib)
def reset_library_cache():
"""测试用"""
with _lib_lock:
_lib.update(dirty=True, ts=0, etag="", json=b"", gzip=b"", data=None)
_client.update(votes_ts=0, etag="")
def stats(db) -> dict:
"""全球部署量(公开):已部署 = 90 天内有心跳的实例,在线 = 2 小时内有心跳"""
now = int(time.time())
# 只数 0.6.21 起注册过的实例:旧中转(0.6.10~0.6.19)留下的无令牌心跳不计,否则升级后会重复计数
q = select(func.count()).select_from(ShareDeployment).where(ShareDeployment.token_hash.is_not(None))
total = db.scalar(q.where(ShareDeployment.last_seen >= now - DEPLOY_KEEP)) or 0
online = db.scalar(q.where(ShareDeployment.last_seen >= now - DEPLOY_ONLINE)) or 0
return {"total": total, "online": online, "offline": total - online}
def inflate(body: bytes, encoding: str | None) -> dict:
"""主站:解析上报请求体(可 gzip 压缩),限制解压后大小"""
if len(body) > MAX_BODY:
raise ShareError(413, "上报内容太大")
if (encoding or "").lower() == "gzip":
import zlib
d = zlib.decompressobj(16 + zlib.MAX_WBITS)
try:
out = d.decompress(body, MAX_INFLATED)
except zlib.error:
raise ShareError(400, "上报内容解压失败") from None
if d.unconsumed_tail:
raise ShareError(413, "上报内容太大")
body = out
try:
data = json.loads(body or b"{}")
except ValueError:
raise ShareError(400, "上报内容不是 JSON") from None
if not isinstance(data, dict):
raise ShareError(400, "上报内容格式不对")
return data
def hub_cycle(engine, db) -> dict:
"""主站自己的一轮:投票在进程内直接计入(不经网络)→ 清理过期 → 重建社区库并用到本机"""
from app import __version__
iid = _setting(db, "share_hub_instance")
row = db.get(ShareDeployment, iid) if iid else None
if row is None:
iid, _token = register(db, HUB_NET, __version__)
_put_setting(db, "share_hub_instance", iid)
db.flush()
row = db.get(ShareDeployment, iid)
body: dict = {"version": __version__}
on = sharing_on(db)
if on and (not row.last_votes or time.time() - row.last_votes >= VOTES_EVERY):
body["entries"], body["domains"] = export_entries(engine, db)
elif not on:
# 主站关掉“参与共享”:照常服务其它实例,只是不再贡献自己的投票、本机也不用社区库
db.execute(delete(ShareEntry).where(ShareEntry.reporter == iid))
db.execute(delete(ShareDomain).where(ShareDomain.reporter == iid))
row.last_votes = None
_lib["dirty"] = True
report(db, row, HUB_NET, body, engine.classifier)
purge(db)
db.flush()
lib = library(db)
if on:
n_ip, n_dom = apply_library(lib["data"])
else:
from app.classify.local import local
local.load_community({}, {})
n_ip = n_dom = 0
st = stats(db)
_set_sync(ts=int(time.time()), ok=True, error="", community_ips=n_ip, community_domains=n_dom, **st)
return {**st, "community_ips": n_ip, "community_domains": n_dom}
# ---------- 普通实例 ----------
def _http() -> httpx.Client:
from app import __version__
return httpx.Client(verify=get_settings().share_verify_tls, timeout=30.0, follow_redirects=False,
headers={"User-Agent": f"ros-dpi/{__version__}"})
def _err(e: Exception) -> str:
if isinstance(e, httpx.HTTPStatusError):
try:
detail = e.response.json().get("detail") or ""
except ValueError:
detail = ""
return f"主站返回 {e.response.status_code}" + (f":{detail}" if detail else "")
if isinstance(e, httpx.ConnectError) and "CERTIFICATE" in str(e).upper():
return "主站证书校验失败(私有社区用自签证书时设 SHARE_VERIFY_TLS=false)"
if isinstance(e, ShareError):
return e.detail
msg = str(e).lower()
if isinstance(e, httpx.ConnectError) and ("errno 101" in msg or "unreachable" in msg
or "cannot assign requested address" in msg):
# 实例不需要公网 IP(都是主动连主站),但主站目前只有 IPv6 地址:本机 / 容器要有 IPv6 出口
return "连不上社区主站:本机没有 IPv6 出口(主站只有 IPv6 地址)。Docker 部署请按 docker-compose.yml 末尾的说明开启 IPv6,或改用 Linux 一键安装"
return f"{type(e).__name__}: {str(e)[:120]}"
def _register(c: httpx.Client, base: str, db) -> tuple[str, str]:
from app import __version__
r = c.post(base + API + "/register", json={"version": __version__})
r.raise_for_status()
d = r.json()
iid, token = str(d["instance"]), str(d["token"])
_put_setting(db, "share_client_instance", iid)
_put_setting(db, "share_client_token", token)
_put_setting(db, "share_client_hub", base)
db.commit()
return iid, token
def client_cycle(engine, db) -> dict:
"""普通实例一轮同步:注册(首次)→ 心跳(每 6 小时带投票)→ 拉社区库 → 拉部署量"""
from app import __version__
base = hub_url()
if not base:
raise ShareError(400, "没有配置社区主站地址(SHARE_HUB_URL)")
with _http() as c:
iid, token = _setting(db, "share_client_instance"), _setting(db, "share_client_token")
if not iid or not token or _setting(db, "share_client_hub") != base: # 换了主站要重新注册
iid, token = _register(c, base, db)
_client["votes_ts"] = 0
body: dict = {"version": __version__}
with_votes = time.time() - _client["votes_ts"] >= VOTES_EVERY
if with_votes:
body["entries"], body["domains"] = export_entries(engine, db)
payload = gzip.compress(json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode(), 6)
def send(i, t):
return c.post(base + API + "/report", content=payload,
headers={"Authorization": f"Bearer {i}:{t}", "Content-Type": "application/json",
"Content-Encoding": "gzip"})
r = send(iid, token)
if r.status_code == 401: # 主站清理过(90 天没心跳)或换过库:重新注册一次
iid, token = _register(c, base, db)
r = send(iid, token)
r.raise_for_status()
if with_votes:
_client["votes_ts"] = time.time()
headers = {"If-None-Match": _client["etag"]} if _client["etag"] else {}
r = c.get(base + API + "/library", headers=headers)
if r.status_code == 304:
st = sync_state()
n_ip, n_dom = st["community_ips"], st["community_domains"]
else:
r.raise_for_status()
data = r.json()
n_ip, n_dom = apply_library(data)
_client["etag"] = r.headers.get("etag", "")
_save_cache(data, _client["etag"])
r = c.get(base + API + "/stats")
r.raise_for_status()
st = {k: int(r.json().get(k) or 0) for k in ("total", "online", "offline")}
_set_sync(ts=int(time.time()), ok=True, error="", community_ips=n_ip, community_domains=n_dom, **st)
return {**st, "community_ips": n_ip, "community_domains": n_dom}
def cycle(engine, db) -> dict | None:
"""定时任务入口(每 30 分钟):主站走进程内流程;普通实例开关关闭时什么都不发"""
if is_hub():
return hub_cycle(engine, db)
if not sharing_on(db):
from app.classify.local import local
local.load_community({}, {})
_set_sync(ts=0, ok=None, error="", community_ips=0, community_domains=0,
total=None, online=None, offline=None)
return None
try:
return client_cycle(engine, db)
except Exception as e: # noqa: BLE001 记录原因给设置页显示;社区库继续用上次缓存
_set_sync(ts=int(time.time()), ok=False, error=_err(e))
raise
def kick(fn):
"""后台立刻跑一轮(启动后、打开开关后),不等 30 分钟的定时任务;测试环境不跑"""
if not os.environ.get("ROSDPI_NO_JOBS"):
threading.Thread(target=fn, daemon=True).start()
def run_once():
from app import database
from app.engine import engine
try:
with database.session() as db:
cycle(engine, db)
db.commit()
except Exception as e: # noqa: BLE001
print(f"[share] 同步失败:{_err(e)}", flush=True)
def status(db) -> dict:
"""设置页的共享状态:角色、主站地址、开关、上次同步结果与全球部署量"""
st = sync_state()
if is_hub(): # 主站的部署量直接查库,不用等下一轮
st.update(stats(db))
# 不同来源网络数:实例很多却只有 1 个网络,说明反向代理没有传 X-Forwarded-For,社区库永远凑不够票
st["nets"] = db.scalar(select(func.count(func.distinct(ShareDeployment.net)))
.where(ShareDeployment.token_hash.is_not(None),
ShareDeployment.net != HUB_NET)) or 0
return {"role": "hub" if is_hub() else "client", "hub_url": hub_url(), "enabled": sharing_on(db),
"verify_tls": bool(get_settings().share_verify_tls), "sync": st}
+227 -125
View File
@@ -1,152 +1,254 @@
"""debug16/22:WebDAV 社区共享——本地库导出过滤、≥2 实例聚合、部署量、自学习接入。"""
"""社区共享库(debug16 → 0.6.21 重做):项目自带社区主站——注册 / 令牌、心跳 + 投票、按来源网络聚合防刷票、
ETag 304、部署量;普通实例的完整同步流程走真实的主站端点(TestClient),不打桩协议本身。"""
import gzip
import json
import time
import pytest
from fastapi.testclient import TestClient
from app import database, share
from app.classify.local import local
from app.engine import Engine
from app.models import HostDomainDay
from app.share import webdav_cycle
from app.config import get_settings
from app.engine import engine
from app.main import app
from app.models import HostDomainDay, Setting, ShareDeployment, ShareDomain, ShareEntry
from app.routers import share as share_router
DAY = 1792900000
HOST = "IP:192.168.8.40"
BASE = "http://nas.local:5005/ros-dpi"
HOST = "IP:192.168.88.41"
HUB = "http://testserver"
def _engine():
e = Engine()
e.set_lan("192.168.8.0/24")
return e
def _clean(db):
for m in (ShareEntry, ShareDomain, ShareDeployment):
db.query(m).delete(synchronize_session=False)
db.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete(synchronize_session=False)
for k in ("share_client_instance", "share_client_token", "share_client_hub", "share_hub_instance"):
row = db.get(Setting, k)
if row is not None:
db.delete(row)
db.commit()
class _FakeDav:
"""最小 WebDAV 仿真:按文件夹分桶的内存键值 + PROPFIND 列表;记录所有 PUT。
files 的键是 "<folder>/<name>.json"(和真实 WebDAV 的路径一致)"""
@pytest.fixture()
def hub(monkeypatch, tmp_path):
"""本进程同时当社区主站和普通实例:主站端点由 TestClient 提供;X-Test-IP 头模拟不同实例的来源地址"""
s = get_settings()
monkeypatch.setattr(s, "share_hub", True)
monkeypatch.setattr(s, "share_hub_url", HUB)
monkeypatch.setattr(share, "LIB_REBUILD_GAP", 0)
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setattr(share_router, "_source", lambda req: req.headers.get("x-test-ip") or "203.0.113.9")
database.connect_from_saved(force=True)
with database.session() as db:
_clean(db)
share.reset_library_cache()
local.reset()
c = TestClient(app)
state = {"ip": "198.51.100.1"}
def __init__(self):
self.files = {}
self.puts = []
class _Client:
"""client_cycle 用的 httpx.Client 替身:请求转给 TestClient,并带上当前模拟的来源地址"""
def __enter__(self):
return self
def handle(self, method, url, content=None):
from httpx import Response
path = str(url).split("/ros-dpi/")[-1].rstrip("/") # deployments/xxx.json 或 votes/ 或 votes/xxx.json
if method == "PROPFIND":
folder = path.rstrip("/")
names = [k for k in self.files if k.startswith(folder + "/")]
body = "".join(f"<d:response><d:href>/{n}</d:href></d:response>" for n in names)
return Response(207, text=f"<d:multistatus>{body}</d:multistatus>")
if method == "PUT":
self.puts.append(path)
self.files[path] = content or b""
return Response(201)
if method == "GET":
if path in self.files:
return Response(200, content=self.files[path])
return Response(404)
if method == "MKCOL":
return Response(201)
return Response(200)
def __exit__(self, *a):
return False
def post(self, url, **kw):
kw["headers"] = {**(kw.get("headers") or {}), "X-Test-IP": state["ip"]}
return c.post(url, **kw)
def get(self, url, **kw):
kw["headers"] = {**(kw.get("headers") or {}), "X-Test-IP": state["ip"]}
return c.get(url, **kw)
monkeypatch.setattr(share, "_http", lambda: _Client())
yield c, state
with database.session() as db:
_clean(db)
share.reset_library_cache()
local.reset()
def _install(monkeypatch, dav):
"""把 share.py 里的 httpx 模块函数换成 WebDAV 仿真"""
import httpx
def _as_new_instance(state, ip):
"""换成另一台实例:清掉本机保存的注册信息,换来源地址"""
with database.session() as db:
for k in ("share_client_instance", "share_client_token", "share_client_hub"):
row = db.get(Setting, k)
if row is not None:
db.delete(row)
db.commit()
share._client.update(votes_ts=0, etag="")
state["ip"] = ip
from app import share as share_mod
monkeypatch.setattr(share_mod.httpx, "put", lambda url, content=None, **kw: dav.handle("PUT", url, content))
monkeypatch.setattr(share_mod.httpx, "get", lambda url, **kw: dav.handle("GET", url))
monkeypatch.setattr(share_mod.httpx, "request", lambda method, url, **kw: dav.handle(method, url))
del httpx
def _seed(db, rows):
for dom, app, b in rows:
db.add(HostDomainDay(ts=DAY, host=HOST, domain=dom, app=app, base=None, bytes=b))
db.commit()
def _client_cycle():
with database.session() as db:
res = share.client_cycle(engine, db)
db.commit()
return res
def test_export_filters_private():
import app.database as database
database.connect_from_saved(force=True)
s = database.SessionLocal()
try:
s.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete(synchronize_session=False)
s.add(HostDomainDay(ts=DAY, host=HOST, domain="93.184.216.34", app="douyin", base=None, bytes=800))
s.add(HostDomainDay(ts=DAY, host=HOST, domain="192.168.8.5", app="douyin", base=None, bytes=999)) # 私网
s.add(HostDomainDay(ts=DAY, host=HOST, domain="239.255.255.250", app="douyin", base=None, bytes=999)) # 组播
s.commit()
eng = _engine()
from app import share as share_mod
entries, _domains = share_mod.export_entries(eng, s)
ips = {e["ip"] for e in entries}
assert "93.184.216.34" in ips
assert "192.168.8.5" not in ips and "239.255.255.250" not in ips
finally:
s.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete(synchronize_session=False)
s.commit()
s.close()
with database.session() as db:
_clean(db)
_seed(db, [("93.184.216.34", "douyin", 800), ("192.168.8.5", "douyin", 999),
("239.255.255.250", "douyin", 999), ("v.douyin.com", "douyin", 50)])
entries, domains = share.export_entries(engine, db)
_clean(db)
ips = {e["ip"] for e in entries}
assert "93.184.216.34" in ips and not ips & {"192.168.8.5", "239.255.255.250"} # 私网 / 组播不出门
assert "v.douyin.com" in {d["domain"] for d in domains}
def test_webdav_aggregates_two_instances(monkeypatch):
"""两个实例各写各的投票文件:≥2 个实例见过的映射进社区层;单实例见过的进不来"""
import app.database as database
database.connect_from_saved(force=True)
dav = _FakeDav()
_install(monkeypatch, dav)
s = database.SessionLocal()
try:
s.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete(synchronize_session=False)
s.commit()
eng = _engine()
# A 的本机实测:1.2.3.4 → douyin(进入自己的投票文件)
s.add(HostDomainDay(ts=DAY, host=HOST, domain="1.2.3.4", app="douyin", base=None, bytes=500))
s.commit()
# 第一轮:A 写入自己的投票文件;此时没有其它文件,社区层为空
with database.session() as db:
res1 = webdav_cycle(eng, db, BASE, "", "")
db.commit()
assert res1["deployed"] == 1 and res1["online"] == 1
assert res1["community_ips"] == 0
# B 的文件出现在 NAS 上:1.2.3.4 → douyin(与 A 相同)+ 5.6.7.8 → bilibili(只有 B 见过)
dav.files["votes/IID_B.json"] = json.dumps({
"version": "0.6.20", "ts": int(time.time()),
"entries": [{"ip": "1.2.3.4", "app": "douyin", "bytes": 100},
{"ip": "5.6.7.8", "app": "bilibili", "bytes": 200}],
"domains": []}).encode()
dav.files["deployments/IID_B.json"] = json.dumps(
{"version": "0.6.20", "ts": int(time.time())}).encode()
# 第二轮:A 读 B 的文件并聚合
with database.session() as db:
res2 = webdav_cycle(eng, db, BASE, "", "")
db.commit()
assert res2["deployed"] == 2 and res2["online"] == 2
assert res2["community_ips"] == 1 # 1.2.3.4 两家都见过
assert local.ip_app("1.2.3.4") == "douyin"
assert local.ip_app("5.6.7.8") is None # 单实例见过的进不了社区层
assert len(dav.puts) == 4 # 2 轮 × (deployments + votes)
finally:
s.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete(synchronize_session=False)
s.commit()
s.close()
local.reset()
dav.files.clear()
dav.puts.clear()
def test_client_ip_behind_proxy():
# 直连公网:用 TCP 对端,伪造的 X-Forwarded-For 无效
assert share.client_ip("8.8.8.8", "1.1.1.1", None) == "8.8.8.8"
# 反向代理在内网:取代理追加在最右边的公网地址
assert share.client_ip("192.168.8.1", "1.1.1.1, 9.9.9.9", None) == "9.9.9.9"
assert share.client_ip("127.0.0.1", "10.0.0.5", "9.9.9.9") == "9.9.9.9"
def test_webdav_single_instance_no_community(monkeypatch):
def test_two_networks_publish_end_to_end(hub):
c, state = hub
with database.session() as db:
_seed(db, [("1.2.3.4", "douyin", 500), ("v.douyin.com", "douyin", 300)])
# 第一台:注册 + 上报;只有一个网络见过,社区库为空
res = _client_cycle()
assert res["total"] == 1 and res["online"] == 1 and res["community_ips"] == 0
assert share.sync_state()["ok"] is True and local.ip_app("1.2.3.4") is None
# 第二台(另一个网络)投了同样的结论:两网一致 → 进社区库,本机自学习的社区层生效
_as_new_instance(state, "203.0.113.50")
res = _client_cycle()
assert res["total"] == 2 and res["community_ips"] >= 1 and res["community_domains"] >= 1
assert local.ip_app("1.2.3.4") == "douyin"
assert local.domain_app("v.douyin.com") == "douyin"
# 社区库没变化:带 ETag 再拉是 304,结果沿用;缓存文件已写,重启后可直接加载
etag = share._client["etag"]
assert etag and c.get(HUB + share.API + "/library", headers={"If-None-Match": etag}).status_code == 304
local.reset()
share.load_cached_library()
assert local.ip_app("1.2.3.4") == "douyin"
# 公开的部署量
assert c.get(HUB + share.API + "/stats").json() == {"total": 2, "online": 2, "offline": 0}
def test_same_network_counts_once(hub):
"""一个人在同一网络里伪造多个实例:只算一票,进不了社区库"""
c, state = hub
with database.session() as db:
_seed(db, [("5.6.7.8", "bilibili", 100)])
_client_cycle()
_as_new_instance(state, "198.51.100.77") # 同一个 /24
res = _client_cycle()
assert res["total"] == 2 and res["community_ips"] == 0 and local.ip_app("5.6.7.8") is None
def test_majority_and_whitelist(hub):
c, _state = hub
with database.session() as db:
rows = []
for i, ip in enumerate(("1.0.0.1", "2.0.0.1", "3.0.0.1")):
iid, _t = share.register(db, share.net_of(db, ip), "t")
db.flush()
rows.append(db.get(ShareDeployment, iid))
cls = engine.classifier
share.report(db, rows[0], rows[0].net, {"entries": [{"ip": "8.8.8.8", "app": "douyin", "bytes": 1}]}, cls)
share.report(db, rows[1], rows[1].net, {"entries": [{"ip": "8.8.8.8", "app": "douyin", "bytes": 1}]}, cls)
share.report(db, rows[2], rows[2].net, {"entries": [{"ip": "8.8.8.8", "app": "bilibili", "bytes": 10 ** 9},
{"ip": "9.9.9.9", "app": "made_up_app", "bytes": 1},
{"ip": "10.0.0.1", "app": "douyin", "bytes": 1}]}, cls)
db.commit()
lib = share.build_library(db)
n = db.query(ShareEntry).filter(ShareEntry.reporter == rows[2].instance).count()
assert lib["entries"] == [{"ip": "8.8.8.8", "app": "douyin"}] # 两个网络对一个网络:按网络数,不按字节
assert n == 1 # 编造的应用、私网地址被拒收
def test_auth_and_limits(hub):
c, _state = hub
url = HUB + share.API
assert c.post(url + "/report", json={"version": "x"}).status_code == 401
reg = c.post(url + "/register", json={"version": "x"}, headers={"X-Test-IP": "192.0.2.1"}).json()
bad = {"Authorization": f"Bearer {reg['instance']}:wrong"}
assert c.post(url + "/report", json={"version": "x"}, headers=bad).status_code == 401
ok = {"Authorization": f"Bearer {reg['instance']}:{reg['token']}", "Content-Encoding": "gzip"}
assert c.post(url + "/report", content=gzip.compress(json.dumps({"version": "x"}).encode()), headers=ok).json()["ok"]
# 解压后超限:拒收(防压缩炸弹)
bomb = gzip.compress(b" " * (share.MAX_INFLATED + 10))
assert c.post(url + "/report", content=bomb, headers=ok).status_code == 413
# 同一网络一天注册超限
for i in range(share.REGISTER_PER_NET):
assert c.post(url + "/register", json={}, headers={"X-Test-IP": f"100.20.30.{i}"}).status_code == 200
assert c.post(url + "/register", json={}, headers={"X-Test-IP": "100.20.30.200"}).status_code == 429
def test_hub_counts_itself_in_process(hub):
"""主站自己的投票在进程内计入(不经网络):主站 + 一个普通实例两网一致即发布"""
c, state = hub
with database.session() as db:
_seed(db, [("4.4.4.4", "douyin", 100)])
res = share.hub_cycle(engine, db)
db.commit()
assert res["total"] == 1 and res["community_ips"] == 0
assert local.ip_app("4.4.4.4") is None
_client_cycle()
with database.session() as db:
share._lib["dirty"] = True
res = share.hub_cycle(engine, db)
db.commit()
assert res["total"] == 2 and res["community_ips"] >= 1 and local.ip_app("4.4.4.4") == "douyin"
def test_non_hub_404(monkeypatch):
monkeypatch.setattr(get_settings(), "share_hub", False)
c = TestClient(app)
for path in ("/stats", "/library"):
assert c.get(share.API + path).status_code == 404
assert c.post(share.API + "/register", json={}).status_code == 404
def test_client_error_is_reported(monkeypatch):
"""主站不可达:设置页能看到原因,社区库沿用缓存"""
import httpx
dav = _FakeDav()
_install(monkeypatch, dav)
eng = _engine()
try:
local.reset()
import app.database as database
database.connect_from_saved(force=True)
s = database.SessionLocal()
with database.session() as db:
res = webdav_cycle(eng, db, BASE, "", "")
db.commit()
assert res["deployed"] == 1 and res["online"] == 1
assert res["community_ips"] == 0 # 没有其它实例 → 社区层空(本地实测本来就优先)
finally:
s.close()
local.reset()
dav.files.clear()
database.connect_from_saved(force=True)
monkeypatch.setattr(get_settings(), "share_hub", False)
monkeypatch.setattr(get_settings(), "share_hub_url", "http://127.0.0.1:9")
class _Down:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def post(self, url, **kw):
raise httpx.ConnectError("connection refused")
get = post
monkeypatch.setattr(share, "_http", lambda: _Down())
with database.session() as db:
with pytest.raises(httpx.ConnectError):
share.cycle(engine, db)
st = share.sync_state()
assert st["ok"] is False and "ConnectError" in st["error"]
def test_no_ipv6_egress_message():
"""实例不需要公网 IP,但主站只有 IPv6:容器没有 IPv6 出口时给出能照着做的提示"""
import httpx
msg = share._err(httpx.ConnectError("[Errno 101] Network is unreachable"))
assert "IPv6" in msg and "Docker" in msg
+22
View File
@@ -92,10 +92,32 @@ def test_poll_success_populates_router_state(monkeypatch):
assert chk["checked"] and sni["problems"] == ["port"] and sni["target_port"] == 37880
# SNI 规则存在、只是端口错:建议改端口,不建议重复新增;但缺 UDP(QUIC)规则
assert "rule_problem" in chk["issues"] and "no_sni" not in chk["issues"] and "no_quic" in chk["issues"]
# 路由器有 IPv6 公网地址,嗅探规则却只有 IPv4 的一份
assert "no_ipv6" in chk["issues"] and "fasttrack" not in chk["issues"]
assert not codes["rules"]["ok"] and codes["rules"]["params"]["wrong"] == [37880]
leases = c.get("/api/dhcp/leases", headers={"Authorization": f"Bearer {tok}"}).json()
assert leases["leases"][0]["mac"] == "AA:BB:CC:DD:EE:31" and leases["leases"][0]["host_name"] == "iPhone"
def test_fasttrack_check_respects_connection_bytes(monkeypatch):
"""FastTrack 开着:规则没限定 connection-bytes 要提示;已经限定(连接开头不走 FastTrack)就不再提示"""
_use_mock(monkeypatch)
monkeypatch.setitem(engine.stats, "dns_answers", 5)
monkeypatch.setitem(ROS, "/rest/ip/settings", {"ipv4-fasttrack-active": "true"})
rule = {".id": "*2", "action": "fasttrack-connection", "chain": "forward",
"connection-state": "established,related", "disabled": "false"}
monkeypatch.setitem(ROS, "/rest/ip/firewall/filter", [rule])
poller.poll()
assert "fasttrack" in poller.check()["issues"]
monkeypatch.setitem(ROS, "/rest/ip/firewall/filter", [{**rule, "connection-bytes": "10000-0"}])
poller.poll()
chk = poller.check()
assert "fasttrack" not in chk["issues"] and chk["fasttrack_limited"]
# 读不到 filter 表(只读账号没权限):按旧办法提示
monkeypatch.delitem(ROS, "/rest/ip/firewall/filter")
poller.poll()
assert "fasttrack" in poller.check()["issues"]
def test_poll_401_gives_actionable_error_and_keeps_status_usable(monkeypatch):
_use_mock(monkeypatch, status=401)
poller.poll()
+28
View File
@@ -0,0 +1,28 @@
"""所有无路径参数的 GET 接口都不能返回 500(0.6.20 的部署量接口调用了已删除的函数,上线后才发现)"""
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from app.main import app
SKIP = {"/api/system/update"} # 会访问 GitHub
def _h(c):
if not c.get("/api/auth/status").json()["initialized"]:
c.post("/api/auth/setup", json={"username": "admin", "password": "secret1"})
return {"Authorization": "Bearer " + c.post("/api/auth/login", json={"username": "admin", "password": "secret1"}).json()["token"]}
def test_get_routes_no_500():
with TestClient(app) as c:
h = _h(c)
bad = []
for r in app.routes:
if not isinstance(r, APIRoute) or "GET" not in r.methods or "{" in r.path:
continue
if not r.path.startswith("/api/") or r.path in SKIP:
continue
resp = c.get(r.path, headers=h)
if resp.status_code >= 500:
bad.append((r.path, resp.status_code, resp.text[:120]))
assert not bad, bad
+7
View File
@@ -53,3 +53,10 @@ services:
interval: 10s
timeout: 5s
retries: 20
# 社区共享库:社区主站目前只有 IPv6 地址,而 Docker 默认网桥没有 IPv6 出口,容器会连不上主站
# (设置页显示“连接失败:本机没有 IPv6 出口”)。Docker 27 及以上取消下面 3 行注释即可(自动分配地址并做 NAT);
# 更老的 Docker(群晖 / 部分 NAS 自带的版本)请改用 Linux 一键安装,或在 daemon.json 里开启 ipv6 + ip6tables。
# networks:
# default:
# enable_ipv6: true
+7
View File
@@ -26,3 +26,10 @@ services:
# 旁路代理(sing-box)和内网 DNS(MosDNS)在网页 系统设置 → 外部数据源 里填写,不需要写在这里
volumes:
- ./data:/app/data # 保存数据库连接配置,务必保留这个卷
# 社区共享库:社区主站目前只有 IPv6 地址,而 Docker 默认网桥没有 IPv6 出口,容器会连不上主站
# (设置页显示“连接失败:本机没有 IPv6 出口”)。Docker 27 及以上取消下面 3 行注释即可(自动分配地址并做 NAT);
# 更老的 Docker(群晖 / 部分 NAS 自带的版本)请改用 Linux 一键安装,或在 daemon.json 里开启 ipv6 + ip6tables。
# networks:
# default:
# enable_ipv6: true
+8 -7
View File
@@ -155,13 +155,13 @@ export default {
agUrl: 'AdGuard Home address', agUser: 'Login username (leave empty if none)', agPassword: 'Login password',
agKept: 'Set; leave empty to keep', agNone: 'Leave empty if none', agClear: 'Clear password',
agHint: 'AdGuard Home query log must be on (enabled by default); empty username and password mean unauthenticated access.',
shareTitle: 'Community library (WebDAV)',
deploys: 'Global deployments', relaysLabel: 'Available relays', deployed: '{n} deployed', online: '{n} online', offline: '{n} offline', deployHint: 'from the relay, refreshed hourly',
shareEnabled: 'Share the learned library (on by default)', shareNoUrl: 'No relay address set', shareWait: 'Configured, waiting for the first sync',
shareTitle: 'Community library',
deploys: 'Global deployments', deployed: '{n} deployed', online: '{n} online', offline: '{n} offline', deployHint: 'from the community hub, refreshed every 30 minutes',
shareEnabled: 'Join community sharing (on by default)', shareWait: 'Waiting for the first sync', shareHub: 'This instance is the community hub',
shareNets: 'Source networks', shareNetsWarn: 'Several instances but only 1 source network: the reverse proxy in front of the hub does not pass X-Forwarded-For, so every instance looks like the same network and nothing reaches the vote threshold. Enable forwarding the client IP in the proxy.',
shareHubUrl: 'Community hub', shareHubHint: 'built in, nothing to fill in; set SHARE_HUB_URL to run a private community',
communitySize: 'Community library', communityDetail: '{ip} IP memories · {dom} domain memories',
shareWhy: 'Every deployment learns different IP→app mappings. Pooling anonymous votes makes the shared identification sharper for everyone. Reports contain remote-IP→app votes only — no local devices, no domain records, no custom apps; private ranges are stripped.',
shareUrl: 'WebDAV folder URL', shareUser: 'WebDAV username', sharePassword: 'WebDAV password',
shareHint: 'Enable WebDAV on your NAS and create a folder (e.g. ros-dpi), then put its URL here. Every 30 minutes: your anonymous votes are written, votes of other instances are read and aggregated locally (entries seen by 2+ instances win). HTTPS or LAN-only recommended.',
shareWhy: 'Every deployment learns different IP/domain→app mappings. Anonymous votes are pooled on the community hub; a mapping is published only when instances on at least 2 different networks agree. Reports contain no local devices, no access records, no custom apps; private ranges are stripped. Turning this off stops reporting and stops using the community library.',
asn: 'IP ownership (ASN)', asnOn: 'On',
asnWhy: 'Domain-less traffic is attributed to the owning company by IP (Tencent, Alibaba, Apple, Netflix…). Data from iptoasn.com (public domain), refreshed daily; off without internet access.',
asnRows: '{n} ranges, updated {d}.', connected: 'Connected', failed: 'Failed', off: 'Off',
@@ -177,7 +177,8 @@ export default {
issue: {
no_dns: 'No working DNS sniffing rule.', no_sni: 'No working SNI rule: HTTPS / QUIC handshakes never reach ROS-DPI.',
no_quic: 'Only a TCP SNI rule; UDP 443 (QUIC / HTTP3) is missing, so QUIC-heavy apps such as Douyin and YouTube are not identified.',
fasttrack: 'FastTrack is on: fast-tracked connections skip mangle, so handshakes are not mirrored. Add connection-bytes=10000-0 to the FastTrack rule.',
fasttrack: 'FastTrack is on and its rule has no connection-bytes limit: fast-tracked connections skip mangle, so handshakes are not mirrored and much HTTPS falls back to IP-only buckets. The command below keeps the first 10 KB of each connection off FastTrack; CPU cost barely changes.',
no_ipv6: 'The router has public IPv6 addresses but sniff rules exist for IPv4 only: DNS answers and HTTPS / QUIC handshakes over IPv6 are invisible. The commands below add the same rules under /ipv6 firewall mangle; if sniff-target is rejected, use the IPv6 address of ROS-DPI.',
},
},
audit: {
+8 -7
View File
@@ -155,13 +155,13 @@ export default {
agUrl: 'AdGuard Home 地址', agUser: '登录用户名(没有可留空)', agPassword: '登录密码',
agKept: '已设置,不修改请留空', agNone: '没有设置可留空', agClear: '清除密码',
agHint: '需要 AdGuard Home 开启查询日志(默认开启);账号和密码都留空表示免认证访问。',
shareTitle: '社区共享库(WebDAV)',
deploys: '全球部署量', relaysLabel: '可用中转', deployed: '已部署 {n}', online: '在线 {n}', offline: '离线 {n}', deployHint: '来自社区中转,每小时刷新',
shareEnabled: '共享识别库(默认打开)', shareNoUrl: '未配置中转地址', shareWait: '已配置,等待首次同步',
shareTitle: '社区共享库',
deploys: '全球部署量', deployed: '已部署 {n}', online: '在线 {n}', offline: '离线 {n}', deployHint: '来自社区主站,每 30 分钟刷新',
shareEnabled: '参与社区共享(默认打开)', shareWait: '等待首次同步', shareHub: '本实例是社区主站',
shareNets: '来源网络', shareNetsWarn: '多个实例却只看到 1 个来源网络:主站前面的反向代理没有传 X-Forwarded-For,所有实例被当成同一网络,社区库凑不够票。请在反向代理里开启传递客户端 IP。',
shareHubUrl: '社区主站', shareHubHint: '项目内置,不需要填写;自建私有社区时用环境变量 SHARE_HUB_URL 修改',
communitySize: '社区库现有', communityDetail: 'IP 记忆 {ip} 条 · 域名记忆 {dom} 条',
shareWhy: '每个部署实例的自学习库都不一样:把各自实测的「远端 IP → 应用」匿名投票汇总到一起,大家共同的识别就更准。上报内容不含本机终端、域名记录与自定义应用,私网地址自动剔除。',
shareUrl: 'WebDAV 共享文件夹地址', shareUser: 'WebDAV 账号', sharePassword: 'WebDAV 密码',
shareHint: '在 NAS 上开启 WebDAV 并建一个共享文件夹(如 ros-dpi),把地址填到这里;每 30 分钟同步一次:写入自己的匿名投票、读取其它实例的投票并在本地聚合(至少 2 台见过才采用)。飞牛 / 群晖在应用中心开启 WebDAV 即可;建议 HTTPS 或仅内网使用。',
shareWhy: '每个部署实例的自学习库都不一样:把各自实测的「远端 IP / 域名 → 应用」匿名投票汇总到社区主站,至少 2 个不同网络的实例都这样认的才进入社区库,大家的识别就更准。上报内容不含本机终端、访问记录与自定义应用,私网地址自动剔除;关闭后不再上报,也不再使用社区库。',
asn: 'IP 归属库(ASN)', asnOn: '已启用',
asnWhy: '没有域名的流量按 IP 归属认出公司(腾讯、阿里、苹果、Netflix……)。数据来自 iptoasn.com(公有领域),容器每天自动更新;没有外网时不启用。',
asnRows: '共 {n} 条,更新于 {d}。', connected: '已连接', failed: '连接失败', off: '未启用',
@@ -177,7 +177,8 @@ export default {
issue: {
no_dns: '没有生效的 DNS 嗅探规则。', no_sni: '没有生效的 SNI 规则:HTTPS / QUIC 握手到不了 ROS-DPI。',
no_quic: '只有 TCP 的 SNI 规则,缺少 UDP 443(QUIC / HTTP3),抖音、YouTube 等大量走 QUIC 的流量认不出来。',
fasttrack: 'FastTrack 已启用:被 FastTrack 的连接不经过 mangle,握手包镜像不到。可以给 FastTrack 规则加 connection-bytes=10000-0。',
fasttrack: 'FastTrack 已启用,且规则没有限定 connection-bytes:被 FastTrack 的连接不经过 mangle,握手包镜像不到,大量 HTTPS 只能按 IP 归到「国内网络」。下方命令让每条连接的前 10KB 不走 FastTrack,CPU 占用几乎不变。',
no_ipv6: '路由器有 IPv6 公网地址,但嗅探规则只有 IPv4 的:走 IPv6 的 DNS 应答和 HTTPS / QUIC 握手都看不到(国内运营商默认下发 IPv6,这部分流量通常很大)。下方命令在 /ipv6 firewall mangle 补一份;若提示 sniff-target 无效,改填 ROS-DPI 的 IPv6 地址。',
},
},
audit: {
+2 -1
View File
@@ -263,7 +263,8 @@ async function reclassify() {
reclassing.value = true
reclassRes.value = ''
try {
const r = await http.post('/rules/reclassify')
// 数据多时要一两分钟(审计报告 P1-3:实测 80 秒),默认 20 秒超时会误报失败而后端其实还在跑
const r = await http.post('/rules/reclassify', null, { timeout: 600000 })
reclassRes.value = t('rules.reclassifyDone', { b: bytes(r.bytes_moved), s: r.seconds })
} catch (e) { reclassRes.value = e.message } finally { reclassing.value = false }
}
+35 -30
View File
@@ -163,38 +163,34 @@
</div>
<div class="src-box">
<div class="sec">{{ t('sources.shareTitle') }}<span class="sp" />
<span v-if="!s.share_enabled" class="pill grey">{{ t('sources.off') }}</span>
<template v-else-if="!src.share_url"><span class="pill grey">{{ t('sources.shareNoUrl') }}</span></template>
<template v-else>
<span v-if="deploys && deploys.sync && deploys.sync.ts" class="pill" :class="deploys.sync.ok ? 'ok' : 'bad'">
{{ deploys.sync.ok ? t('sources.connected') : t('sources.failed') }}</span>
<span v-else class="pill grey">{{ t('sources.shareWait') }}</span>
</template></div>
<span v-if="deploys && deploys.role === 'hub'" class="pill ok">{{ t('sources.shareHub') }}</span>
<span v-else-if="!s.share_enabled" class="pill grey">{{ t('sources.off') }}</span>
<span v-else-if="deploys && deploys.sync.ts" class="pill" :class="deploys.sync.ok ? 'ok' : 'bad'">
{{ deploys.sync.ok ? t('sources.connected') : t('sources.failed') }}</span>
<span v-else class="pill grey">{{ t('sources.shareWait') }}</span></div>
<p class="faint small">{{ t('sources.shareWhy') }}</p>
<label class="chk" style="display:flex;gap:6px;align-items:center;cursor:pointer">
<input type="checkbox" :checked="s.share_enabled" @change="src.share_enabled = $event.target.checked" /> {{ t('sources.shareEnabled') }}
<input type="checkbox" :checked="src.share_enabled" @change="src.share_enabled = $event.target.checked" /> {{ t('sources.shareEnabled') }}
</label>
<label for="share-url">{{ t('sources.shareUrl') }}</label>
<input id="share-url" class="input addr" v-model.trim="src.share_url" placeholder="http://192.168.8.10:5005/ros-dpi" autocomplete="off" />
<label for="share-user">{{ t('sources.shareUser') }}</label>
<input id="share-user" class="input" v-model.trim="src.share_user" autocomplete="off" />
<label for="share-password">{{ t('sources.sharePassword') }}</label>
<div class="row">
<input id="share-password" class="input" type="password" v-model="src.share_password" autocomplete="new-password"
:placeholder="s.has_webdav_password ? t('sources.agKept') : t('sources.agNone')" />
<button v-if="s.has_webdav_password" type="button" class="btn sm" @click="clearSharePassword">{{ t('sources.agClear') }}</button>
<div class="small" style="margin-top:6px" v-if="deploys">
<b>{{ t('sources.shareHubUrl') }}</b>:<span class="addr">{{ deploys.hub_url || '—' }}</span>
<span class="faint small" v-if="deploys.role !== 'hub'">({{ t('sources.shareHubHint') }})</span>
</div>
<div class="faint small">{{ t('sources.shareHint') }}</div>
<div class="small" style="margin-top:8px" v-if="deploys && deploys.sync && deploys.sync.ts">
<div class="err small" v-if="deploys && deploys.sync.ts && !deploys.sync.ok && deploys.sync.error">{{ deploys.sync.error }}</div>
<div class="small" style="margin-top:8px" v-if="deploys && deploys.sync.ts">
<b>{{ t('sources.communitySize') }}</b>:{{ t('sources.communityDetail', { ip: (deploys.sync.community_ips || 0).toLocaleString(), dom: (deploys.sync.community_domains || 0).toLocaleString() }) }}
<span class="faint small">({{ t('settings.last') }} {{ ago(deploys.sync.ts) }})</span>
</div>
<div class="small" style="margin-top:8px" v-if="deploys">
<b>{{ t('sources.deploys') }}</b>:{{ t('sources.deployed', { n: deploys.total }) }} ·
<span style="color:var(--up)">{{ t('sources.online', { n: deploys.online }) }}</span> ·
{{ t('sources.offline', { n: deploys.offline }) }}
<div class="small" style="margin-top:8px" v-if="deploys && deploys.sync.total != null">
<b>{{ t('sources.deploys') }}</b>:{{ t('sources.deployed', { n: deploys.sync.total }) }} ·
<span style="color:var(--up)">{{ t('sources.online', { n: deploys.sync.online }) }}</span> ·
{{ t('sources.offline', { n: deploys.sync.offline }) }}
<span class="faint small">({{ t('sources.deployHint') }})</span>
</div>
<div class="small" style="margin-top:6px" v-if="deploys && deploys.role === 'hub'">
<b>{{ t('sources.shareNets') }}</b>:{{ deploys.sync.nets }}
<div class="err small" v-if="deploys.sync.total > 2 && deploys.sync.nets <= 1">{{ t('sources.shareNetsWarn') }}</div>
</div>
</div>
<div class="acts">
<span class="err" v-if="srcErr">{{ srcErr }}</span><span class="sp" />
@@ -330,7 +326,7 @@ onMounted(async () => {
})
// ---------- 外部数据源 ----------
const src = reactive({ singbox_url: '', singbox_secret: '', proxy_ips: '', mosdns_url: '', adguard_url: '', adguard_username: '', adguard_password: '', share_url: '', share_enabled: true, share_user: '', share_password: '' })
const src = reactive({ singbox_url: '', singbox_secret: '', proxy_ips: '', mosdns_url: '', adguard_url: '', adguard_username: '', adguard_password: '', share_enabled: true })
const deploys = ref(null)
const srcErr = ref('')
const srcSaved = ref(false)
@@ -339,7 +335,7 @@ function fillSources() {
const i = s.value.integrations
Object.assign(src, { singbox_url: i.singbox_url, singbox_secret: '', proxy_ips: i.proxy_ips, mosdns_url: i.mosdns_url,
adguard_url: i.adguard_url, adguard_username: i.adguard_username, adguard_password: '',
share_url: i.webdav_url, share_user: i.webdav_user || '', share_enabled: !!s.value.share_enabled, share_password: '' })
share_enabled: !!s.value.share_enabled })
}
async function saveSources() {
srcErr.value = ''
@@ -347,23 +343,20 @@ async function saveSources() {
// 密钥留空表示不修改;要清除用“清除密钥/密码”按钮
const body = { singbox_url: src.singbox_url, proxy_ips: src.proxy_ips, mosdns_url: src.mosdns_url,
adguard_url: src.adguard_url, adguard_username: src.adguard_username,
share_url: src.share_url, share_user: src.share_user }
share_enabled: src.share_enabled }
if (src.singbox_secret) body.singbox_secret = src.singbox_secret
if (src.adguard_password) body.adguard_password = src.adguard_password
if (src.share_password) body.webdav_password = src.share_password
s.value = await http.put('/settings', body)
fillSources()
srcSaved.value = true
setTimeout(() => (srcSaved.value = false), 1500)
await testSources()
setTimeout(loadDeploys, 3000) // 打开共享开关后后台立刻同步一轮,稍后刷新状态
} catch (e) { srcErr.value = e.message }
}
async function clearSecret() {
try { s.value = await http.put('/settings', { singbox_secret: '' }); fillSources() } catch (e) { srcErr.value = e.message }
}
async function clearSharePassword() {
try { s.value = await http.put('/settings', { webdav_password: '' }); fillSources() } catch (e) { srcErr.value = e.message }
}
async function clearAdguardPassword() {
try { s.value = await http.put('/settings', { adguard_password: '' }); fillSources() } catch (e) { srcErr.value = e.message }
}
@@ -469,6 +462,18 @@ const fixes = computed(() => {
} else if (c.issues.includes('no_quic')) {
out.push(`/ip firewall mangle add chain=forward protocol=udp dst-port=443 connection-bytes=0-4000 ${tail}`)
}
if (c.issues.includes('no_ipv6')) {
// IPv6 再加一份同样的 DNS / SNI 嗅探(镜像包仍发到 ROS-DPI 的 IPv4 地址)
const dns = `action=sniff-tzsp sniff-target=${tgt} sniff-target-port=${c.port} comment=ROSDPI-DNS`
out.push(`/ipv6 firewall mangle add chain=output protocol=udp src-port=53 ${dns}`)
out.push(`/ipv6 firewall mangle add chain=forward protocol=udp src-port=53 ${dns}`)
out.push(`/ipv6 firewall mangle add chain=forward protocol=tcp dst-port=443 connection-bytes=0-4000 ${tail}`)
out.push(`/ipv6 firewall mangle add chain=forward protocol=udp dst-port=443 connection-bytes=0-4000 ${tail}`)
}
if (c.issues.includes('fasttrack')) {
// 每条连接的前 10KB 走正常路径(握手能被镜像),之后照常 FastTrack
out.push('/ip firewall filter set [find action=fasttrack-connection] connection-bytes=10000-0')
}
return [...new Set(out)]
})
const fixCopied = ref(false)