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/api.py ADDED
@@ -0,0 +1,226 @@
1
+ """编程式启动接口:在 Python 里直接起 ProcProxy(不需要 CLI / 不需要 PowerShell)。
2
+
3
+ 三种用法:
4
+
5
+ ```python
6
+ # ① 最简:起服务,拿到句柄,自己用
7
+ from proxycore.api import start_proxy
8
+
9
+ with start_proxy("samples/profiles/dev.yaml", backend="null") as ppx:
10
+ print(ppx.status()) # 后端/规则/会话统计
11
+ print(ppx.sessions()) # 活跃会话
12
+ print(ppx.proxy.of("127.0.0.1:8081")) # sink 地址
13
+
14
+ # ② 只想验证转发链路(不做抓包):把 sink 收到的连接转发到固定目标
15
+ from proxycore.api import start_proxy
16
+ with start_proxy("samples/profiles/smoke.yaml", backend="null",
17
+ sink_test_target="127.0.0.1:8080", upstream="local-mock") as ppx:
18
+ ...
19
+ # 此时 curl -x http://127.0.0.1:19080 http://any/ 会被转发到 127.0.0.1:8080
20
+
21
+ # ③ 内嵌到自己的程序里:自己喂连接、自己看决策
22
+ from proxycore.api import make_runtime
23
+ rt = make_runtime("samples/profiles/dev.yaml", backend="null")
24
+ decision = rt.decide(conn_request) # 纯决策,不抓包
25
+ ```
26
+
27
+ `start_proxy()` 返回的 `ProxyHandle` 支持 `with`(退出时自动 stop),
28
+ 也可以手动 `handle.stop()`。
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ from dataclasses import dataclass
35
+ from typing import Any
36
+
37
+ from .config import Config, ConfigError, ConfigManager
38
+ from .log import EventLogger, build_logger, get_logger
39
+ from .runtime import Runtime
40
+ from .types import ConnRequest, Decision
41
+
42
+ LOG = EventLogger(get_logger("proxycore.api"))
43
+
44
+
45
+ class ProxyStartError(RuntimeError):
46
+ """启动失败(配置非法 / 后端不可用 / sink 端口占用)。"""
47
+
48
+
49
+ def _coerce_config(config: Config | str | os.PathLike[str], *, check_sink: bool = False) -> Config:
50
+ if isinstance(config, Config):
51
+ return config
52
+ return load_config(os.fspath(config), check_sink=check_sink)
53
+
54
+
55
+ def load_config(path: str | os.PathLike[str], *, check_sink: bool = False) -> Config:
56
+ """读配置(含 V1–V10 校验)。失败抛 `ProxyStartError`。"""
57
+ try:
58
+ return Config.load(path, check_sink=check_sink)
59
+ except ConfigError as exc:
60
+ raise ProxyStartError(f"配置无效:[{exc.code}] {exc.message}") from exc
61
+
62
+
63
+ def make_runtime(
64
+ config: Config | str | os.PathLike[str],
65
+ *,
66
+ backend: str = "",
67
+ log_level: str = "INFO",
68
+ log_jsonl: str = "",
69
+ ) -> Runtime:
70
+ """只装配 Runtime(不 start),便于在测试/嵌入场景里自己控制生命周期。"""
71
+ config = _coerce_config(config)
72
+ if backend:
73
+ config.service.backend = backend
74
+ if log_level:
75
+ config.service.log_level = log_level
76
+ if log_jsonl:
77
+ config.service.log_jsonl = log_jsonl
78
+
79
+ build_logger("proxycore", level=config.service.log_level, jsonl_path=config.service.log_jsonl or None)
80
+
81
+ from .backend import BackendUnavailable, make_backend
82
+
83
+ try:
84
+ backend_obj = make_backend(config.service.backend, config=config)
85
+ except BackendUnavailable as exc:
86
+ # 绝不静默直连,启动失败就明确失败
87
+ raise ProxyStartError(
88
+ f"后端不可用({getattr(exc, 'code', 'E_BACKEND_UNAVAILABLE')}):{exc}"
89
+ ) from exc
90
+
91
+ return Runtime(ConfigManager(config), backend_obj)
92
+
93
+
94
+ @dataclass
95
+ class ProxyHandle:
96
+ """已启动的 ProcProxy 句柄。"""
97
+
98
+ runtime: Runtime
99
+ sink: str
100
+ backend: str
101
+ _stopped: bool = False
102
+
103
+ # ------------------------------------------------------------ 控制 -----
104
+ def stop(self, *, reason: str = "api-stop") -> None:
105
+ if self._stopped:
106
+ return
107
+ self.runtime.stop(reason=reason)
108
+ self._stopped = True
109
+
110
+ def __enter__(self) -> ProxyHandle:
111
+ return self
112
+
113
+ def __exit__(self, *exc_info: object) -> None:
114
+ self.stop()
115
+
116
+ @property
117
+ def running(self) -> bool:
118
+ return not self._stopped
119
+
120
+ # ------------------------------------------------------------ 查询 -----
121
+ def status(self) -> dict[str, Any]:
122
+ snapshot = self.runtime.snapshot()
123
+ return {
124
+ "name": self.backend, # 后端名("null" | "windivert")
125
+ "backend": snapshot["backend"], # 后端计数字典
126
+ "rules": snapshot["rules"],
127
+ "sessions": snapshot["sessions"],
128
+ "breaker": snapshot["breaker"],
129
+ "dns": snapshot["dns"],
130
+ "sink": self.sink,
131
+ "running": self.running,
132
+ }
133
+
134
+ def sessions(self, limit: int = 50) -> list[dict[str, Any]]:
135
+ return [s.to_dict() for s in self.runtime.sessions.all()][-limit:]
136
+
137
+ def active_sessions(self) -> int:
138
+ return self.runtime.sessions.count()
139
+
140
+ def reload(self, path: str = "") -> int:
141
+ """热重载配置,返回新的 generation(失败抛 `ProxyStartError`)。"""
142
+ target = path or self.runtime.config.path
143
+ try:
144
+ self.runtime.manager.reload(target)
145
+ except ConfigError as exc:
146
+ raise ProxyStartError(f"热重载被拒绝:[{exc.code}] {exc.message}") from exc
147
+ # 与文件监视器走同一个收口(DNS/UDP 策略 + 守卫 + 粘性迁移),
148
+ # 否则这条入口会悄悄少刷新几样东西。
149
+ self.runtime.apply_profile()
150
+ return self.runtime.manager.generation
151
+
152
+ def decide(self, conn: ConnRequest) -> Decision:
153
+ """直接问规则引擎"这条连接怎么处理"(不抓包、不改道)。"""
154
+ return self.runtime.decide(conn)
155
+
156
+ @property
157
+ def profile(self) -> Any:
158
+ return self.runtime.config.profile
159
+
160
+ def __repr__(self) -> str:
161
+ return f"<ProxyHandle backend={self.backend} sink={self.sink} running={self.running}>"
162
+
163
+
164
+ def start_proxy(
165
+ config: Config | str | os.PathLike[str] = "samples/profiles/dev.yaml",
166
+ *,
167
+ backend: str = "",
168
+ sink_test_target: str = "",
169
+ upstream: str = "",
170
+ log_level: str = "INFO",
171
+ log_jsonl: str = "",
172
+ ) -> ProxyHandle:
173
+ """启动 ProcProxy 数据面(sink 监听 + 后端 + 规则 + 会话表)。
174
+
175
+ * 返回的 `ProxyHandle` 支持 `with`,退出会自动 stop;
176
+ * `backend=""` 用配置里的 backend;`backend="null"` 用于联调(不抓包);
177
+ * `sink_test_target` 非空 = 测试模式:sink 把所有连接转发到该固定目标;
178
+ * 后端不可用(WinDivert 不可加载/非管理员)→ 抛 `ProxyStartError`,不会静默直连。
179
+ """
180
+ config_obj = _coerce_config(config)
181
+ runtime = make_runtime(
182
+ config_obj,
183
+ backend=backend,
184
+ log_level=log_level,
185
+ log_jsonl=log_jsonl,
186
+ )
187
+ try:
188
+ runtime.start(sink_test_target=sink_test_target, upstream_alias=upstream)
189
+ except Exception as exc: # 启动失败要保证不留半开的监听
190
+ runtime.stop(reason="start-failed")
191
+ raise ProxyStartError(f"启动失败:{exc}") from exc
192
+
193
+ sink_cfg = runtime.config.service.sink
194
+ LOG.info(
195
+ "api_started",
196
+ backend=runtime.backend.name,
197
+ sink=f"{sink_cfg.host}:{sink_cfg.port}",
198
+ test_target=sink_test_target or "-",
199
+ )
200
+ return ProxyHandle(
201
+ runtime=runtime,
202
+ sink=f"{sink_cfg.host}:{sink_cfg.port}",
203
+ backend=runtime.backend.name,
204
+ )
205
+
206
+
207
+ def run_forever(config: Config | str | os.PathLike[str] = "samples/profiles/dev.yaml", **kwargs: Any) -> int:
208
+ """启动并阻塞(等价于 `cli run`),Ctrl+C 退出。返回进程退出码。"""
209
+ handle = start_proxy(config, **kwargs)
210
+ try:
211
+ handle.runtime.serve_forever(reload_watch=True)
212
+ except KeyboardInterrupt:
213
+ print("\n收到 Ctrl+C,正在停止…")
214
+ finally:
215
+ handle.stop(reason="signal")
216
+ return 0
217
+
218
+
219
+ __all__ = [
220
+ "ProxyHandle",
221
+ "ProxyStartError",
222
+ "load_config",
223
+ "make_runtime",
224
+ "run_forever",
225
+ "start_proxy",
226
+ ]
@@ -0,0 +1,49 @@
1
+ """后端工厂。
2
+
3
+ 当前只包含两条实现:
4
+
5
+ * `windivert`:路线 A,用户态 WinDivert(pydivert 自带已签名的 `WinDivert64.sys`);
6
+ * `null`:联调用的空后端:不抓包、不改道,只提供 `decide()` 与状态输出。
7
+
8
+ 硬性约束:后端不可用时必须明确报错退出,禁止静默直连
9
+ (用户会以为在走代理,实际是把真实 IP 漏出去了)。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from .base import (
17
+ Backend,
18
+ BackendUnavailable,
19
+ OriginalDstUnknown,
20
+ RedirectFailed,
21
+ )
22
+ from .null import NullBackend
23
+
24
+ SUPPORTED_BACKENDS = ("null", "windivert")
25
+
26
+ __all__ = [
27
+ "SUPPORTED_BACKENDS",
28
+ "Backend",
29
+ "BackendUnavailable",
30
+ "NullBackend",
31
+ "OriginalDstUnknown",
32
+ "RedirectFailed",
33
+ "make_backend",
34
+ ]
35
+
36
+
37
+ def make_backend(name: str, *, config: Any = None, **kwargs: Any) -> Backend:
38
+ """按名字构造后端;不可用时抛 `BackendUnavailable`(绝不静默返回直连实现)。"""
39
+ backend = (name or "null").strip().lower()
40
+
41
+ if backend == "null":
42
+ return NullBackend()
43
+
44
+ if backend == "windivert":
45
+ from .windivert import WinDivertBackend
46
+
47
+ return WinDivertBackend(config=config, **kwargs)
48
+
49
+ raise BackendUnavailable(f"未知后端 {name!r}(可选:{', '.join(SUPPORTED_BACKENDS)})")
@@ -0,0 +1,159 @@
1
+ """Backend 抽象(硬约束:改道失败不得静默直连)。
2
+
3
+ 各后端实现必须满足的语义:
4
+ * `redirect()` 返回 `False` = 改道失败 → 核心必须记录并关闭该连接
5
+ (对 TCP 表示丢弃 SYN → 客户端超时;禁止"失败了就直连");
6
+ * `lookup_original()` 必须能区分"没有重定向记录"(返回 None)与"查询出错"(抛异常);
7
+ * 所有后端必须支持热停:`stop()` 后 2 秒内不再回调 `decide`。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ from collections.abc import Callable
14
+ from typing import Any, Protocol, runtime_checkable
15
+
16
+ from ..types import Action, ConnRequest, Decision, Endpoint
17
+
18
+ # 后端起停的硬性时限
19
+ STOP_QUIESCE_SECONDS = 2.0
20
+
21
+
22
+ class BackendUnavailable(RuntimeError):
23
+ """后端不可用(对应错误码 `E_BACKEND_UNAVAILABLE`)。"""
24
+
25
+ code = "E_BACKEND_UNAVAILABLE"
26
+
27
+
28
+ class OriginalDstUnknown(LookupError):
29
+ """无法恢复原始目标(对应错误码 `E_ORIGINAL_DST_UNKNOWN`)。
30
+
31
+ `lookup_original()` 的契约:没有记录 → 返回 None;查询出错 → 抛本异常。
32
+ """
33
+
34
+ code = "E_ORIGINAL_DST_UNKNOWN"
35
+
36
+
37
+ class RedirectFailed(RuntimeError):
38
+ """改道失败(对应错误码 `E_REDIRECT_FAILED`)。"""
39
+
40
+ code = "E_REDIRECT_FAILED"
41
+
42
+
43
+ @runtime_checkable
44
+ class Backend(Protocol):
45
+ name: str # "windivert" | "null"
46
+ supports_udp: bool
47
+ supports_pid_native: bool # True=后端自身能给出可靠 PID;False=需查表
48
+ counts_decisions: bool # 决策类计数归谁记;`Runtime.start()` 会置 False(见 BaseBackend)
49
+
50
+ def start(self) -> None:
51
+ """打开句柄 / 注册过滤器。失败抛 BackendUnavailable。"""
52
+
53
+ def stop(self) -> None:
54
+ """卸载过滤器、销毁句柄、等待在途请求结束。幂等。"""
55
+
56
+ def set_decider(self, decide: Callable[[ConnRequest], Decision]) -> None:
57
+ """核心把决策函数注册给后端。后端在捕获到连接时调用。"""
58
+
59
+ def redirect(self, conn: ConnRequest, sink: Endpoint) -> bool:
60
+ """把这条连接(conn)改道到 sink。
61
+
62
+ WinDivert 后端:改写报文头并回注。
63
+ 必须在 `set_decider` 的返回之前完成(同步语义)。
64
+ """
65
+
66
+ def lookup_original(self, client_socket: object, backend_ctx: int) -> Endpoint | None:
67
+ """sink accept 之后调用:恢复原始目标。
68
+
69
+ WinDivert 后端:用 `backend_ctx`(=客户端源端口) 查本地表。
70
+ 路线A: 用 `backend_ctx`(=客户端源端口) 查本地表
71
+ 返回 None 时 sink 必须关闭该连接并记 ERROR 日志。
72
+ """
73
+
74
+ def stats(self) -> dict[str, Any]:
75
+ """`{"captured": n, "redirected": n, "active_flows": n, "active_redirects": n}`"""
76
+
77
+ def on_udp(self, handler: Callable[[ConnRequest, bytes], Action]) -> None:
78
+ """可选:UDP 数据报到达(不支持的后端抛 NotImplementedError)。"""
79
+
80
+
81
+ class BaseBackend:
82
+ """后端公共基类:统一计数、决策回调登记、线程安全停止。
83
+
84
+ 不做平台相关的事情;平台后端继承它并实现 `start/stop/redirect/lookup_original`。
85
+ """
86
+
87
+ name: str = "base"
88
+ supports_udp: bool = False
89
+ supports_pid_native: bool = False
90
+
91
+ def __init__(self) -> None:
92
+ self._decider: Callable[[ConnRequest], Decision] | None = None
93
+ self._udp_handler: Callable[[ConnRequest, bytes], Action] | None = None
94
+ self._stopped = True
95
+ self._lock = threading.RLock()
96
+ self._counters: dict[str, int] = {"captured": 0, "redirected": 0, "direct": 0, "blocked": 0, "failed": 0}
97
+ self._active_flows = 0
98
+ self._active_redirects = 0
99
+
100
+ # ------------------------------------------------------------ 生命周期 --
101
+ def start(self) -> None:
102
+ self._stopped = False
103
+
104
+ def stop(self) -> None:
105
+ self._stopped = True
106
+
107
+ @property
108
+ def running(self) -> bool:
109
+ return not self._stopped
110
+
111
+ # --------------------------------------------------------------- 决策 ---
112
+ def set_decider(self, decide: Callable[[ConnRequest], Decision]) -> None:
113
+ self._decider = decide
114
+
115
+ def decide(self, conn: ConnRequest) -> Decision:
116
+ """后端在捕获到连接时调用(`redirect()` 必须在返回前完成)。"""
117
+ if self._decider is None: # pragma: no cover - 启动顺序错误
118
+ raise RedirectFailed("set_decider() 尚未注册决策函数")
119
+ return self._decider(conn)
120
+
121
+ def on_udp(self, handler: Callable[[ConnRequest, bytes], Action]) -> None:
122
+ if not self.supports_udp:
123
+ raise NotImplementedError(f"{self.name} 后端不支持 UDP")
124
+ self._udp_handler = handler
125
+
126
+ # --------------------------------------------------------------- 计数 ---
127
+ #: 决策类计数(`captured`/`direct`/`blocked`)由谁来记。
128
+ #:
129
+ #: 后端可以脱离 `Runtime` 独立使用(单测、手工投喂联调),那时它得自己记。
130
+ #: 但一旦核心通过 `set_decider()` 接管,`Runtime.decide()` 自己就会记这三个键。
131
+ #: 后端再记一遍就是双倍(实测:喂 1 个包,`captured` 变 2)。
132
+ #: `Runtime.start()` 会把它置 `False`,那是这个关系的建立点,一眼能看见。
133
+ #:
134
+ #: 区分原则:决策计数归核心,传输计数(`redirected`/`failed`/`injected`)
135
+ #: 归后端:后者只有后端知道(改道是否真的发出去了)。
136
+ counts_decisions: bool = True
137
+
138
+ def count(self, key: str, delta: int = 1) -> None:
139
+ with self._lock:
140
+ self._counters[key] = self._counters.get(key, 0) + delta
141
+
142
+ def count_decision(self, key: str, delta: int = 1) -> None:
143
+ """记一个决策类计数;核心接管后是空操作(见 `counts_decisions`)。"""
144
+ if self.counts_decisions:
145
+ self.count(key, delta)
146
+
147
+ def set_active(self, flows: int | None = None, redirects: int | None = None) -> None:
148
+ with self._lock:
149
+ if flows is not None:
150
+ self._active_flows = flows
151
+ if redirects is not None:
152
+ self._active_redirects = redirects
153
+
154
+ def stats(self) -> dict[str, Any]:
155
+ with self._lock:
156
+ out = dict(self._counters)
157
+ out["active_flows"] = self._active_flows
158
+ out["active_redirects"] = self._active_redirects
159
+ return out
@@ -0,0 +1,51 @@
1
+ """`null` 后端:不做任何抓包/改道,只提供手工把连接送进 sink 的能力。
2
+
3
+ 用途:
4
+ * 联调与测试(`--backend null --sink-test-target host:port`);
5
+ * L2 后端契约一致性测试的基准实现。
6
+
7
+ 它不会代理任何真实流量,这是刻意的,避免"看起来在走代理其实没走"。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ..types import ConnRequest, Endpoint
13
+ from .base import BaseBackend
14
+
15
+
16
+ class NullBackend(BaseBackend):
17
+ name = "null"
18
+ supports_udp = True
19
+ supports_pid_native = False
20
+
21
+ def __init__(self) -> None:
22
+ super().__init__()
23
+ self._origins: dict[int, Endpoint] = {}
24
+ self._next_id = 1
25
+ self._sink: Endpoint | None = None
26
+
27
+ # ------------------------------------------------------------ 生命周期 --
28
+ def start(self) -> None:
29
+ super().start()
30
+
31
+ def stop(self) -> None:
32
+ super().stop()
33
+ self._origins.clear()
34
+
35
+ # --------------------------------------------------------------- 改道 ---
36
+ def redirect(self, conn: ConnRequest, sink: Endpoint) -> bool:
37
+ if not self.running:
38
+ self.count("failed")
39
+ return False
40
+ self._sink = sink
41
+ # 先记账再"发包"
42
+ self._origins[conn.backend_id or (self._next_id)] = conn.dst
43
+ self._next_id += 1
44
+ self.count("redirected")
45
+ self.set_active(redirects=len(self._origins))
46
+ return True
47
+
48
+ def lookup_original(self, client_socket: object, backend_ctx: int) -> Endpoint | None:
49
+ return self._origins.get(backend_ctx)
50
+
51
+ # ------------------------------------------------------- 手工投喂(测试)--