feat(proxy): add China IPv4 CIDR direct candidates to rule mode

Pin gaoyifan/china-operator-ip china.txt and compile its IPv4 CIDRs into
the proxy_all ACL alongside China domain candidates. Explicit maintainer
update normalizes and deduplicates CIDRs and blocks replacement on
abnormal format, corruption, scale change, or all-invalid input.

Closes #64
This commit is contained in:
Qiu Yuzhou
2026-09-26 18:16:44 +08:00
parent 827fb98be3
commit 7a59ca7827
17 changed files with 106484 additions and 41 deletions
+2 -2
View File
@@ -14,7 +14,7 @@
- **Proxy mode**: The user-facing way traffic is routed through the local proxy: rule mode, local PAC, global (public targets default through Shadowsocks, fixed local targets stay direct via a minimal ACL), or direct mode through an ACL-backed local SOCKS endpoint.
- **Rule mode**: The ACL-backed routing mode in which built-in (and later custom) rules choose proxy versus direct per target; the unmatched default action is a separate persisted sub-option.
- **Rule default action**: The rule-mode sub-option that decides proxy versus direct for targets no rule matches. Factory default is proxy-when-unmatched. _Avoid_: fallback (ambiguous).
- **Built-in rule snapshot**: The app-shipped, pinned geolocation-cn rule set produced by an explicit maintainer update; ordinary builds only read the local snapshot and never fetch or convert.
- **Built-in rule snapshot**: An app-shipped, pinned rule set produced by an explicit maintainer update; ordinary builds only read local snapshots and never fetch or convert. Two sources ship today: **geolocation-cn** (China domain direct candidates) and **china-ipv4** (China mainland IPv4 CIDR direct candidates from gaoyifan/china-operator-ip). Each snapshot carries its own source identity, digest, license, and attribution.
- **Local PAC**: The PAC document generated and served by the 2.0 proxy runtime; it is distinct from a user-provided remote PAC URL.
- **Proxy mode selector**: The common user action for choosing local PAC, global, or direct mode; it is distinct from enabling or disabling the proxy runtime, from the system proxy switch, and from editing PAC user rules. Its choice persists across GUI restarts.
- **Agent switch**: The user's independent on/off choice for the proxy agent (the per-user background proxy runtime). It defaults to on, so a first run starts the agent and keeps local SOCKS/HTTP listening even before any active target exists; an explicit off is persisted, survives GUI restarts, and quitting the GUI never stops the launchd-hosted agent. Turning the agent off first restores the system proxy settings 2.0 holds and then stops the local listeners. _Avoid_: proxy switch (ambiguous with the system proxy switch).
@@ -78,7 +78,7 @@
- Exported GUI event lines are an allowlisted, field-cleaned subset of runtime events; raw event detail remains available only through the explicit diagnostic log view.
- A diagnostic report presents fixed proxy-state categories rather than free-form failure detail. If its safe projection cannot be constructed, no report is emitted; an unavailable individual fact source remains a marked partial fact instead.
- A diagnostic export is considered complete only after the user-selected report file is successfully written; opening the export panel or preparing report text is not an export completion.
- Proxy modes are mutually exclusive: rule mode with proxy-when-unmatched writes the local SOCKS proxy and runs sslocal with a `[proxy_all]` ACL whose bypass list is the fixed local safety rules plus China domain direct candidates from the built-in snapshot; local PAC writes the runtime PAC URL; global writes the local SOCKS proxy and runs sslocal with a `[proxy_all]` ACL whose bypass list is only the fixed local safety rules (no China list, GFWList, or custom rules); direct mode writes the local SOCKS proxy and runs sslocal with `[bypass_all]`, so SOCKS and HTTP requests go direct without a Shadowsocks server. In both ACL-backed modes the SystemConfiguration exceptions include the same fixed local scope — IPv4 loopback, RFC1918, link-local, IPv6 loopback, link-local, unique-local, `localhost`, `.local`, and no-dot hostnames — merged with the user's exception list; CGNAT is excluded. IPv6 CIDR strings in system exceptions are not a verified bypass; the ACL is the routing guarantee for requests that enter sslocal, including LAN-sharing clients. ACL path, content, digest, or summary changes require a full sslocal restart. The wrapper publishes the ACL-backed receipt only after the child owns every configured TCP listener; the controller retains the previous mode, runtime, and applied system proxy until this instance is verified, and restores the previous runtime on verification failure. System proxy writes are allowed only while the system proxy switch is on and the agent is healthy with an available exit; 2.0 stores the complete per-service Proxies dictionaries it owns and restores them only while the applied values still match, otherwise it reports an ownership conflict. When a PAC/global active target becomes invalid, the agent keeps listening but the held system proxy configuration is safely withdrawn while the system proxy intent stays on.
- Proxy modes are mutually exclusive: rule mode with proxy-when-unmatched writes the local SOCKS proxy and runs sslocal with a `[proxy_all]` ACL whose bypass list is the fixed local safety rules plus China domain and China IPv4 CIDR direct candidates from the built-in snapshots; local PAC writes the runtime PAC URL; global writes the local SOCKS proxy and runs sslocal with a `[proxy_all]` ACL whose bypass list is only the fixed local safety rules (no China list, GFWList, or custom rules); direct mode writes the local SOCKS proxy and runs sslocal with `[bypass_all]`, so SOCKS and HTTP requests go direct without a Shadowsocks server. IP-CIDR matching may cause sslocal to issue local DNS queries for unmatched hostnames; the product does not promise that all DNS queries travel through the remote Shadowsocks server. In both ACL-backed modes the SystemConfiguration exceptions include the same fixed local scope — IPv4 loopback, RFC1918, link-local, IPv6 loopback, link-local, unique-local, `localhost`, `.local`, and no-dot hostnames — merged with the user's exception list; CGNAT is excluded. IPv6 CIDR strings in system exceptions are not a verified bypass; the ACL is the routing guarantee for requests that enter sslocal, including LAN-sharing clients. ACL path, content, digest, or summary changes require a full sslocal restart. The wrapper publishes the ACL-backed receipt only after the child owns every configured TCP listener; the controller retains the previous mode, runtime, and applied system proxy until this instance is verified, and restores the previous runtime on verification failure. System proxy writes are allowed only while the system proxy switch is on and the agent is healthy with an available exit; 2.0 stores the complete per-service Proxies dictionaries it owns and restores them only while the applied values still match, otherwise it reports an ownership conflict. When a PAC/global active target becomes invalid, the agent keeps listening but the held system proxy configuration is safely withdrawn while the system proxy intent stays on.
- The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, local PAC, global, and direct mode are always available, and user-provided remote PAC URLs are not accepted as proxy settings. Resetting preferences returns the mode to local PAC and the rule default action to proxy-when-unmatched.
- The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model.
- The presentation form is governed solely by the workspace window: opening it enters the foreground form, and closing it returns the app to the background form while the GUI process keeps running with the status menu available; hiding the window without closing it does not leave the foreground form.
@@ -103,11 +103,13 @@ extension ProxyRuntimeController {
}
}
/// 内置中国域名直连候选。快照缺失/损坏/版本不匹配时抛错,调用方必须失败
/// 内置中国直连候选:geolocation-cn 域名 + china-operator-ip IPv4 CIDR
/// (issue #63/#64)。快照缺失/损坏/版本不匹配时抛错,调用方必须失败
/// 并保留旧 ACL,不得静默退化成全局(issue #63 AC4)。
func chinaDirectRules() throws -> [ProxyRule] {
let snapshot = try BuiltinRuleCatalog.loadGeolocationCN()
return BuiltinRuleCatalog.chinaDirectRules(from: snapshot)
let geolocation = try BuiltinRuleCatalog.loadGeolocationCN()
let chinaIPv4 = try BuiltinRuleCatalog.loadChinaIPv4()
return BuiltinRuleCatalog.chinaDirectRules(from: [geolocation, chinaIPv4])
}
private func deployModeTransition(
@@ -1,20 +1,37 @@
import Foundation
/// 内置规则目录(issue #63):从 bundle 资源加载已固定的 geolocation-cn
/// 快照。普通构建只读本地快照,运行时不抓取或转换;快照缺失/损坏/版本
/// 不匹配时加载失败,不生成空规则 ACL。
/// 内置规则目录(issue #63/#64):从 bundle 资源加载已固定的 geolocation-cn
/// 与 china-ipv4 快照。普通构建只读本地快照,运行时不抓取或转换;快照缺失/
/// 损坏/版本不匹配时加载失败,不生成空规则 ACL。
struct BuiltinRuleCatalog {
/// 生产加载缝:bundle 内固定快照。
/// 生产加载缝:bundle 内固定快照(`rules/<source>/snapshot.json`)。
static func loadGeolocationCN(from bundle: Bundle = .main) throws -> RuleSnapshot {
guard let url = bundle.url(forResource: "snapshot", withExtension: "json")
try loadSnapshot(named: "geolocation-cn", from: bundle)
}
/// china-operator-ip 中国 IPv4 CIDR 直连候选(issue #64)。
static func loadChinaIPv4(from bundle: Bundle = .main) throws -> RuleSnapshot {
try loadSnapshot(named: "china-ipv4", from: bundle)
}
private static func loadSnapshot(named name: String, from bundle: Bundle) throws -> RuleSnapshot {
// folder reference 保留 `rules/<source>/snapshot.json` 目录结构。
guard
let url = bundle.url(
forResource: "snapshot", withExtension: "json", subdirectory: "rules/\(name)")
else {
throw RuleSnapshotError.missing
}
return try RuleSnapshotStore(fileURL: url).load()
}
/// 供 ACL 编译的中国域名直连候选(`.cn` 后缀 + geolocation-cn)。
/// 供 ACL 编译的中国直连候选(`.cn` 后缀 + geolocation-cn + china-ipv4)。
static func chinaDirectRules(from snapshot: RuleSnapshot) -> [ProxyRule] {
snapshot.rules.filter { $0.action == .direct }
}
/// 合并多份快照的直连候选;保持输入顺序(域名在前,CIDR 在后)。
static func chinaDirectRules(from snapshots: [RuleSnapshot]) -> [ProxyRule] {
snapshots.flatMap { chinaDirectRules(from: $0) }
}
}
@@ -0,0 +1,149 @@
import Foundation
// MARK: - 转换器
/// china-operator-ip IPv4 CIDR 转换器(issue #64):解析一行一个 CIDR 的文本、
/// 规范化并去重、输出规范化快照与损失报告。异常格式、全部失效或异常规模变化
/// 阻止替换快照;普通构建离线读取已固定快照。
struct ChinaIPv4Converter {
/// 转换异常规模阈值:规则数低于下限或高于上限视为产物异常。
static let minimumRuleCount = 100
static let maximumRuleCount = 50_000
/// 相对上一份快照的规模漂移阈值:新数量必须落在
/// `[previous × minimumScaleRatio, previous × maximumScaleRatio]` 内。
static let minimumScaleRatio = 0.5
static let maximumScaleRatio = 2.0
/// 异常格式阈值:拒绝行占代码行百分比超过该值即阻止替换(整数百分比)。
static let maximumRejectionPercent = 5
let source: RuleSourceIdentity
/// 可注入规模阈值(测试夹具用);生产使用默认值。
let minimumRuleCount: Int
let maximumRuleCount: Int
let maximumRejectionPercent: Int
init(
source: RuleSourceIdentity,
minimumRuleCount: Int = ChinaIPv4Converter.minimumRuleCount,
maximumRuleCount: Int = ChinaIPv4Converter.maximumRuleCount,
maximumRejectionPercent: Int = ChinaIPv4Converter.maximumRejectionPercent
) {
self.source = source
self.minimumRuleCount = minimumRuleCount
self.maximumRuleCount = maximumRuleCount
self.maximumRejectionPercent = maximumRejectionPercent
}
enum ConversionError: Error, Equatable, Sendable {
case emptyInput
case allEntriesInvalid
case abnormalFormat(rejected: Int, total: Int)
case abnormalRuleCount(Int)
case abnormalScaleChange(found: Int, previous: Int)
}
/// 快照元数据中由人工输入提供、与具体输入文本无关的部分;
/// `source` 与 `inputDigest` 由转换器自行补齐。
struct Provenance: Sendable {
let fetchedAt: Date
let upstreamReference: String
let license: String
let attribution: String
}
/// `previousRuleCount`:上一份有效快照的规则数,用于异常规模变化检测。
/// 首次建快照传 `nil` 时跳过漂移检查。
func convert(
document: String,
provenance: Provenance,
previousRuleCount: Int? = nil
) throws -> RuleSnapshot {
let lines = document.split(whereSeparator: \.isNewline).map(String.init)
guard lines.contains(where: { !Self.isIgnorableLine($0) }) else {
throw ConversionError.emptyInput
}
var report = RuleConversionLossReport()
let parsed = parseRules(from: lines, report: &report)
guard parsed.rejectedCount == 0 || !parsed.rules.isEmpty else {
throw ConversionError.allEntriesInvalid
}
// 异常格式:拒绝率超过阈值说明输入不是预期的 CIDR 文本,阻止替换快照。
let codeLineCount =
parsed.rules.count + parsed.rejectedCount + report.skipped["duplicate", default: 0]
if codeLineCount > 0, parsed.rejectedCount * 100 > codeLineCount * maximumRejectionPercent {
throw ConversionError.abnormalFormat(rejected: parsed.rejectedCount, total: codeLineCount)
}
try validateScale(
ruleCount: parsed.rules.count, previousRuleCount: previousRuleCount)
report.convertedCount = parsed.rules.count
report.notes.append("china-ipv4-direct-candidates")
let metadata = RuleSnapshotMetadata(
source: source,
upstreamReference: provenance.upstreamReference,
inputDigest: ProxyACLDocument.digest(Data(document.utf8)),
fetchedAt: provenance.fetchedAt,
license: provenance.license,
attribution: provenance.attribution)
return RuleSnapshot(
metadata: metadata,
rules: parsed.rules,
absorbed: [],
lossReport: report)
}
private func parseRules(
from lines: [String], report: inout RuleConversionLossReport
) -> (rules: [ProxyRule], rejectedCount: Int) {
var rules: [ProxyRule] = []
var seen = Set<RuleMatch>()
var rejectedCount = 0
for rawLine in lines {
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
guard !Self.isIgnorableLine(line) else { continue }
do {
let match = try RuleMatch(ipv4CIDR: line)
// 去重按规范化后的 match;同一网络只保留一条。
guard seen.insert(match).inserted else {
report.incrementSkipped("duplicate")
continue
}
rules.append(
ProxyRule(
action: .direct,
match: match,
source: source,
conflict: RuleConflictMetadata(originalEntry: line)))
} catch {
rejectedCount += 1
report.incrementRejected("invalidCIDR")
}
}
return (rules, rejectedCount)
}
private func validateScale(ruleCount: Int, previousRuleCount: Int?) throws {
guard ruleCount >= minimumRuleCount else {
throw ConversionError.abnormalRuleCount(ruleCount)
}
guard ruleCount <= maximumRuleCount else {
throw ConversionError.abnormalRuleCount(ruleCount)
}
guard let previous = previousRuleCount, previous > 0 else { return }
let lower = Int(Double(previous) * Self.minimumScaleRatio)
let upper = Int(Double(previous) * Self.maximumScaleRatio)
guard ruleCount >= lower, ruleCount <= upper else {
throw ConversionError.abnormalScaleChange(found: ruleCount, previous: previous)
}
}
/// 空行与 `#` 注释行。
private static func isIgnorableLine(_ line: String) -> Bool {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty || trimmed.hasPrefix("#")
}
}
+3 -3
View File
@@ -75,9 +75,9 @@ struct ProxyACLDocument: Codable, Equatable, Sendable {
at: fileURL, header: "[proxy_all]", summary: "global")
}
/// 规则模式(issue #63):`.cn` 与 geolocation-cn 域名候选直连,其余目标
/// 默认代理(「未匹配时代理」)。固定本地绕过优先;中国 IPv4 CIDR 由后续
/// 票据加入。
/// 规则模式(issue #63/#64):`.cn`、geolocation-cn 域名与中国 IPv4 CIDR
/// 候选直连,其余目标默认代理(「未匹配时代理」)。固定本地绕过优先;
/// CIDR 判定可能触发本地 DNS 查询,产品不承诺 DNS 均经远端。
static func rule(
at fileURL: URL,
defaultAction: RuleDefaultAction,
+10 -2
View File
@@ -16,7 +16,8 @@ ShadowsocksX-NG 的现代化重写版本。新工程的全部源码与构建配
- `Tests/` — 单元测试 target,随 `ShadowsocksX-NG2` scheme 运行。
- `Vendor/<name>/manifest.json` — 外部二进制的固定供应链清单(tag + 资产 URL + 归档 SHA-256 + bundle 内位置 + 签名 identifier);二进制本体与 `.fetched.sha256` 戳是构建缓存,不入库。
- `Vendor/rules/geolocation-cn/` — 内置中国域名规则快照(issue #63):`snapshot.json`(规范化规则 + 元数据 + 损失报告)、`manifest.json`(上游版本与快照 SHA-256)、`NOTICE`(许可证与归属)。普通构建只读本地快照,绝不抓取或转换。
- `Scripts/` — 供应链脚本(见下节)与打包门槛断言;另有 `update-geolocation-cn.sh`(显式维护动作,抓取固定版 geosite.dat 并转换)与 `verify-rule-snapshots.sh`(构建前离线校验快照完整性)。
- `Vendor/rules/china-ipv4/` — 内置中国 IPv4 CIDR 直连候选快照(issue #64):同上三件套,来源为 [gaoyifan/china-operator-ip](https://github.com/gaoyifan/china-operator-ip) `china.txt` 固定 commit。规则模式的 `proxy_all` ACL 同时编入中国域名与 IPv4 CIDR 直连候选。
- `Scripts/` — 供应链脚本(见下节)与打包门槛断言;另有 `update-geolocation-cn.sh` / `update-china-ipv4.sh`(显式维护动作,抓取固定版上游并转换)与 `verify-rule-snapshots.sh`(构建前离线校验快照完整性)。
## 外部二进制供应链
@@ -74,7 +75,14 @@ Scripts/packaging-gate.sh \
## 内置规则快照
规则模式使用的 `geolocation-cn` 快照是显式维护产物:人工复验上游后运行 `Scripts/update-geolocation-cn.sh`,从固定版 [Loyalsoldier/domain-list-custom](https://github.com/Loyalsoldier/domain-list-custom) `geosite.dat` 解析 typed 条目并生成 `Vendor/rules/geolocation-cn/snapshot.json`。更新失败保留上一份有效快照。普通构建由 `Scripts/verify-rule-snapshots.sh` 离线校验快照存在、摘要匹配且 schema/转换器版本一致,缺失或损坏即构建失败。分发物必须携带 `NOTICE`(许可证与归属)。
规则模式使用的内置快照是显式维护产物:
- **geolocation-cn**(issue #63):人工复验上游后运行 `Scripts/update-geolocation-cn.sh`,从固定版 [Loyalsoldier/domain-list-custom](https://github.com/Loyalsoldier/domain-list-custom) `geosite.dat` 解析 typed 条目并生成 `Vendor/rules/geolocation-cn/snapshot.json`。
- **china-ipv4**(issue #64):运行 `Scripts/update-china-ipv4.sh`,从固定版 [gaoyifan/china-operator-ip](https://github.com/gaoyifan/china-operator-ip) `china.txt` 规范化并去重 IPv4 CIDR,生成 `Vendor/rules/china-ipv4/snapshot.json`。
更新失败保留上一份有效快照。geolocation-cn 转换会因未知语法/损坏输入失败;china-ipv4 转换还会因异常格式(拒绝率过高)、全部失效或相对上一份快照的异常规模变化失败。普通构建由 `Scripts/verify-rule-snapshots.sh` 离线校验快照存在、摘要匹配且 schema/转换器版本一致,缺失或损坏即构建失败。分发物必须携带各自的 `NOTICE`(许可证与归属)。
规则模式「未匹配时代理」生成的 `proxy_all` ACL 同时包含中国域名与中国 IPv4 CIDR 直连候选,固定本地绕过优先。**CIDR 判定可能触发本地 DNS 查询**:sslocal 为未命中域名做 IP 匹配时可能发起本地 DNS 查询,产品不承诺 DNS 查询均经远端 Shadowsocks 服务器。
## 依赖升级
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Convert gaoyifan/china-operator-ip china.txt into an NG2 rule snapshot
(issue #64).
Parses one IPv4 CIDR per line, normalizes (masks host bits), deduplicates, and
writes a normalized RuleSnapshot JSON plus loss report. Failure leaves any
previous snapshot untouched.
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import ipaddress
import json
import sys
from pathlib import Path
CONVERTER_VERSION = "1.0.0"
SCHEMA_VERSION = 1
MINIMUM_RULE_COUNT = 100
MAXIMUM_RULE_COUNT = 50_000
MINIMUM_SCALE_RATIO = 0.5
MAXIMUM_SCALE_RATIO = 2.0
MAXIMUM_REJECTION_PERCENT = 5
def parse_lines(text: str) -> tuple[list[dict], int, int]:
"""Return (rules, duplicate_count, rejected_count)."""
rules: list[dict] = []
seen: set[str] = set()
duplicates = 0
rejected = 0
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
try:
network = ipaddress.IPv4Network(line, strict=False)
except ValueError:
rejected += 1
continue
normalized = f"{network.network_address}/{network.prefixlen}"
if normalized in seen:
duplicates += 1
continue
seen.add(normalized)
rules.append(
{
"action": "direct",
"match": {"kind": "ipv4CIDR", "value": normalized},
"source": {}, # filled by caller
"conflict": {
"originalEntry": line,
"absorbedBy": None,
"notes": [],
},
}
)
return rules, duplicates, rejected
def build_snapshot(
document: str,
source: dict,
upstream_reference: str,
license_name: str,
attribution: str,
previous_rule_count: int | None,
) -> dict:
rules, duplicates, rejected = parse_lines(document)
if not any(line.strip() and not line.strip().startswith("#") for line in document.splitlines()):
raise SystemExit("empty input")
if not rules and rejected:
raise SystemExit("all entries invalid")
code_lines = len(rules) + duplicates + rejected
if code_lines and rejected * 100 > code_lines * MAXIMUM_REJECTION_PERCENT:
raise SystemExit(f"abnormal format: {rejected}/{code_lines} lines rejected")
rule_count = len(rules)
if rule_count < MINIMUM_RULE_COUNT:
raise SystemExit(f"abnormal rule count {rule_count} (min {MINIMUM_RULE_COUNT})")
if rule_count > MAXIMUM_RULE_COUNT:
raise SystemExit(f"abnormal rule count {rule_count} (max {MAXIMUM_RULE_COUNT})")
if previous_rule_count is not None and previous_rule_count > 0:
lower = int(previous_rule_count * MINIMUM_SCALE_RATIO)
upper = int(previous_rule_count * MAXIMUM_SCALE_RATIO)
if not (lower <= rule_count <= upper):
raise SystemExit(
f"abnormal scale change: {rule_count} vs previous {previous_rule_count} "
f"(allowed {lower}..{upper})"
)
for rule in rules:
rule["source"] = source
report = {
"convertedCount": rule_count,
"absorbedCount": 0,
"skipped": {"duplicate": duplicates} if duplicates else {},
"rejected": {"invalidCIDR": rejected} if rejected else {},
"notes": ["china-ipv4-direct-candidates"],
}
return {
"schemaVersion": SCHEMA_VERSION,
"metadata": {
"source": source,
"upstreamReference": upstream_reference,
"inputDigest": hashlib.sha256(document.encode("utf-8")).hexdigest(),
"fetchedAt": dt.datetime.now(dt.timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z"),
"converterVersion": CONVERTER_VERSION,
"license": license_name,
"attribution": attribution,
},
"rules": rules,
"absorbed": [],
"lossReport": report,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path, help="path to china.txt")
parser.add_argument("--upstream", required=True, help="pinned upstream version (commit)")
parser.add_argument(
"--upstream-reference",
required=True,
help="upstream repo/product identifier including the pinned commit or URL",
)
parser.add_argument("--license", required=True)
parser.add_argument("--attribution", required=True)
parser.add_argument("--out", type=Path, required=True, help="output snapshot.json")
parser.add_argument(
"--previous-count",
type=int,
default=None,
help="previous snapshot rule count for scale-change detection",
)
args = parser.parse_args()
document = args.input.read_text(encoding="utf-8")
source = {
"kind": "china-ipv4",
"upstreamVersion": args.upstream,
"label": "china-operator-ip",
}
snapshot = build_snapshot(
document,
source,
args.upstream_reference,
args.license,
args.attribution,
args.previous_count,
)
args.out.parent.mkdir(parents=True, exist_ok=True)
tmp = args.out.with_suffix(".json.tmp")
tmp.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8")
tmp.replace(args.out)
loss = snapshot["lossReport"]
print(
f"wrote {args.out}: {loss['convertedCount']} rules, "
f"skipped={loss['skipped']}, rejected={loss['rejected']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Explicit maintainer update for the built-in china-operator-ip IPv4 CIDR rule
# snapshot (issue #64). Normal builds never run this script — they only read the
# local pinned snapshot. On any failure the previous valid snapshot is kept.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
RULES_DIR="${ROOT}/Vendor/rules/china-ipv4"
SNAPSHOT="${RULES_DIR}/snapshot.json"
MANIFEST="${RULES_DIR}/manifest.json"
NOTICE="${RULES_DIR}/NOTICE"
# Pinned upstream: gaoyifan/china-operator-ip ip-lists branch commit.
# Bump these together after human review of the new commit.
UPSTREAM_VERSION="75f2eb035cb53a7a4af3437e2c2110d0f73cf14a"
UPSTREAM_URL="https://raw.githubusercontent.com/gaoyifan/china-operator-ip/${UPSTREAM_VERSION}/china.txt"
UPSTREAM_SHA256="98493d3ec7f2c77b9f191600dc741df731cfbaef775201594d0bda66b0181479"
LICENSE="MIT"
ATTRIBUTION="China mainland IPv4 CIDR derived from gaoyifan/china-operator-ip china.txt (ip-lists branch) at commit ${UPSTREAM_VERSION}. BGP/ASN-derived candidate routing data; not a guarantee of geographic ownership or reachability."
usage() {
cat <<'EOF'
Usage: Scripts/update-china-ipv4.sh [--fetch-only]
Explicitly refresh Vendor/rules/china-ipv4/snapshot.json from the pinned
china-operator-ip china.txt commit. This is a maintainer action; ordinary
builds are offline and only read the local snapshot.
--fetch-only Download and verify china.txt without converting.
EOF
}
FETCH_ONLY=0
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ "${1:-}" == "--fetch-only" ]]; then
FETCH_ONLY=1
elif [[ $# -gt 0 ]]; then
usage >&2
exit 2
fi
TMP="$(mktemp -d)"
cleanup() { rm -rf "${TMP}"; }
trap cleanup EXIT
echo "Fetching china.txt ${UPSTREAM_VERSION} …"
if command -v curl >/dev/null 2>&1; then
curl -fsSL --retry 3 -o "${TMP}/china.txt" "${UPSTREAM_URL}"
else
echo "error: curl is required" >&2
exit 1
fi
ACTUAL="$(shasum -a 256 "${TMP}/china.txt" | awk '{print $1}')"
if [[ "${ACTUAL}" != "${UPSTREAM_SHA256}" ]]; then
echo "error: china.txt sha256 mismatch" >&2
echo " expected ${UPSTREAM_SHA256}" >&2
echo " actual ${ACTUAL}" >&2
echo "previous snapshot kept"
exit 1
fi
echo "Verified china.txt sha256"
if [[ "${FETCH_ONLY}" -eq 1 ]]; then
echo "fetch-only: leaving snapshot untouched"
exit 0
fi
mkdir -p "${RULES_DIR}"
# Previous rule count feeds scale-change detection; missing snapshot is first run.
PREVIOUS_COUNT=""
if [[ -f "${SNAPSHOT}" ]]; then
PREVIOUS_COUNT="$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["rules"]))' "${SNAPSHOT}")"
fi
CONVERT_ARGS=(
"${TMP}/china.txt"
--upstream "${UPSTREAM_VERSION}"
--upstream-reference "${UPSTREAM_URL} @ ${UPSTREAM_VERSION}"
--license "${LICENSE}"
--attribution "${ATTRIBUTION}"
--out "${TMP}/snapshot.json"
)
if [[ -n "${PREVIOUS_COUNT}" ]]; then
CONVERT_ARGS+=(--previous-count "${PREVIOUS_COUNT}")
fi
# Convert into a temporary snapshot first; only replace on success.
if ! python3 "${ROOT}/Scripts/convert-china-ipv4.py" "${CONVERT_ARGS[@]}"; then
echo "error: conversion failed; previous snapshot kept"
exit 1
fi
# Sanity: snapshot must load as JSON and carry required metadata keys.
python3 - "${TMP}/snapshot.json" <<'PY'
import json, sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
meta = data.get("metadata") or {}
for key in ("source", "upstreamReference", "inputDigest", "fetchedAt", "converterVersion", "license", "attribution"):
if key not in meta:
raise SystemExit(f"snapshot metadata missing {key}")
if not data.get("rules"):
raise SystemExit("snapshot has no rules")
print(f"snapshot ok: {len(data['rules'])} rules")
PY
cp "${TMP}/snapshot.json" "${SNAPSHOT}"
# Refresh manifest (build-time integrity anchor).
python3 - "${SNAPSHOT}" "${MANIFEST}" "${UPSTREAM_VERSION}" "${UPSTREAM_URL}" "${UPSTREAM_SHA256}" <<'PY'
import hashlib, json, sys
from pathlib import Path
snapshot_path, manifest_path, version, url, sha256 = sys.argv[1:6]
data = Path(snapshot_path).read_bytes()
digest = hashlib.sha256(data).hexdigest()
manifest = {
"name": "china-ipv4",
"upstreamVersion": version,
"upstreamURL": url,
"upstreamArchiveSHA256": sha256,
"snapshotSHA256": digest,
"converterVersion": json.loads(data)["metadata"]["converterVersion"],
}
Path(manifest_path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"wrote {manifest_path}")
PY
# NOTICE: license and attribution accompany the distributed snapshot.
cat > "${NOTICE}" <<EOF
china-ipv4 rule snapshot
========================
Upstream: gaoyifan/china-operator-ip china.txt (ip-lists branch)
Upstream commit: ${UPSTREAM_VERSION}
Upstream URL: ${UPSTREAM_URL}
Upstream archive SHA-256: ${UPSTREAM_SHA256}
License: ${LICENSE}
${ATTRIBUTION}
This list is candidate routing data. It is not a guarantee of network
reachability, geographic ownership, or privacy. IP-CIDR matching may cause
sslocal to issue local DNS queries for unmatched hostnames; the product does
not promise that all DNS queries travel through the remote Shadowsocks server.
EOF
echo "Updated ${SNAPSHOT}"
echo "Remember to review the diff and commit the snapshot together with its NOTICE and manifest."
@@ -1,31 +1,37 @@
#!/usr/bin/env bash
# Build-time offline verification of pinned rule snapshots (issue #63).
# Ordinary builds never fetch or convert; they only verify the local snapshot
# is present, intact, and matches its manifest. Missing, corrupt, or
# Build-time offline verification of pinned rule snapshots (issue #63/#64).
# Ordinary builds never fetch or convert; they only verify the local snapshots
# are present, intact, and match their manifests. Missing, corrupt, or
# version-mismatched snapshots fail the build.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
RULES_DIR="${ROOT}/Vendor/rules/geolocation-cn"
SNAPSHOT="${RULES_DIR}/snapshot.json"
MANIFEST="${RULES_DIR}/manifest.json"
NOTICE="${RULES_DIR}/NOTICE"
fail() {
echo "error: $*" >&2
echo " (run Scripts/update-geolocation-cn.sh after reviewing upstream changes)" >&2
echo " (run the matching Scripts/update-*.sh after reviewing upstream changes)" >&2
exit 1
}
[[ -f "${SNAPSHOT}" ]] || fail "missing ${SNAPSHOT}"
[[ -f "${MANIFEST}" ]] || fail "missing ${MANIFEST}"
[[ -f "${NOTICE}" ]] || fail "missing ${NOTICE} (license/attribution must ship with the snapshot)"
verify_one() {
local name="$1"
local min_count="$2"
local max_count="$3"
local rules_dir="${ROOT}/Vendor/rules/${name}"
local snapshot="${rules_dir}/snapshot.json"
local manifest="${rules_dir}/manifest.json"
local notice="${rules_dir}/NOTICE"
python3 - "${SNAPSHOT}" "${MANIFEST}" <<'PY' || fail "rule snapshot verification failed"
[[ -f "${snapshot}" ]] || fail "missing ${snapshot}"
[[ -f "${manifest}" ]] || fail "missing ${manifest}"
[[ -f "${notice}" ]] || fail "missing ${notice} (license/attribution must ship with the snapshot)"
python3 - "${snapshot}" "${manifest}" "${min_count}" "${max_count}" "${name}" <<'PY' || fail "${name} rule snapshot verification failed"
import hashlib, json, sys
from pathlib import Path
snapshot_path, manifest_path = sys.argv[1], sys.argv[2]
snapshot_path, manifest_path, min_count, max_count, name = sys.argv[1:6]
min_count, max_count = int(min_count), int(max_count)
snapshot_bytes = Path(snapshot_path).read_bytes()
try:
snapshot = json.loads(snapshot_bytes)
@@ -49,8 +55,8 @@ if meta.get("converterVersion") != "1.0.0":
rules = snapshot.get("rules")
if not isinstance(rules, list) or not rules:
raise SystemExit("snapshot has no rules")
if len(rules) < 100 or len(rules) > 200_000:
raise SystemExit(f"abnormal rule count {len(rules)}")
if len(rules) < min_count or len(rules) > max_count:
raise SystemExit(f"abnormal rule count {len(rules)} (allowed {min_count}..{max_count})")
try:
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
@@ -74,7 +80,13 @@ if digest != manifest.get("snapshotSHA256"):
if manifest.get("upstreamVersion") != meta.get("source", {}).get("upstreamVersion"):
raise SystemExit("manifest upstreamVersion does not match snapshot metadata")
print(f"rule snapshot ok: {len(rules)} rules, upstream {meta.get('upstreamVersion')}")
print(f"{name} snapshot ok: {len(rules)} rules, upstream {meta.get('source', {}).get('upstreamVersion')}")
PY
}
# geolocation-cn: domain rules (issue #63).
verify_one "geolocation-cn" 100 200000
# china-ipv4: China mainland IPv4 CIDR (issue #64).
verify_one "china-ipv4" 100 50000
echo "verify-rule-snapshots: ok"
@@ -8,9 +8,9 @@ final class BuiltinRuleCatalogTests: XCTestCase {
func testGeolocationCNSnapshotLoadsFromBundleOrSourceTree() throws {
// 优先 bundle(随 App 分发);测试环境回退到源码树固定快照。
let snapshot: RuleSnapshot
if let bundle = try? BuiltinRuleCatalog.loadGeolocationCN() {
snapshot = bundle
} else {
do {
snapshot = try BuiltinRuleCatalog.loadGeolocationCN()
} catch {
let sourceURL = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // Tests/
.deletingLastPathComponent() // ShadowsocksX-NG2/
@@ -45,6 +45,65 @@ final class BuiltinRuleCatalogTests: XCTestCase {
XCTAssertTrue(independentCN.isEmpty, "同动作 .cn 域名应已被吸收")
}
/// china-ipv4 快照(issue #64):从 bundle 或源码树加载,直连候选为 IPv4 CIDR。
func testChinaIPv4SnapshotLoadsFromBundleOrSourceTree() throws {
let snapshot: RuleSnapshot
do {
snapshot = try BuiltinRuleCatalog.loadChinaIPv4()
} catch {
let sourceURL = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // Tests/
.deletingLastPathComponent() // ShadowsocksX-NG2/
.appendingPathComponent("Vendor/rules/china-ipv4/snapshot.json")
snapshot = try RuleSnapshotStore(fileURL: sourceURL).load()
}
XCTAssertEqual(snapshot.schemaVersion, RuleSnapshot.currentSchemaVersion)
XCTAssertEqual(snapshot.metadata.source.kind, .chinaIPv4)
XCTAssertEqual(
snapshot.metadata.converterVersion, RuleSnapshotMetadata.currentConverterVersion)
XCTAssertFalse(snapshot.metadata.license.isEmpty)
XCTAssertFalse(snapshot.metadata.attribution.isEmpty)
XCTAssertFalse(snapshot.metadata.inputDigest.isEmpty)
XCTAssertFalse(snapshot.metadata.upstreamReference.isEmpty)
let rules = BuiltinRuleCatalog.chinaDirectRules(from: snapshot)
XCTAssertFalse(rules.isEmpty)
XCTAssertTrue(rules.allSatisfy { $0.action == .direct })
XCTAssertTrue(
rules.allSatisfy {
if case .ipv4CIDR = $0.match { return true }
return false
}, "china-ipv4 快照应只含 IPv4 CIDR 直连候选")
}
/// 合并投影(issue #64):域名 + CIDR 直连候选一起进入 ACL 编译输入。
func testChinaDirectRulesMergeDomainAndCIDRSnapshots() throws {
let geolocation = try loadSourceTreeSnapshot(path: "Vendor/rules/geolocation-cn/snapshot.json")
let chinaIPv4 = try loadSourceTreeSnapshot(path: "Vendor/rules/china-ipv4/snapshot.json")
let merged = BuiltinRuleCatalog.chinaDirectRules(from: [geolocation, chinaIPv4])
XCTAssertTrue(
merged.contains { rule in
if case .domainSuffix("cn") = rule.match { return true }
return false
}, "合并结果应含 .cn 域名直连候选")
XCTAssertTrue(
merged.contains { rule in
if case .ipv4CIDR = rule.match { return true }
return false
}, "合并结果应含 IPv4 CIDR 直连候选")
XCTAssertTrue(merged.allSatisfy { $0.action == .direct })
}
private func loadSourceTreeSnapshot(path: String) throws -> RuleSnapshot {
let sourceURL = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent(path)
return try RuleSnapshotStore(fileURL: sourceURL).load()
}
func testRuleDefaultActionLabelsAndFallback() {
XCTAssertEqual(RuleDefaultAction.proxyWhenUnmatched.label, "未匹配时代理")
XCTAssertEqual(RuleDefaultAction.directWhenUnmatched.label, "未匹配时直连")
@@ -0,0 +1,202 @@
import Foundation
import XCTest
@testable import ShadowsocksX_NG2
/// china-operator-ip IPv4 CIDR 转换器(issue #64):规范化、去重、拒绝计数、
/// 异常规模失败与来源元数据。
final class ChinaIPv4ConverterTests: XCTestCase {
private let source = RuleSourceIdentity(
kind: .chinaIPv4,
upstreamVersion: "test-commit",
label: "china-operator-ip")
private var converter: ChinaIPv4Converter {
// 测试夹具:放宽规模与拒绝率阈值,单独用例再收紧。
ChinaIPv4Converter(
source: source, minimumRuleCount: 1, maximumRuleCount: 10_000,
maximumRejectionPercent: 100)
}
private let fetchedAt = Date(timeIntervalSince1970: 1_764_000_000)
private func convert(
_ document: String,
previousRuleCount: Int? = nil
) throws -> RuleSnapshot {
try converter.convert(
document: document,
provenance: .init(
fetchedAt: fetchedAt,
upstreamReference: "test://china-operator-ip@v1",
license: "MIT",
attribution: "test attribution"),
previousRuleCount: previousRuleCount)
}
// MARK: - 解析与规范化
func testParsesAndNormalizesIPv4CIDRs() throws {
let snapshot = try convert(
"""
# comment
1.0.1.0/24
1.0.2.5/23
203.0.113.7
""")
let values = snapshot.rules.map { rule -> String in
switch rule.match {
case .ipv4CIDR(let value): return value
default: return "non-cidr"
}
}
XCTAssertEqual(values, ["1.0.1.0/24", "1.0.2.0/23", "203.0.113.7/32"])
XCTAssertTrue(snapshot.rules.allSatisfy { $0.action == .direct })
XCTAssertEqual(snapshot.rules.allSatisfy { $0.source.kind == .chinaIPv4 }, true)
XCTAssertEqual(snapshot.lossReport.convertedCount, 3)
XCTAssertEqual(snapshot.lossReport.rejected["invalidCIDR"] ?? 0, 0)
}
func testDeduplicatesNormalizedCIDRs() throws {
let snapshot = try convert(
"""
1.0.1.0/24
1.0.1.0/24
1.0.1.7/24
""")
XCTAssertEqual(snapshot.rules.count, 1)
XCTAssertEqual(snapshot.lossReport.skipped["duplicate"], 2)
XCTAssertEqual(snapshot.lossReport.convertedCount, 1)
}
func testCountsRejectedInvalidEntries() throws {
let snapshot = try convert(
"""
1.0.1.0/24
not-a-cidr
999.999.999.999/33
::1/128
2.0.0.0/8
""")
XCTAssertEqual(snapshot.rules.count, 2)
XCTAssertEqual(snapshot.lossReport.rejected["invalidCIDR"], 3)
}
// MARK: - 转换失败
func testEmptyInputFails() {
XCTAssertThrowsError(try convert("")) { error in
XCTAssertEqual(error as? ChinaIPv4Converter.ConversionError, .emptyInput)
}
XCTAssertThrowsError(try convert("# only comments\n\n")) { error in
XCTAssertEqual(error as? ChinaIPv4Converter.ConversionError, .emptyInput)
}
}
func testAllEntriesInvalidFails() {
XCTAssertThrowsError(
try convert("not-a-cidr\nalso-bad\n")
) { error in
XCTAssertEqual(error as? ChinaIPv4Converter.ConversionError, .allEntriesInvalid)
}
}
func testAbnormalFormatRejectionRateFails() {
// 默认 5% 拒绝率阈值:20 坏 / 30 代码行 ≈ 67% 应阻止。
let strict = ChinaIPv4Converter(
source: source, minimumRuleCount: 1, maximumRuleCount: 10_000)
let bad = (0..<20).map { _ in "not-a-cidr" }.joined(separator: "\n")
let good = (0..<10).map { "10.\($0).0.0/24" }.joined(separator: "\n")
XCTAssertThrowsError(
try strict.convert(
document: bad + "\n" + good + "\n",
provenance: .init(
fetchedAt: fetchedAt, upstreamReference: "u", license: "MIT", attribution: "a"))
) { error in
XCTAssertEqual(
error as? ChinaIPv4Converter.ConversionError,
.abnormalFormat(rejected: 20, total: 30))
}
}
func testAbnormalRuleCountFails() {
let tiny = ChinaIPv4Converter(source: source, minimumRuleCount: 5, maximumRuleCount: 10_000)
XCTAssertThrowsError(
try tiny.convert(
document: "1.0.0.0/24\n1.0.1.0/24\n",
provenance: .init(
fetchedAt: fetchedAt, upstreamReference: "u", license: "MIT", attribution: "a"))
) { error in
XCTAssertEqual(error as? ChinaIPv4Converter.ConversionError, .abnormalRuleCount(2))
}
let huge = ChinaIPv4Converter(source: source, minimumRuleCount: 1, maximumRuleCount: 2)
XCTAssertThrowsError(
try huge.convert(
document: "1.0.0.0/24\n1.0.1.0/24\n1.0.2.0/24\n",
provenance: .init(
fetchedAt: fetchedAt, upstreamReference: "u", license: "MIT", attribution: "a"))
) { error in
XCTAssertEqual(error as? ChinaIPv4Converter.ConversionError, .abnormalRuleCount(3))
}
}
func testAbnormalScaleChangeFails() throws {
// 3 条相对上一份 10 条:低于 50% 下限。
XCTAssertThrowsError(
try convert("1.0.0.0/24\n1.0.1.0/24\n1.0.2.0/24\n", previousRuleCount: 10)
) { error in
XCTAssertEqual(
error as? ChinaIPv4Converter.ConversionError,
.abnormalScaleChange(found: 3, previous: 10))
}
// 25 条相对上一份 10 条:高于 200% 上限。
let many = (0..<25).map { "10.\($0).0.0/24" }.joined(separator: "\n")
XCTAssertThrowsError(try convert(many, previousRuleCount: 10)) { error in
XCTAssertEqual(
error as? ChinaIPv4Converter.ConversionError,
.abnormalScaleChange(found: 25, previous: 10))
}
// 落在区间内通过。
let snapshot = try convert("1.0.0.0/24\n1.0.1.0/24\n1.0.2.0/24\n", previousRuleCount: 4)
XCTAssertEqual(snapshot.rules.count, 3)
}
// MARK: - 元数据
func testSnapshotMetadataAndSchema() throws {
let snapshot = try convert("1.0.1.0/24\n")
XCTAssertEqual(snapshot.schemaVersion, RuleSnapshot.currentSchemaVersion)
XCTAssertEqual(snapshot.metadata.source.kind, .chinaIPv4)
XCTAssertEqual(snapshot.metadata.source.upstreamVersion, "test-commit")
XCTAssertEqual(snapshot.metadata.upstreamReference, "test://china-operator-ip@v1")
XCTAssertEqual(snapshot.metadata.license, "MIT")
XCTAssertEqual(snapshot.metadata.attribution, "test attribution")
XCTAssertEqual(snapshot.metadata.fetchedAt, fetchedAt)
XCTAssertEqual(
snapshot.metadata.converterVersion, RuleSnapshotMetadata.currentConverterVersion)
XCTAssertEqual(
snapshot.metadata.inputDigest,
ProxyACLDocument.digest(Data("1.0.1.0/24\n".utf8)))
XCTAssertEqual(snapshot.lossReport.notes, ["china-ipv4-direct-candidates"])
XCTAssertTrue(snapshot.absorbed.isEmpty)
}
func testSnapshotRoundTripsThroughStore() throws {
let snapshot = try convert("1.0.1.0/24\n1.0.2.0/23\n")
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("ssxng-china-ipv4-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("snapshot.json")
let store = RuleSnapshotStore(fileURL: url)
try store.save(snapshot)
let loaded = try store.load()
XCTAssertEqual(loaded.rules.map(\.match), snapshot.rules.map(\.match))
XCTAssertEqual(loaded.metadata, snapshot.metadata)
XCTAssertEqual(
loaded.metadata.converterVersion, RuleSnapshotMetadata.currentConverterVersion)
}
}
@@ -127,6 +127,38 @@ final class ProxyACLTests: XCTestCase {
XCTAssertFalse(acl.content.contains("[proxy_all]"))
}
/// 规则+未匹配时代理(issue #64):proxy_all 同时含中国域名与 IPv4 CIDR
/// 直连候选,固定本地绕过仍优先。
func testRuleProxyDefaultACLIncludesChinaIPv4CIDRCandidates() throws {
let domainSource = RuleSourceIdentity(
kind: .geolocationCN, upstreamVersion: "v1", label: "geolocation-cn")
let cidrSource = RuleSourceIdentity(
kind: .chinaIPv4, upstreamVersion: "c1", label: "china-operator-ip")
let rules = [
ProxyRule(
action: .direct, match: try RuleMatch(nationalDomainSuffix: "cn"), source: domainSource),
ProxyRule(
action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24"), source: cidrSource),
ProxyRule(
action: .direct, match: try RuleMatch(ipv4CIDR: "198.51.100.0/24"), source: cidrSource),
]
let acl = ProxyACLDocument.rule(
at: URL(fileURLWithPath: "/tmp/ssxng-test/sslocal-active.acl"),
defaultAction: .proxyWhenUnmatched,
chinaRules: rules)
XCTAssertTrue(acl.isWellFormed)
XCTAssertTrue(acl.content.hasPrefix("[proxy_all]\n[bypass_list]\n"))
XCTAssertTrue(acl.content.contains("||cn\n"), "中国域名直连候选")
XCTAssertTrue(acl.content.contains("203.0.113.0/24\n"), "中国 IPv4 CIDR 直连候选")
XCTAssertTrue(acl.content.contains("198.51.100.0/24\n"), "中国 IPv4 CIDR 直连候选")
// 固定本地绕过仍排在中国候选之前(优先级)。
let lines = acl.content.split(separator: "\n").map(String.init)
let localIndex = lines.firstIndex(of: "127.0.0.0/8") ?? -1
let cidrIndex = lines.firstIndex(of: "203.0.113.0/24") ?? -1
XCTAssertTrue(localIndex >= 0 && cidrIndex > localIndex, "固定本地绕过应优先于中国 CIDR")
}
/// 冲突元数据不影响 ACL 行;同匹配只输出一行。
func testRuleACLLineMapping() throws {
let source = RuleSourceIdentity(kind: .custom, upstreamVersion: "v1", label: "custom")
@@ -137,6 +137,85 @@ extension RealSslocalSmokeTests {
stopWrapperAndAssertCleanExit(wrapper, description: "rule wrapper exits")
}
/// 规则模式 ACL 路由(issue #64):中国 IPv4 CIDR 直连候选。命中 CIDR 的
/// 目标不触达 SS 出口;未命中 CIDR 的公网目标默认走代理;SOCKS 与 HTTP
/// 入站共用同一 ACL。CIDR 判定可能触发本地 DNS 查询(文档已声明)。
func testRuleProxyDefaultACLRoutesChinaCIDRHitDirectAndMissThroughProxy() throws {
let echoServer = try LoopbackEchoServer()
let fakeSSServer = try ConnectionCountingServer()
let selectedPorts = try grabThreeListenPorts(excluding: [echoServer.port, fakeSSServer.port])
let listen = SslocalListenSettings(
socksPort: selectedPorts[0], httpPort: selectedPorts[1], pacPort: selectedPorts[2])
let document = SslocalRuntimeDocument(
servers: [
SslocalServerDocument(
id: "cidr-smoke-server",
remarks: "cidr-smoke",
server: "127.0.0.1",
serverPort: fakeSSServer.port,
password: "smoke-password",
method: "aes-256-gcm",
plugin: nil,
pluginOpts: nil)
],
listen: listen,
acl: .rule(
at: workDir.appendingPathComponent("sslocal-active.acl"),
defaultAction: .proxyWhenUnmatched,
chinaRules: try chinaCIDRSmokeRules()))
XCTAssertTrue(document.isWellFormed)
let wrapper = try launchWrapper(document)
defer {
if wrapper.isRunning { kill(wrapper.processIdentifier, SIGTERM) }
}
try awaitGlobalInboundsReady(listen: listen, document: document)
try assertLocalTargetsBypass(listen: listen, echoPort: echoServer.port, fakeSS: fakeSSServer)
try assertCIDRModeRouting(listen: listen, fakeSS: fakeSSServer)
stopWrapperAndAssertCleanExit(wrapper, description: "cidr rule wrapper exits")
}
/// issue #64 冒烟夹具:`.cn` 域名 + 测试用 `8.8.8.0/24` CIDR 直连候选
/// (仅验证命中/未命中路由,不代表真实中国 IP 归属)。
private func chinaCIDRSmokeRules() throws -> [ProxyRule] {
let domainSource = RuleSourceIdentity(
kind: .geolocationCN, upstreamVersion: "test", label: "geolocation-cn")
let cidrSource = RuleSourceIdentity(
kind: .chinaIPv4, upstreamVersion: "test", label: "china-operator-ip")
return [
ProxyRule(
action: .direct, match: try RuleMatch(nationalDomainSuffix: "cn"),
source: domainSource),
ProxyRule(
action: .direct, match: try RuleMatch(ipv4CIDR: "8.8.8.0/24"),
source: cidrSource),
]
}
/// CIDR 路由断言:命中 `8.8.8.0/24` 直连不触 SS;未命中公网目标走代理。
private func assertCIDRModeRouting(
listen: SslocalListenSettings, fakeSS: ConnectionCountingServer
) throws {
let beforeHit = fakeSS.connectionCount
performSocksConnectReply(socksPort: listen.socksPort, targetHost: "8.8.8.8", targetPort: 53)
Thread.sleep(forTimeInterval: 0.5)
XCTAssertEqual(
fakeSS.connectionCount, beforeHit,
"命中中国 IPv4 CIDR 的目标应直连,不得触达 Shadowsocks 出口")
performSocksConnectReply(socksPort: listen.socksPort, targetHost: "1.1.1.1", targetPort: 443)
XCTAssertTrue(
try waitForCondition(timeout: 5) { fakeSS.connectionCount > beforeHit },
"未命中 CIDR 的公网目标应连接 Shadowsocks 出口")
let afterSocks = fakeSS.connectionCount
performHTTPConnect(httpPort: listen.httpPort, targetHost: "9.9.9.9", targetPort: 443)
XCTAssertTrue(
try waitForCondition(timeout: 5) { fakeSS.connectionCount > afterSocks },
"HTTP 入站应应用同一规则 ACL")
}
func awaitGlobalInboundsReady(
listen: SslocalListenSettings, document: SslocalRuntimeDocument
) throws {
+19
View File
@@ -0,0 +1,19 @@
china-ipv4 rule snapshot
========================
Upstream: gaoyifan/china-operator-ip china.txt (ip-lists branch)
Upstream commit: 75f2eb035cb53a7a4af3437e2c2110d0f73cf14a
Upstream URL: https://raw.githubusercontent.com/gaoyifan/china-operator-ip/75f2eb035cb53a7a4af3437e2c2110d0f73cf14a/china.txt
Upstream archive SHA-256: 98493d3ec7f2c77b9f191600dc741df731cfbaef775201594d0bda66b0181479
License: MIT
China mainland IPv4 CIDR derived from gaoyifan/china-operator-ip china.txt
(ip-lists branch) at commit 75f2eb035cb53a7a4af3437e2c2110d0f73cf14a.
BGP/ASN-derived candidate routing data; not a guarantee of geographic
ownership or reachability.
This list is candidate routing data. It is not a guarantee of network
reachability, geographic ownership, or privacy. IP-CIDR matching may cause
sslocal to issue local DNS queries for unmatched hostnames; the product does
not promise that all DNS queries travel through the remote Shadowsocks server.
@@ -0,0 +1,8 @@
{
"converterVersion": "1.0.0",
"name": "china-ipv4",
"snapshotSHA256": "f97086c877982e860d0fb3ee5564e23e6c9f934b8e053a128150551e309296c6",
"upstreamArchiveSHA256": "98493d3ec7f2c77b9f191600dc741df731cfbaef775201594d0bda66b0181479",
"upstreamURL": "https://raw.githubusercontent.com/gaoyifan/china-operator-ip/75f2eb035cb53a7a4af3437e2c2110d0f73cf14a/china.txt",
"upstreamVersion": "75f2eb035cb53a7a4af3437e2c2110d0f73cf14a"
}
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -45,14 +45,13 @@ targets:
copyFiles:
destination: wrapper
subpath: Contents/Helpers/Plugins
# 内置规则快照(issue #63):普通构建只读本地已固定快照,离线且不重新
# 内置规则快照(issue #63/#64):普通构建只读本地已固定快照,离线且不重新
# 抓取或转换;缺失、损坏或版本不匹配时 verify-rule-snapshots.sh 使构建失败。
- path: Vendor/rules/geolocation-cn/snapshot.json
# folder reference 保留 rules/<source>/snapshot.json 结构,避免多个
# snapshot.json 在 bundle 根目录撞名。
- path: Vendor/rules
buildPhase: resources
type: file
- path: Vendor/rules/geolocation-cn/NOTICE
buildPhase: resources
type: file
type: folder
preBuildScripts:
- name: Fetch & verify external binaries
basedOnDependencyAnalysis: false