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/log.py ADDED
@@ -0,0 +1,298 @@
1
+ """结构化日志 + 脱敏(日志字段与脱敏)。
2
+
3
+ 红线:
4
+ * 口令、`Proxy-Authorization`/`Authorization` 头内容、Cookie 一律不落盘;
5
+ * URL 的 query 参数截断;
6
+ * 报文级内容只在 TRACE 且显式开启。
7
+
8
+ 本模块只依赖标准库,`decide()` 热路径不得调用其中的格式化函数(用惰性参数)。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ import re
17
+ import sys
18
+ from datetime import datetime
19
+ from typing import Any
20
+
21
+ # ------------------------------------------------------------------ 级别 ----
22
+ TRACE = 5
23
+ logging.addLevelName(TRACE, "TRACE")
24
+
25
+ LEVELS: dict[str, int] = {
26
+ "TRACE": TRACE,
27
+ "DEBUG": logging.DEBUG,
28
+ "INFO": logging.INFO,
29
+ "WARN": logging.WARNING,
30
+ "WARNING": logging.WARNING,
31
+ "ERROR": logging.ERROR,
32
+ "CRITICAL": logging.CRITICAL,
33
+ }
34
+
35
+ DEFAULT_LEVEL = "INFO"
36
+
37
+ # 报文级内容是否允许出现(默认关闭)
38
+ TRACE_PAYLOAD_ENABLED = False
39
+ _REDACTED = "***REDACTED***"
40
+ _USER = "user"
41
+
42
+ # ------------------------------------------------ 敏感字段名 / 正则 --------
43
+ _SENSITIVE_KEYS = {
44
+ "password",
45
+ "passwd",
46
+ "pass",
47
+ "pwd",
48
+ "secret",
49
+ "token",
50
+ "authorization",
51
+ "proxy-authorization",
52
+ "cookie",
53
+ "set-cookie",
54
+ "password_ref",
55
+ "api_key",
56
+ "apikey",
57
+ "credential",
58
+ "credentials",
59
+ # 代理"别名/用户名"统一替换成 user
60
+ "user",
61
+ "username",
62
+ "proxy_user",
63
+ }
64
+
65
+ # 值里出现 "key: value" / "key=value" 形态的敏感赋值,一律替换。
66
+ # 值允许带引号、也允许是 "Basic xxx" / "NTLM xxx" 这种两段式认证头。
67
+ _KV_PATTERN = re.compile(
68
+ r"""(?ix)
69
+ (?P<q>["']?) # 可选的键引号(JSON 形态)
70
+ (?P<key>password|passwd|pass|pwd|secret|token|api[_-]?key|authorization|proxy-authorization|cookie)
71
+ (?P=q) # 引号必须闭合
72
+ \s*(?P<sep>[:=]\s*)
73
+ (?P<val>"[^"]*"|'[^']*'|(?:Basic|NTLM|Bearer)\s+[A-Za-z0-9+/=._~-]+|[^\s,;"'&]+)
74
+ """
75
+ )
76
+
77
+ # 必须先处理的两类:① 整行认证头;② URL;③ 键值对
78
+ _AUTH_LINE_PATTERN = re.compile(
79
+ r"(?im)^(?P<indent>\s*)(?P<key>proxy-authorization|authorization|cookie|set-cookie)\s*:\s*(?P<val>.*)$"
80
+ )
81
+ _JS_AUTH_PATTERN = re.compile(
82
+ r"(?i)(?P<key>proxy-authorization|authorization|cookie)(?P<sep>['\"]?\s*:\s*['\"])(?P<val>[^'\"]*)"
83
+ )
84
+
85
+ # URL query:只保留 path,query 一律截断(保留键名便于排错,值替换)
86
+ _URL_QUERY_PATTERN = re.compile(r"(?P<url>[a-zA-Z][a-zA-Z0-9+.-]*://[^\s?#]*)\?(?P<query>[^\s#]*)(?P<frag>#[^\s]*)?")
87
+
88
+ # user:pass@host 形态
89
+ _USERINFO_PATTERN = re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.-]*://)(?P<user>[^:/@\s]+):(?P<pw>[^@/\s]+)@")
90
+
91
+
92
+ def _redact_query(query: str) -> str:
93
+ if not query:
94
+ return ""
95
+ out: list[str] = []
96
+ for pair in query.split("&"):
97
+ if not pair:
98
+ continue
99
+ key, sep, _value = pair.partition("=")
100
+ out.append(f"{key}{sep}{_REDACTED}" if sep else key)
101
+ return "&".join(out)
102
+
103
+
104
+ def redact(value: Any) -> Any:
105
+ """对任意值做脱敏;递归处理 dict/list/str。
106
+
107
+ * dict 里敏感键名 → `user`(代理别名/用户名/口令统一替换)
108
+ * 字符串里的 `password=xxx`、`Proxy-Authorization: xxx`、URL query → 替换
109
+ """
110
+ if isinstance(value, dict):
111
+ return {k: (_USER if str(k).lower() in _SENSITIVE_KEYS else redact(v)) for k, v in value.items()}
112
+ if isinstance(value, tuple):
113
+ return tuple(redact(v) for v in value)
114
+ if isinstance(value, list):
115
+ return [redact(v) for v in value]
116
+ if isinstance(value, str):
117
+ return redact_text(value)
118
+ return value
119
+
120
+
121
+ def redact_text(text: str) -> str:
122
+ """字符串级脱敏(可单独单测)。
123
+
124
+ 顺序很重要:
125
+ 1. URL 里的 `user:pass@host`;
126
+ 2. URL 的 query(保留键名,值替换);
127
+ 3. 整行认证头(`Authorization:` / `Cookie:` 等,整值替换);
128
+ 4. 行内 `"Proxy-Authorization": "xxx"` 形态(JSON/JS);
129
+ 5. `key=value` / `key: value` 形态(含 JSON 的 `"key":"value"`)。
130
+ """
131
+ out = _USERINFO_PATTERN.sub(lambda m: f"{m.group('scheme')}{_USER}:{_REDACTED}@", text)
132
+ out = _URL_QUERY_PATTERN.sub(
133
+ lambda m: f"{m.group('url')}?{_redact_query(m.group('query') or '')}{m.group('frag') or ''}", out
134
+ )
135
+ out = _AUTH_LINE_PATTERN.sub(lambda m: f"{m.group('indent')}{m.group('key')}: {_REDACTED}", out)
136
+ out = _JS_AUTH_PATTERN.sub(lambda m: f"{m.group('key')}{m.group('sep')}{_REDACTED}", out)
137
+ out = _KV_PATTERN.sub(lambda m: f"{m.group('q')}{m.group('key')}{m.group('q')}{m.group('sep')}{_REDACTED}", out)
138
+ return out
139
+
140
+
141
+ # ------------------------------------------------------------- JSONL ------
142
+ def _now_iso() -> str:
143
+ return datetime.now().astimezone().isoformat(timespec="milliseconds")
144
+
145
+
146
+ class JsonlFormatter(logging.Formatter):
147
+ """固定字段的 JSONL 输出。"""
148
+
149
+ def format(self, record: logging.LogRecord) -> str:
150
+ payload: dict[str, Any] = {
151
+ "ts": _now_iso(),
152
+ "level": record.levelname,
153
+ "event": getattr(record, "event", record.name),
154
+ "msg": redact_text(record.getMessage()) if record.getMessage() else "",
155
+ }
156
+ extra = getattr(record, "fields", None)
157
+ if isinstance(extra, dict):
158
+ payload.update(redact(extra))
159
+ if record.exc_info:
160
+ payload["exc"] = redact_text(self.formatException(record.exc_info))
161
+ return json.dumps(payload, ensure_ascii=False)
162
+
163
+
164
+ class ConsoleFormatter(logging.Formatter):
165
+ """人读格式(Windows 控制台友好)。"""
166
+
167
+ def format(self, record: logging.LogRecord) -> str:
168
+ ts = datetime.now().astimezone().strftime("%H:%M:%S.%f")[:-3]
169
+ event = getattr(record, "event", "")
170
+ fields = getattr(record, "fields", None)
171
+ tail = ""
172
+ if isinstance(fields, dict) and fields:
173
+ safe = redact(fields)
174
+ tail = " " + " ".join(f"{k}={_flat(v)}" for k, v in safe.items())
175
+ base = f"{ts} {record.levelname:<5} {redact_text(record.getMessage())}"
176
+ if event:
177
+ base += f" [{event}]"
178
+ return base + tail + ("\n" + redact_text(self.formatException(record.exc_info)) if record.exc_info else "")
179
+
180
+
181
+ def _flat(value: Any) -> str:
182
+ if isinstance(value, str):
183
+ return value if " " not in value else json.dumps(value, ensure_ascii=False)
184
+ if isinstance(value, (dict, list)):
185
+ return json.dumps(value, ensure_ascii=False)
186
+ return str(value)
187
+
188
+
189
+ def build_logger(
190
+ name: str = "proxycore",
191
+ level: str | int = DEFAULT_LEVEL,
192
+ jsonl_path: str | None = None,
193
+ console: bool = True,
194
+ *,
195
+ propagate: bool = True,
196
+ ) -> logging.Logger:
197
+ """构造日志器:默认 INFO + 人读控制台;给 `jsonl_path` 时额外输出 JSONL。
198
+
199
+ 注意:模块内用的是 `proxycore.*` 子日志器(如 `proxycore.runtime`)。
200
+ 为了让它们的记录汇到同一处,本函数会把父日志器设为负责输出的那一层,
201
+ 并让子日志器只用 `propagate` 把记录传上去(不再各自建 handler)。
202
+ """
203
+ logger = logging.getLogger(name)
204
+ logger.handlers.clear()
205
+ logger.setLevel(_coerce_level(level))
206
+ # 子日志器交给父层处理(否则 proxycore.runtime 等会各自输出一份/或什么都不输出)
207
+ logger.propagate = propagate
208
+
209
+ if console:
210
+ handler = logging.StreamHandler(sys.stderr)
211
+ handler.setFormatter(ConsoleFormatter())
212
+ logger.addHandler(handler)
213
+
214
+ if jsonl_path:
215
+ os.makedirs(os.path.dirname(os.path.abspath(jsonl_path)), exist_ok=True)
216
+ file_handler = logging.FileHandler(jsonl_path, encoding="utf-8")
217
+ file_handler.setFormatter(JsonlFormatter())
218
+ logger.addHandler(file_handler)
219
+
220
+ return logger
221
+
222
+
223
+ def _coerce_level(level: str | int) -> int:
224
+ if isinstance(level, int):
225
+ return level
226
+ return LEVELS.get(level.upper(), logging.INFO)
227
+
228
+
229
+ def enable_trace_payload(enabled: bool = True) -> None:
230
+ """报文级内容开关(默认关闭,必须显式开启)。"""
231
+ global TRACE_PAYLOAD_ENABLED
232
+ TRACE_PAYLOAD_ENABLED = enabled
233
+
234
+
235
+ class EventLogger:
236
+ """薄封装:`log.event("session_end", session_id=1, ...)`。"""
237
+
238
+ def __init__(self, logger: logging.Logger) -> None:
239
+ self._logger = logger
240
+ self._extra_enabled = True
241
+
242
+ @property
243
+ def logger(self) -> logging.Logger:
244
+ return self._logger
245
+
246
+ def _emit(self, level: int, event: str, msg: str = "", **fields: Any) -> None:
247
+ self._logger.log(level, msg or event, extra={"event": event, "fields": fields}, stacklevel=3)
248
+
249
+ def trace(self, event: str, msg: str = "", **fields: Any) -> None:
250
+ if self._logger.isEnabledFor(TRACE):
251
+ self._emit(TRACE, event, msg, **fields)
252
+
253
+ def debug(self, event: str, msg: str = "", **fields: Any) -> None:
254
+ if self._logger.isEnabledFor(logging.DEBUG):
255
+ self._emit(logging.DEBUG, event, msg, **fields)
256
+
257
+ def info(self, event: str, msg: str = "", **fields: Any) -> None:
258
+ if self._logger.isEnabledFor(logging.INFO):
259
+ self._emit(logging.INFO, event, msg, **fields)
260
+
261
+ def warn(self, event: str, msg: str = "", **fields: Any) -> None:
262
+ self._emit(logging.WARNING, event, msg, **fields)
263
+
264
+ def error(self, event: str, msg: str = "", **fields: Any) -> None:
265
+ self._emit(logging.ERROR, event, msg, **fields)
266
+
267
+ def session_end(self, session: Any, reason: str = "ok") -> None:
268
+ """每条会话结束必须输出固定字段的一行。"""
269
+ self.info(
270
+ "session_end",
271
+ session_id=session.id,
272
+ pid=session.proc.pid,
273
+ proc=session.proc.name,
274
+ rule=session.rule_id,
275
+ proto=session.proto.value,
276
+ dst=str(session.original),
277
+ upstream=session.upstream,
278
+ state=session.state,
279
+ bytes_up=session.bytes_up,
280
+ bytes_down=session.bytes_down,
281
+ duration_ms=round(session.duration_ms, 3),
282
+ reason=reason,
283
+ )
284
+
285
+
286
+ def get_logger(name: str = "proxycore") -> logging.Logger:
287
+ """取日志器。未配置时用默认配置初始化父层(`proxycore`),子日志器只负责传递。"""
288
+ root_name = name.split(".", 1)[0] if "." in name else name
289
+ root = logging.getLogger(root_name)
290
+ if not root.handlers and root_name == "proxycore":
291
+ build_logger(root_name)
292
+ logger = logging.getLogger(name)
293
+ if name != root_name:
294
+ # 子日志器不自己建 handler:交给 `proxycore` 层统一输出
295
+ logger.handlers.clear()
296
+ logger.propagate = True
297
+ logger.setLevel(logging.NOTSET)
298
+ return logger
proxycore/pathsetup.py ADDED
@@ -0,0 +1,112 @@
1
+ """让 `python -m proxycore ...` 在任意目录可用(零依赖,可撤销)。
2
+
3
+ 问题:`python -m proxycore` 需要 `src/` 在 `sys.path` 上。常规做法是
4
+ `pip install -e .`,但它需要 setuptools/网络;本机没有 setuptools。
5
+
6
+ 本工具用的是同一套机制(`site-packages` 下的 `.pth` 文件会被解释器自动执行),
7
+ 但不依赖 setuptools,也不需要网络:
8
+
9
+ python -m proxycore install-path # 写入 <site-packages>/proxycore-dev.pth
10
+ python -m proxycore install-path status # 查看当前状态
11
+ python -m proxycore install-path uninstall # 删除该 .pth
12
+
13
+ 写入的内容是 `src/` 的绝对路径。所以:
14
+ * 之后在任何目录都能 `python -m proxycore ...`;
15
+ * 仓库移动位置后要重新 `install`;
16
+ * 卸载就是删掉那个文件(`uninstall` 或手动删)。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import pathlib
23
+ import site
24
+ import sys
25
+
26
+ # 本模块现在住在 `src/proxycore/` 里,所以"要写进 .pth 的目录"就是它的上上级(即 `src/`)。
27
+ SRC_DIR = pathlib.Path(__file__).resolve().parent.parent
28
+ REPO_ROOT = SRC_DIR.parent
29
+ PTH_NAME = "proxycore-dev.pth"
30
+
31
+
32
+ def site_packages_dirs() -> list[pathlib.Path]:
33
+ out: list[pathlib.Path] = []
34
+ for candidate in [*site.getsitepackages(), site.getusersitepackages()]:
35
+ path = pathlib.Path(candidate)
36
+ if path.is_dir():
37
+ out.append(path)
38
+ return out
39
+
40
+
41
+ def primary_site_packages() -> pathlib.Path:
42
+ """优先用户级 site-packages(不需要管理员)。"""
43
+ user = pathlib.Path(site.getusersitepackages())
44
+ user.mkdir(parents=True, exist_ok=True)
45
+ return user
46
+
47
+
48
+ def installed_paths() -> list[pathlib.Path]:
49
+ return [d / PTH_NAME for d in site_packages_dirs() if (d / PTH_NAME).exists()]
50
+
51
+
52
+ def cmd_install() -> int:
53
+ target = primary_site_packages() / PTH_NAME
54
+ if not SRC_DIR.is_dir():
55
+ print(f"找不到源码目录:{SRC_DIR}", file=sys.stderr)
56
+ return 2
57
+ target.write_text(str(SRC_DIR) + "\n", encoding="utf-8")
58
+ print(f"已写入:{target}")
59
+ print(f"内容:{SRC_DIR}")
60
+ print()
61
+ print("验证(在任意目录执行):")
62
+ print(" python -m proxycore rules --config <仓库>/samples/profiles/dev.yaml")
63
+ print(" python -m proxycore --version")
64
+ print()
65
+ print("注意:把仓库挪走/改名后要重新执行 install。")
66
+ return 0
67
+
68
+
69
+ def cmd_uninstall() -> int:
70
+ removed = 0
71
+ for path in installed_paths():
72
+ path.unlink()
73
+ print(f"已删除:{path}")
74
+ removed += 1
75
+ if not removed:
76
+ print("没有找到已注册的 .pth(无需卸载)")
77
+ return 0
78
+
79
+
80
+ def cmd_status() -> int:
81
+ print(f"仓库根:{REPO_ROOT}")
82
+ print(f"源码目录:{SRC_DIR} ({'存在' if SRC_DIR.is_dir() else '不存在!'})")
83
+ print()
84
+ print("site-packages 搜索路径:")
85
+ for directory in site_packages_dirs():
86
+ marker = " <-- 本工具使用" if directory == primary_site_packages() else ""
87
+ pth = directory / PTH_NAME
88
+ state = "已注册" if pth.exists() else "未注册"
89
+ print(f" {directory} [{state}]{marker}")
90
+ print()
91
+ try:
92
+ import proxycore
93
+
94
+ print(f"import proxycore: OK({proxycore.__file__})")
95
+ except ImportError as exc:
96
+ print(f"import proxycore: 失败({exc})")
97
+ print("→ 执行 `python -m proxycore install-path` 或设置 PYTHONPATH 指向 src")
98
+ return 0
99
+
100
+
101
+ def main(argv: list[str] | None = None) -> int:
102
+ ap = argparse.ArgumentParser(prog="procproxy install-path", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
103
+ ap.add_argument("action", choices=["install", "uninstall", "status"], nargs="?", default="status")
104
+ args = ap.parse_args(argv)
105
+ if args.action == "install":
106
+ return cmd_install()
107
+ if args.action == "uninstall":
108
+ return cmd_uninstall()
109
+ return cmd_status()
110
+
111
+ if __name__ == "__main__": # pragma: no cover - 也支持以模块方式直接运行
112
+ raise SystemExit(main())
proxycore/probe.py ADDED
@@ -0,0 +1,168 @@
1
+ """确认一个本地端口是不是"能当上游的代理监听口"(例如 Yakit MITM)。
2
+
3
+ 为什么需要它:
4
+ ProcProxy 的上游是代理协议客户端(SOCKS5 / HTTP CONNECT),不是裸端口转发。
5
+ 所以对端必须真的在"代理监听"模式;如果它只是个普通 HTTP 服务器或别的什么,
6
+ 配置看起来没错但每个连接都会握手失败。与其在几百行日志里找原因,不如先探一下。
7
+
8
+ 它做三件事:
9
+ 1. 谁在监听这个端口(用 GetExtendedTcpTable 反查 PID + 进程名)
10
+ 2. 用 HTTP 代理协议说一句 CONNECT,看对端是不是按代理语义应答
11
+ 3. 打印可以直接复制的 ProcProxy 启动命令
12
+
13
+ 用法:
14
+ python procproxy.py probe-upstream # 默认探 127.0.0.1:8899
15
+ python procproxy.py probe-upstream --port 8083 # Yakit MITM 的常见默认端口
16
+ python procproxy.py probe-upstream --test-host www.baidu.com --test-port 443
17
+
18
+ 退出码:0 = 对端能以 HTTP 代理语义应答;1 = 不是代理监听口 / 连不上。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import socket
25
+
26
+ from proxycore.proc import IS_WINDOWS, get_proc_info
27
+
28
+
29
+ def describe_listener(host: str, port: int) -> str:
30
+ """反查监听该端口的进程(Windows 用 TCP 表;其他平台跳过)。"""
31
+ if not IS_WINDOWS:
32
+ return "(非 Windows,跳过进程反查)"
33
+ try:
34
+ from proxycore.backend.windivert.pidmap import PortPidTable
35
+ except Exception as exc: # noqa: BLE001
36
+ return f"(反查不可用:{exc})"
37
+
38
+ table = PortPidTable()
39
+ table.refresh(force=True)
40
+ if table.last_error:
41
+ return f"(反查失败:{table.last_error};管理员权限下更准)"
42
+
43
+ hits = []
44
+ for row in table.all_rows():
45
+ if row.local_port != port:
46
+ continue
47
+ # state 2 = LISTEN
48
+ if row.state != 2:
49
+ continue
50
+ try:
51
+ info = get_proc_info(row.pid)
52
+ name = info.name or "?"
53
+ path = info.path or ""
54
+ except Exception: # noqa: BLE001
55
+ name, path = "?", ""
56
+ hits.append(f"pid={row.pid} name={name} path={path}")
57
+ if not hits:
58
+ return "(没找到 LISTEN 记录:可能没在监听,或需要管理员才能看到别人的套接字)"
59
+ return ";".join(hits)
60
+
61
+
62
+ def probe_http_proxy(host: str, port: int, test_host: str, test_port: int, timeout: float) -> tuple[bool, str]:
63
+ """说一句 HTTP 代理的 CONNECT,看应答是不是代理语义。"""
64
+ target = f"{test_host}:{test_port}"
65
+ request = (
66
+ f"CONNECT {target} HTTP/1.1\r\n"
67
+ f"Host: {target}\r\n"
68
+ "Proxy-Connection: keep-alive\r\n"
69
+ "\r\n"
70
+ ).encode()
71
+ try:
72
+ with socket.create_connection((host, port), timeout=timeout) as sock:
73
+ sock.sendall(request)
74
+ sock.settimeout(timeout)
75
+ data = sock.recv(1024)
76
+ except OSError as exc:
77
+ return False, f"连不上 / 读不到应答:{exc}"
78
+
79
+ if not data:
80
+ return False, "对端直接关连接(不是代理监听口的典型表现)"
81
+
82
+ first = data.split(b"\r\n", 1)[0].decode("latin-1", "replace")
83
+ status = first.split(" ", 2)
84
+ code = int(status[1]) if len(status) > 1 and status[1].isdigit() else 0
85
+ detail = f"对端应答:{first!r}"
86
+ if code == 200:
87
+ return True, detail + " → 是代理监听口(CONNECT 已建立隧道)"
88
+ if code == 407:
89
+ return True, detail + " → 是代理监听口,但要求代理认证(在配置里补 auth/user/password_ref)"
90
+ if code in {502, 503, 504}:
91
+ return True, detail + " → 是代理监听口(它自己没能连上目标,但代理语义正确)"
92
+ if code in {400, 405, 501} or first.upper().startswith("HTTP/1."):
93
+ return False, detail + " → 有 HTTP 服务,但不是代理语义(多半是普通 Web 服务/管理端口)"
94
+ return False, detail + " → 不是 HTTP 代理"
95
+
96
+
97
+ def probe_socks5(host: str, port: int, timeout: float) -> tuple[bool, str]:
98
+ """顺手试一下 SOCKS5 无认证握手(有些工具默认起的是 socks5 而不是 http)。"""
99
+ try:
100
+ with socket.create_connection((host, port), timeout=timeout) as sock:
101
+ sock.sendall(b"\x05\x01\x00")
102
+ sock.settimeout(timeout)
103
+ data = sock.recv(2)
104
+ except OSError as exc:
105
+ return False, f"socks5 探测失败:{exc}"
106
+ if len(data) == 2 and data[0] == 0x05:
107
+ methods = {0x00: "无需认证", 0x02: "需要用户名口令"}.get(data[1], f"方法 0x{data[1]:02x}")
108
+ return True, f"对端像 SOCKS5 代理(协商结果:{methods})"
109
+ return False, f"不是 SOCKS5(应答 {data!r})"
110
+
111
+
112
+ def main(argv: list[str] | None = None) -> int:
113
+ ap = argparse.ArgumentParser(prog="procproxy probe-upstream", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
114
+ ap.add_argument("--host", default="127.0.0.1")
115
+ ap.add_argument("--port", type=int, default=8899, help="要探测的本地端口(Yakit MITM 常见 8083)")
116
+ ap.add_argument("--test-host", default="www.baidu.com", help="CONNECT 的目标(只用于握手,不下载内容)")
117
+ ap.add_argument("--test-port", type=int, default=80)
118
+ ap.add_argument("--timeout", type=float, default=5.0)
119
+ args = ap.parse_args(argv)
120
+
121
+ print()
122
+ print("=" * 72)
123
+ print(f" 端口自检:{args.host}:{args.port} 能不能当 ProcProxy 的上游")
124
+ print("=" * 72)
125
+
126
+ print()
127
+ print("[1/3] 谁在监听?")
128
+ print(f" {describe_listener(args.host, args.port)}")
129
+
130
+ print()
131
+ print("[2/3] 它是 HTTP 代理吗?(ProcProxy 的 kind: http)")
132
+ http_ok, http_detail = probe_http_proxy(
133
+ args.host, args.port, args.test_host, args.test_port, args.timeout
134
+ )
135
+ print(f" [{'OK ' if http_ok else 'FAIL'}] {http_detail}")
136
+
137
+ socks_ok, socks_detail = probe_socks5(args.host, args.port, args.timeout)
138
+ print(f" [{'OK ' if socks_ok else '-- '}] {socks_detail}(仅参考:socks5 上游用 kind: socks5)")
139
+
140
+ print()
141
+ print("[3/3] 结论与下一步")
142
+ if http_ok:
143
+ kind = "http"
144
+ print(f" ✅ 这个端口可以当上游,配置里写 kind: {kind}")
145
+ print()
146
+ print(" 1) 复制一份带你自己端口/进程名的 profile:")
147
+ print(" samples\\profiles\\yakit.yaml ← 改 upstreams[0].port 与 rules[0].targets")
148
+ print(" 2) 以管理员身份启动(真后端需要管理员):")
149
+ print(" python -m proxycore run --config samples\\profiles\\yakit.yaml --backend windivert --log-level DEBUG")
150
+ print(" 3) 让被观察的进程发一次请求,然后看两边:")
151
+ print(" Yakit 的 MITM 历史里应出现该请求;")
152
+ print(" 控制台里应出现 session_end 且 upstream=yakit")
153
+ elif socks_ok:
154
+ print(" ⚠️ 它不是 HTTP 代理,但像 SOCKS5 代理 —— 上游改 kind: socks5")
155
+ else:
156
+ print(" ❌ 这个端口不是一个代理监听口。常见原因:")
157
+ print(" * 端口填错了(Yakit MITM 默认监听 8083;确认 GUI 里'劫持'页面显示的端口)")
158
+ print(" * Yakit 的 MITM 没启动(GUI 里需要点开劫持/开启监听)")
159
+ print(" * 你看的是另一个服务:8899 上跑的可能不是 Yakit 的代理监听")
160
+
161
+ print()
162
+ print(" 说明:ProcProxy 不自己解 TLS,也不做 MITM —— 它把原始字节交给上游。")
163
+ print(" 想看 HTTPS 明文,靠的是 Yakit 的 MITM(别忘了让目标程序信任 Yakit 的 CA)。")
164
+ print()
165
+ return 0 if (http_ok or socks_ok) else 1
166
+
167
+ if __name__ == "__main__": # pragma: no cover - 也支持以模块方式直接运行
168
+ raise SystemExit(main())