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
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""会话表 + 粘性 + 统计 + 超时回收。
|
|
2
|
+
|
|
3
|
+
会话键的语义:
|
|
4
|
+
* 路线 A:`sessions[src_port] = (pid, dst)`,sink `accept()` 用 `peer[1]` 反查;
|
|
5
|
+
* 记录必须在改写前写入(先记账再发包);
|
|
6
|
+
* 记录带时间戳,超过 `pending_ttl`(默认 5 分钟)未被 accept 则回收,防止泄漏。
|
|
7
|
+
|
|
8
|
+
粘性(`sticky_per_pid`):键是 `(pid, rule_id)`,避免"规则改了但粘性还指向旧代理"。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..types import Action, ConnRequest, Decision, Endpoint, ProcInfo, Proto, Session
|
|
20
|
+
|
|
21
|
+
DEFAULT_PENDING_TTL_S = 300.0 # 5 分钟未被 accept 则回收
|
|
22
|
+
DEFAULT_HALF_CLOSE_GRACE_S = 30.0 # :TCP 半关闭后残留
|
|
23
|
+
|
|
24
|
+
STATE_CONNECTING = "connecting"
|
|
25
|
+
STATE_RELAYING = "relaying"
|
|
26
|
+
STATE_CLOSED = "closed"
|
|
27
|
+
STATE_FAILED = "failed"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class PendingRedirect:
|
|
32
|
+
"""一条"已改道但尚未被 sink accept"的记录(先记账后发包的那个账)。"""
|
|
33
|
+
|
|
34
|
+
backend_id: int # 路线 A = 客户端源端口
|
|
35
|
+
proc: ProcInfo
|
|
36
|
+
dst: Endpoint
|
|
37
|
+
proto: Proto
|
|
38
|
+
upstream: str
|
|
39
|
+
rule_id: str
|
|
40
|
+
created_at: float
|
|
41
|
+
is_v6: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionTable:
|
|
45
|
+
"""会话表。线程安全(拦截线程写、sink 线程读、维护线程回收)。"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
max_sessions: int = 20000,
|
|
51
|
+
log: Any = None,
|
|
52
|
+
pending_ttl_s: float = DEFAULT_PENDING_TTL_S,
|
|
53
|
+
clock: Callable[[], float] = time.time,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.max_sessions = max_sessions
|
|
56
|
+
self._log = log
|
|
57
|
+
self._pending_ttl_s = pending_ttl_s
|
|
58
|
+
self._clock = clock
|
|
59
|
+
self._lock = threading.RLock()
|
|
60
|
+
self._pending: dict[int, PendingRedirect] = {}
|
|
61
|
+
self._sessions: dict[int, Session] = {}
|
|
62
|
+
self._sticky: dict[tuple[int, str], str] = {}
|
|
63
|
+
self._by_backend_id: dict[int, int] = {} # backend_id → session_id
|
|
64
|
+
self._next_id = 1
|
|
65
|
+
self._test_target: str = ""
|
|
66
|
+
self.force_upstream: str = ""
|
|
67
|
+
self._counters: dict[str, int] = {
|
|
68
|
+
"created": 0,
|
|
69
|
+
"closed": 0,
|
|
70
|
+
"failed": 0,
|
|
71
|
+
"original_unknown": 0,
|
|
72
|
+
"redirect_failed": 0,
|
|
73
|
+
}
|
|
74
|
+
self._extra_self_pids: set[int] = set()
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------- 记账 ------
|
|
77
|
+
def put_pending(self, conn: ConnRequest, decision: Decision) -> None:
|
|
78
|
+
"""先记账再发包。"""
|
|
79
|
+
# 注意:`backend_id` 可能是合法的 0(例如被截断的源端口),
|
|
80
|
+
# 因此这里必须用 `is None` 判断缺省,而不是真值判断。
|
|
81
|
+
raw_id = conn.backend_id
|
|
82
|
+
key = int(raw_id) if raw_id is not None else self._auto_key()
|
|
83
|
+
with self._lock:
|
|
84
|
+
if len(self._pending) >= self.max_sessions:
|
|
85
|
+
self._evict_oldest_pending_locked()
|
|
86
|
+
self._pending[key] = PendingRedirect(
|
|
87
|
+
backend_id=key,
|
|
88
|
+
proc=conn.proc,
|
|
89
|
+
dst=conn.dst,
|
|
90
|
+
proto=conn.proto,
|
|
91
|
+
upstream=decision.upstream,
|
|
92
|
+
rule_id=decision.rule_id,
|
|
93
|
+
created_at=self._clock(),
|
|
94
|
+
is_v6=conn.dst.is_v6,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _auto_key(self) -> int:
|
|
98
|
+
key = self._next_id + 10_000_000
|
|
99
|
+
self._next_id += 1
|
|
100
|
+
return key
|
|
101
|
+
|
|
102
|
+
def pop_pending(self, backend_id: int) -> PendingRedirect | None:
|
|
103
|
+
"""sink `accept()` 之后调用:取出并删除该记录(原子)。"""
|
|
104
|
+
with self._lock:
|
|
105
|
+
return self._pending.pop(int(backend_id), None)
|
|
106
|
+
|
|
107
|
+
def pending_count(self) -> int:
|
|
108
|
+
with self._lock:
|
|
109
|
+
return len(self._pending)
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------- 会话生命周期 ---
|
|
112
|
+
def open(
|
|
113
|
+
self,
|
|
114
|
+
pending: PendingRedirect,
|
|
115
|
+
*,
|
|
116
|
+
upstream: str = "",
|
|
117
|
+
state: str = STATE_CONNECTING,
|
|
118
|
+
) -> Session:
|
|
119
|
+
with self._lock:
|
|
120
|
+
session = Session(
|
|
121
|
+
id=self._next_id,
|
|
122
|
+
proc=pending.proc,
|
|
123
|
+
proto=pending.proto,
|
|
124
|
+
original=pending.dst,
|
|
125
|
+
upstream=upstream or pending.upstream,
|
|
126
|
+
rule_id=pending.rule_id,
|
|
127
|
+
started_at=self._clock(),
|
|
128
|
+
state=state,
|
|
129
|
+
)
|
|
130
|
+
self._next_id += 1
|
|
131
|
+
self._sessions[session.id] = session
|
|
132
|
+
self._by_backend_id[int(pending.backend_id)] = session.id
|
|
133
|
+
self._counters["created"] += 1
|
|
134
|
+
return session
|
|
135
|
+
|
|
136
|
+
def session_for_backend_id(self, backend_id: int) -> Session | None:
|
|
137
|
+
with self._lock:
|
|
138
|
+
sid = self._by_backend_id.get(int(backend_id))
|
|
139
|
+
return self._sessions.get(sid) if sid else None
|
|
140
|
+
|
|
141
|
+
def mark_relaying(self, session: Session) -> None:
|
|
142
|
+
session.state = STATE_RELAYING
|
|
143
|
+
|
|
144
|
+
def close(self, session: Session, *, reason: str = "ok", error: str = "") -> None:
|
|
145
|
+
with self._lock:
|
|
146
|
+
session.state = STATE_FAILED if error else STATE_CLOSED
|
|
147
|
+
session.error = error
|
|
148
|
+
self._counters["closed" if not error else "failed"] += 1
|
|
149
|
+
for backend_id, sid in list(self._by_backend_id.items()):
|
|
150
|
+
if sid == session.id:
|
|
151
|
+
del self._by_backend_id[backend_id]
|
|
152
|
+
self._sessions.pop(session.id, None)
|
|
153
|
+
if self._log is not None:
|
|
154
|
+
self._log.session_end(session, reason=reason)
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------ 查询 ------
|
|
157
|
+
|
|
158
|
+
def active(self) -> list[Session]:
|
|
159
|
+
with self._lock:
|
|
160
|
+
return [s for s in self._sessions.values() if s.state in (STATE_CONNECTING, STATE_RELAYING)]
|
|
161
|
+
|
|
162
|
+
def all(self) -> list[Session]:
|
|
163
|
+
with self._lock:
|
|
164
|
+
return list(self._sessions.values())
|
|
165
|
+
|
|
166
|
+
def count(self) -> int:
|
|
167
|
+
with self._lock:
|
|
168
|
+
return len(self._sessions)
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------ 粘性 ------
|
|
171
|
+
def sticky_upstream(self, pid: int, rule_id: str, *, default: str = "") -> str:
|
|
172
|
+
"""粘性上游选择(规则引擎热路径调用,必须 O(1) 无锁竞争)。"""
|
|
173
|
+
if self.force_upstream:
|
|
174
|
+
return self.force_upstream
|
|
175
|
+
key = (int(pid), rule_id)
|
|
176
|
+
with self._lock:
|
|
177
|
+
existing = self._sticky.get(key)
|
|
178
|
+
if existing is not None:
|
|
179
|
+
return existing
|
|
180
|
+
if default:
|
|
181
|
+
self._sticky[key] = default
|
|
182
|
+
return default
|
|
183
|
+
|
|
184
|
+
def set_sticky(self, pid: int, rule_id: str, upstream: str) -> None:
|
|
185
|
+
with self._lock:
|
|
186
|
+
self._sticky[(int(pid), rule_id)] = upstream
|
|
187
|
+
|
|
188
|
+
def sticky_pids(self) -> list[int]:
|
|
189
|
+
with self._lock:
|
|
190
|
+
return sorted({pid for pid, _ in self._sticky})
|
|
191
|
+
|
|
192
|
+
def forget_pid(self, pid: int) -> None:
|
|
193
|
+
with self._lock:
|
|
194
|
+
for key in [k for k in self._sticky if k[0] == int(pid)]:
|
|
195
|
+
del self._sticky[key]
|
|
196
|
+
self._extra_self_pids.discard(int(pid))
|
|
197
|
+
|
|
198
|
+
def on_profile_reload(self, profile: Any) -> None:
|
|
199
|
+
"""热重载后:按 `rule_id` 迁移粘性。"""
|
|
200
|
+
with self._lock:
|
|
201
|
+
valid_upstreams = set(getattr(profile, "upstreams", {}) or {})
|
|
202
|
+
valid_rules = {r.id for r in getattr(profile, "rules", [])}
|
|
203
|
+
for key in list(self._sticky):
|
|
204
|
+
_pid, rule_id = key
|
|
205
|
+
# 新 profile 是完整集合:即使它一条规则/上游都没有,
|
|
206
|
+
# 旧粘性也必须全部失效(空集合不是"不检查")。
|
|
207
|
+
if rule_id not in valid_rules:
|
|
208
|
+
del self._sticky[key]
|
|
209
|
+
continue
|
|
210
|
+
if self._sticky[key] not in valid_upstreams:
|
|
211
|
+
del self._sticky[key]
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------ 自 PID 白名单 ---
|
|
214
|
+
def add_self_pid(self, pid: int) -> None:
|
|
215
|
+
with self._lock:
|
|
216
|
+
self._extra_self_pids.add(int(pid))
|
|
217
|
+
|
|
218
|
+
def self_pids(self) -> set[int]:
|
|
219
|
+
with self._lock:
|
|
220
|
+
return set(self._extra_self_pids)
|
|
221
|
+
|
|
222
|
+
def set_test_target(self, target: str) -> None:
|
|
223
|
+
with self._lock:
|
|
224
|
+
self._test_target = target
|
|
225
|
+
|
|
226
|
+
def test_mode(self) -> bool:
|
|
227
|
+
"""是否处于"手工投喂"测试模式(`--sink-test-target` / `--upstream`)。"""
|
|
228
|
+
with self._lock:
|
|
229
|
+
return bool(self._test_target) or bool(self.force_upstream)
|
|
230
|
+
|
|
231
|
+
def test_target_endpoint(self) -> Endpoint | None:
|
|
232
|
+
"""测试模式(`--sink-test-target host:port`):sink 转发到该固定目标。 验收方式:不做任何抓包,`curl -x http://<sink> http://anything` 即可
|
|
233
|
+
走完 `accept → 选上游 → dial → relay` 全链路。
|
|
234
|
+
"""
|
|
235
|
+
with self._lock:
|
|
236
|
+
return _parse_endpoint(self._test_target) if self._test_target else None
|
|
237
|
+
|
|
238
|
+
def make_test_pending(self, backend_id: int) -> PendingRedirect:
|
|
239
|
+
"""测试模式下为一条 accept 到的连接合成 pending 记录(真实目标未知 → 用测试目标占位)。"""
|
|
240
|
+
target = self.test_target_endpoint() or Endpoint("0.0.0.0", 0)
|
|
241
|
+
return PendingRedirect(
|
|
242
|
+
backend_id=int(backend_id),
|
|
243
|
+
proc=ProcInfo(pid=0, name="sink-client", path=""),
|
|
244
|
+
dst=target,
|
|
245
|
+
proto=Proto.TCP,
|
|
246
|
+
upstream=self.force_upstream,
|
|
247
|
+
rule_id="sink-test-mode",
|
|
248
|
+
created_at=self._clock(),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------ 回收 ------
|
|
252
|
+
def reap(self, *, now: float | None = None) -> int:
|
|
253
|
+
"""回收:① 未被 accept 的过期 pending;② 超时的活动会话。"""
|
|
254
|
+
stamp = now if now is not None else self._clock()
|
|
255
|
+
removed = 0
|
|
256
|
+
with self._lock:
|
|
257
|
+
for key in [k for k, p in self._pending.items() if stamp - p.created_at > self._pending_ttl_s]:
|
|
258
|
+
del self._pending[key]
|
|
259
|
+
removed += 1
|
|
260
|
+
self._counters["original_unknown"] += 1
|
|
261
|
+
# 只回收卡在 connecting 的会话。relaying 的会话由中继线程自己收尾;
|
|
262
|
+
# 按"建立时刻"回收活跃连接会把长连接从统计里抹掉,而流量还在跑。
|
|
263
|
+
stale = [
|
|
264
|
+
s
|
|
265
|
+
for s in self._sessions.values()
|
|
266
|
+
if s.state == STATE_CONNECTING and stamp - s.started_at > self._pending_ttl_s * 20
|
|
267
|
+
]
|
|
268
|
+
for session in stale:
|
|
269
|
+
session.state = STATE_CLOSED
|
|
270
|
+
session.error = "reaped"
|
|
271
|
+
self._sessions.pop(session.id, None)
|
|
272
|
+
for backend_id, sid in list(self._by_backend_id.items()):
|
|
273
|
+
if sid == session.id:
|
|
274
|
+
del self._by_backend_id[backend_id]
|
|
275
|
+
removed += 1
|
|
276
|
+
return removed
|
|
277
|
+
|
|
278
|
+
def _evict_oldest_pending_locked(self) -> None:
|
|
279
|
+
if not self._pending:
|
|
280
|
+
return
|
|
281
|
+
oldest = min(self._pending, key=lambda k: self._pending[k].created_at)
|
|
282
|
+
del self._pending[oldest]
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------ 统计/记录 ------
|
|
285
|
+
def record_decision(self, conn: ConnRequest, decision: Decision) -> None:
|
|
286
|
+
"""记录一次决策(供 `cli status`/统计使用,不做任何 IO)。"""
|
|
287
|
+
with self._lock:
|
|
288
|
+
if decision.action is Action.PROXY:
|
|
289
|
+
self.put_pending(conn, decision)
|
|
290
|
+
elif decision.action is Action.BLOCK:
|
|
291
|
+
self._counters["blocked"] = self._counters.get("blocked", 0) + 1
|
|
292
|
+
|
|
293
|
+
def note_original_unknown(self) -> None:
|
|
294
|
+
with self._lock:
|
|
295
|
+
self._counters["original_unknown"] += 1
|
|
296
|
+
|
|
297
|
+
def stats(self) -> dict[str, int]:
|
|
298
|
+
with self._lock:
|
|
299
|
+
out = dict(self._counters)
|
|
300
|
+
out["active"] = len(self._sessions)
|
|
301
|
+
out["pending"] = len(self._pending)
|
|
302
|
+
out["sticky"] = len(self._sticky)
|
|
303
|
+
out["bytes_up"] = sum(s.bytes_up for s in self._sessions.values())
|
|
304
|
+
out["bytes_down"] = sum(s.bytes_down for s in self._sessions.values())
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _parse_endpoint(text: str) -> Endpoint | None:
|
|
309
|
+
text = text.strip()
|
|
310
|
+
if not text:
|
|
311
|
+
return None
|
|
312
|
+
host, _, port = text.rpartition(":")
|
|
313
|
+
if not host or not port.isdigit():
|
|
314
|
+
return None
|
|
315
|
+
return Endpoint(addr=host, port=int(port), is_v6=":" in host)
|
proxycore/tunnel/sink.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""sink:本地拦截端口:`accept → lookup_original → 选上游 → dial → relay`。
|
|
2
|
+
|
|
3
|
+
端到端流程:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
sink.accept()
|
|
7
|
+
├─ pending = sessions.pop_pending(peer_port) # 路线 A:源端口反查原始目标
|
|
8
|
+
│ (由 backend.lookup_original(sock, ctx) 反查)
|
|
9
|
+
├─ pending is None → E_ORIGINAL_DST_UNKNOWN → 关闭连接(不猜,不直连)
|
|
10
|
+
├─ 选上游(粘性已由规则引擎写入 decision)
|
|
11
|
+
└─ 落 worker 线程池:dial(upstream).connect(original) → relay(client, upstream)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
红线:
|
|
15
|
+
* 反查失败必须关闭连接并记 ERROR(禁止"猜目标"或"失败直连");
|
|
16
|
+
* accept 循环不做任何阻塞 IO(握手丢给 worker 线程池)。
|
|
17
|
+
|
|
18
|
+
双栈:同时监听 `host:port`(v4)与 `[::1]:port`(v6,`IPV6_V6ONLY=1`)。
|
|
19
|
+
后端改道 v6 连接时把目的写成 `[::1]:port`,没有这个监听就哪也落不下来。
|
|
20
|
+
v6 监听是必需的而不是可选的:本机 IPv6 一旦可用(默认就有),
|
|
21
|
+
只监听 v4 会让所有 v6 连接落空,而且是在后端已经改道之后落空。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import os
|
|
28
|
+
import select
|
|
29
|
+
import socket
|
|
30
|
+
import threading
|
|
31
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
32
|
+
from typing import TYPE_CHECKING, Any
|
|
33
|
+
|
|
34
|
+
from ..backend.base import Backend, OriginalDstUnknown
|
|
35
|
+
from ..types import Endpoint
|
|
36
|
+
from ..upstream.base import UpstreamDialer, UpstreamError
|
|
37
|
+
from .relay import relay_bidirectional
|
|
38
|
+
from .sessions import SessionTable
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from ..config import Config
|
|
42
|
+
from ..dns.modes import DnsPolicy
|
|
43
|
+
from ..log import EventLogger
|
|
44
|
+
|
|
45
|
+
DEFAULT_BACKLOG = 512
|
|
46
|
+
DEFAULT_ACCEPT_THREADS = 4 # :N = min(4, CPU)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Sink:
|
|
50
|
+
"""本地拦截端口监听器(每个 accept 线程一个 socket 共享队列)。"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
backend: Backend,
|
|
56
|
+
sessions: SessionTable,
|
|
57
|
+
config: Config,
|
|
58
|
+
log: EventLogger,
|
|
59
|
+
dialer_factory: Any = None,
|
|
60
|
+
dns: DnsPolicy | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.backend = backend
|
|
63
|
+
self.sessions = sessions
|
|
64
|
+
self.config = config
|
|
65
|
+
self.log = log
|
|
66
|
+
self._dialer_factory = dialer_factory
|
|
67
|
+
# `DnsPolicy`(`proxycore.dns.modes`)。None = 不做 DNS 决策,按 IP 直给上游
|
|
68
|
+
# (单测/嵌入场景)。Runtime 起 sink 时总会传。
|
|
69
|
+
self.dns = dns
|
|
70
|
+
self._listener: socket.socket | None = None
|
|
71
|
+
self._listener6: socket.socket | None = None
|
|
72
|
+
self._threads: list[threading.Thread] = []
|
|
73
|
+
self._stop = threading.Event()
|
|
74
|
+
self._workers = ThreadPoolExecutor(max_workers=64, thread_name_prefix="ppx-dial")
|
|
75
|
+
self.accepted = 0
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------ 生命周期 --
|
|
78
|
+
def start(self) -> None:
|
|
79
|
+
sink_cfg = self.config.service.sink
|
|
80
|
+
listener = self._bind4(str(sink_cfg.host), int(sink_cfg.port))
|
|
81
|
+
self._listener = listener
|
|
82
|
+
self._listener6 = self._bind6(int(sink_cfg.port))
|
|
83
|
+
cpu = os.cpu_count() or 4
|
|
84
|
+
count = max(1, min(DEFAULT_ACCEPT_THREADS, cpu))
|
|
85
|
+
for index in range(count):
|
|
86
|
+
thread = threading.Thread(target=self._accept_loop, name=f"ppx-accept-{index}", daemon=True)
|
|
87
|
+
thread.start()
|
|
88
|
+
self._threads.append(thread)
|
|
89
|
+
self.log.info(
|
|
90
|
+
"sink_listening",
|
|
91
|
+
host=sink_cfg.host,
|
|
92
|
+
port=sink_cfg.port,
|
|
93
|
+
accept_threads=count,
|
|
94
|
+
ipv6=self._listener6 is not None,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _bind4(self, host: str, port: int) -> socket.socket:
|
|
98
|
+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
99
|
+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
100
|
+
try:
|
|
101
|
+
# 启动时做占用检测
|
|
102
|
+
listener.bind((host, port))
|
|
103
|
+
except OSError as exc:
|
|
104
|
+
listener.close()
|
|
105
|
+
raise OriginalDstUnknown(f"sink 端口不可用 {host}:{port}: {exc}") from exc
|
|
106
|
+
listener.listen(DEFAULT_BACKLOG)
|
|
107
|
+
listener.settimeout(0.5)
|
|
108
|
+
return listener
|
|
109
|
+
|
|
110
|
+
def _bind6(self, port: int) -> socket.socket | None:
|
|
111
|
+
"""v6 侧的 sink 监听(`[::1]:port`)。失败不抛:但要吵。
|
|
112
|
+
|
|
113
|
+
bind 到具体地址 `::1`(不是 `::`),并且显式置 `IPV6_V6ONLY`:
|
|
114
|
+
否则这个套接字会顺带接管 v4 的 `0.0.0.0:port`,和 v4 监听抢端口。
|
|
115
|
+
"""
|
|
116
|
+
listener = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
|
|
117
|
+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
118
|
+
with contextlib.suppress(OSError, AttributeError):
|
|
119
|
+
listener.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
|
120
|
+
try:
|
|
121
|
+
listener.bind(("::1", port))
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
listener.close()
|
|
124
|
+
# 不静默降级:v6 一旦解析到就会走这条路径,落不下来就是"改道后连接必挂"
|
|
125
|
+
self.log.error(
|
|
126
|
+
"sink_ipv6_unavailable",
|
|
127
|
+
port=port,
|
|
128
|
+
code="E_SINK_IPV6_UNAVAILABLE",
|
|
129
|
+
error=str(exc),
|
|
130
|
+
hint="v6 连接改道后无处落地,会在 sink 端口上连接失败(不是直连,但应用会连不上)",
|
|
131
|
+
)
|
|
132
|
+
return None
|
|
133
|
+
listener.listen(DEFAULT_BACKLOG)
|
|
134
|
+
listener.settimeout(0.5)
|
|
135
|
+
return listener
|
|
136
|
+
|
|
137
|
+
def stop(self) -> None:
|
|
138
|
+
self._stop.set()
|
|
139
|
+
for attr in ("_listener", "_listener6"):
|
|
140
|
+
listener = getattr(self, attr)
|
|
141
|
+
setattr(self, attr, None)
|
|
142
|
+
if listener is not None:
|
|
143
|
+
with contextlib.suppress(OSError):
|
|
144
|
+
listener.close()
|
|
145
|
+
for thread in self._threads:
|
|
146
|
+
thread.join(timeout=1.0)
|
|
147
|
+
self._threads.clear()
|
|
148
|
+
self._workers.shutdown(wait=False, cancel_futures=True)
|
|
149
|
+
|
|
150
|
+
def _live_listeners(self) -> list[socket.socket]:
|
|
151
|
+
return [s for s in (self._listener, self._listener6) if s is not None]
|
|
152
|
+
|
|
153
|
+
# -------------------------------------------------------------- accept --
|
|
154
|
+
def _accept_loop(self) -> None:
|
|
155
|
+
while not self._stop.is_set():
|
|
156
|
+
listeners = self._live_listeners()
|
|
157
|
+
if not listeners:
|
|
158
|
+
return
|
|
159
|
+
try:
|
|
160
|
+
# 两个监听一起等:串行 accept 会让 v6 连接平白多等一个超时。
|
|
161
|
+
# select 在 Windows 上同样可用(这里最多 2 个套接字)。
|
|
162
|
+
ready, _, _ = select.select(listeners, [], [], 0.5)
|
|
163
|
+
except (OSError, ValueError):
|
|
164
|
+
return # 套接字被 stop() 关掉了
|
|
165
|
+
for listener in ready:
|
|
166
|
+
self._accept_one(listener)
|
|
167
|
+
|
|
168
|
+
def _accept_one(self, listener: socket.socket) -> None:
|
|
169
|
+
try:
|
|
170
|
+
client, peer = listener.accept()
|
|
171
|
+
except (TimeoutError, BlockingIOError):
|
|
172
|
+
return
|
|
173
|
+
except OSError:
|
|
174
|
+
return
|
|
175
|
+
self.accepted += 1
|
|
176
|
+
with contextlib.suppress(OSError):
|
|
177
|
+
client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
178
|
+
self._workers.submit(self._handle_client, client, peer)
|
|
179
|
+
|
|
180
|
+
def _handle_client(self, client: socket.socket, peer: tuple[Any, ...]) -> None:
|
|
181
|
+
peer_port = int(peer[1])
|
|
182
|
+
backend_ctx = peer_port
|
|
183
|
+
original: Endpoint | None = None
|
|
184
|
+
|
|
185
|
+
if self.sessions.test_mode():
|
|
186
|
+
# 测试模式(--sink-test-target / --upstream):不做抓包,连接是直接打到 sink 端口的;
|
|
187
|
+
# 目标由测试目标决定、上游被强制指定,会话照常记账。
|
|
188
|
+
original = self.sessions.test_target_endpoint()
|
|
189
|
+
else:
|
|
190
|
+
try:
|
|
191
|
+
original = self.backend.lookup_original(client, backend_ctx)
|
|
192
|
+
except OriginalDstUnknown as exc:
|
|
193
|
+
self.log.error(
|
|
194
|
+
"original_dst_unknown",
|
|
195
|
+
client_port=peer_port,
|
|
196
|
+
code="E_ORIGINAL_DST_UNKNOWN",
|
|
197
|
+
error=str(exc),
|
|
198
|
+
)
|
|
199
|
+
self.sessions.note_original_unknown()
|
|
200
|
+
_close(client)
|
|
201
|
+
return
|
|
202
|
+
if original is None:
|
|
203
|
+
# 无重定向记录 → 该连接不是我们改道的 → 关闭(不猜、不直连)
|
|
204
|
+
self.log.warn("original_dst_missing", client_port=peer_port, code="E_ORIGINAL_DST_UNKNOWN")
|
|
205
|
+
self.sessions.note_original_unknown()
|
|
206
|
+
_close(client)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
pending = self.sessions.pop_pending(backend_ctx)
|
|
210
|
+
if pending is None:
|
|
211
|
+
# 测试模式 / 后端自建连接:合成一条决策记录(真实目标未知,用测试目标占位)
|
|
212
|
+
pending = self.sessions.make_test_pending(backend_ctx)
|
|
213
|
+
upstream_alias = pending.upstream or self.sessions.force_upstream
|
|
214
|
+
session = self.sessions.open(pending, upstream=upstream_alias)
|
|
215
|
+
dial_target = self.sessions.test_target_endpoint() or original
|
|
216
|
+
|
|
217
|
+
dialer = self._make_dialer(upstream_alias)
|
|
218
|
+
if dialer is None:
|
|
219
|
+
self.log.error(
|
|
220
|
+
"upstream_missing",
|
|
221
|
+
upstream=upstream_alias or "-",
|
|
222
|
+
code="E_UPSTREAM_DOWN",
|
|
223
|
+
hint="规则命中了 proxy 但上游别名未配置/不存在,连接会被关闭(不会直连)",
|
|
224
|
+
)
|
|
225
|
+
self.sessions.close(session, reason="upstream-missing", error="E_UPSTREAM_DOWN")
|
|
226
|
+
_close(client)
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
if dial_target is None:
|
|
230
|
+
# 既没有恢复出原始目标、也没有测试目标 → 记 E_ORIGINAL_DST_UNKNOWN 并关闭
|
|
231
|
+
self.log.error(
|
|
232
|
+
"original_dst_unknown",
|
|
233
|
+
client_port=peer_port,
|
|
234
|
+
code="E_ORIGINAL_DST_UNKNOWN",
|
|
235
|
+
hint="既没有重定向记录也没有测试目标",
|
|
236
|
+
)
|
|
237
|
+
self.sessions.note_original_unknown()
|
|
238
|
+
self.sessions.close(session, reason="original-dst-unknown", error="E_ORIGINAL_DST_UNKNOWN")
|
|
239
|
+
_close(client)
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
upstream_sock = dialer.dial_tcp_sync(*self._dial_args(dial_target))
|
|
244
|
+
except UpstreamError as exc:
|
|
245
|
+
self.log.error(
|
|
246
|
+
"upstream_dial_failed",
|
|
247
|
+
dst=str(dial_target),
|
|
248
|
+
upstream=upstream_alias,
|
|
249
|
+
code=exc.code,
|
|
250
|
+
error=str(exc),
|
|
251
|
+
)
|
|
252
|
+
self.sessions.close(session, reason=exc.code, error=str(exc))
|
|
253
|
+
_close(client)
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
self.sessions.mark_relaying(session)
|
|
257
|
+
try:
|
|
258
|
+
up, down, reason = relay_bidirectional(
|
|
259
|
+
client, upstream_sock, idle_timeout=self.config.service.idle_timeout_s,
|
|
260
|
+
half_close_grace=self.config.service.half_close_timeout_s,
|
|
261
|
+
)
|
|
262
|
+
finally:
|
|
263
|
+
_close(upstream_sock)
|
|
264
|
+
_close(client)
|
|
265
|
+
session.bytes_up += up
|
|
266
|
+
session.bytes_down += down
|
|
267
|
+
self.sessions.close(session, reason=reason)
|
|
268
|
+
|
|
269
|
+
# -------------------------------------------------------------- DNS -----
|
|
270
|
+
def _dial_args(self, target: Endpoint) -> tuple[Endpoint, str | None]:
|
|
271
|
+
"""按 DNS 策略决定"交给上游的是什么"。
|
|
272
|
+
|
|
273
|
+
* `remote`(默认)→ 有可信域名就把域名交给上游远端解析
|
|
274
|
+
(SOCKS5 `ATYP=3` / SOCKS4a / CONNECT 原样传域名),本地不查 DNS;
|
|
275
|
+
* `local` → 先在本地解析成 IP,再把 IP 交给上游(`hostname=None`);
|
|
276
|
+
* `bypass` → 命中 `dns_bypass` 的域名走 local,其余走 remote。
|
|
277
|
+
|
|
278
|
+
路线 A 下大多是"只有 IP":应用自己 `getaddrinfo` 完才 `connect`,
|
|
279
|
+
我们在 IP 层只看到 IP 。这是应用行为决定的,不是缺陷。
|
|
280
|
+
`plan_for_connection` 在这种情况下查 DNS 缓存,但只认可信项:
|
|
281
|
+
嗅探来的映射是近似的(TTL 内有效、CDN 会误判),拿它去 dial 等于猜一个
|
|
282
|
+
域名让上游连过去,猜错就是连到别人的站点。
|
|
283
|
+
"""
|
|
284
|
+
if self.dns is None:
|
|
285
|
+
return target, None
|
|
286
|
+
plan = self.dns.plan_for_connection(target.addr)
|
|
287
|
+
dial = (
|
|
288
|
+
target
|
|
289
|
+
if plan.addr == target.addr
|
|
290
|
+
else Endpoint(addr=plan.addr, port=target.port, is_v6=":" in plan.addr)
|
|
291
|
+
)
|
|
292
|
+
if plan.hostname or plan.used_local_dns:
|
|
293
|
+
# 只在"真的发生了名字决策"时记一笔;纯 IP 透传是常态,不必刷屏
|
|
294
|
+
self.log.debug(
|
|
295
|
+
"dns_plan",
|
|
296
|
+
target=str(target),
|
|
297
|
+
mode=plan.mode,
|
|
298
|
+
dial=str(dial),
|
|
299
|
+
hostname=plan.hostname or "-",
|
|
300
|
+
reason=plan.reason,
|
|
301
|
+
)
|
|
302
|
+
return dial, plan.hostname
|
|
303
|
+
|
|
304
|
+
# ------------------------------------------------------------- 上游 -----
|
|
305
|
+
def _make_dialer(self, alias: str) -> UpstreamDialer | None:
|
|
306
|
+
if not alias:
|
|
307
|
+
return None
|
|
308
|
+
upstream = self.config.profile.upstreams.get(alias)
|
|
309
|
+
if upstream is None:
|
|
310
|
+
return None
|
|
311
|
+
if self._dialer_factory is not None:
|
|
312
|
+
return self._dialer_factory(upstream)
|
|
313
|
+
from ..upstream import make_dialer
|
|
314
|
+
|
|
315
|
+
# 注入别名解析器:跳链(chain)需要按别名取到其它 upstream
|
|
316
|
+
upstreams = self.config.profile.upstreams
|
|
317
|
+
return make_dialer(
|
|
318
|
+
upstream,
|
|
319
|
+
timeout=self.config.service.upstream_timeout_s,
|
|
320
|
+
resolver=upstreams.get,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def _close(sock: socket.socket) -> None:
|
|
324
|
+
with contextlib.suppress(OSError):
|
|
325
|
+
sock.close()
|