Files
proxy-station/scripts/check-surge.py
YANG JIANKUAN 5326c572a2 feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。

完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。

验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
2026-10-03 23:19:59 +08:00

142 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Read-only Surge profile validation. Credential-bearing files stay in a private temp dir."""
import argparse
import concurrent.futures
import csv
import hashlib
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
def fields(line):
return [x.strip() for x in next(csv.reader([line], skipinitialspace=True, escapechar='\\'))]
def sections(text):
result = {}
section = None
for number, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if line.startswith('[') and line.endswith(']'):
section = line[1:-1]
elif section and line and not line.startswith(('#', ';', '//')):
result.setdefault(section, []).append((number, line))
return result
def native_check(cli, profile):
result = subprocess.run([cli, '--check', str(profile)], capture_output=True, text=True, timeout=60)
if result.returncode:
# Native diagnostics may echo node passwords; do not print them to a shared log.
raise RuntimeError('Surge CLI 校验失败;请在本地对该文件运行 surge-cli --check 查看详情(输出可能含凭据)')
def domain_match(kind, value, host):
host, value = host.lower(), value.lower()
if kind == 'DOMAIN':
return host == value
if kind == 'DOMAIN-SUFFIX':
return host == value or host.endswith('.' + value)
if kind == 'DOMAIN-KEYWORD':
return value in host
return False
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('profile', type=Path)
parser.add_argument('--fetch-rulesets', action='store_true', help='下载远程规则集,替换为本地快照再校验,并检查域名遮蔽')
parser.add_argument('--cli', default='surge-cli')
args = parser.parse_args()
if not shutil.which(args.cli):
raise RuntimeError('找不到 surge-cli;请在安装 Surge 的 macOS 上校验,不能以其他客户端校验替代')
text = args.profile.read_text(encoding='utf-8-sig')
parsed = sections(text)
rules = [(number, fields(line)) for number, line in parsed.get('Rule', [])]
finals = [r for r in rules if r[1][0] == 'FINAL']
if len(finals) != 1 or not rules or rules[-1][1][0] != 'FINAL':
raise RuntimeError('必须恰有一条 FINAL 且位于规则末尾')
for number, line in parsed.get('Proxy Group', []):
if re.search(r',\s*url\s*=', line):
raise RuntimeError(f'配置第 {number} 行含 Surge 无效的组级 url=')
for number, line in parsed.get('General', []):
if re.match(r'(?:http-api|external-controller-access)\s*=\s*["\']?(?:surge|clashconnectrules)@', line, re.I):
raise RuntimeError(f'配置第 {number} 行使用公开控制口令')
if any(re.match(r'skip-server-cert-verify\s*=\s*["\']?(?:true|1)["\']?\s*(?:$|[#;]|//)', line, re.I) for _, line in parsed.get('MITM', [])):
raise RuntimeError('MITM 关闭了上游证书验证')
native_check(args.cli, args.profile)
print('官方 Surge CLI:OK;FINAL、测速参数、控制口令与 MITM 检查通过')
if not args.fetch_rulesets:
return
urls = {}
for _, parts in rules:
if parts[0] in ('RULE-SET', 'DOMAIN-SET') and parts[1].startswith(('https://', 'http://')):
urls[parts[1]] = parts[0]
for _, line in parsed.get('Host', []):
match = re.match(r'(RULE-SET|DOMAIN-SET):(https?://\S+)\s+=', line)
if match:
urls[match[2]] = match[1]
env = {k: v for k, v in os.environ.items() if k.lower() not in ('http_proxy', 'https_proxy', 'all_proxy')}
with tempfile.TemporaryDirectory(prefix='proxy-station-surge-check-') as directory:
root = Path(directory)
def fetch(item):
url, kind = item
file = root / (hashlib.sha256(url.encode()).hexdigest() + '.txt')
result = subprocess.run(['curl', '--silent', '--show-error', '--location', '--max-time', '25', '--max-filesize', '10000000', '--output', str(file), '--write-out', '%{http_code}', url], capture_output=True, text=True, timeout=30, env=env)
if result.returncode or result.stdout != '200':
raise RuntimeError('规则集下载失败(URL 未输出,避免泄露私有资源 token)')
data = file.read_text(encoding='utf-8-sig')
if re.search(r'<(?:!DOCTYPE|html)', data[:200], re.I):
raise RuntimeError('规则集返回 HTML')
entries = []
for line in data.splitlines():
line = line.strip()
if not line or line.startswith(('#', ';', '//')):
continue
parts = fields(line) if kind == 'RULE-SET' else ['DOMAIN-SUFFIX' if line.startswith('.') else 'DOMAIN', line.lstrip('.')]
if parts[0] == 'FINAL':
raise RuntimeError('外部规则集不允许含 FINAL')
entries.append(parts)
return url, file, entries
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
snapshots = list(pool.map(fetch, urls.items()))
local = text
coverage = {}
for url, file, entries in snapshots:
local = local.replace(url, str(file))
coverage[url] = entries
profile = root / 'profile.conf'
profile.write_text(local)
profile.chmod(0o600)
native_check(args.cli, profile)
print(f'远程规则集 {len(snapshots)}/{len(urls)} 下载成功;本地快照官方 CLI:OK')
warnings = 0
for index, (number, current) in enumerate(rules):
if current[0] not in ('DOMAIN', 'DOMAIN-SUFFIX') or len(current) < 3:
continue
for previous_number, previous in rules[:index]:
if len(previous) < 3 or previous[2] == current[2]:
continue
entries = coverage.get(previous[1], []) if previous[0] in ('RULE-SET', 'DOMAIN-SET') else [previous]
if any(len(entry) >= 2 and domain_match(entry[0], entry[1], current[1]) for entry in entries):
print(f'需审阅:第 {number} 行域名规则的样例被第 {previous_number} 行的不同策略覆盖(仅静态域名判断)')
warnings += 1
break
print(f'静态域名遮蔽提示:{warnings};不模拟 IP/进程/协议匹配或设备缓存')
if __name__ == '__main__':
try:
main()
except subprocess.TimeoutExpired:
raise SystemExit('校验或规则集下载超时;URL 与配置内容未输出')
except (RuntimeError, OSError, csv.Error) as error:
raise SystemExit(str(error))