proxyctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
proxyctl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """proxyctl — Proxy configuration lifecycle management."""
2
+
3
+ __version__ = "0.1.0"
proxyctl/audit.py ADDED
@@ -0,0 +1,385 @@
1
+ """proxyctl audit — 审计走代理但可能应直连的域名
2
+
3
+ 通过扫描代理日志,找出那些走了代理但实际是国内 IP 的域名,
4
+ 建议添加到直连规则,优化分流效果。
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import socket
11
+ import subprocess
12
+ from collections import defaultdict
13
+ from typing import Optional
14
+
15
+
16
+ RED = "\033[0;31m"
17
+ GREEN = "\033[0;32m"
18
+ YELLOW = "\033[0;33m"
19
+ BOLD = "\033[1m"
20
+ NC = "\033[0m"
21
+
22
+ HOME = os.path.expanduser("~")
23
+ DEFAULT_CONFIG_DIR = os.path.join(HOME, ".config", "proxyctl")
24
+
25
+ # 日志和配置路径(可通过配置覆盖)
26
+ SB_LOG = os.path.join(HOME, ".config", "sing-box", "sing-box.err")
27
+ MH_LOG = os.path.join(HOME, ".config", "mihomo", "mihomo.log")
28
+ SB_CONFIG = os.path.join(HOME, ".config", "sing-box", "config.json")
29
+ MH_CONFIG = os.path.join(HOME, ".config", "mihomo", "config.yaml")
30
+
31
+ ANSI_RE = re.compile(r'\x1b\[[0-9;]*m')
32
+
33
+ # sing-box 日志:outbound/tuic[节点]: outbound connection to domain:port
34
+ SB_PROXY_RE = re.compile(
35
+ r'outbound/(?:tuic|shadowsocks|hysteria2?)\[[^\]]+\]: outbound connection to ([^:]+):\d+'
36
+ )
37
+ # mihomo 日志:[TCP] ... --> domain:port match Rule using GROUP[NODE]
38
+ MH_PROXY_OK_RE = re.compile(
39
+ r'\[TCP\].*?-->\s+([^:]+):\d+\s+match\s+\S+.*?using\s+(?!DIRECT|REJECT)(\S+)'
40
+ )
41
+ MH_PROXY_ERR_RE = re.compile(
42
+ r'\[TCP\]\s+dial\s+proxy.*?-->\s+([^:]+):\d+\s+error:'
43
+ )
44
+
45
+ SKIP_HOSTS = {'www.gstatic.com', 'cp.cloudflare.com'}
46
+
47
+ # 已知需要代理的海外服务关键词
48
+ KNOWN_PROXY_KW = [
49
+ 'google', 'github', 'discord', 'telegram', 'twitter',
50
+ 'youtube', 'reddit', 'openai', 'anthropic', 'cloudflare',
51
+ 'apple.com', 'icloud', 'amazonaws', 'microsoft', 'azure',
52
+ 'intercom', 'datadog', 'datadoghq', 'sentry',
53
+ 'adblockplus', '1password', 'notion', 'docker', 'npmjs',
54
+ 'pypi', 'crates.io', 'huggingface', 'wikipedia', 'medium',
55
+ 'stackoverflow', 'x.com', 'twitch', 'netflix', 'spotify',
56
+ 'whatsapp', 'signal', 'mozilla', 'firefox', 'brave',
57
+ 'grammarly', 'linear.app', 'figma', 'vercel', 'netlify',
58
+ 'heroku', 'digitalocean', 'linode', 'vultr', 'hetzner',
59
+ 'shields.io', 'gravatar',
60
+ ]
61
+
62
+ IPGEO_CACHE_FILE = os.path.join(DEFAULT_CONFIG_DIR, ".ipgeo-audit-cache")
63
+
64
+
65
+ def _is_valid_domain(host: str) -> bool:
66
+ """过滤纯 IP、无点假域名、非域名格式。"""
67
+ try:
68
+ socket.inet_aton(host)
69
+ return False
70
+ except socket.error:
71
+ pass
72
+ if "." not in host:
73
+ return False
74
+ if not any(c.isalpha() for c in host.split(".")[-1]):
75
+ return False
76
+ return host not in SKIP_HOSTS
77
+
78
+
79
+ def _scan_log(log_path: str, engine_type: str, audit_days: int) -> dict:
80
+ """扫描日志文件,返回 {domain: count}。"""
81
+ domains: dict = defaultdict(int)
82
+ if not os.path.exists(log_path):
83
+ return domains
84
+
85
+ log_size = os.path.getsize(log_path)
86
+ read_bytes = max(10 * 1024 * 1024, min(log_size, audit_days * 50 * 1024 * 1024))
87
+
88
+ with open(log_path, "rb") as f:
89
+ f.seek(max(0, log_size - read_bytes))
90
+ if f.tell() > 0:
91
+ f.readline() # 跳过可能被截断的不完整行
92
+ for raw in f:
93
+ try:
94
+ line = ANSI_RE.sub("", raw.decode("utf-8", errors="replace"))
95
+ except Exception:
96
+ continue
97
+ host = None
98
+ if engine_type == "singbox":
99
+ m = SB_PROXY_RE.search(line)
100
+ if m:
101
+ host = m.group(1)
102
+ else:
103
+ m = MH_PROXY_OK_RE.search(line) or MH_PROXY_ERR_RE.search(line)
104
+ if m:
105
+ host = m.group(1)
106
+ if host and _is_valid_domain(host):
107
+ domains[host] += 1
108
+ return domains
109
+
110
+
111
+ def _load_geo_cache() -> dict:
112
+ """加载 IP → country 缓存。"""
113
+ try:
114
+ with open(IPGEO_CACHE_FILE) as f:
115
+ return json.load(f)
116
+ except Exception:
117
+ return {}
118
+
119
+
120
+ def _save_geo_cache(cache: dict):
121
+ try:
122
+ with open(IPGEO_CACHE_FILE, "w") as f:
123
+ json.dump(cache, f)
124
+ except Exception:
125
+ pass
126
+
127
+
128
+ _geo_cache: dict = {}
129
+
130
+
131
+ def _ip_country(ip: str) -> str:
132
+ """查询 IP 所属国家代码,带本地文件缓存。"""
133
+ global _geo_cache
134
+ if not _geo_cache:
135
+ _geo_cache = _load_geo_cache()
136
+ if ip in _geo_cache:
137
+ return _geo_cache[ip]
138
+ try:
139
+ r = subprocess.run(
140
+ ["curl", "-s", "--noproxy", "*", "--max-time", "3",
141
+ f"https://ipinfo.io/{ip}/country"],
142
+ capture_output=True, text=True, timeout=5
143
+ )
144
+ country = r.stdout.strip().upper()
145
+ if len(country) == 2 and country.isalpha():
146
+ _geo_cache[ip] = country
147
+ return country
148
+ except Exception:
149
+ pass
150
+ return ""
151
+
152
+
153
+ def _is_cn_ip(ip: str) -> bool:
154
+ return _ip_country(ip) == "CN"
155
+
156
+
157
+ def _resolve_direct(domain: str) -> str:
158
+ """用阿里 DoH 反查域名的真实 IP。"""
159
+ try:
160
+ r = subprocess.run(
161
+ ["curl", "-s", "--noproxy", "*", "--max-time", "3",
162
+ f"https://223.5.5.5/resolve?name={domain}&type=A"],
163
+ capture_output=True, text=True, timeout=5
164
+ )
165
+ data = json.loads(r.stdout)
166
+ for a in data.get("Answer", []):
167
+ if a.get("type") == 1:
168
+ return a["data"]
169
+ except Exception:
170
+ pass
171
+ return ""
172
+
173
+
174
+ def _is_covered(host: str, suffix_set: set) -> bool:
175
+ """判断域名是否已被规则集覆盖。"""
176
+ parts = host.split(".")
177
+ return any(".".join(parts[i:]) in suffix_set for i in range(len(parts)))
178
+
179
+
180
+ def _load_rules() -> tuple:
181
+ """从双 config 读取 direct/proxy 域名后缀规则集。"""
182
+ direct_suffixes: set = set()
183
+ proxy_suffixes: set = set()
184
+
185
+ # sing-box JSON
186
+ try:
187
+ cfg = json.load(open(SB_CONFIG))
188
+ for rule in cfg.get("route", {}).get("rules", []):
189
+ ob = rule.get("outbound", "")
190
+ for s in rule.get("domain_suffix", []):
191
+ s = s.lstrip(".")
192
+ if ob == "direct":
193
+ direct_suffixes.add(s)
194
+ elif ob in ("proxy", "claude"):
195
+ proxy_suffixes.add(s)
196
+ except Exception:
197
+ pass
198
+
199
+ # mihomo YAML (文本扫描,无需解析完整 YAML)
200
+ try:
201
+ for line in open(MH_CONFIG):
202
+ m = re.match(r'\s*-\s+DOMAIN-SUFFIX,([^,]+),(DIRECT|proxy|claude)', line, re.I)
203
+ if m:
204
+ dom, target = m.group(1), m.group(2)
205
+ if target.upper() == "DIRECT":
206
+ direct_suffixes.add(dom)
207
+ else:
208
+ proxy_suffixes.add(dom)
209
+ except Exception:
210
+ pass
211
+
212
+ return direct_suffixes, proxy_suffixes
213
+
214
+
215
+ def _apply_to_configs(new_suffixes: list) -> list:
216
+ """将建议直连的后缀写入双 config,返回操作摘要列表。"""
217
+ applied = []
218
+
219
+ # 1. sing-box config.json
220
+ try:
221
+ cfg = json.load(open(SB_CONFIG))
222
+ target_rule = None
223
+ for rule in cfg.get("route", {}).get("rules", []):
224
+ if rule.get("outbound") == "direct" and "domain_suffix" in rule:
225
+ target_rule = rule
226
+ if target_rule:
227
+ existing = {s.lstrip(".") for s in target_rule["domain_suffix"]}
228
+ added = [s for s in new_suffixes if s not in existing]
229
+ if added:
230
+ target_rule["domain_suffix"].extend(added)
231
+ with open(SB_CONFIG, "w") as f:
232
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
233
+ f.write("\n")
234
+ applied.append(f"sing-box: +{len(added)} 条")
235
+ except Exception as e:
236
+ applied.append(f"sing-box: 失败 ({e})")
237
+
238
+ # 2. mihomo config.yaml: 在 ".cn 后缀" 注释行前插入
239
+ try:
240
+ lines = open(MH_CONFIG).readlines()
241
+ insert_idx = None
242
+ existing_mh: set = set()
243
+ for i, line in enumerate(lines):
244
+ m = re.match(r'\s*-\s+DOMAIN-SUFFIX,([^,]+),', line)
245
+ if m:
246
+ existing_mh.add(m.group(1))
247
+ if ".cn 后缀" in line or "DOMAIN-SUFFIX,cn,DIRECT" in line:
248
+ insert_idx = i
249
+ if insert_idx is not None:
250
+ added_mh = []
251
+ for s in new_suffixes:
252
+ if s not in existing_mh:
253
+ lines.insert(insert_idx, f" - DOMAIN-SUFFIX,{s},DIRECT\n")
254
+ insert_idx += 1
255
+ added_mh.append(s)
256
+ if added_mh:
257
+ open(MH_CONFIG, "w").writelines(lines)
258
+ applied.append(f"mihomo: +{len(added_mh)} 条")
259
+ except Exception as e:
260
+ applied.append(f"mihomo: 失败 ({e})")
261
+
262
+ return applied
263
+
264
+
265
+ def cmd_audit(audit_days: int, api_base: str, api_secret: str, do_apply: bool):
266
+ """proxyctl audit — 扫描日志,找走代理但实际是国内 IP 的域名。"""
267
+ print(f"{BOLD}代理链路审计{NC} (最近 {audit_days} 天,双引擎扫描)\n")
268
+
269
+ # 步骤 1: 扫描双引擎日志
270
+ proxy_domains: dict = defaultdict(int)
271
+ scanned = []
272
+ for log_path, etype, label in [
273
+ (SB_LOG, "singbox", "sing-box"),
274
+ (MH_LOG, "mihomo", "mihomo"),
275
+ ]:
276
+ if os.path.exists(log_path):
277
+ d = _scan_log(log_path, etype, audit_days)
278
+ for host, count in d.items():
279
+ proxy_domains[host] += count
280
+ sz = os.path.getsize(log_path)
281
+ scanned.append(f"{label}({sz // 1024 // 1024}MB, {len(d)} 域名)")
282
+ print(f"日志扫描:{', '.join(scanned) or '无日志文件'}")
283
+
284
+ # 步骤 2: 合并当前活跃连接
285
+ try:
286
+ r = subprocess.run(
287
+ ["curl", "-s", "--noproxy", "*",
288
+ "-H", f"Authorization: Bearer {api_secret}",
289
+ f"{api_base}/connections"],
290
+ capture_output=True, text=True, timeout=5
291
+ )
292
+ data = json.loads(r.stdout)
293
+ for c in data.get("connections", []):
294
+ chain_str = str(c.get("chains", []))
295
+ host = c.get("metadata", {}).get("host", "")
296
+ if host and _is_valid_domain(host):
297
+ if any(kw in chain_str for kw in
298
+ ["tuic", "shadowsocks", "auto", "proxy"]):
299
+ proxy_domains[host] += 1
300
+ except Exception:
301
+ pass
302
+
303
+ if not proxy_domains:
304
+ print(" 没有发现走代理的域名流量。")
305
+ return
306
+
307
+ # 步骤 3: 读双 config 规则,过滤已有明确规则的域名
308
+ direct_suffixes, proxy_suffixes = _load_rules()
309
+ uncovered = {
310
+ h: c for h, c in proxy_domains.items()
311
+ if not _is_covered(h, direct_suffixes) and not _is_covered(h, proxy_suffixes)
312
+ }
313
+
314
+ if not uncovered:
315
+ print(" 所有走代理的域名都已被显式规则覆盖,无遗漏。")
316
+ return
317
+
318
+ # 步骤 4 & 5: 快速分类 + DoH 反查
319
+ print(f"未覆盖域名:{len(uncovered)} 个,DoH 反查中...\n")
320
+ candidates = [] # (host, count, ip, tag) — 疑似应直连
321
+ proxy_ok = [] # 确认需要代理
322
+ unknown = [] # 无法判断
323
+
324
+ for host, count in sorted(uncovered.items(), key=lambda x: -x[1]):
325
+ if any(kw in host for kw in KNOWN_PROXY_KW):
326
+ proxy_ok.append((host, count, "", "known"))
327
+ continue
328
+ real_ip = _resolve_direct(host)
329
+ if not real_ip:
330
+ unknown.append((host, count, "", "no-A"))
331
+ continue
332
+ country = _ip_country(real_ip)
333
+ if country == "CN":
334
+ candidates.append((host, count, real_ip, "cn"))
335
+ else:
336
+ proxy_ok.append((host, count, real_ip, country or "?"))
337
+
338
+ # 输出
339
+ if candidates:
340
+ print(f"{RED}■ 疑似应直连(国内 IP):{NC}")
341
+ for host, count, ip, _ in candidates:
342
+ print(f" {host:<45s} → {ip:<16s} x{count}")
343
+
344
+ seen: set = set()
345
+ for host, _, _, _ in candidates:
346
+ parts = host.split(".")
347
+ if len(parts) >= 2:
348
+ seen.add(".".join(parts[-2:]))
349
+ new_suffixes = sorted(seen)
350
+ print(f"\n 建议添加到 direct 规则:")
351
+ for s in new_suffixes:
352
+ print(f" .{s}")
353
+ print()
354
+
355
+ if do_apply and new_suffixes:
356
+ applied = _apply_to_configs(new_suffixes)
357
+ if applied:
358
+ print(f"{GREEN}■ 已写入:{', '.join(applied)}{NC}")
359
+ print("执行 proxyctl restart 生效\n")
360
+
361
+ if unknown:
362
+ print(f"{YELLOW}■ 无法判断 ({len(unknown)} 个):{NC}")
363
+ for host, count, _, reason in unknown:
364
+ print(f" {host:<45s} x{count:5d} ({reason})")
365
+ print()
366
+
367
+ if proxy_ok:
368
+ show = proxy_ok[:20]
369
+ more = len(proxy_ok) - len(show)
370
+ print(f"{GREEN}■ 确认需要代理 ({len(proxy_ok)} 个):{NC}")
371
+ for host, count, ip, _ in show:
372
+ print(f" {host:<45s} {ip:<16s} x{count:5d}")
373
+ if more:
374
+ print(f" ... 另有 {more} 个")
375
+ print()
376
+
377
+ _save_geo_cache(_geo_cache)
378
+
379
+ total = len(candidates) + len(proxy_ok) + len(unknown)
380
+ print(f"共 {total} 个未覆盖域名:"
381
+ f"{RED}{len(candidates)} 疑似可直连{NC}, "
382
+ f"{len(proxy_ok)} 确认代理,"
383
+ f"{len(unknown)} 待定")
384
+ if candidates and not do_apply:
385
+ print(f"\n执行 {BOLD}proxyctl audit apply{NC} 自动写入双 config")
@@ -0,0 +1,5 @@
1
+ """proxyctl 内置插件目录。
2
+
3
+ 放在这里的插件随 proxyctl 一起分发,提供通用增强(如 google/github 连通性测试等)。
4
+ 本机特例请放到 ~/.config/proxyctl/plugins/。
5
+ """
@@ -0,0 +1,35 @@
1
+ """通用连通性基线插件。
2
+
3
+ 提供一组跨所有用户都通用的连通性测试点:
4
+ - 海外(走代理):google / github
5
+ - 国内(直连):baidu
6
+
7
+ 本机特例(discord/anthropic/telegram、企业内网等)请走用户插件。
8
+
9
+ 代理组:默认提供 "proxy"(绝大多数 mihomo/sing-box 配置都有这个组)。
10
+ 其他组(claude / residential-* 等)由用户插件提供。
11
+ """
12
+
13
+ from proxyctl.core.plugin import CheckTarget, OutboundProbe, Plugin
14
+
15
+
16
+ class ConnectivityBasic(Plugin):
17
+ name = "connectivity-basic"
18
+
19
+ def check_groups(self) -> list[str]:
20
+ return ["proxy"]
21
+
22
+ def check_targets(self, ctx: dict) -> list[CheckTarget]:
23
+ return [
24
+ CheckTarget(name="google", url="https://www.google.com", mode="proxy"),
25
+ CheckTarget(name="github", url="https://github.com", mode="proxy"),
26
+ CheckTarget(name="baidu", url="https://www.baidu.com", mode="direct"),
27
+ ]
28
+
29
+ def check_outbound_probes(self, ctx: dict) -> list[OutboundProbe]:
30
+ # 只提供 proxy 出口探测——direct 探测严重依赖本地网络环境
31
+ # (国内用户用 myip.ipip.net 这种国内服务才稳,海外用户用 ipify 更好),
32
+ # 因此 direct 探测由用户插件提供。
33
+ return [
34
+ OutboundProbe(name="proxy", mode="proxy", url="https://api.ipify.org"),
35
+ ]
@@ -0,0 +1,57 @@
1
+ """企业内网通用插件(只在 config.corp_dns.server 配置时启用)。
2
+
3
+ 把 corp_dns 相关的检查点(企业 DNS 可达性 + check_targets 里列的内网目标)
4
+ 从 check.py 里剥离,让 core 完全不关心 corp_dns 这个 schema。
5
+
6
+ config schema(保留在 config.yaml 里供企业用户填):
7
+
8
+ corp_dns:
9
+ server: "10.0.0.53" # 企业 DNS 服务器 IP
10
+ test_domain: "wiki.corp.example.com" # 用来 dig 测试的内网域名
11
+ ip_prefix: "10." # 用于判定"是否在企业网"的 IP 前缀
12
+ check_targets:
13
+ - {url: "tcp:10.0.0.1:22", name: "gw", mode: "tcp"}
14
+ - {url: "https://wiki.corp.example.com", name: "wiki", mode: "direct"}
15
+ """
16
+
17
+ from proxyctl.core.plugin import CheckTarget, Plugin
18
+
19
+
20
+ class CorpNetwork(Plugin):
21
+ name = "corp-network"
22
+
23
+ def _corp(self) -> dict:
24
+ return (self.config.get("corp_dns") or {})
25
+
26
+ def _enabled(self) -> bool:
27
+ c = self._corp()
28
+ return bool(c.get("server"))
29
+
30
+ def check_targets(self, ctx: dict) -> list[CheckTarget]:
31
+ if not self._enabled():
32
+ return []
33
+ if not ctx.get("corp_net"):
34
+ return [] # 不在企业网时跳过
35
+
36
+ c = self._corp()
37
+ targets: list[CheckTarget] = []
38
+
39
+ # 1) 企业 DNS 可达性(用 dig 测一个内网域名)
40
+ server = c.get("server", "")
41
+ test_domain = c.get("test_domain", "")
42
+ if server and test_domain:
43
+ targets.append(CheckTarget(
44
+ name="corp-dns",
45
+ url=f"dns:{server}:{test_domain}",
46
+ mode="dns",
47
+ ))
48
+
49
+ # 2) 用户在 corp_dns.check_targets 里列的目标
50
+ for t in (c.get("check_targets") or []):
51
+ targets.append(CheckTarget(
52
+ name=t.get("name", "corp-target"),
53
+ url=t.get("url", ""),
54
+ mode=t.get("mode", "direct"),
55
+ ))
56
+
57
+ return targets