procproxy 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.
Files changed (45) hide show
  1. procproxy-0.1.0.dist-info/METADATA +489 -0
  2. procproxy-0.1.0.dist-info/RECORD +45 -0
  3. procproxy-0.1.0.dist-info/WHEEL +5 -0
  4. procproxy-0.1.0.dist-info/entry_points.txt +2 -0
  5. procproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. procproxy-0.1.0.dist-info/top_level.txt +1 -0
  7. proxycore/__init__.py +7 -0
  8. proxycore/__main__.py +65 -0
  9. proxycore/api.py +226 -0
  10. proxycore/backend/__init__.py +49 -0
  11. proxycore/backend/base.py +159 -0
  12. proxycore/backend/null.py +51 -0
  13. proxycore/backend/windivert/__init__.py +657 -0
  14. proxycore/backend/windivert/filter.py +123 -0
  15. proxycore/backend/windivert/pidmap.py +289 -0
  16. proxycore/backend/windivert/pkt.py +424 -0
  17. proxycore/canproxy.py +229 -0
  18. proxycore/checkroute.py +247 -0
  19. proxycore/cli.py +605 -0
  20. proxycore/config.py +1010 -0
  21. proxycore/dns/__init__.py +46 -0
  22. proxycore/dns/cache.py +209 -0
  23. proxycore/dns/modes.py +451 -0
  24. proxycore/dns/udp.py +606 -0
  25. proxycore/forward.py +572 -0
  26. proxycore/ipc.py +637 -0
  27. proxycore/log.py +298 -0
  28. proxycore/pathsetup.py +112 -0
  29. proxycore/probe.py +168 -0
  30. proxycore/proc.py +342 -0
  31. proxycore/rules.py +730 -0
  32. proxycore/runtime.py +654 -0
  33. proxycore/service/__init__.py +536 -0
  34. proxycore/service/__main__.py +124 -0
  35. proxycore/tunnel/__init__.py +8 -0
  36. proxycore/tunnel/relay.py +393 -0
  37. proxycore/tunnel/sessions.py +315 -0
  38. proxycore/tunnel/sink.py +325 -0
  39. proxycore/types.py +165 -0
  40. proxycore/upstream/__init__.py +52 -0
  41. proxycore/upstream/base.py +150 -0
  42. proxycore/upstream/chain.py +92 -0
  43. proxycore/upstream/http_connect.py +148 -0
  44. proxycore/upstream/socks4.py +106 -0
  45. proxycore/upstream/socks5.py +266 -0
