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/cli.py ADDED
@@ -0,0 +1,605 @@
1
+ """ProcProxy CLI:`run | status | rules | sessions | config | doctor | stop`。
2
+
3
+ 职责边界:CLI 只做配置与展示,数据面永远在服务里。
4
+ `--backend null` 允许在前台直接跑数据面,便于联调。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import contextlib
11
+ import importlib
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from proxycore import __version__
20
+ from proxycore.config import Config, ConfigError, ConfigManager, port_in_use
21
+ from proxycore.log import LEVELS, build_logger, enable_trace_payload, get_logger, redact
22
+ from proxycore.rules import GuardSet, stats_snapshot
23
+ from proxycore.types import ConnRequest, Endpoint, ProcInfo, Proto
24
+
25
+ DEFAULT_CONFIG = "samples/profiles/dev.yaml"
26
+ RUNTIME_DIR_ENV = "PROCPROXY_RUNTIME_DIR"
27
+
28
+
29
+ # ============================================================ 运行时目录 ====
30
+ def runtime_dir() -> Path:
31
+ """配置/状态/日志目录(Windows 用 `%ProgramData%\\ProcProxy`,其余用 `~/.procproxy`)。"""
32
+ override = os.environ.get(RUNTIME_DIR_ENV)
33
+ if override:
34
+ return Path(override)
35
+ if os.name == "nt":
36
+ base = os.environ.get("PROGRAMDATA", r"C:\ProgramData")
37
+ return Path(base) / "ProcProxy"
38
+ return Path.home() / ".procproxy"
39
+
40
+
41
+ def state_path() -> Path:
42
+ return runtime_dir() / "state.json"
43
+
44
+
45
+ def _write_json(path: Path, payload: dict[str, Any]) -> None:
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ tmp = path.with_suffix(path.suffix + ".tmp")
48
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
49
+ os.replace(tmp, path)
50
+
51
+
52
+ def _read_json(path: Path) -> dict[str, Any]:
53
+ try:
54
+ data = json.loads(path.read_text(encoding="utf-8"))
55
+ except (OSError, json.JSONDecodeError):
56
+ return {}
57
+ return data if isinstance(data, dict) else {}
58
+
59
+
60
+ # ============================================================ 输出辅助 ====
61
+ def _row_text(cells: list[str], widths: list[int], align: list[str]) -> str:
62
+ parts: list[str] = []
63
+ for index, cell in enumerate(cells):
64
+ text = str(cell)
65
+ pad = max(0, widths[index] - _display_width(text))
66
+ parts.append(text + " " * pad if align[index] == "<" else " " * pad + text)
67
+ return " ".join(parts)
68
+
69
+
70
+ def _print_table(headers: list[str], rows: list[list[str]], align: list[str] | None = None) -> None:
71
+ if not rows:
72
+ print("(空)")
73
+ return
74
+ widths = [len(h) for h in headers]
75
+ for row in rows:
76
+ for i, cell in enumerate(row):
77
+ # 中文按 2 列宽计算,保证表格对齐
78
+ widths[i] = max(widths[i], _display_width(cell))
79
+ align = align or ["<"] * len(headers)
80
+ out = [_row_text(headers, widths, align)]
81
+ out.append(" ".join("-" * w for w in widths))
82
+ out.extend(_row_text(row, widths, align) for row in rows)
83
+ print("\n".join(out))
84
+
85
+
86
+ def _display_width(text: str) -> int:
87
+ return sum(2 if ord(ch) > 0x2E80 else 1 for ch in str(text))
88
+
89
+
90
+ def repo_root() -> Path | None:
91
+ """若当前包来自源码树(`<repo>/src/proxycore/`),返回 `<repo>`;否则 None(已安装场景)。"""
92
+ package_dir = Path(__file__).resolve().parent
93
+ candidate = package_dir.parent.parent
94
+ if (candidate / "samples").is_dir() and (candidate / "pyproject.toml").is_file():
95
+ return candidate
96
+ return None
97
+
98
+
99
+ def resolve_config_path(raw: str) -> Path:
100
+ """解析配置路径。相对路径找不到时,回退到仓库根下的同名路径。
101
+
102
+ 这样 `cd src` 之后仍然可以 `--config samples/profiles/dev.yaml`(CLI 不强制
103
+ "必须在仓库根执行")。若两种情况都不存在,返回原路径,让报错信息保持直白。
104
+ """
105
+ path = Path(raw)
106
+ if path.is_absolute() or path.exists():
107
+ return path
108
+ root = repo_root()
109
+ if root is not None:
110
+ candidate = root / path
111
+ if candidate.exists():
112
+ return candidate
113
+ return path
114
+
115
+
116
+ def _load_config(args: argparse.Namespace) -> Config:
117
+ raw = getattr(args, "config", DEFAULT_CONFIG) or DEFAULT_CONFIG
118
+ path = resolve_config_path(raw)
119
+ if not path.exists():
120
+ hint = ""
121
+ root = repo_root()
122
+ if root is not None:
123
+ hint = f"\n提示:仓库根是 {root},可用 --config {root / 'samples' / 'profiles' / 'dev.yaml'}"
124
+ raise SystemExit(f"配置文件不存在: {path}{hint}")
125
+ try:
126
+ return Config.load(path, check_sink=bool(getattr(args, "check_sink", False)))
127
+ except ConfigError as exc:
128
+ raise SystemExit(f"配置无效:{exc.code} — {exc.message}") from exc
129
+
130
+
131
+ def _print_warnings(config: Config, verbose: bool = False) -> None:
132
+ if not config.warnings:
133
+ return
134
+ shown = config.warnings if verbose else config.warnings[:5]
135
+ for warning in shown:
136
+ print(f"[WARN] {warning}", file=sys.stderr)
137
+ if not verbose and len(config.warnings) > len(shown):
138
+ print(f"[WARN] ... 另有 {len(config.warnings) - len(shown)} 条未知字段警告(--verbose 查看)", file=sys.stderr)
139
+
140
+
141
+ # ============================================================ 子命令 ====
142
+ def cmd_rules(args: argparse.Namespace) -> int:
143
+ config = _load_config(args)
144
+ _print_warnings(config, args.verbose)
145
+ profile = config.profile
146
+
147
+ if args.trace:
148
+ return _cmd_rules_trace(config, args)
149
+
150
+ print(f"Profile: {profile.name} default_action={profile.default_action.value} "
151
+ f"sticky_per_pid={profile.sticky_per_pid} dns_mode={profile.dns_mode}")
152
+ print(f"Backend: {config.service.backend} Sink: {config.service.sink.host}:{config.service.sink.port} "
153
+ f"on_unknown_pid={config.service.on_unknown_pid} "
154
+ f"on_decide_error={config.service.on_decide_error}")
155
+ print()
156
+
157
+ rows: list[list[str]] = []
158
+ for index, rule in enumerate(profile.rules, start=1):
159
+ up = rule.upstream or "-"
160
+ rows.append(
161
+ [
162
+ str(index),
163
+ rule.id,
164
+ "on" if rule.enabled else "off",
165
+ rule.action.value,
166
+ up,
167
+ rule.targets,
168
+ rule.dst_ip,
169
+ rule.dst_port,
170
+ rule.proto,
171
+ rule.dst_name,
172
+ "yes" if rule.keep_private_proxied else "",
173
+ ]
174
+ )
175
+ _print_table(
176
+ ["#", "id", "en", "action", "upstream", "targets", "dst_ip", "dst_port", "proto", "dst_name", "priv"],
177
+ rows,
178
+ )
179
+
180
+ print()
181
+ ups = profile.upstreams
182
+ if ups:
183
+ up_rows = [
184
+ [u.alias, u.kind, f"{u.host}:{u.port}", u.user or "-", u.auth, "yes" if u.udp else "no",
185
+ ",".join(u.chain) or "-"]
186
+ for u in ups.values()
187
+ ]
188
+ _print_table(["alias", "kind", "endpoint", "user", "auth", "udp", "chain"], up_rows)
189
+ else:
190
+ print("(未配置上游 —— 任何 action=proxy 的规则都无法工作)")
191
+ return 0
192
+
193
+
194
+ def _cmd_rules_trace(config: Config, args: argparse.Namespace) -> int:
195
+ from proxycore.rules import explain
196
+
197
+ conn = ConnRequest(
198
+ proc=ProcInfo(pid=args.pid, name=args.proc, path=args.proc_path),
199
+ proto=Proto.UDP if args.proto == "udp" else Proto.TCP,
200
+ src=Endpoint("127.0.0.1", 50000),
201
+ dst=Endpoint(args.dst, args.port, is_v6=":" in args.dst),
202
+ )
203
+ guards = GuardSet(self_pids={os.getpid()}, upstream_endpoints=config.profile.upstream_endpoints())
204
+ print(f"# trace: pid={args.pid} proc={args.proc} -> {args.dst}:{args.port}/{args.proto}")
205
+ for step in explain(conn, config.profile, guards):
206
+ print(json.dumps(redact(step), ensure_ascii=False))
207
+ print()
208
+ print("(trace 仅用于排错,热路径不产生这些输出)")
209
+ return 0
210
+
211
+
212
+ def cmd_config(args: argparse.Namespace) -> int:
213
+ config = _load_config(args)
214
+ _print_warnings(config, args.verbose)
215
+ if args.json:
216
+ print(json.dumps(config.to_dict(), ensure_ascii=False, indent=2))
217
+ else:
218
+ print(config.dump_yaml())
219
+ return 0
220
+
221
+
222
+ def cmd_status(args: argparse.Namespace) -> int:
223
+ state = _read_json(state_path())
224
+ config: Config | None = None
225
+ try:
226
+ config = _load_config(args)
227
+ except SystemExit:
228
+ config = None
229
+
230
+ if not state:
231
+ print(f"运行时状态:未找到 {state_path()}(服务可能未启动或未使用默认运行时目录)")
232
+ else:
233
+ _print_table(
234
+ ["field", "value"],
235
+ [[k, _fmt(v)] for k, v in sorted(state.items()) if not isinstance(v, (dict, list))],
236
+ )
237
+
238
+ if config is not None:
239
+ print()
240
+ print("生效配置摘要:")
241
+ snapshot = stats_snapshot(config.profile)
242
+ for key, value in snapshot.items():
243
+ print(f" {key} = {_fmt(value)}")
244
+ if args.follow:
245
+ print("\n--follow:每 2s 刷新(Ctrl+C 退出)")
246
+ try:
247
+ while True:
248
+ time.sleep(2)
249
+ state = _read_json(state_path())
250
+ ts = time.strftime("%H:%M:%S")
251
+ print(f"[{ts}] captured={state.get('captured', '?')} redirected={state.get('redirected', '?')} "
252
+ f"sessions={state.get('active_sessions', '?')} unknown_pid={state.get('unknown_pid', '?')}")
253
+ except KeyboardInterrupt:
254
+ return 0
255
+ return 0
256
+
257
+
258
+ def _fmt(value: Any) -> str:
259
+ if isinstance(value, list):
260
+ return ", ".join(str(v) for v in value) or "-"
261
+ if isinstance(value, dict):
262
+ return json.dumps(redact(value), ensure_ascii=False)
263
+ return str(value)
264
+
265
+
266
+ def cmd_sessions(args: argparse.Namespace) -> int:
267
+ """从 JSONL 日志里读会话(`--follow` 实时跟随)。"""
268
+ config: Config | None = None
269
+ try:
270
+ config = _load_config(args)
271
+ except SystemExit:
272
+ config = None
273
+ log_path = args.log or (config.service.log_jsonl if config else "") or str(runtime_dir() / "sessions.jsonl")
274
+ path = Path(log_path)
275
+ if not path.exists():
276
+ print(f"未找到会话日志: {path}")
277
+ print("提示:用 `--log-jsonl <path>` 启动服务后才会产生会话日志。")
278
+ return 0
279
+
280
+ if args.follow:
281
+ return _follow_jsonl(path, args)
282
+ return _print_sessions_from_file(path, args)
283
+
284
+
285
+ def _print_sessions_from_file(path: Path, args: argparse.Namespace) -> int:
286
+ rows: list[list[str]] = []
287
+ for payload in _iter_jsonl(path):
288
+ if payload.get("event") != "session_end":
289
+ continue
290
+ rows.append(
291
+ [
292
+ str(payload.get("session_id", "")),
293
+ str(payload.get("pid", "")),
294
+ str(payload.get("proc", "")),
295
+ str(payload.get("proto", "")),
296
+ str(payload.get("dst", "")),
297
+ str(payload.get("upstream", "") or "-"),
298
+ str(payload.get("rule", "") or "-"),
299
+ str(payload.get("state", "")),
300
+ f"{payload.get('bytes_up', 0)}/{payload.get('bytes_down', 0)}",
301
+ str(payload.get("reason", "")),
302
+ ]
303
+ )
304
+ if args.limit:
305
+ rows = rows[-args.limit :]
306
+ _print_table(
307
+ ["sid", "pid", "proc", "proto", "dst", "upstream", "rule", "state", "up/down", "reason"], rows
308
+ )
309
+ return 0
310
+
311
+
312
+ def _follow_jsonl(path: Path, args: argparse.Namespace) -> int:
313
+ print(f"following {path}(Ctrl+C 退出)")
314
+ with path.open("r", encoding="utf-8") as handle:
315
+ handle.seek(0, os.SEEK_END)
316
+ try:
317
+ while True:
318
+ line = handle.readline()
319
+ if not line:
320
+ time.sleep(0.2)
321
+ continue
322
+ try:
323
+ payload = json.loads(line)
324
+ except json.JSONDecodeError:
325
+ continue
326
+ if args.json:
327
+ print(json.dumps(payload, ensure_ascii=False))
328
+ continue
329
+ if payload.get("event") == "session_end":
330
+ print(
331
+ f"{payload.get('ts', '')} pid={payload.get('pid')} {payload.get('proc')} "
332
+ f"{payload.get('proto')} -> {payload.get('dst')} via {payload.get('upstream') or '-'} "
333
+ f"[{payload.get('rule') or '-'}] {payload.get('state')} "
334
+ f"up={payload.get('bytes_up')} down={payload.get('bytes_down')} "
335
+ f"{payload.get('reason')}"
336
+ )
337
+ else:
338
+ print(json.dumps(payload, ensure_ascii=False))
339
+ except KeyboardInterrupt:
340
+ return 0
341
+
342
+
343
+ def _iter_jsonl(path: Path):
344
+ try:
345
+ with path.open("r", encoding="utf-8") as handle:
346
+ for line in handle:
347
+ line = line.strip()
348
+ if not line:
349
+ continue
350
+ try:
351
+ payload = json.loads(line)
352
+ except json.JSONDecodeError:
353
+ continue
354
+ if isinstance(payload, dict):
355
+ yield payload
356
+ except OSError as exc:
357
+ print(f"读取日志失败: {exc}", file=sys.stderr)
358
+
359
+
360
+ def cmd_run(args: argparse.Namespace) -> int:
361
+ """数据面入口。`--backend null` 只联调不抓包;默认用配置里的后端。"""
362
+ config = _load_config(args)
363
+ _print_warnings(config)
364
+ if args.backend:
365
+ config.service.backend = args.backend
366
+ if args.log_level:
367
+ config.service.log_level = args.log_level
368
+ if args.log_jsonl:
369
+ config.service.log_jsonl = args.log_jsonl
370
+
371
+ build_logger(
372
+ "proxycore",
373
+ level=config.service.log_level,
374
+ jsonl_path=config.service.log_jsonl or str(runtime_dir() / "sessions.jsonl"),
375
+ console=True,
376
+ )
377
+ if config.service.enable_trace_payload:
378
+ enable_trace_payload(True)
379
+ # 子日志器(proxycore.runtime / proxycore.config / ...)共用这一份输出配置
380
+ for child in ("runtime", "config", "sink", "upstream", "dns", "backend"):
381
+ get_logger(f"proxycore.{child}")
382
+
383
+ from proxycore.backend import BackendUnavailable, make_backend
384
+ from proxycore.runtime import Runtime
385
+
386
+ manager = ConfigManager(config)
387
+ # `make_backend()` 只是构造,真正打开句柄/加载驱动发生在 `backend.start()`
388
+ # (由 `runtime.start()` 调用)。所以两处都要包在同一个处理里
389
+ # route A 的主要失败模式(非管理员)正是在 start() 里才暴露。
390
+ try:
391
+ backend = make_backend(config.service.backend, config=config)
392
+ runtime = Runtime(manager=manager, backend=backend)
393
+ runtime.start(sink_test_target=args.sink_test_target, upstream_alias=args.upstream)
394
+ except BackendUnavailable as exc:
395
+ # 绝不静默直连,后端不可用就明确失败
396
+ print(f"[E_BACKEND_UNAVAILABLE] {exc}", file=sys.stderr)
397
+ print(
398
+ "提示:WinDivert 需要管理员权限(右键 → 以管理员身份运行)。",
399
+ file=sys.stderr,
400
+ )
401
+ print(" 也可以显式用 backend=null —— 但它只做联调,不代理任何流量。", file=sys.stderr)
402
+ return 2
403
+
404
+ try:
405
+ runtime.serve_forever(reload_watch=not args.no_watch)
406
+ except KeyboardInterrupt:
407
+ print("\n收到 Ctrl+C,正在停止…")
408
+ finally:
409
+ runtime.stop()
410
+ return 0
411
+
412
+
413
+ def cmd_doctor(args: argparse.Namespace) -> int:
414
+ config = _load_config(args)
415
+ _print_warnings(config)
416
+ backend = args.backend or config.service.backend
417
+ checks: list[tuple[str, str, str]] = []
418
+
419
+ checks.append(("python", sys.version.split()[0], "ok" if sys.version_info >= (3, 10) else "需要 3.10+"))
420
+ checks.append(("platform", sys.platform, "ok"))
421
+ checks.append(("admin", "yes" if _is_admin() else "no",
422
+ "ok" if _is_admin() else "拦截/驱动/服务需要管理员权限"))
423
+ checks.append(("config", config.path or DEFAULT_CONFIG, "ok"))
424
+ checks.append(("backend", backend, _backend_probe(backend)))
425
+ checks.append(("sink_port", f"{config.service.sink.host}:{config.service.sink.port}",
426
+ "free" if not _port_busy(config.service.sink.port, config.service.sink.host) else "occupied"))
427
+ checks.append(("upstreams", str(len(config.profile.upstreams)), "ok"))
428
+ checks.append(("rules", str(len(config.profile.rules)), "ok"))
429
+
430
+ _print_table(["check", "value", "detail"], [[a, b, c] for a, b, c in checks])
431
+ if backend == "null":
432
+ print("\n注意:backend=null 只做本地联调,不会代理任何流量。")
433
+ return 0
434
+
435
+
436
+ def _is_admin() -> bool:
437
+ if os.name != "nt":
438
+ return os.geteuid() == 0 if hasattr(os, "geteuid") else False
439
+ import ctypes
440
+
441
+ try:
442
+ return bool(ctypes.windll.shell32.IsUserAnAdmin())
443
+ except Exception: # noqa: BLE001 - 探测失败按非管理员处理
444
+ return False
445
+
446
+
447
+ def _port_busy(port: int, host: str) -> bool:
448
+ # 统一用 config.port_in_use:Windows 上必须 SO_EXCLUSIVEADDRUSE,
449
+ # SO_REUSEADDR 对同样设置过它的监听者会把"已占用"误判成空闲。
450
+ return port_in_use(port, host)
451
+
452
+
453
+ def _backend_probe(name: str) -> str:
454
+ if name == "null":
455
+ return "available (联调用)"
456
+ if name == "windivert":
457
+ # 复用后端的探测实现,不在这里再写一份等价逻辑
458
+ from .backend.windivert import probe_availability
459
+
460
+ _ok, detail = probe_availability()
461
+ return detail
462
+ return f"未知后端 {name}"
463
+
464
+
465
+ def cmd_stop(args: argparse.Namespace) -> int:
466
+ """紧急停止:优先恢复业务。"""
467
+ control = runtime_dir() / "control.json"
468
+ payload = {"action": "panic" if args.panic else "stop", "ts": time.time()}
469
+ _write_json(control, payload)
470
+ print(f"已写入控制指令 {payload['action']} → {control}")
471
+ print("提示:`stop` 靠控制文件通知正在跑的数据面自行退出(前台模式直接 Ctrl+C)。")
472
+ print(" 数据面进程退出后 WinDivert 句柄自动释放,不会留下「流量被拦着」的状态。")
473
+ return 0
474
+
475
+
476
+ # ============================================================ 参数解析 ====
477
+
478
+ # ====================================================== 外包子命令(透传)====
479
+ #: 这几个子命令的实现在各自模块里(自带完整 argparse)。在 argparse 之前拦下,
480
+ #: 把剩下的参数原样交给它们:这样参数定义只写一份;`<cmd> --help` 显示的是
481
+ #: 那个模块自己的帮助,不会被外层解析器截断。
482
+ _PASSTHROUGH: dict[str, tuple[str, str]] = {
483
+ "forward": ("forward", "把指定进程的 TCP 流量转发给上游代理/抓包工具"),
484
+ "can-proxy": ("canproxy", "一句话回答:现在能不能转发进程流量"),
485
+ "check-route": ("checkroute", "核对某进程的连接走了哪条路"),
486
+ "probe-upstream": ("probe", "探测某个端口是不是可用的上游代理监听口"),
487
+ "install-path": ("pathsetup", "让 `python -m proxycore` 在任何目录可用(写 .pth,可撤销)"),
488
+ }
489
+
490
+
491
+ def _passthrough_help() -> str:
492
+ width = max(len(k) for k in _PASSTHROUGH)
493
+ head = "另外这些子命令(各自带完整参数,可用 `<命令> --help` 查看):"
494
+ return head + chr(10) + chr(10).join(f" {k:<{width}} {v[1]}" for k, v in _PASSTHROUGH.items())
495
+
496
+
497
+ def _dispatch_passthrough(argv: list[str]) -> int | None:
498
+ """首参数是外包子命令就交给对应模块;否则返回 None(走 argparse)。
499
+
500
+ 模块名来自 `_PASSTHROUGH` 的字符串,所以 IDE 里点不进去 ——
501
+ 例如 `forward` 的实现就在 `proxycore/forward.py`。
502
+ """
503
+ if not argv:
504
+ return None
505
+ entry = _PASSTHROUGH.get(argv[0])
506
+ if entry is None:
507
+ return None
508
+ module = importlib.import_module(f"{__package__}.{entry[0]}")
509
+ return int(module.main(list(argv[1:])))
510
+
511
+
512
+ def build_parser() -> argparse.ArgumentParser:
513
+ parser = argparse.ArgumentParser(
514
+ prog="procproxy",
515
+ description="ProcProxy —— 进程级代理(按进程决定哪条连接走哪个上游)",
516
+ epilog=_passthrough_help(),
517
+ formatter_class=argparse.RawDescriptionHelpFormatter,
518
+ )
519
+ parser.add_argument("--version", action="version", version=f"ProcProxy {__version__}")
520
+ sub = parser.add_subparsers(dest="command", required=True)
521
+
522
+ def add_common(p: argparse.ArgumentParser) -> None:
523
+ p.add_argument("--config", default=DEFAULT_CONFIG, help=f"配置文件路径(默认 {DEFAULT_CONFIG})")
524
+ p.add_argument("--verbose", action="store_true", help="打印所有配置警告")
525
+
526
+ p_rules = sub.add_parser("rules", help="打印规则表(--trace 打印命中链路)")
527
+ add_common(p_rules)
528
+ p_rules.add_argument("--trace", action="store_true", help="对一条模拟连接打印逐条规则判定链路")
529
+ p_rules.add_argument("--pid", type=int, default=os.getpid())
530
+ p_rules.add_argument("--proc", default="curl.exe")
531
+ p_rules.add_argument("--proc-path", default="")
532
+ p_rules.add_argument("--dst", default="1.1.1.1")
533
+ p_rules.add_argument("--port", type=int, default=443)
534
+ p_rules.add_argument("--proto", choices=["tcp", "udp"], default="tcp")
535
+ p_rules.set_defaults(func=cmd_rules)
536
+
537
+ p_config = sub.add_parser("config", help="打印生效配置(--json 输出原始结构)")
538
+ add_common(p_config)
539
+ p_config.add_argument("--json", action="store_true")
540
+ p_config.set_defaults(func=cmd_config)
541
+
542
+ p_status = sub.add_parser("status", help="打印运行时状态与生效配置摘要")
543
+ add_common(p_status)
544
+ p_status.add_argument("--follow", action="store_true", help="每 2s 刷新")
545
+ p_status.set_defaults(func=cmd_status)
546
+
547
+ p_sessions = sub.add_parser("sessions", help="列出/跟随会话(contract 第 7 节的 JSONL)")
548
+ add_common(p_sessions)
549
+ p_sessions.add_argument("--log", default="", help="JSONL 日志路径")
550
+ p_sessions.add_argument("--follow", action="store_true")
551
+ p_sessions.add_argument("--json", action="store_true")
552
+ p_sessions.add_argument("--limit", type=int, default=50)
553
+ p_sessions.set_defaults(func=cmd_sessions)
554
+
555
+ p_run = sub.add_parser("run", help="前台运行数据面(--backend null 只联调不抓包)")
556
+ add_common(p_run)
557
+ p_run.add_argument("--backend", choices=["null", "windivert"], default="")
558
+ p_run.add_argument("--log-level", choices=sorted(LEVELS), default="")
559
+ p_run.add_argument("--log-jsonl", default="")
560
+ p_run.add_argument("--sink-test-target", default="", help="测试模式:sink 把所有连接转发到该 host:port")
561
+ p_run.add_argument("--upstream", default="", help="测试模式:强制使用该上游别名")
562
+ p_run.add_argument("--no-watch", action="store_true", help="不监视配置热重载")
563
+ p_run.add_argument("--check-sink", action="store_true")
564
+ p_run.set_defaults(func=cmd_run)
565
+
566
+ p_doctor = sub.add_parser("doctor", help="环境自检(后端/端口/权限)")
567
+ add_common(p_doctor)
568
+ p_doctor.add_argument("--backend", choices=["null", "windivert"], default="")
569
+ p_doctor.set_defaults(func=cmd_doctor)
570
+
571
+ p_stop = sub.add_parser("stop", help="停止服务(--panic 为紧急全量放行)")
572
+ add_common(p_stop)
573
+ p_stop.add_argument("--panic", action="store_true", help="紧急停止:尽快停掉数据面并退出")
574
+ p_stop.set_defaults(func=cmd_stop)
575
+
576
+ return parser
577
+
578
+
579
+ def main(argv: list[str] | None = None) -> int:
580
+ _force_utf8_console()
581
+ argv = list(sys.argv[1:] if argv is None else argv)
582
+ handled = _dispatch_passthrough(argv)
583
+ if handled is not None:
584
+ return handled
585
+ parser = build_parser()
586
+ args = parser.parse_args(argv)
587
+ func = getattr(args, "func", None)
588
+ if func is None: # pragma: no cover - argparse 已保证
589
+ parser.print_help()
590
+ return 1
591
+ return int(func(args))
592
+
593
+
594
+ def _force_utf8_console() -> None:
595
+ """Windows 控制台默认不是 UTF-8,中文输出会乱码;这里显式纠正。"""
596
+ for stream_name in ("stdout", "stderr"):
597
+ stream = getattr(sys, stream_name, None)
598
+ reconfigure = getattr(stream, "reconfigure", None)
599
+ if callable(reconfigure):
600
+ with contextlib.suppress(ValueError, OSError):
601
+ reconfigure(encoding="utf-8", errors="replace")
602
+
603
+
604
+ if __name__ == "__main__": # pragma: no cover
605
+ sys.exit(main())