diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 947dcdb..421fa39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: bash -n "$script" done + - name: Install test dependencies + run: python3 -m pip install -r requirements-test.txt + - name: Run unit tests run: python3 -m unittest discover -s tests -p 'test_*.py' -v @@ -52,3 +55,4 @@ jobs: gzip --decompress "$RUNNER_TEMP/mihomo.gz" chmod 755 "$RUNNER_TEMP/mihomo" MIHOMO_BIN="$RUNNER_TEMP/mihomo" python3 -m unittest discover -s tests -p 'test_mihomo_config.py' -v + MIHOMO_BIN="$RUNNER_TEMP/mihomo" python3 -m unittest discover -s tests -p 'test_aggregate.py' -v diff --git a/README.md b/README.md index 5eecdaa..084e482 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火 ## 导入客户端 +需要把多台服务器汇总为一份配置、敏感服务固定主备而普通海外自动选线时,使用 [多服务器汇总与日常分流](docs/aggregate-routing.md)。每个业务组保留默认策略和手动排查入口,完整节点列表只维护一处。 + 部署成功后,每个平台默认得到两份名称明确的 YAML: - `clash-configs/gcloud-mac.yaml` diff --git a/aggregate.py b/aggregate.py new file mode 100644 index 0000000..aa0b282 --- /dev/null +++ b/aggregate.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Render one device configuration from explicitly selected local profiles.""" +import argparse +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT / 'core')) +from client_aggregate import generate + + +def main(): + parser = argparse.ArgumentParser(description='汇总本地节点;不部署服务器、不显示凭据') + parser.add_argument('action', choices=('render', 'check')) + parser.add_argument('--profile', required=True, help='保存 aggregate.json 的汇总 profile') + parser.add_argument('--client', choices=('stash', 'mihomo')) + args = parser.parse_args() + return generate(ROOT, args.profile, target=args.client, check=args.action == 'check') + + +if __name__ == '__main__': + try: + sys.exit(main()) + except (ValueError, OSError) as exc: + sys.exit(f'ERROR: {exc}') diff --git a/config/aggregate.json.example b/config/aggregate.json.example new file mode 100644 index 0000000..c0100a3 --- /dev/null +++ b/config/aggregate.json.example @@ -0,0 +1,10 @@ +{ + "sources": [ + {"profile": "cstonecloud-cuii-a", "name": "cstone"}, + {"profile": "lax-cmin2-01", "name": "lax"}, + {"profile": "gcloud", "name": "gcloud"} + ], + "sensitive": ["cstone/Reality", "gcloud/CDN"], + "devices": ["mac", "iphone"], + "client": "stash" +} diff --git a/core/client_aggregate.py b/core/client_aggregate.py new file mode 100644 index 0000000..7f01ffd --- /dev/null +++ b/core/client_aggregate.py @@ -0,0 +1,269 @@ +"""Compose only our own generated node blocks with the shared daily rules. + +Source YAML is rendered afresh in a private temporary directory, never read from +distribution copies. This is a section composer, not a general YAML parser. +""" +from contextlib import ExitStack +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + +from client_output import write_outputs +from client_policy import adapt_config +from profile_lock import profile_lock +from settings import DEVICE, NAME, load_settings, validate + +CORE = Path(__file__).resolve().parent +AI = '🤖 AI / Meta 服务' +SENSITIVE = '🛡 敏感服务' +AUTO = '⚡ 自动测速' +MANUAL = '🔧 手动选择' +OVERSEAS = '🌐 海外流量' +CN = '🇨🇳 国内流量' +APPLE = '🍎 Apple 基础服务' +ADS = '🛑 屏蔽流量' +META_DOMAINS = ('facebook.com', 'facebook.net', 'fb.com', 'fb.me', 'fbcdn.net', + 'fbsbx.com', 'instagram.com', 'cdninstagram.com', 'whatsapp.com', + 'whatsapp.net', 'wa.me', 'messenger.com', 'meta.com', 'meta.ai') +DNS = { + AI: ('1.1.1.1', '1.0.0.1'), + OVERSEAS: ('9.9.9.9', '149.112.112.112'), + CN: ('223.5.5.5', '120.53.53.53'), +} + + +def quote(value): + return json.dumps(value, ensure_ascii=False) + + +def load_plan(root, profile): + if not NAME.fullmatch(profile): + raise ValueError('汇总 profile 名称无效') + path = root / 'profiles' / profile / 'aggregate.json' + try: + plan = json.loads(path.read_text()) + except (OSError, ValueError): + raise ValueError('无法读取汇总 profile 的 aggregate.json(未显示内容)') from None + if not isinstance(plan, dict) or set(plan) != {'sources', 'sensitive', 'devices', 'client'}: + raise ValueError('aggregate.json 需要 sources、sensitive、devices、client 四个字段') + if plan['client'] not in ('stash', 'mihomo'): + raise ValueError('client 必须为 stash/mihomo') + devices = plan['devices'] + if (not isinstance(devices, list) or not devices + or any(not isinstance(d, str) or not DEVICE.fullmatch(d) for d in devices) + or len(devices) != len(set(devices))): + raise ValueError('devices 必须是非空且不重复的有效设备名列表') + sources = plan['sources'] + if not isinstance(sources, list) or not sources: + raise ValueError('sources 不能为空') + names, profiles = set(), set() + for source in sources: + if (not isinstance(source, dict) or set(source) != {'profile', 'name'} + or any(not isinstance(v, str) or not NAME.fullmatch(v) for v in source.values())): + raise ValueError('每个 source 需要有效的 profile 和 name') + if source['name'] in names or source['profile'] in profiles or source['profile'] == profile: + raise ValueError('source 别名/profile 不得重复,也不能引用汇总 profile 自身') + names.add(source['name']) + profiles.add(source['profile']) + sensitive = plan['sensitive'] + if (not isinstance(sensitive, list) or not sensitive + or any(not isinstance(n, str) or '/' not in n or n.split('/')[0] not in names + or n.split('/', 1)[1] not in ('Reality', 'CDN') for n in sensitive) + or len(set(sensitive)) != len(sensitive)): + raise ValueError('sensitive 必须按优先顺序列出 source 别名/Reality 或 source 别名/CDN') + return plan + + +def node_blocks(text, alias): + """Accept only the generator's known section and node-name contract.""" + try: + section = text.split('\nproxies:\n', 1)[1].split('\nproxy-groups:\n', 1)[0] + except IndexError: + raise ValueError('节点生成器输出结构改变,已停止汇总') from None + matches = list(re.finditer(r'^ - name: "US-([A-Za-z0-9-]+)"\n', section, re.M)) + if not matches or section[:matches[0].start()].strip(): + raise ValueError('节点生成器未提供可识别节点') + result = {} + for i, match in enumerate(matches): + protocol = match[1] + if protocol not in ('Reality', 'HY2', 'AnyTLS', 'CDN', 'Reality-WARP'): + raise ValueError('节点类型尚未纳入汇总策略') + name = f'{alias}/{protocol}' + if name in result: + raise ValueError('生成器返回重复节点名称') + end = matches[i + 1].start() if i + 1 < len(matches) else len(section) + body = section[match.end():end].rstrip() + result[name] = f' - name: {quote(name)}\n{body}\n' + return result + + +def collect_nodes(root, plan, target): + nodes = {device: {} for device in plan['devices']} + with ExitStack() as stack, tempfile.TemporaryDirectory(prefix='network-node-aggregate-') as tmp: + sources = [] + # Match the GCP deployment/address-operation lock; fail instead of reading + # a partially changed profile. Hold all locks until every source is read. + for source in sorted(plan['sources'], key=lambda s: s['profile']): + state = root / 'profiles' / source['profile'] + if not (state / 'deploy.conf').is_file(): + raise ValueError(f"源 profile {source['profile']} 不存在") + stack.enter_context(profile_lock(state)) + for source in plan['sources']: + state = root / 'profiles' / source['profile'] + settings = load_settings(state) + settings['CLIENT_TARGET'] = target + validate(settings) + if settings.get('CLIENT_CONFIG_ENABLE', 'true') == 'false': + continue + fingerprint = tuple((state / name).read_bytes() for name in ('deploy.conf', '.secrets.env')) + directory = Path(tmp) / source['name'] + env = os.environ | { + 'NETWORK_NODE_ROOT': str(root), 'NETWORK_NODE_STATE_DIR': str(state), + 'NETWORK_NODE_PROFILE': source['profile'], 'NETWORK_NODE_CLIENTS_DIR': str(directory), + } + try: + run = subprocess.run([sys.executable, str(CORE / 'gen-clash.py'), '--client', target], + env=env, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + raise ValueError(f"源 profile {source['profile']} 生成超时;已保留旧汇总文件") from None + if run.returncode: + # Never relay unexpected generator tracebacks containing local state. + raise ValueError(f"源 profile {source['profile']} 生成失败;请用 node.py validate 检查") + prefix = settings.get('CLIENT_FILE_PREFIX', '').strip() or source['profile'] + for device in set(settings.get('DEVICES', 'mac iphone').split()) & nodes.keys(): + text = (directory / f'{prefix}-{device}.yaml').read_text() + nodes[device].update(node_blocks(text, source['name'])) + sources.append((state, fingerprint)) + for state, fingerprint in sources: + if fingerprint != tuple((state / name).read_bytes() for name in ('deploy.conf', '.secrets.env')): + raise ValueError('源 profile 在生成期间发生变化,请重新汇总') + for device, available in nodes.items(): + if not available or any(name not in available for name in plan['sensitive']): + raise ValueError(f'{device} 缺少指定的敏感服务主线/备用线,已保留旧汇总文件') + return nodes + + +def group(name, kind, proxies, target): + if not proxies: + raise ValueError('禁止生成空策略组,避免客户端将它视为直连') + text = f' - name: {quote(name)}\n type: {kind}\n' + if kind != 'select': + text += f' interval: {60 if kind == "fallback" else 300}\n lazy: false\n' + if target == 'mihomo': + text += ' url: https://www.gstatic.com/generate_204\n' + if kind == 'url-test': + text += ' tolerance: 50\n' + return text + ' proxies:\n' + ''.join(f' - {quote(n)}\n' for n in proxies) + '\n' + + +def groups(nodes, sensitive, target): + auto = [name for name in nodes if not name.endswith('/Reality-WARP')] + selections = [(AI, [SENSITIVE, MANUAL]), + (OVERSEAS, [AUTO, MANUAL]), (CN, ['DIRECT', AUTO, MANUAL]), + (APPLE, ['DIRECT', AUTO, MANUAL]), + (ADS, ['REJECT', AUTO, MANUAL, 'DIRECT'])] + return ('proxy-groups:\n' + ''.join(group(n, 'select', options, target) for n, options in selections) + + group(SENSITIVE, 'fallback', sensitive, target) + + group(AUTO, 'url-test', auto, target) + + group(MANUAL, 'select', list(nodes), target)) + + +def dns_policy(rules): + """Use separate resolver endpoints so manual selections also apply to DNS.""" + policies = {} + for line in rules.splitlines(): + if not line.startswith(' - '): + continue + fields = line[4:].split(',') + if len(fields) < 3 or fields[2] != AI: + continue + if fields[0] == 'DOMAIN': + policies[fields[1]] = AI + elif fields[0] == 'DOMAIN-SUFFIX': + policies['+.' + fields[1]] = AI + # Sensitive sets precede CN when a domain belongs to more than one set. + policies['geosite:category-ai-!cn'] = AI + for name in ('facebook', 'instagram', 'whatsapp'): + policies['geosite:' + name] = AI + policies['+.cn'] = CN + policies['geosite:cn'] = CN + return ' nameserver-policy:\n' + ''.join( + f' {quote(domain)}:\n' + ''.join(f' - https://{ip}/dns-query\n' for ip in DNS[policy]) + for domain, policy in policies.items()) + + +def compose(profile, device, target, nodes, sensitive): + template = (CORE / 'client.yaml.tmpl').read_text() + values = {name: '' for name in re.findall(r'\{([A-Z0-9_]+)\}', template)} + revision = hashlib.sha256(template.encode() + Path(__file__).read_bytes()).hexdigest()[:12] + values.update(DEVICE=device, PROFILE_OWNER=profile, TARGET_LABEL=target, + STRICT_LABEL='false', TEMPLATE_REVISION=revision, + SERVER_LABEL='Aggregate; per-server credentials remain in source profiles', + DNS_FOLLOW_RULE=' follow-rule: true' if target == 'stash' else ' respect-rules: true', + STUN_PROTOCOL_RULE=f' - PROTOCOL,STUN,{AI}' if target == 'stash' else '') + # Adapt the shared header/rules once, then insert already-adapted node blocks. + base = adapt_config(template.format(**values), target, False, []) + header = base.split('\nproxies:\n', 1)[0] + providers = base.split('\nrule-providers:\n', 1)[1].split('\nrules:\n', 1)[0] + rules = base.split('\nrules:\n', 1)[1] + mapping = {'🤖 AI 隐私出口': AI, '🌐 代理流量': OVERSEAS, '↪️ 直连流量': APPLE, + '🎯 兜底策略': OVERSEAS} + for old, new in mapping.items(): + rules = rules.replace(old, new) + rules = '\n'.join(line for line in rules.splitlines() if line.startswith(' - ')) + '\n' + rules = rules.replace(f'DOMAIN-KEYWORD,spotify,{APPLE}', 'DOMAIN-KEYWORD,spotify,DIRECT') + rules = rules.replace(f'DOMAIN-SUFFIX,scdn.co,{APPLE}', 'DOMAIN-SUFFIX,scdn.co,DIRECT') + rules = rules.replace(f'DOMAIN-SUFFIX,raw.githubusercontent.com,{AI}', + f'DOMAIN-SUFFIX,raw.githubusercontent.com,{OVERSEAS}') + # Resolver routes must remain ahead of CN IP rules. Replace inherited endpoints. + old_resolvers = ('1.1.1.1', '8.8.8.8', '223.5.5.5', '120.53.53.53') + rules = '\n'.join(line for line in rules.splitlines() + if not any(line.startswith(f' - IP-CIDR,{ip}/32,') for ip in old_resolvers)) + '\n' + dns_rules = ''.join(f' - IP-CIDR,{ip}/32,{policy},no-resolve\n' + for policy, addresses in DNS.items() for ip in addresses) + # Static LAN rules remain first. Resolver IPs are public and cannot overlap LAN. + anchor = ' - DOMAIN-SUFFIX,raw.githubusercontent.com,' + index = rules.index(anchor) + rules = rules[:index] + dns_rules + rules[index:] + meta_rules = ''.join(f' - DOMAIN-SUFFIX,{domain},{AI}\n' for domain in META_DOMAINS) + for name in ('facebook', 'instagram', 'whatsapp'): + providers += (f'\n {name}:\n type: http\n behavior: domain\n format: mrs\n' + f' url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/{name}.mrs"\n' + f' path: ./ruleset/meta_{name}.mrs\n interval: 86400\n') + meta_rules += f' - RULE-SET,{name},{AI}\n' + # Meta shares the sensitive group, ahead of ads and domestic routing. + rules = rules.replace(f' - RULE-SET,ai,{AI}\n', meta_rules + f' - RULE-SET,ai,{AI}\n') + header = header.split(' nameserver:\n', 1)[0] + # Bootstrap remains independent of sensitive resolver endpoints. + header = header.replace(' - https://1.1.1.1/dns-query', ' - https://1.12.12.12/dns-query') + header += ' nameserver:\n' + ''.join(f' - https://{ip}/dns-query\n' for ip in DNS[OVERSEAS]) + header += dns_policy(rules) + header = '\n'.join(line for line in header.splitlines() if not line.lstrip().startswith('#') or line.startswith('#')) + return (header + '\n\nproxies:\n' + '\n'.join(nodes.values()) + '\n' + + groups(nodes, sensitive, target) + 'rule-providers:\n' + providers + + '\nrules:\n' + rules) + + +def generate(root, profile, *, target=None, check=False): + root = Path(root) + plan = load_plan(root, profile) + target = target or plan['client'] + if target not in ('stash', 'mihomo'): + raise ValueError('客户端目标无效') + nodes = collect_nodes(root, plan, target) + directory = root / 'clash-configs' + rendered = {directory / f'{profile}-{device}.yaml': compose(profile, device, target, available, plan['sensitive']) + for device, available in nodes.items()} + current = write_outputs(directory, profile, rendered, check=check) + if check: + print('汇总配置与所有当前源一致' if current else '汇总配置缺失或已过期,请重新 render') + return 0 if current else 1 + print(f'已生成 {len(rendered)} 份 {target} 汇总配置;每设备节点数:' + + '、'.join(f'{device}={len(available)}' for device, available in nodes.items())) + print('敏感服务使用显式主备顺序;手动选择集中在一个组。未部署或切换客户端。') + return 0 diff --git a/docs/aggregate-routing.md b/docs/aggregate-routing.md new file mode 100644 index 0000000..53d5608 --- /dev/null +++ b/docs/aggregate-routing.md @@ -0,0 +1,63 @@ +# 多服务器汇总与日常分流 + +`aggregate.py` 将显式列出的本地 profile 汇总成每设备一份客户端配置。默认以 Stash 为目标,支持显式生成 Mihomo。单服务器部署与配置生成入口保持独立;汇总不访问云端、不修改服务端或自动切换客户端。 + +## 默认行为与手动排查 + +| 分流入口 | 默认策略 | 可手动调整 | +|---|---|---| +| AI / Meta 服务(包括 Facebook、Instagram、WhatsApp 等) | 敏感服务主备 | 手动选择 | +| 海外流量,包括未匹配请求 | 自动测速 | 手动选择 | +| 国内流量 | DIRECT | 自动测速、手动选择 | +| Apple 基础服务 | DIRECT;Siri 等既有海外规则仍进入海外流量 | 自动测速、手动选择 | +| 屏蔽流量 | REJECT | 自动测速、手动选择、DIRECT | + +敏感服务主备按 `sensitive` 数组的顺序执行,示例为 **cstone/Reality → gcloud/CDN**。只引用这些指定节点,不自动退到 GCP 直连、其他服务器、WARP 或 DIRECT。CDN 保护本地到 GCP 的入口;出口仍是 GCP,不能改善目的站对 GCP IP 的信誉判断。切换备用时出口 IP 会改变。主备全部不通时敏感服务失败,用户可以主动切换到手动选择排查。 + +所有业务组的手动入口指向同一个「手动选择」组,完整节点列表只出现一次。操作顺序:先在「手动选择」中选节点,再把需要排查的业务组切到「手动选择」。这会影响所有已经选择手动的业务组;其他业务组的默认策略不变。排查结束后恢复该业务组的第一个选项。若以后需要多个业务同时固定到不同节点,应增加各自的独立选择组。 + +普通海外和未匹配流量共用一个入口,不再经过「代理流量 → 代理策略 → 兜底」多层选择。Spotify 保留原有直连行为,使用显式 DIRECT 规则,不显示独立分组,也不会跟随海外流量组选线。局域网保持直连,避免改动业务分流后影响本地设备访问。 + +「自动测速」每 300 秒测试一次,参与范围是启用来源中当前生成的普通节点;WARP 如已生成,只在手动列表中出现。「敏感服务」每 60 秒检查一次,采用固定优先顺序。它们都跳过不健康节点。HTTP 延迟探测不能等同于下载带宽、IP 信誉或某个网站的业务可用性;连得上测速 URL 但网站异常时需要手动切换。Stash 的测试地址/超时沿用生成器中的节点级 `benchmark-url` / `benchmark-timeout`,不假定 Mihomo 的组级 `url` / `tolerance` 在 Stash 生效。 + +## 配置与生成 + +在汇总 profile 下保存 `aggregate.json`,格式参考 [配置示例](../config/aggregate.json.example): + +```bash +mkdir -p profiles/routing +chmod 700 profiles/routing +cp config/aggregate.json.example profiles/routing/aggregate.json +chmod 600 profiles/routing/aggregate.json +python3 aggregate.py render --profile routing +python3 aggregate.py check --profile routing +``` + +修改 `sources` 中的 profile 名称以匹配实际服务器。`name` 是节点显示前缀,必须唯一;最终名称如 `cstone/Reality`、`gcloud/CDN`。`devices` 显式列出要生成的设备,敏感主备的每个来源都必须具备对应设备凭据。普通来源缺少某设备时,仅从该设备的节点池中省略;明确指定的敏感主备缺失时,整个生成失败并保留已有输出。 + +输出为 `clash-configs/routing-mac.yaml`、`clash-configs/routing-iphone.yaml`,权限为 `600`,目录为 `700`。各设备只应导入自己的文件。需要 Mihomo 时使用 `--client mihomo`;同名文件会被替换,不能把该目标的文件当成 Stash 配置。 + +汇总从源 profile 重新生成节点,不依赖已有客户端 YAML 或 iCloud 副本。临时生成目录为私有目录,用后清理;凭据继续归各源 profile 管理,不写进公开规则。生成期间使用 profile 操作锁;遇到部署、换 IP 或源文件变化时停止,避免混合两版状态。源节点生成/校验失败发生在写入前,保留旧输出;发布阶段沿用逐文件原子替换,并非跨文件事务,磁盘写入失败后应修复问题并重新 render/check。 + +源 profile 的 `CLIENT_CONFIG_ENABLE=false` 会将普通来源移出汇总;若它被指定为敏感主备,则报错,要求明确调整主备计划。不会生成空策略组,因为 Stash 会把空组视为 DIRECT。 + +增加节点、修改端口/密码、GCP 换 IP、停用来源之后,都需要重新执行 `aggregate.py render` 并更新设备分发副本。`check` 会从当前源重新计算并判断漂移;不会修改现有 YAML,也不测试网络在线状态。源 profile 的 `AI_STRICT_MODE`、国内组偏好只影响其单服务器配置,汇总始终使用本文定义的日常分流策略。 + +## DNS 与规则边界 + +AI 和 Meta 的明确域名/域名集共用敏感服务的加密解析器地址,解析器连接路由到「AI / Meta 服务」组,因此切换该组到手动节点时,其 DNS 也跟随选择。普通海外 DNS 使用独立解析器,经海外流量组,避免依赖敏感主备。国内 DNS 沿用国内组;节点域名由 `proxy-server-nameserver` 独立启动解析。规则集下载经普通海外组,避免敏感线路同时故障时影响规则更新。 + +DNS 分组覆盖静态精确/后缀规则及对应 AI、Meta geosite 集合。域名规则无法推断未知第三方依赖或请求归属;原有 DOMAIN-KEYWORD、IP、协议规则与 DNS 策略也不是逐条等价。新增网站依赖时仍需补精确规则;通配 DNS 规则可能优先于 geosite,见 [Stash DNS 文档](https://stash.wiki/features/dns-server)。 + +Meta 的静态域名和远程域名集优先于广告拦截、国内分流和普通兜底;AI 认证、上传、监控等已知依赖继续复用共享模板。汇总仅增补策略,不复制维护另一份完整业务规则。 + +## 验证 + +运行时只依赖 Python 标准库。测试使用 PyYAML 独立解析生成的 YAML,验证引用、循环、DNS 路由和客户端字段: + +```bash +python3 -m pip install -r requirements-test.txt +python3 -m unittest discover -s tests -p 'test_*.py' +``` + +真实连通性、Stash 导入后的组选择和设备网络差异,需要在各设备上验证。规则策略类型依据 [Stash 策略组文档](https://stash.wiki/proxy-protocols/proxy-groups)。 diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..76d9f3a --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,2 @@ +# Independent parser for generated Stash/Mihomo YAML and strategy graph tests. +PyYAML==6.0.2 diff --git a/tests/test_aggregate.py b/tests/test_aggregate.py new file mode 100644 index 0000000..f6739ac --- /dev/null +++ b/tests/test_aggregate.py @@ -0,0 +1,209 @@ +"""Routing boundaries and source lifecycle for multi-profile client output.""" +import copy +import json +import os +from pathlib import Path +import sys +import subprocess +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'core')) +import client_aggregate as aggregate + +try: + import yaml +except ImportError: + yaml = None + + +class AggregateTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.plan = { + 'sources': [{'profile': 'primary', 'name': 'cstone'}, + {'profile': 'backup', 'name': 'gcloud'}, + {'profile': 'fast', 'name': 'lax'}], + 'sensitive': ['cstone/Reality', 'gcloud/CDN'], + 'devices': ['mac', 'iphone'], 'client': 'stash', + } + for name in ('primary', 'backup', 'fast'): + path = self.root / 'profiles' / name + path.mkdir(parents=True) + (path / 'deploy.conf').write_text( + 'DEVICES=mac iphone\nREALITY_PORT=443\nAI_STRICT_MODE=true\n' + 'CDN_ENABLE=' + ('true' if name == 'backup' else 'false') + '\n' + 'CDN_HOSTNAME=cdn.example.com\nCDN_WS_PATH=ws-test\n') + (path / '.secrets.env').write_text( + 'STATIC_IP=203.0.113.10\nREALITY_PUBLIC=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n' + 'REALITY_SHORTID=0123456789abcdef\nHY2_PORT=31000\nANYTLS_PORT=21000\n' + 'ANYTLS_PASS=test-anytls-' + name + '\n' + 'REALITY_UUID_mac=00000000-0000-4000-8000-000000000001\nHY2_PASS_mac=mac-only\n' + 'REALITY_UUID_iphone=00000000-0000-4000-8000-000000000002\nHY2_PASS_iphone=phone-only\n' + 'CDN_UUID_mac=00000000-0000-4000-8000-000000000003\n' + 'CDN_UUID_iphone=00000000-0000-4000-8000-000000000004\n') + state = self.root / 'profiles' / 'routing' + state.mkdir() + self.plan_path = state / 'aggregate.json' + self.save_plan() + + def save_plan(self): + self.plan_path.write_text(json.dumps(self.plan)) + + def generate(self, **kwargs): + return aggregate.generate(self.root, 'routing', **kwargs) + + def output(self, device='mac'): + return self.root / 'clash-configs' / f'routing-{device}.yaml' + + def test_sources_are_fresh_devices_isolated_and_outputs_owned(self): + self.assertEqual(self.generate(), 0) + text = self.output().read_text() + self.assertIn('mac-only', text) + self.assertNotIn('phone-only', text) + self.assertIn('gcloud/CDN', text) + self.assertNotIn('phone-only', text) + self.assertIn('phone-only', self.output('iphone').read_text()) + self.assertEqual(self.output().stat().st_mode & 0o777, 0o600) + self.assertEqual(self.output().parent.stat().st_mode & 0o777, 0o700) + self.assertEqual(self.generate(check=True), 0) + # Distribution copies have no authority over a subsequent render. + unrelated = self.output().parent / 'primary-mac.yaml' + unrelated.write_text('manual distribution copy') + secret = self.root / 'profiles/fast/.secrets.env' + secret.write_text(secret.read_text().replace('203.0.113.10', '203.0.113.11')) + self.assertEqual(self.generate(check=True), 1) + self.generate() + self.assertIn('203.0.113.11', self.output().read_text()) + self.assertEqual(unrelated.read_text(), 'manual distribution copy') + + def test_missing_sensitive_cdn_fails_without_replacing_existing_files(self): + self.generate() + original = self.output().read_bytes() + path = self.root / 'profiles/backup/deploy.conf' + path.write_text(path.read_text().replace('CDN_ENABLE=true', 'CDN_ENABLE=false')) + with self.assertRaisesRegex(ValueError, '缺少指定的敏感'): + self.generate() + self.assertEqual(self.output().read_bytes(), original) + + def test_disabled_source_is_removed_but_sensitive_source_cannot_disappear(self): + path = self.root / 'profiles/fast/deploy.conf' + path.write_text(path.read_text() + 'CLIENT_CONFIG_ENABLE=false\n') + self.generate() + self.assertNotIn('lax/', self.output().read_text()) + backup = self.root / 'profiles/backup/deploy.conf' + backup.write_text(backup.read_text() + 'CLIENT_CONFIG_ENABLE=false\n') + with self.assertRaisesRegex(ValueError, '缺少指定的敏感'): + self.generate() + + def test_missing_device_on_sensitive_source_fails(self): + path = self.root / 'profiles/backup/deploy.conf' + path.write_text(path.read_text().replace('DEVICES=mac iphone', 'DEVICES=mac')) + with self.assertRaisesRegex(ValueError, 'iphone 缺少指定'): + self.generate() + self.assertFalse(self.output().exists()) + + def test_generator_timeout_is_sanitized_and_preserves_files(self): + self.generate() + original = self.output().read_bytes() + failure = subprocess.TimeoutExpired(['private-command'], 60, output='private-output') + with mock.patch.object(aggregate.subprocess, 'run', side_effect=failure): + with self.assertRaisesRegex(ValueError, '生成超时') as error: + self.generate() + self.assertNotIn('private-', str(error.exception)) + self.assertEqual(self.output().read_bytes(), original) + + def test_bad_plan_and_empty_sensitive_group_rejected(self): + bad_plans = [] + for field, value in [('sensitive', []), ('sensitive', ['gcloud/HY2']), + ('devices', ['../mac']), ('sources', [])]: + plan = copy.deepcopy(self.plan) + plan[field] = value + bad_plans.append(plan) + duplicate = copy.deepcopy(self.plan) + duplicate['sources'][1]['name'] = 'cstone' + bad_plans.append(duplicate) + for plan in bad_plans: + self.plan_path.write_text(json.dumps(plan)) + with self.assertRaises(ValueError): + self.generate() + + def test_mihomo_engine_accepts_bundle(self): + binary = os.environ.get('MIHOMO_BIN') + if not binary or not Path(binary).is_file(): + self.skipTest('MIHOMO_BIN is not available') + self.generate(target='mihomo') + result = subprocess.run([binary, '-t', '-d', str(self.root), '-f', str(self.output())], + capture_output=True, text=True, timeout=180) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + @unittest.skipUnless(yaml, 'PyYAML unavailable; install requirements-test.txt for independent YAML validation') + def test_routing_graph_dns_defaults_and_client_schema(self): + for target in ('stash', 'mihomo'): + with self.subTest(target=target): + self.generate(target=target) + data = yaml.safe_load(self.output().read_text()) + proxies = {p['name']: p for p in data['proxies']} + groups = {g['name']: g for g in data['proxy-groups']} + self.assertEqual(len(proxies), 10) + self.assertEqual(len(groups), 8) + self.assertEqual(groups[aggregate.SENSITIVE]['proxies'], ['cstone/Reality', 'gcloud/CDN']) + self.assertEqual(groups[aggregate.SENSITIVE]['type'], 'fallback') + self.assertFalse(groups[aggregate.SENSITIVE]['lazy']) + self.assertEqual(groups[aggregate.AUTO]['type'], 'url-test') + self.assertEqual(groups[aggregate.AUTO]['interval'], 300) + self.assertEqual(groups[aggregate.AI]['proxies'], [aggregate.SENSITIVE, aggregate.MANUAL]) + self.assertEqual(groups[aggregate.OVERSEAS]['proxies'], [aggregate.AUTO, aggregate.MANUAL]) + self.assertEqual(groups[aggregate.CN]['proxies'][0], 'DIRECT') + self.assertEqual(groups[aggregate.ADS]['proxies'][0], 'REJECT') + self.assertEqual(set(groups[aggregate.MANUAL]['proxies']), set(proxies)) + allowed = set(proxies) | set(groups) | {'DIRECT', 'REJECT'} + def visit(name, stack): + self.assertNotIn(name, stack, 'strategy cycle') + if name in groups: + self.assertTrue(groups[name]['proxies']) + for child in groups[name]['proxies']: + self.assertIn(child, allowed) + visit(child, stack | {name}) + for name in groups: + visit(name, set()) + rules = data['rules'] + for rule in rules: + fields = rule.split(',') + self.assertIn(fields[1 if fields[0] == 'MATCH' else 2], allowed) + if fields[0] == 'RULE-SET': + self.assertIn(fields[1], data['rule-providers']) + self.assertEqual(rules[-1], f'MATCH,{aggregate.OVERSEAS}') + self.assertIn('DOMAIN-KEYWORD,spotify,DIRECT', rules) + self.assertIn('DOMAIN-SUFFIX,scdn.co,DIRECT', rules) + self.assertFalse(any('Spotify' in name for name in groups)) + meta_rule = f'DOMAIN-SUFFIX,facebook.com,{aggregate.AI}' + self.assertLess(rules.index(meta_rule), rules.index(f'RULE-SET,ads-lite,{aggregate.ADS}')) + self.assertLess(rules.index(meta_rule), rules.index(f'RULE-SET,cn,{aggregate.CN}')) + dns = data['dns'] + self.assertEqual(dns['nameserver'], ['https://9.9.9.9/dns-query', 'https://149.112.112.112/dns-query']) + self.assertEqual(dns['nameserver-policy']['+.facebook.com'], + ['https://1.1.1.1/dns-query', 'https://1.0.0.1/dns-query']) + self.assertEqual(dns['nameserver-policy']['+.facebook.com'], + dns['nameserver-policy']['+.openai.com']) + self.assertFalse(any('💬' in name for name in groups)) + for policy, addresses in aggregate.DNS.items(): + for ip in addresses: + self.assertIn(f'IP-CIDR,{ip}/32,{policy},no-resolve', rules) + self.assertIn('proxy-server-nameserver', dns) + for proxy in proxies.values(): + self.assertEqual('benchmark-url' in proxy, target == 'stash') + if proxy['type'] == 'hysteria2': + self.assertEqual('auth' in proxy, target == 'stash') + self.assertEqual('password' in proxy, target == 'mihomo') + self.assertEqual('follow-rule' in dns, target == 'stash') + self.assertEqual('tun' in data, target == 'mihomo') + if target == 'stash': + self.assertNotIn('url', groups[aggregate.SENSITIVE]) + + +if __name__ == '__main__': + unittest.main()