proxycore/forward.py ADDED
@@ -0,0 +1,572 @@
1
+ """把指定进程的 TCP 流量转发给抓包工具(`procproxy <进程名>` 的实现)。
2
+
3
+ 让目标进程以为自己在直连,实际每条 TCP 连接都被改道到本机 sink,
4
+ 再经你指定的上游代理(Yakit / Burp / Charles)转发出去。
5
+
6
+ 它替你做四件事:自检 → 生成配置 → 起数据面 → 收尾。
7
+
8
+ ## 用法
9
+
10
+ python procproxy.py WeChat.exe --port 8848
11
+ python procproxy.py Weixin.exe WeChatAppEx.exe --port 8848 # 多个进程
12
+ python procproxy.py WeChat.exe --port 8848 --check-only # 只自检,不改流量
13
+
14
+ ## 边界
15
+
16
+ UDP/QUIC 抓不到;按域名写的规则不命中;上游必须写 IP;默认只代理点名的进程。
17
+ 完整说明、三层成功判据与排错见 `docs/14-把进程流量转发给抓包工具.md`。
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import _thread
23
+ import argparse
24
+ import contextlib
25
+ import fnmatch
26
+ import json
27
+ import os
28
+ import pathlib
29
+ import sys
30
+ import threading
31
+ from typing import Any
32
+
33
+ import yaml
34
+
35
+ from proxycore.backend import BackendUnavailable, make_backend
36
+ from proxycore.cli import runtime_dir
37
+ from proxycore.config import Config, ConfigError, ConfigManager, port_in_use
38
+ from proxycore.log import build_logger, get_logger
39
+ from proxycore.runtime import Runtime
40
+
41
+ from .probe import describe_listener, probe_http_proxy, probe_socks5
42
+
43
+ DEFAULT_PORT = 8848 # Yakit 常用;Burp 8080、Charles 8888 自己传 --port
44
+ DEFAULT_SINK_PORT = 19080
45
+ PROBE_TARGET = ("www.baidu.com", 80) # 只用来握一次手,不下载内容
46
+ UPSTREAM_ALIAS = "capture"
47
+
48
+ # 这些网段永远直连:代理它们没有意义,还会把内网探测流量灌进抓包工具。
49
+ PRIVATE_RANGES = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,169.254.0.0/16"
50
+
51
+
52
+ def _default_out_dir() -> pathlib.Path:
53
+ """生成配置的默认落盘目录(`<仓库根>/build/proxy-process/`;已安装场景退回 cwd)。"""
54
+ from .cli import repo_root
55
+
56
+ root = repo_root() or pathlib.Path.cwd()
57
+ return root / "build" / "proxy-process"
58
+
59
+
60
+ # ------------------------------------------------------------------ 小工具 ----
61
+ def _harden_stdout() -> None:
62
+ """输出被重定向时(管道/文件)Python 用 locale 编码(中文 Windows 是 GBK),
63
+ 碰上一个编不出的字符就抛 UnicodeEncodeError 把脚本打死,比乱码严重得多。
64
+ 本文件的字符都挑过(GBK 全覆盖),这里再兜一层:编不出就退化成 '?'。"""
65
+ for stream in (sys.stdout, sys.stderr):
66
+ with contextlib.suppress(Exception): # 老解释器/被重定向时可能没有 reconfigure
67
+ stream.reconfigure(errors="replace") # type: ignore[union-attr]
68
+
69
+
70
+ def _is_admin() -> bool:
71
+ if os.name != "nt":
72
+ return False
73
+ import ctypes
74
+
75
+ try:
76
+ return bool(ctypes.windll.shell32.IsUserAnAdmin())
77
+ except Exception: # noqa: BLE001 - 探测失败一律按"不是管理员"处理,宁可多报一次
78
+ return False
79
+
80
+
81
+ def _port_busy(host: str, port: int) -> bool:
82
+ # 与 config.port_in_use 共用实现(Windows 上 SO_REUSEADDR 会漏判占用)。
83
+ return port_in_use(port, host)
84
+
85
+
86
+ def _pick_sink_port(host: str, preferred: int) -> tuple[int, str]:
87
+ """sink 是本地入口,占用冲突很常见(上一次没退干净)。换一个比报错友好。"""
88
+ if not _port_busy(host, preferred):
89
+ return preferred, ""
90
+ for candidate in range(preferred + 1, preferred + 40):
91
+ if not _port_busy(host, candidate):
92
+ return candidate, f"端口 {preferred} 被占用,改用 {candidate}"
93
+ return preferred, "" # 全占了就交给启动时报错,别在这里猜
94
+
95
+
96
+ def _split_names(values: list[str]) -> list[str]:
97
+ """`--process a --process b` 与 `--process "a;b,c"` 两种写法都收。"""
98
+ out: list[str] = []
99
+ for chunk in values:
100
+ for piece in chunk.replace(",", ";").split(";"):
101
+ name = piece.strip()
102
+ if name and name not in out:
103
+ out.append(name)
104
+ return out
105
+
106
+
107
+ def _human_bytes(n: int) -> str:
108
+ step = float(n)
109
+ for unit in ("B", "KB", "MB", "GB"):
110
+ if step < 1024 or unit == "GB":
111
+ return f"{step:.0f}{unit}" if unit == "B" else f"{step:.1f}{unit}"
112
+ step /= 1024
113
+ return f"{step:.1f}GB"
114
+
115
+
116
+ def _running_names() -> dict[str, list[int]]:
117
+ """当前在跑的进程:名字 → pid 列表。没装 psutil 就返回空表(绝不因此报错)。
118
+
119
+ psutil 本来是路线 A 的依赖之一,但不是所有环境都装了,这只是个提示功能,
120
+ 拿不到就当没有,不能因此让入口脚本跑不起来。
121
+ """
122
+ try:
123
+ import psutil
124
+ except ImportError:
125
+ return {}
126
+ out: dict[str, list[int]] = {}
127
+ for proc in psutil.process_iter(["name", "pid"]):
128
+ try:
129
+ name = str(proc.info.get("name") or "")
130
+ pid = int(proc.info.get("pid") or 0)
131
+ except Exception: # noqa: BLE001 - 单个进程取不到就跳过(权限/竞态都常见)
132
+ continue
133
+ if name and pid:
134
+ out.setdefault(name, []).append(pid)
135
+ return out
136
+
137
+
138
+ def _check_names(names: list[str], running: dict[str, list[int]]) -> list[str]:
139
+ """点名了、但当前没有同名进程在跑:这是最常见的失败原因。
140
+
141
+ 微信尤其容易踩:3.x 叫 `WeChat.exe`、4.x 叫 `Weixin.exe`;而规则里的精确名字
142
+ 比的是完整 exe 名(`Weixin` 不匹配 `Weixin.exe`,实测确认)。所以这里主动提示形似名字,
143
+ 省得用户盯着"走代理 0 条"猜半天。
144
+ """
145
+ if not running:
146
+ return []
147
+ hints: list[str] = []
148
+ for pattern in names:
149
+ if any(fnmatch.fnmatchcase(name.lower(), pattern.lower()) for name in running):
150
+ continue
151
+ stem = pattern.lower()[:-4] if pattern.lower().endswith(".exe") else pattern.lower()
152
+ similar = sorted(name for name in running if stem and stem in name.lower())
153
+ line = f" [!] 当前没有进程叫 {pattern!r}"
154
+ if similar:
155
+ shown = "、".join(f"{name}({running[name][0]})" for name in similar[:4])
156
+ line += f"\n 形似的:{shown}"
157
+ if stem == pattern.lower():
158
+ line += " ← 规则比的是完整 exe 名,写全(带 .exe)或用通配符"
159
+ hints.append(line)
160
+ return hints
161
+
162
+
163
+ class _RunLedger:
164
+ r"""本次运行新产生的 `session_end` 记录合计(条数 + 上下行字节)。
165
+
166
+ 为什么要自己数:`sessions.stats()` 里的 `bytes_up/bytes_down` 只统计此刻还活着
167
+ 的会话(`sum(s.bytes_up for s in self._sessions.values())`,见 tunnel/sessions.py),
168
+ 连接一关就归零。于是状态行会打印出"走代理 1 条 | 字节 ↑0B ↓0B"这种自相矛盾的东西
169
+ ,实测一次 curl 明明过了 727KB,显示的却是 0B。
170
+
171
+ 这里改成从 jsonl 里累加本次运行之后新写入的那些行,单调递增,和真实转发量对得上。
172
+ 用二进制偏移量续读,避免文本模式 seek 的编码坑;半行(日志正在写)留到下一轮。
173
+
174
+ 注意:jsonl 是按用户全局共享的(`%ProgramData%\ProcProxy\sessions.jsonl`)。
175
+ 如果同时跑着第二个 ProcProxy 实例,它的 session_end 也会被算进来。
176
+ """
177
+
178
+ def __init__(self, path: str) -> None:
179
+ self.path = pathlib.Path(path)
180
+ self._lock = threading.Lock()
181
+ self._offset = self.path.stat().st_size if self.path.exists() else 0
182
+ self.count = 0
183
+ self.up = 0
184
+ self.down = 0
185
+
186
+ def refresh(self) -> None:
187
+ with self._lock:
188
+ try:
189
+ size = self.path.stat().st_size
190
+ except OSError:
191
+ return
192
+ if size < self._offset: # 日志被轮转/清空过,从头再来
193
+ self._offset = 0
194
+ if size == self._offset:
195
+ return
196
+ try:
197
+ with open(self.path, "rb") as handle:
198
+ handle.seek(self._offset)
199
+ for raw in handle:
200
+ if not raw.endswith(b"\n"): # 半行:日志还在写,下轮再读
201
+ break
202
+ self._offset += len(raw)
203
+ try:
204
+ rec = json.loads(raw.decode("utf-8", "replace"))
205
+ except ValueError: # 不是 JSON 就当没看见,绝不因此打断转发
206
+ continue
207
+ if rec.get("event") != "session_end":
208
+ continue
209
+ self.count += 1
210
+ self.up += int(rec.get("bytes_up") or 0)
211
+ self.down += int(rec.get("bytes_down") or 0)
212
+ except OSError: # 读日志失败只是统计不准,绝不能影响正在跑的转发
213
+ return
214
+
215
+
216
+ # ------------------------------------------------------------------ 上游自检 ----
217
+ def _probe_upstream(host: str, port: int, kind: str, timeout: float) -> tuple[str, str]:
218
+ """判断这个端口能不能当上游,并返回该用哪种协议。
219
+
220
+ 为什么要先探:ProcProxy 的上游是"代理协议客户端",不是裸端口转发。
221
+ 抓包工具若只是普通 Web 服务,配置看着没错但每条连接都握手失败
222
+ 那种失败在几百行日志里很难看出来。
223
+ """
224
+ if kind in {"auto", "http"}:
225
+ ok, detail = probe_http_proxy(host, port, PROBE_TARGET[0], PROBE_TARGET[1], timeout)
226
+ if ok:
227
+ return "http", detail
228
+ if kind == "http":
229
+ return "", detail
230
+ http_detail = detail
231
+ else:
232
+ http_detail = "(未试)"
233
+
234
+ if kind in {"auto", "socks5"}:
235
+ ok, detail = probe_socks5(host, port, timeout)
236
+ if ok:
237
+ return "socks5", detail
238
+ if kind == "socks5":
239
+ return "", detail
240
+ # 两种都失败:把各自的原因都报出来。两个 detail 必须分开放,
241
+ # 混用会让"HTTP 失败"后面跟着一句 socks5 的报错,看日志的人会查错方向。
242
+ return "", f"HTTP 代理语义: {http_detail} / SOCKS5 握手: {detail}"
243
+ return "", http_detail
244
+
245
+
246
+ # ------------------------------------------------------------------ 配置生成 ----
247
+ def build_config_doc(
248
+ *,
249
+ names: list[str],
250
+ upstream_host: str,
251
+ upstream_port: int,
252
+ kind: str,
253
+ sink_host: str,
254
+ sink_port: int,
255
+ backend: str,
256
+ log_level: str,
257
+ trace_payload: bool,
258
+ ) -> dict[str, Any]:
259
+ """生成配置。只写必要项:改动越少,出问题时越容易看懂。"""
260
+ return {
261
+ "version": 1,
262
+ "service": {
263
+ "sink": {"host": sink_host, "port": sink_port},
264
+ "backend": backend,
265
+ "max_sessions": 4000,
266
+ "loop_breaker": {"window_s": 5, "threshold": 50},
267
+ # 查不到进程归属时直连:不猜、不把别人的流量也拖进抓包工具。
268
+ "on_unknown_pid": "direct",
269
+ # 决策抛异常时不放行,放行等于漏真实 IP。
270
+ "on_decide_error": "block",
271
+ "log_level": log_level,
272
+ "enable_trace_payload": trace_payload,
273
+ },
274
+ "profile": {
275
+ "name": "capture-one-process",
276
+ # 没点名的进程一律直连。别改成 proxy:那会把整机流量灌进抓包工具,
277
+ # 真正要看的那条会被淹掉,抓包工具也可能被压垮。
278
+ "default_action": "direct",
279
+ "sticky_per_pid": True,
280
+ "handle_services": False,
281
+ "handle_other_users": False,
282
+ "dns_mode": "remote",
283
+ },
284
+ "upstreams": [
285
+ {
286
+ "alias": UPSTREAM_ALIAS,
287
+ "kind": kind,
288
+ "host": upstream_host,
289
+ "port": upstream_port,
290
+ "auth": "none",
291
+ # HTTP 代理没有 UDP ASSOCIATE;置 false 是明确的,不靠默认值。
292
+ "udp": False,
293
+ }
294
+ ],
295
+ "rules": [
296
+ # ① 私网直连,优先级最高:内网地址走代理没有意义。
297
+ {"id": "r-private-direct", "action": "direct", "targets": "*", "dst_ip": PRIVATE_RANGES},
298
+ # ② 点名进程 → 走抓包工具。
299
+ {"id": "r-watch", "action": "proxy", "upstream": UPSTREAM_ALIAS,
300
+ "targets": ";".join(names), "dst_port": "*"},
301
+ # ③ 其余由 profile.default_action = direct 兜底,不另写规则。
302
+ ],
303
+ }
304
+
305
+
306
+ def _write_config(doc: dict[str, Any], out_path: pathlib.Path) -> str:
307
+ out_path.parent.mkdir(parents=True, exist_ok=True)
308
+ out_path.write_text(
309
+ yaml.safe_dump(doc, allow_unicode=True, sort_keys=False, default_flow_style=False),
310
+ encoding="utf-8",
311
+ )
312
+ return str(out_path)
313
+
314
+
315
+ def _arm_timer(seconds: float) -> None:
316
+ """到点往主线程抛 KeyboardInterrupt:走的就是按 Ctrl+C 的同一条分支,
317
+ 而不是另写一套停止逻辑(那样就等于测了个假东西)。"""
318
+
319
+ def _fire() -> None:
320
+ _thread.interrupt_main()
321
+
322
+ timer = threading.Timer(seconds, _fire)
323
+ timer.daemon = True
324
+ timer.start()
325
+
326
+
327
+ # ------------------------------------------------------------------ 状态行 ----
328
+ def _heartbeat(runtime: Runtime, ledger: _RunLedger, stop: threading.Event, every: float) -> None:
329
+ """定期打一行总账。只读快照,不碰任何热路径。"""
330
+ last = ""
331
+ while not stop.wait(every):
332
+ try:
333
+ snap = runtime.snapshot()
334
+ except Exception: # noqa: BLE001 - 统计失败绝不能让运行中的服务受影响
335
+ continue
336
+ ledger.refresh()
337
+ backend_stats = snap.get("backend") or {}
338
+ sessions = snap.get("sessions") or {}
339
+ line = (
340
+ f"走代理 {sessions.get('created', 0)} 条(活跃 {sessions.get('active', 0)})"
341
+ # backend 的 captured 是全机 TCP 连接数:规则只管点名进程,别的连接
342
+ # 只是被驱动看见后原样放行。不写清楚的话,用户会以为"抓到 22 条"是微信的。
343
+ f" | 本机 TCP 路过 {backend_stats.get('captured', 0)} 条"
344
+ f"(其中直连放行 {backend_stats.get('direct', 0)})"
345
+ f" | 阻断 {backend_stats.get('blocked', 0)}"
346
+ f" | 累计 ↑{_human_bytes(ledger.up)} ↓{_human_bytes(ledger.down)}"
347
+ )
348
+ if line != last: # 没变化就不刷屏
349
+ print(f" [状态] {line}", flush=True)
350
+ last = line
351
+
352
+
353
+ # ------------------------------------------------------------------ 主流程 ----
354
+ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
355
+ ap = argparse.ArgumentParser(prog="procproxy forward",
356
+ description="把指定进程的 TCP 流量转发给抓包工具(Yakit / Burp / Charles …)",
357
+ formatter_class=argparse.RawDescriptionHelpFormatter,
358
+ epilog=(
359
+ "例子:\n"
360
+ " python procproxy.py WeChat.exe --port 8848\n"
361
+ " python procproxy.py Weixin.exe WeChatAppEx.exe --port 8848\n"
362
+ " python procproxy.py WeChat.exe --port 8848 --check-only\n"
363
+ ),
364
+ )
365
+ ap.add_argument("--process", action="append", default=[], required=True,
366
+ help="要抓的进程名,可重复;也支持 'a.exe;b.exe' 一次给多个。支持 * 通配符")
367
+ ap.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"抓包工具的监听端口(默认 {DEFAULT_PORT})")
368
+ ap.add_argument("--upstream-host", default="127.0.0.1",
369
+ help="抓包工具所在主机。写 IP 不要写域名:环路守卫按字面量比对上游端点")
370
+ ap.add_argument("--kind", choices=["auto", "http", "socks5"], default="auto",
371
+ help="上游代理协议。auto = 两种都探一次(默认)")
372
+ ap.add_argument("--backend", default="windivert", choices=["windivert", "null"],
373
+ help="windivert = 真抓包改道(需管理员);null = 不抓包只联调")
374
+ ap.add_argument("--sink-port", type=int, default=DEFAULT_SINK_PORT, help="本地 sink 端口(被占用时自动 +1)")
375
+ ap.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
376
+ ap.add_argument("--trace-payload", action="store_true", help="把 payload 也记进日志(含内容,注意隐私)")
377
+ ap.add_argument("--status-every", type=float, default=10.0, help="状态行间隔秒数;0 = 不打")
378
+ ap.add_argument("--duration", type=float, default=0.0,
379
+ help="跑满 N 秒后自动停(等价于按 Ctrl+C);0 = 一直跑到 Ctrl+C。做演示/自动化用")
380
+ ap.add_argument("--config-out", default="", help="生成配置的落盘路径(默认 build/proxy-process/)")
381
+ ap.add_argument("--check-only", action="store_true",
382
+ help="只做自检 + 生成配置并打印,不启动、不改任何流量、不需要管理员")
383
+ ap.add_argument("--skip-probe", action="store_true", help="跳过上游端口探测(确认过端口没问题时用)")
384
+ return ap.parse_args(argv)
385
+
386
+
387
+ def main(argv: list[str] | None = None) -> int:
388
+ _harden_stdout()
389
+ args = _parse_args(argv)
390
+ names = _split_names(args.process)
391
+
392
+ print()
393
+ print("=" * 78)
394
+ print(" 把进程流量转发给抓包工具")
395
+ print("=" * 78)
396
+ print(f" 目标进程 : {'; '.join(names)}")
397
+ print(f" 上游 : {args.upstream_host}:{args.port} (协议 {args.kind})")
398
+ print(f" 后端 : {args.backend}")
399
+ if args.backend == "null":
400
+ print(" [!] backend=null 不抓包、不改道,只验证转发链路")
401
+ print()
402
+
403
+ # ---------------------------------------------------------- ① 权限自检
404
+ admin = _is_admin()
405
+ print("[1/5] 权限")
406
+ if args.backend == "windivert":
407
+ if admin:
408
+ print(" [ok] 管理员")
409
+ elif args.check_only:
410
+ print(" · 非管理员(--check-only 不需要,继续)")
411
+ else:
412
+ print(" [!!] 不是管理员。路线 A 要靠 WinDivert 驱动抓包,必须管理员。")
413
+ print(" → 右键 PowerShell「以管理员身份运行」后重试")
414
+ print(" → 只想先看配置:加 --check-only")
415
+ return 2
416
+ else:
417
+ print(f" · 后端 {args.backend}:{'管理员' if admin else '非管理员'}")
418
+
419
+ # ---------------------------------------------------------- ② 上游自检
420
+ print()
421
+ print("[2/5] 上游端口自检(抓包工具是不是在'代理监听'模式)")
422
+ print(f" 谁在监听 {args.upstream_host}:{args.port}:{describe_listener(args.upstream_host, args.port)}")
423
+ kind = args.kind
424
+ if kind == "auto":
425
+ kind = "http" # 探测失败时按 http 记录,下面的分支会给出真实结论
426
+ if args.skip_probe:
427
+ print(" · 已用 --skip-probe 跳过")
428
+ else:
429
+ kind, detail = _probe_upstream(args.upstream_host, args.port, args.kind, 5.0)
430
+ if not kind:
431
+ print(f" [!!] 这个端口不能当上游:{detail}")
432
+ print()
433
+ print(" 常见原因与处理:")
434
+ print(" · 端口填错 / 抓包工具没开监听 → 在抓包工具里确认 MITM 监听端口")
435
+ print(" · 那是 Web 管理端口而不是代理端口 → 找'代理/MITM 劫持'那个监听口")
436
+ print(" · 它要求代理认证 → 本脚本暂不支持带认证的上游,先在工具里关掉认证")
437
+ print(" · 确认过没问题只是想先跑起来 → 加 --skip-probe")
438
+ return 3
439
+ print(f" [ok] 判为 {kind} 代理:{detail}")
440
+ print(" (这次探测会在抓包工具里留下一条到 "
441
+ f"{PROBE_TARGET[0]}:{PROBE_TARGET[1]} 的 CONNECT,属正常)")
442
+
443
+ # ---------------------------------------------------------- ③ 生成配置
444
+ print()
445
+ print("[3/5] 生成配置")
446
+ sink_host = "127.0.0.1"
447
+ sink_port, note = _pick_sink_port(sink_host, args.sink_port)
448
+ if note:
449
+ print(f" · {note}")
450
+
451
+ doc = build_config_doc(
452
+ names=names,
453
+ upstream_host=args.upstream_host,
454
+ upstream_port=args.port,
455
+ kind=kind,
456
+ sink_host=sink_host,
457
+ sink_port=sink_port,
458
+ backend=args.backend,
459
+ log_level=args.log_level,
460
+ trace_payload=args.trace_payload,
461
+ )
462
+ if args.config_out:
463
+ out_path = pathlib.Path(args.config_out)
464
+ else:
465
+ stem = "-".join(n.split(".")[0] for n in names[:3]) or "watch"
466
+ out_path = _default_out_dir() / f"procproxy-{stem}.yaml"
467
+ cfg_path = _write_config(doc, out_path)
468
+ print(f" 写入 {cfg_path}")
469
+
470
+ try:
471
+ config = Config.load(cfg_path) # 走一遍真校验:能加载 = 配置是合法的
472
+ except ConfigError as exc:
473
+ print(f" [!!] 生成的配置没通过校验(这是脚本的 bug,请把上面那段贴给我):{exc}")
474
+ return 4
475
+
476
+ print(" 生效的规则:")
477
+ for rule in config.profile.rules:
478
+ target = f" → {rule.upstream}" if rule.upstream else ""
479
+ print(f" {rule.id:<18} {rule.action.value:<6} targets={rule.targets!r} dst_ip={rule.dst_ip!r}{target}")
480
+ print(f" (其余一律 {config.profile.default_action.value})")
481
+
482
+ print()
483
+ print("[3.5/5] 点名进程在不在跑")
484
+ running = _running_names() # 只枚举一次,别为了打印重复扫进程表
485
+ if not running:
486
+ print(" · 装个 psutil 就能顺手检查进程名(pip install psutil);现在跳过")
487
+ else:
488
+ for name in names:
489
+ hit = sorted(n for n in running if fnmatch.fnmatchcase(n.lower(), name.lower()))
490
+ if hit:
491
+ print(f" [ok] {name}:{', '.join(f'{n}({running[n][0]})' for n in hit[:3])}")
492
+ hints = _check_names(names, running)
493
+ for line in hints:
494
+ print(line)
495
+ if hints:
496
+ print(" (进程还没启动也没关系:规则按名字匹配,等它起来照样命中)")
497
+
498
+ if args.check_only:
499
+ print()
500
+ print("[check-only] 到此为止:没有启动、没有改任何流量")
501
+ print()
502
+ print(" 去掉 --check-only 就是真跑。也可以先看一条模拟连接会命中哪条规则:")
503
+ print(f" python -m proxycore rules --config \"{cfg_path}\" --trace "
504
+ f"--proc {names[0]} --dst 1.2.3.4 --port 443")
505
+ return 0
506
+
507
+ # ---------------------------------------------------------- ④ 起数据面
508
+ print()
509
+ print("[4/5] 启动数据面(Ctrl+C 停止)")
510
+ jsonl_path = str(runtime_dir() / "sessions.jsonl")
511
+ # 必须在起运行时先记住日志当前位置:本次运行的字节数=之后新增的那些行。
512
+ ledger = _RunLedger(jsonl_path)
513
+ build_logger("proxycore", level=config.service.log_level, jsonl_path=jsonl_path, console=True)
514
+ for child in ("runtime", "config", "sink", "upstream", "dns", "backend"):
515
+ get_logger(f"proxycore.{child}")
516
+
517
+ try:
518
+ backend = make_backend(config.service.backend, config=config)
519
+ except BackendUnavailable as exc:
520
+ # 绝不静默直连:装不了就明确报错收工。
521
+ print(f" [!!] [E_BACKEND_UNAVAILABLE] {exc}", file=sys.stderr)
522
+ print(" 路线 A 需要:Windows + 管理员 + pip install pydivert psutil", file=sys.stderr)
523
+ return 5
524
+
525
+ # ---- 数据面启动点:以上全是探测与生成配置,真正的运行时从这里开始 ----
526
+ runtime = Runtime(manager=ConfigManager(config), backend=backend)
527
+ runtime.start()
528
+
529
+ print()
530
+ print(" [ok] 已在运行。现在去操作那个进程(比如发一条微信消息),然后看:")
531
+ print(" 抓包工具 : 应该出现 CONNECT 目标:端口 以及随后的报文")
532
+ print(" 本终端 : 每条代理连接结束会打一行 session_end")
533
+ print()
534
+ print(" [!] UDP(含 QUIC)抓不到 —— 路线 A 的过滤器只抓 TCP,这部分流量直连出去。")
535
+ print()
536
+
537
+ stop_beat = threading.Event()
538
+ beat: threading.Thread | None = None
539
+ if args.status_every > 0:
540
+ beat = threading.Thread(target=_heartbeat, args=(runtime, ledger, stop_beat, args.status_every),
541
+ name="ppx-status", daemon=True)
542
+ beat.start()
543
+ if args.duration > 0:
544
+ print(f" (--duration {args.duration:g}:跑满就自己停)")
545
+ _arm_timer(args.duration)
546
+
547
+ try:
548
+ runtime.serve_forever()
549
+ except KeyboardInterrupt:
550
+ print("\n 收到停止信号,正在收尾…")
551
+ finally:
552
+ stop_beat.set()
553
+ with contextlib.suppress(Exception):
554
+ runtime.stop()
555
+
556
+ # ---------------------------------------------------------- ⑤ 收尾
557
+ print()
558
+ print("[5/5] 已停止")
559
+ ledger.refresh()
560
+ with contextlib.suppress(Exception):
561
+ snap = runtime.snapshot()
562
+ sessions = snap.get("sessions") or {}
563
+ print(f" 本次走代理 {sessions.get('created', 0)} 条连接(其中 "
564
+ f"{ledger.count} 条已结束并记账),"
565
+ f"字节 ↑{_human_bytes(ledger.up)} ↓{_human_bytes(ledger.down)}")
566
+ print(f" 会话明细(每条连接的 pid/进程/目标/上游/规则/字节数):{jsonl_path}")
567
+ print(f" python -m proxycore sessions --log \"{jsonl_path}\" --limit 50")
568
+ print(f" 本次生效的配置:{cfg_path}")
569
+ return 0
570
+
571
+ if __name__ == "__main__": # pragma: no cover - 也支持以模块方式直接运行
572
+ raise SystemExit(main())