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.
- procproxy-0.1.0.dist-info/METADATA +489 -0
- procproxy-0.1.0.dist-info/RECORD +45 -0
- procproxy-0.1.0.dist-info/WHEEL +5 -0
- procproxy-0.1.0.dist-info/entry_points.txt +2 -0
- procproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
- procproxy-0.1.0.dist-info/top_level.txt +1 -0
- proxycore/__init__.py +7 -0
- proxycore/__main__.py +65 -0
- proxycore/api.py +226 -0
- proxycore/backend/__init__.py +49 -0
- proxycore/backend/base.py +159 -0
- proxycore/backend/null.py +51 -0
- proxycore/backend/windivert/__init__.py +657 -0
- proxycore/backend/windivert/filter.py +123 -0
- proxycore/backend/windivert/pidmap.py +289 -0
- proxycore/backend/windivert/pkt.py +424 -0
- proxycore/canproxy.py +229 -0
- proxycore/checkroute.py +247 -0
- proxycore/cli.py +605 -0
- proxycore/config.py +1010 -0
- proxycore/dns/__init__.py +46 -0
- proxycore/dns/cache.py +209 -0
- proxycore/dns/modes.py +451 -0
- proxycore/dns/udp.py +606 -0
- proxycore/forward.py +572 -0
- proxycore/ipc.py +637 -0
- proxycore/log.py +298 -0
- proxycore/pathsetup.py +112 -0
- proxycore/probe.py +168 -0
- proxycore/proc.py +342 -0
- proxycore/rules.py +730 -0
- proxycore/runtime.py +654 -0
- proxycore/service/__init__.py +536 -0
- proxycore/service/__main__.py +124 -0
- proxycore/tunnel/__init__.py +8 -0
- proxycore/tunnel/relay.py +393 -0
- proxycore/tunnel/sessions.py +315 -0
- proxycore/tunnel/sink.py +325 -0
- proxycore/types.py +165 -0
- proxycore/upstream/__init__.py +52 -0
- proxycore/upstream/base.py +150 -0
- proxycore/upstream/chain.py +92 -0
- proxycore/upstream/http_connect.py +148 -0
- proxycore/upstream/socks4.py +106 -0
- 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())
|