网站审查与封锁检测
网站审查与封锁检测的默认视图基于 OONI web_connectivity 历史测量:查询目标主机名及其 www. 变体,将最近 30 天的数据按国家合并,再区分明确封锁、高比例异常、部分异常迹象和其余结果。本页重点解释已经核实的查询接口、分级算法及结果解读边界。
目的与范围
本页覆盖 /api/ooni-blocking 的请求处理、OONI 上游集成、主机名变体合并、国家级统计、阈值、排序、重试和降级行为。接口注释明确说明它支撑前端 CensorshipCheck 的默认视图。
本次源码材料未能确认前端组件位置,因而不描述具体按钮、地图、交互状态或实时检测流程。Globalping 探针、MTR、全球延迟检测、通用域名验证和缓存中间件属于相邻能力;本页只说明当前接口对这些基础设施已可见的依赖,不推断其完整实现。运行时未提供相邻 Wiki 页面路径,因此不构造未经确认的目录链接。
**证据边界:**已直接读取查询处理器和纯分类模块;路由注册、requireValidDomain、cacheable()、fetchUpstream 的实际实现及专用测试未在本次预算内核实。
概述
这一机制不是从应用服务器即时连接目标网站,也不是从访问者浏览器即时验证目标网站。处理器向 OONI 聚合接口请求已有测量结果,时间窗长度为 30 天,日期在服务器端按 UTC 日期格式生成。
理解结果时需要区分三个概念:
- 测量异常
anomaly:参与风险分级的异常计数,并不自动等同于已经证明存在审查。 - 已确认
confirmed:源码注释将其解释为 OONI 通过可识别封锁页面指纹确认的封锁。没有这种页面的干扰方式可能主要表现为异常,而非confirmed。 - 国家等级
tier:应用根据样本量及异常占比计算的摘要,不是 OONI 原始记录中的直接字段。
因此,该功能适合查看某域名在不同国家的历史异常分布、比较疑似封锁强度及观察相关手段;不应将其作为对某位用户当前网络状态的确定诊断。
依据:ooni-blocking.js、ooni-blocking.js。
架构
Sources: ooni-blocking.js、ooni-blocking.js。
图中的前端关系来自处理器注释,其余调用关系来自实际实现。处理器负责网络 I/O 和响应,classifyOoniCountries 只负责内存中的统计与分级。这样的职责分离使阈值和排序逻辑不依赖 HTTP 请求对象,也便于独立验证分类规则。
已读取的代码没有数据库写入、后台任务或跨请求可变结果存储。处理器注释提到外层 cacheable() 和 Cloudflare 缓存,但缓存包装不在该文件内,不能据此给出 TTL 或失效规则。
请求处理:从域名到国家列表
1. 方法检查与域名输入边界
导出处理器首先检查 req.method。非 GET 请求返回 HTTP 405,以及错误文本 Method Not Allowed。随后读取 req.query.domain,处理器本身不再检查其是否存在、是否为字符串或是否是合法域名。
源码注释将“存在性、格式及小写转换”交给 requireValidDomain。这是一项调用前置条件,而不是本处理器内部已经实现的校验。尤其需要注意,域名字符串处理发生在主体 try 之前:绕过外层验证而直接调用时,缺失或类型不正确的 domain 可能在进入该错误处理范围之前失败。
依据:ooni-blocking.js。
2. 主机名变体,而不是公共后缀解析
OONI 按被测试的确切主机名匹配 domain。只查一个名称可能遗漏 www. 版本的大量测量,因此代码同时查询两个变体:
const domain = req.query.domain;
const apex = domain.replace(/^www\./, '');
const variants = [...new Set([apex, `www.${apex}`])];Source: ooni-blocking.js。
这里的 apex 只是变量名:算法仅移除开头的一次 www.,没有解析 Public Suffix List,也不会把任意子域转换成可注册根域。对其他子域,仍然是“原主机名及其添加 www. 的版本”。接入方不应把它当作通用的根域提取器。
3. UTC 时间窗
处理器先取得服务器当前时间,until 为 toISOString().slice(0, 10),since 为当前时间减去 OONI_WINDOW_DAYS × 24 × 60 × 60 × 1000 后的同类日期字符串。
其效果是同一个 UTC 日内,生成的 OONI 查询日期保持一致,避免每次请求都带有不同的时间戳。源码将这一做法与缓存稳定性联系起来。上游对 since、until 边界是否包含当日的精确定义不在已读实现中,不能将它描述成已经验证的闭区间或实时滑动窗口。
依据:ooni-blocking.js、ooni-blocking.js。
4. 固定维度的 OONI 聚合查询
fetchAggregationOnce(hostname, since, until) 使用固定上游地址 https://api.ooni.io/api/v1/aggregation,参数构造如下:
1 const params = new URLSearchParams({
2 domain: hostname,
3 test_name: 'web_connectivity',
4 since,
5 until,
6 axis_x: 'probe_cc',
7 axis_y: 'blocking_type',
8 });Source: ooni-blocking.js。
probe_cc × blocking_type 意味着同一国家可能有多条聚合行,后续必须再次按国家累加。上游返回非成功 HTTP 状态时,函数抛出包含状态码的 Error;成功响应则执行 JSON 解析,并仅在 data.result 是数组时保留它,否则返回空数组。
“上游请求成功”不等于“存在测量数据”。成功响应的 result 缺失或非数组会成为空结果,不会自动触发降级警告或接口错误。
5. 并行执行、部分成功与输出
两个变体通过 Promise.allSettled 并行执行。处理器等待两者都结束后,筛出 fulfilled 结果,再将数组展平交给分类函数。
1 const settled = await Promise.allSettled(variants.map((v) => fetchAggregation(v, since, until)));
2 const fulfilled = settled.filter((s) => s.status === 'fulfilled').map((s) => s.value);
3 if (fulfilled.length === 0) {
4 throw settled[0].reason;
5 }
6 if (fulfilled.length < settled.length) {
7 logger.warn({ domain }, 'ooni-blocking: one hostname variant query failed, serving partial merge');
8 }Source: ooni-blocking.js。
- 两者成功:合并两个主机名的数据。
- 一个成功:继续返回成功响应,仅通过日志记录部分合并。
- 两者失败:抛出第一个 settled 条目的失败原因,进入统一 HTTP 500 路径。
响应没有 partial 或失败主机名字段,因此客户端不能仅凭响应体区分完整结果和部分成功结果。
核心交互流程
Source: ooni-blocking.js。
该流程的容错单位是“主机名查询”,不是国家。一个主机名失败时,其全部测量都不会进入分类;处理器不会补齐缺失国家,也不会给这些国家制造 ok 条目。
国家统计与分类算法
按 probe_cc 累加
classifyOoniCountries(rows) 创建局部 Map,逐行读取 row?.probe_cc,跳过没有有效国家键的行。同一国家的两个主机名结果及不同阻断类型结果都累加到一个条目中。
| 输出字段 | 数据来源与含义 |
|---|---|
country | probe_cc,原样作为国家键使用 |
measurements | 累加 measurement_count |
ok | 累加 ok_count |
anomaly | 累加 anomaly_count |
confirmed | 累加 confirmed_count |
failure | 累加 failure_count |
methods | 按允许的 blocking_type 累加异常与确认计数 |
tier | classifyTier 计算的等级 |
每项计数都使用 row.<字段> || 0 作为缺省处理。实现没有强制数值转换、负数检查或总数一致性校验,因此它依赖上游计数符合预期格式。这里是聚合行相加,不是依据测量 ID 去重;函数也没有主机名维度的最终输出。
依据:ooni-blocking.js。
四级判定与优先级
设总测量数为 M,异常数为 A,确认数为 C。阻断相关占比为 (A + C) / M。实现按下表顺序判定,命中后立即返回。
| 顺序 | 条件 | 等级 | 解读限制 |
|---|---|---|---|
| 1 | M < 5 | ok | 样本不足也进入此等级,不能视作已证实可访问 |
| 2 | C / M >= 0.05 | confirmed | 达到确认比例门槛,并非只要有一次 confirmed 即成立 |
| 3 | (A + C) / M >= 0.5 | likely | 高比例异常或确认信号 |
| 4 | M >= 30 且 (A + C) / M >= 0.2 | signs | 样本较充分时的中等异常比例 |
| 5 | 其余 | ok | 未达到以上阈值,不代表没有异常或故障 |
真实判定代码:
1const classifyTier = ({ measurements, anomaly, confirmed }) => {
2 if (measurements < MIN_SAMPLES) return 'ok';
3 const blockedRatio = (anomaly + confirmed) / measurements;
4 if (confirmed / measurements >= CONFIRMED_RATIO) return 'confirmed';
5 if (blockedRatio >= LIKELY_RATIO) return 'likely';
6 if (measurements >= SIGNS_MIN_SAMPLES && blockedRatio >= SIGNS_RATIO) return 'signs';
7 return 'ok';
8};Source: ooni-blocking.js。
阈值的设计理由在注释中有明确说明:总体最低样本数保持较低,以保留较少被测试域名中的明显异常信号;signs 对样本量要求更高,因为中等异常比例更容易来自间歇故障或 CDN 地理差异。likely 也不是无关紧要的降级等级:DNS 污染或 RST 注入等场景可能没有可识别封锁页面,主要体现为异常占比。
特别注意,failure 被保留展示,但不进入 (A + C) 分子,也没有独立的失败率等级。不能从 tier === 'ok' 推导出 failure === 0。
阻断方式统计
只有 dns、tcp_ip、http-failure 和 http-diff 会进入 methods。每个方法累计的是该类型行的 anomaly_count + confirmed_count,不是该类型的所有测量数。
1 const bt = row.blocking_type;
2 if (BLOCKING_TYPES.has(bt)) {
3 const hits = (row.anomaly_count || 0) + (row.confirmed_count || 0);
4 if (hits > 0) entry.methods[bt] = (entry.methods[bt] || 0) + hits;
5 }Source: ooni-blocking.js。
空类型或未知类型不进入方法明细,但其计数仍可能已经进入国家总计。因此,消费端不应假设 methods 的值之和一定等于国家的 anomaly + confirmed,也不应将 methods 理解为所有连接故障原因的完整诊断。
排序规则
分类结束后,返回数组按以下优先级排序:
- 等级顺序:
confirmed、likely、signs、ok。 - 同等级内,
(anomaly + confirmed) / measurements降序;零测量时比例取 0。 - 占比仍相同时,
measurements降序。
没有国家名称或国家代码的显式末级排序条件。调用方如果需要字母排序,应明确它与当前“风险优先”返回顺序不同。
依据:ooni-blocking.js、ooni-blocking.js。
API 参考与使用示例
HTTP 接口:GET /api/ooni-blocking
| 参数 | 位置 | 类型及要求 | 行为 |
|---|---|---|---|
domain | 查询参数 | 域名字符串;处理器假定外层已经验证并转小写 | 用于构造两个 OONI 主机名查询 |
客户端不能通过本处理器参数设置时间窗、国家过滤或分级阈值。成功响应直接使用 res.json,没有显式调用 res.status。
响应构造同时展示分类函数的实际调用方式:
1 res.json({
2 domain: apex,
3 since,
4 until,
5 countries: classifyOoniCountries(fulfilled.flat()),
6 });Source: ooni-blocking.js。
| 响应字段 | 类型 | 说明 |
|---|---|---|
domain | string | 移除开头一次 www. 后的主机名 |
since | string | UTC 日期格式的查询起点 |
until | string | UTC 日期格式的查询终点 |
countries | Array | 已合并、分级并排序的国家条目;允许为空数组 |
模块函数
以下参数类型和返回形状根据 JavaScript 实现描述,不是额外的 TypeScript 声明。
| 实际函数形式 | 可见性 | 输入 | 返回及错误行为 |
|---|---|---|---|
async (req, res) => | 默认导出处理器 | HTTP 请求和响应对象 | 写入 JSON 响应;非 GET 为 405,查询主体捕获的错误为 500 |
classifyOoniCountries(rows) | 命名导出 | 可迭代的 OONI 聚合行,实际调用传入数组 | 同步返回国家条目数组;无网络 I/O,没有显式异常处理 |
classifyTier({ measurements, anomaly, confirmed }) | 模块内部 | 三个计数字段 | 同步返回四种等级之一 |
fetchAggregationOnce(hostname, since, until) | 模块内部、async | 主机名及两个日期字符串 | 返回 result 数组或空数组;上游非成功状态、请求或 JSON 解析失败会拒绝 |
fetchAggregation(hostname, since, until) | 模块内部、async | 同上 | 最多执行两次单次查询;AbortError 不重试,最终错误继续上抛 |
依据:ooni-blocking.js、ooni-blocking.js。
配置与固定策略
下表均是源码常量或固定查询参数,并非已验证的环境变量或用户设置。
| 名称 | 类型 | 默认/当前固定值 | 作用 |
|---|---|---|---|
OONI_WINDOW_DAYS | number | 30 | 服务器端查询时间窗长度;命名导出 |
MIN_SAMPLES | number | 5 | 分类最低样本量 |
SIGNS_MIN_SAMPLES | number | 30 | signs 额外样本门槛 |
CONFIRMED_RATIO | number | 0.05 | 确认计数占比阈值 |
LIKELY_RATIO | number | 0.5 | 高异常占比阈值 |
SIGNS_RATIO | number | 0.2 | 中等异常占比阈值 |
OONI_AGGREGATION_URL | string | https://api.ooni.io/api/v1/aggregation | 上游聚合服务 |
test_name | string | web_connectivity | OONI 测试类型 |
axis_x / axis_y | string | probe_cc / blocking_type | 国家与阻断类型聚合维度 |
fetchAggregation 注释提及等待超时可能再花费 8 秒,但实际超时机制位于未读取的 fetchUpstream 实现。因此,不能把 8 秒写成这里已经验证的可配置默认超时,也不能据此给出严格的整体耗时上限。
依据:ooni-blocking.js、ooni-blocking.js。
失败模式、边界与并发
重试的实际范围
源码注释说明重试用于缓解 OONI 偶发 5xx,但实现并未检查 HTTP 状态码来决定重试,而是对除 AbortError 外的第一次异常统一再尝试一次:
1const fetchAggregation = async (hostname, since, until) => {
2 try {
3 return await fetchAggregationOnce(hostname, since, until);
4 } catch (err) {
5 if (err.name === 'AbortError') throw err;
6 return await fetchAggregationOnce(hostname, since, until);
7 }
8};Source: ooni-blocking.js。
因此,非 5xx 的 HTTP 错误、非 AbortError 的请求失败以及 JSON 解析错误也可能重试。没有退避等待、抖动、重试循环或熔断器。两个主机名每个至多调用 fetchAggregationOnce 两次,即该处理器逻辑至多发起四次这样的调用;底层请求助手内部是否另有行为未核实。
边界情况速查
| 情况 | 实际行为 | 对接建议 |
|---|---|---|
| 非 GET | 405 和固定错误文本 | 不把它当作上游故障 |
| 单个主机名失败 | 警告日志,返回另一份数据 | 结果可能不完整,响应体没有降级标记 |
| 两个主机名都失败 | 错误日志,500,error.message | 区分接口失败与“无封锁证据” |
成功但 result 非数组 | 转换为空数组 | 空列表不表示全球均可访问 |
行缺少 probe_cc | 跳过该行 | 不会建立“未知国家”条目 |
| 样本数不足 5 | ok | 展示时应同时保留样本量 |
| 未知阻断类型 | 不进 methods,总计仍可累加 | 不依赖方法明细覆盖全部异常 |
| 未经验证的域名输入 | 可能在主体 try 之前失败 | 不绕过外层域名校验 |
并发与一致性
两个变体同时查询,但 Promise.allSettled 必须等较慢的一方也成功或失败后才能生成响应。它不是“第一个成功就返回”的竞速模型。
Map、计数对象和日期变量都是每次调用局部创建,已读模块没有共享可变国家统计。另一方面,两个上游请求并不是事务性快照,也没有结果去重或测量一致性核验。合并值反映的是两份聚合响应的直接累加。
依据:ooni-blocking.js、ooni-blocking.js。
性能、运维与扩展
性能与日志
- 网络请求是主要外部依赖。双主机名并发避免在本层串行等待,但立即重试也会增加上游负载。
- 对
R条聚合行和C个国家,分类累加约为O(R),最终排序约为O(C log C);国家累加器占用约为O(C),调用方另通过flat()创建合并输入数组。 - 部分成功日志携带
domain;全部失败日志携带err和domain。已读代码未提供成功率、分类分布或延迟指标。 - UTC 日期对齐支持稳定查询窗口。缓存 TTL、错误响应是否缓存、跨日失效及部署级限流策略未在已读取源码中确认。
扩展时应保持的语义
- 调整阈值:修改分类模块的常量及对应验证用例,不要将
ok改写为“无封锁”而忽略其包含样本不足的现有语义。 - 新增阻断类型:更新
BLOCKING_TYPES后才能进入methods;还应单独验证消费端是否认识新键。 - 增加主机名变体:会影响上游请求量和统计混合范围;现有算法不会保留不同主机名的分项结果。
- 暴露降级信息:当前仅日志可见,若消费端需要区分部分结果,需要明确扩展响应契约,而不是假设已有此字段。
- 增加输入保护:若把纯分类函数作为更通用入口复用,需要自行评估计数字段类型与一致性检查;当前函数没有完整输入 schema 校验。
这些是基于现有控制流的维护建议,不代表仓库已经实现了上述扩展。
测试与验证边界
本次未读取到专门覆盖 OONI 接口或国家分类的测试文件,因此不声称这些逻辑已有自动化覆盖,也未执行测试。若补充测试,优先核验以下由实现直接决定的边界:
- 最低样本量 5 与
signs样本量 30 的边界。 - 三个比例阈值的等号条件,以及
confirmed先于likely判定。 - 空数组、缺少国家键、未知阻断类型和缺失计数。
- 同国家多条行的合并,以及等级、异常比例和测量数的三级排序。
- 一份结果失败时的成功降级、两份都失败时的 500,以及超时不重试。
- 上游成功但结构不符合预期时的空结果行为。
相关链接
- 查询处理、重试与降级:用于排查无数据、部分结果和上游失败。
- 等级语义与阈值:用于理解或维护风险分级。
- 国家统计、方法明细及排序:用于对接结果展示或验证聚合输出。