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/ipc.py ADDED
@@ -0,0 +1,637 @@
1
+ """CLI/GUI ↔ 服务 的 IPC。
2
+
3
+ Windows:命名管道 `\\\\.\\pipe\\ProcProxy`(ACL 只允许 Administrators + 本机)。
4
+ Unix:`~/.procproxy/procproxy.sock`(AF_UNIX,目录权限 0700)。
5
+
6
+ 设计约束:
7
+ * 管道安全描述符只允许 Administrators + 本机 → 低权用户连接必须失败;
8
+ * 服务侧对每条命令做来源校验(`GetNamedPipeClientProcessId`)并在审计日志里留痕;
9
+ * 协议用一行 JSON(`{"cmd": ...}` → `{"ok": bool, ...}`),便于 CLI 与 GUI 复用。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import json
16
+ import os
17
+ import socket
18
+ import threading
19
+ import time
20
+ from collections.abc import Callable, Iterable
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ from .log import EventLogger, get_logger
25
+ from .proc import IS_WINDOWS, current_pid
26
+
27
+ LOG = EventLogger(get_logger("proxycore.ipc"))
28
+
29
+ PIPE_NAME_WINDOWS = r"\\.\pipe\ProcProxy"
30
+ UNIX_SOCKET_NAME = "procproxy.sock"
31
+ MAX_REQUEST = 65536
32
+ #: 服务端读取一条请求的最长等待(秒)。客户端连上后不发送请求不能拖死整个 IPC。
33
+ PIPE_READ_TIMEOUT_S = 5.0
34
+ #: Windows ERROR_PIPE_BUSY:命名管道实例都被占用时 CreateFile 返回它。
35
+ _ERROR_PIPE_BUSY = 231
36
+
37
+ # AF_UNIX 在部分 Windows Python 构建里不存在 → 显式解析一次,避免 getattr 的 Optional 类型污染
38
+ AF_UNIX = int(getattr(socket, "AF_UNIX", 0))
39
+
40
+ CMD_STATUS = "status"
41
+ CMD_SESSIONS = "sessions"
42
+ CMD_RULES = "rules"
43
+ CMD_RELOAD = "reload"
44
+ CMD_STOP = "stop"
45
+ CMD_PANIC = "panic"
46
+ VALID_COMMANDS = {CMD_STATUS, CMD_SESSIONS, CMD_RULES, CMD_RELOAD, CMD_STOP, CMD_PANIC}
47
+
48
+ #: 默认 DACL:只允许管理员 + SYSTEM(Everyone 的 `...GRGWGX;;;WD` 不在其中)。
49
+ PIPE_SDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)"
50
+
51
+ # ---- 按命令的权限分离 --------------------------------------------------------
52
+ # 管道 ACL 是整体授权:要让人能发命令,就必须给他写权限。所以"能不能改规则"
53
+ # 这件事表达不到 ACL 上,必须由服务侧按命令再判一次。
54
+ #: 只读命令:被放行的组可以调。
55
+ READ_COMMANDS = frozenset({CMD_STATUS, CMD_SESSIONS, CMD_RULES})
56
+ #: 变更类命令:只有管理员能调。
57
+ #: `reload` 能把别人的流量导到另一个上游;`stop` / `panic` 能让服务停摆。
58
+ #: 这两类都不该交给"只是想看状态"的普通用户。
59
+ ADMIN_ONLY_COMMANDS = frozenset({CMD_RELOAD, CMD_STOP, CMD_PANIC})
60
+
61
+
62
+ def build_pipe_sddl(allow_sids: Iterable[str] = ()) -> str:
63
+ """构造命名管道的 DACL。
64
+
65
+ * `allow_sids` 为空 = 现状:只给 SYSTEM 与 Administrators `GA`;
66
+ * 传了 SID 则额外授予它们 `GRGW`(读+写)而不是 `GA`:管道是双工的,要发命令就必须能写;但不该把 `WRITE_DAC` / `WRITE_OWNER`
67
+ 也交出去(那等于连管道自己的 ACL 都能改)。
68
+
69
+ `sid` 可以是 `S-1-5-21-...` 字面量,也可以用别名(`IU` / `BU` / `WD`)。
70
+ 自定义组请传 SID 字符串(组名要先解析,见 `resolve_group_sid()`)。
71
+ """
72
+ aces = ["(A;;GA;;;SY)", "(A;;GA;;;BA)"]
73
+ aces += [f"(A;;GRGW;;;{sid})" for sid in allow_sids if sid]
74
+ return "D:P" + "".join(aces)
75
+
76
+
77
+ def resolve_group_sid(name: str) -> str:
78
+ """把本地组名解析成 SID 字符串;解析不了返回 `""`(调用方按"不放行"处理)。
79
+
80
+ 已经是 `S-1-...` 形式则原样返回。只在 Windows 上可行。
81
+ """
82
+ name = (name or "").strip()
83
+ if not name:
84
+ return ""
85
+ if name.upper().startswith("S-1-"):
86
+ return name
87
+ if os.name != "nt":
88
+ return ""
89
+ try:
90
+ import win32security
91
+
92
+ sid, _domain, _type = win32security.LookupAccountName(None, name)
93
+ return str(win32security.ConvertSidToStringSid(sid))
94
+ except Exception as exc: # 组不存在 / 无权限查询,一律不放行
95
+ LOG.warn("ipc_allow_group_unresolved", group=name, error=str(exc))
96
+ return ""
97
+
98
+
99
+ @dataclass(slots=True)
100
+ class IpcRequest:
101
+ cmd: str
102
+ args: dict[str, Any] = field(default_factory=dict)
103
+ client_pid: int = 0
104
+
105
+ @classmethod
106
+ def loads(cls, raw: bytes | str) -> IpcRequest:
107
+ text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
108
+ doc = json.loads(text)
109
+ if not isinstance(doc, dict):
110
+ raise ValueError("IPC 请求必须是 JSON 对象")
111
+ cmd = str(doc.get("cmd", "")).strip().lower()
112
+ if cmd not in VALID_COMMANDS:
113
+ raise ValueError(f"未知命令 {cmd!r}(可选:{', '.join(sorted(VALID_COMMANDS))})")
114
+ args = doc.get("args") or {}
115
+ if not isinstance(args, dict):
116
+ raise ValueError("args 必须是对象")
117
+ return cls(cmd=cmd, args=args, client_pid=int(doc.get("pid", 0)))
118
+
119
+ def dumps(self) -> bytes:
120
+ return (json.dumps({"cmd": self.cmd, "args": self.args, "pid": self.client_pid}) + "\n").encode("utf-8")
121
+
122
+
123
+ def encode_response(ok: bool, **fields: Any) -> bytes:
124
+ payload = {"ok": bool(ok), "ts": time.time(), **fields}
125
+ return (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
126
+
127
+
128
+ class IpcServer:
129
+ """服务侧:把命令分发给 handler。"""
130
+
131
+ def __init__(self, handlers: dict[str, Callable[[IpcRequest], dict[str, Any]]] | None = None) -> None:
132
+ self.handlers: dict[str, Callable[[IpcRequest], dict[str, Any]]] = dict(handlers or {})
133
+ self._stop = threading.Event()
134
+ self._threads: list[threading.Thread] = []
135
+ self._server: Any = None
136
+ self._audit: list[dict[str, Any]] = []
137
+ self.served = 0
138
+ self.rejected = 0
139
+
140
+ def start(self) -> None:
141
+ """启动 IPC 监听(子类实现;基类默认不监听,便于纯逻辑测试)。"""
142
+
143
+ def stop(self) -> None:
144
+ self._stop.set()
145
+
146
+ def register(self, cmd: str, handler: Callable[[IpcRequest], dict[str, Any]]) -> None:
147
+ if cmd not in VALID_COMMANDS:
148
+ raise ValueError(f"未知命令 {cmd!r}")
149
+ self.handlers[cmd] = handler
150
+
151
+ # ------------------------------------------------------------ 分发 -----
152
+ def dispatch(
153
+ self,
154
+ request: IpcRequest,
155
+ *,
156
+ client_pid: int = 0,
157
+ client_is_admin: bool = True,
158
+ ) -> dict[str, Any]:
159
+ """分发一条命令。
160
+
161
+ `client_is_admin` 由传输层给出(Windows 命名管道查客户端令牌;Unix
162
+ socket 靠目录 0700)。基类默认 `True`,因为默认的管道 ACL 本身就只放行
163
+ 管理员,那时再判一次是多余的。一旦放行了普通组(`allow_sids`),
164
+ 这个参数就必须由 `WindowsPipeServer` 真实计算,否则变更类命令会被
165
+ 任何能被 ACL 放行的人调走。
166
+ """
167
+ self._audit.append(
168
+ {"ts": time.time(), "cmd": request.cmd, "client_pid": client_pid, "admin": client_is_admin}
169
+ )
170
+ if len(self._audit) > 1000:
171
+ del self._audit[:500]
172
+ if request.cmd in ADMIN_ONLY_COMMANDS and not client_is_admin:
173
+ self.rejected += 1
174
+ LOG.warn("ipc_command_denied_not_admin", cmd=request.cmd, client_pid=client_pid)
175
+ return {"ok": False, "error": f"命令 {request.cmd} 需要管理员权限", "code": "E_IPC_NOT_ADMIN"}
176
+ handler = self.handlers.get(request.cmd)
177
+ if handler is None:
178
+ self.rejected += 1
179
+ LOG.warn("ipc_command_unhandled", cmd=request.cmd, client_pid=client_pid)
180
+ return {"ok": False, "error": f"命令 {request.cmd} 未被实现"}
181
+ try:
182
+ result = handler(request)
183
+ self.served += 1
184
+ return {"ok": True, **result}
185
+ except Exception as exc: # 服务侧不能因单条命令崩溃
186
+ LOG.error("ipc_handler_failed", cmd=request.cmd, error=str(exc))
187
+ return {"ok": False, "error": str(exc)}
188
+
189
+ def handle_raw(self, raw: bytes, *, client_pid: int = 0, client_is_admin: bool = True) -> bytes:
190
+ try:
191
+ request = IpcRequest.loads(raw)
192
+ except (ValueError, json.JSONDecodeError) as exc:
193
+ self.rejected += 1
194
+ return encode_response(False, error=f"请求非法:{exc}")
195
+ return encode_response(
196
+ **self.dispatch(request, client_pid=client_pid, client_is_admin=client_is_admin)
197
+ )
198
+
199
+ def audit_log(self) -> list[dict[str, Any]]:
200
+ return list(self._audit)
201
+
202
+ def stats(self) -> dict[str, int]:
203
+ return {"served": self.served, "rejected": self.rejected, "audit_entries": len(self._audit)}
204
+
205
+
206
+ # ---------------------------------------------------------- Windows 实现 ----
207
+ class WindowsPipeServer(IpcServer):
208
+ """命名管道服务(`pywin32` 可用时走真实管道;否则退化为"未安装依赖")。"""
209
+
210
+ def __init__(
211
+ self,
212
+ *args: Any,
213
+ allow_sids: Iterable[str] = (),
214
+ pipe_name: str = PIPE_NAME_WINDOWS,
215
+ read_timeout_s: float = PIPE_READ_TIMEOUT_S,
216
+ **kwargs: Any,
217
+ ) -> None:
218
+ super().__init__(*args, **kwargs)
219
+ #: 额外放行的 SID(只给读写)。为空 = 只允许管理员 + SYSTEM(默认,保持原行为)。
220
+ self.allow_sids: tuple[str, ...] = tuple(s for s in allow_sids if s)
221
+ #: 管道名可注入:测试用独立名字,避免撞上正在运行的真实服务。
222
+ self.pipe_name = pipe_name or PIPE_NAME_WINDOWS
223
+ self.read_timeout_s = max(0.05, float(read_timeout_s))
224
+ self.available = False
225
+ self.error = ""
226
+
227
+ def start(self) -> None: # pragma: no cover - 需要 Windows + pywin32
228
+ if not IS_WINDOWS:
229
+ raise RuntimeError("命名管道只在 Windows 可用")
230
+ try:
231
+ import win32security # noqa: F401 # 仅探测可用性
232
+ except ImportError as exc:
233
+ self.error = f"需要 pywin32 才能提供命名管道 IPC:{exc}"
234
+ LOG.warn("ipc_pipe_unavailable", error=self.error)
235
+ return
236
+ self.available = True
237
+ self._threads.append(threading.Thread(target=self._serve_loop, daemon=True, name="ppx-ipc"))
238
+ self._threads[-1].start()
239
+
240
+ def stop(self) -> None:
241
+ self._stop.set()
242
+
243
+ def _serve_loop(self) -> None: # pragma: no cover - Windows only
244
+ import win32file
245
+ import win32pipe
246
+ import win32security
247
+
248
+ sddl = build_pipe_sddl(self.allow_sids)
249
+ if self.allow_sids:
250
+ # 放行了非管理员 → 变更类命令改为"按命令判"(见 ADMIN_ONLY_COMMANDS)。
251
+ # 这条日志很重要:它说明这台机器的 IPC 不再是管理员专属。
252
+ LOG.warn("ipc_pipe_open_to_group", sids=list(self.allow_sids),
253
+ hint="这些 SID 能连管道;变更类命令仍需管理员令牌")
254
+ try:
255
+ sd = win32security.ConvertStringSecurityDescriptorToSecurityDescriptor(
256
+ sddl, win32security.SDDL_REVISION_1
257
+ )
258
+ sa = win32security.SECURITY_ATTRIBUTES()
259
+ sa.SECURITY_DESCRIPTOR = sd
260
+ except Exception as exc:
261
+ self.error = f"构造管道安全描述符失败:{exc}"
262
+ LOG.error("ipc_sddl_failed", error=self.error)
263
+ return
264
+
265
+ while not self._stop.is_set():
266
+ try:
267
+ handle = win32pipe.CreateNamedPipe(
268
+ self.pipe_name,
269
+ win32pipe.PIPE_ACCESS_DUPLEX,
270
+ win32pipe.PIPE_TYPE_MESSAGE | win32pipe.PIPE_READMODE_MESSAGE | win32pipe.PIPE_WAIT,
271
+ 8,
272
+ MAX_REQUEST,
273
+ MAX_REQUEST,
274
+ 0,
275
+ sa,
276
+ )
277
+ except Exception as exc:
278
+ # 常见原因:已有实例占用同名管道 / 权限不足。明确记录并退出循环,
279
+ # 不让异常冒到线程外(否则 pytest/服务日志里会出现"未处理线程异常")。
280
+ self.error = f"创建命名管道失败:{exc}(是否已有另一个服务实例在跑?)"
281
+ LOG.error("ipc_pipe_create_failed", error=self.error)
282
+ return
283
+ try:
284
+ win32pipe.ConnectNamedPipe(handle, None)
285
+ # `ReadFile`/`WriteFile` 在 win32file 里,不在 win32pipe。
286
+ # 写错会抛 AttributeError,被下面的 except 吞掉并关掉实例,
287
+ # 客户端只看到"233 管道的另一端上无任何进程",很难联想到这里。
288
+ # 读取必须带超时:客户端连上后一个字节都不发,不能让唯一的
289
+ # 管道实例永远挂在这里(放行了普通组的机器上就是一次 DoS)。
290
+ data = self._read_request(handle)
291
+ if data is None:
292
+ continue
293
+ client_pid = self._client_pid(handle)
294
+ client_is_admin = self._client_is_admin(client_pid)
295
+ win32file.WriteFile(
296
+ handle,
297
+ self.handle_raw(data, client_pid=client_pid, client_is_admin=client_is_admin),
298
+ )
299
+ except Exception as exc: # 单连接失败不影响服务
300
+ LOG.error("ipc_pipe_error", error=str(exc))
301
+ finally:
302
+ with contextlib.suppress(Exception):
303
+ win32pipe.DisconnectNamedPipe(handle)
304
+ with contextlib.suppress(Exception):
305
+ win32pipe.CloseHandle(handle)
306
+
307
+ def _read_request(self, handle: Any) -> bytes | None: # pragma: no cover - Windows only
308
+ """带超时地读一条请求;超时返回 None(调用方关闭该实例并重建)。"""
309
+ import win32file
310
+
311
+ result: dict[str, Any] = {}
312
+
313
+ def worker() -> None:
314
+ try:
315
+ _hr, data = win32file.ReadFile(handle, MAX_REQUEST)
316
+ result["data"] = data
317
+ except Exception as exc: # 取消/断连都是正常收尾
318
+ result["error"] = exc
319
+
320
+ thread = threading.Thread(target=worker, daemon=True, name="ppx-ipc-read")
321
+ thread.start()
322
+ thread.join(self.read_timeout_s)
323
+ if thread.is_alive():
324
+ _cancel_sync_io(thread.ident or 0)
325
+ thread.join(1.0)
326
+ LOG.warn(
327
+ "ipc_pipe_read_timeout",
328
+ timeout_s=self.read_timeout_s,
329
+ hint="客户端连接后未发送请求,已关闭该管道实例",
330
+ )
331
+ return None
332
+ if "data" in result:
333
+ return result["data"]
334
+ if "error" in result:
335
+ raise result["error"]
336
+ return None
337
+
338
+ def _client_pid(self, handle: Any) -> int: # pragma: no cover - Windows only
339
+ try:
340
+ import win32pipe
341
+
342
+ return int(win32pipe.GetNamedPipeClientProcessId(handle))
343
+ except Exception:
344
+ return 0
345
+
346
+ def _client_is_admin(self, pid: int) -> bool: # pragma: no cover - Windows only
347
+ """管道的这个客户端本次能否执行管理操作。
348
+
349
+ 判据是"令牌里的 Administrators 组已启用",不是 `TokenElevation`。
350
+
351
+ 本机实测(`runas /trustlevel:0x20000` 造的受限令牌):
352
+
353
+ 令牌 TokenElevation Administrators 属性
354
+ 提权管理员 1 0xf (SE_GROUP_ENABLED)
355
+ 受限令牌 1 0x10 (SE_GROUP_USE_FOR_DENY_ONLY)
356
+ ^^^ 两者都是 1,所以 TokenElevation 区分不出来,
357
+ 受限令牌会被误判成管理员(实测确实被放行了)
358
+
359
+ `CheckTokenMembership(token, Administrators)` 也不行:pywin32 对主令牌
360
+ 返回 `1309 ERROR_NO_IMPERSONATION_TOKEN`(它要的是线程的模仿令牌)。
361
+
362
+ 改用组属性:`SE_GROUP_ENABLED(0x4)` 置位、且
363
+ `SE_GROUP_USE_FOR_DENY_ONLY(0x10)` 未置位,才算管理员。
364
+ 这与管道 ACL 对 `BA` 的判定同源,受限令牌之所以连默认管道都连不上,
365
+ 正是因为它的 BA 是 deny-only。
366
+
367
+ SYSTEM 单独放行:它的令牌里根本没有 Administrators 组,但管道 ACL 给了 `SY`。
368
+
369
+ 任何异常一律按 False(fail-closed),绝不让"查不出来"变成"当作管理员"。
370
+ """
371
+ if pid <= 0 or os.name != "nt":
372
+ return False
373
+ # WinNT.h:组属性位
374
+ se_group_enabled = 0x00000004
375
+ se_group_use_for_deny_only = 0x00000010
376
+ local_system = "S-1-5-18"
377
+ handle = None
378
+ token = None
379
+ try:
380
+ import win32api
381
+ import win32con
382
+ import win32security
383
+
384
+ handle = win32api.OpenProcess(win32con.PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
385
+ token = win32security.OpenProcessToken(handle, win32security.TOKEN_QUERY)
386
+ to_str = win32security.ConvertSidToStringSid
387
+
388
+ user = to_str(win32security.GetTokenInformation(token, win32security.TokenUser)[0])
389
+ if user == local_system:
390
+ return True
391
+
392
+ admins = to_str(
393
+ win32security.CreateWellKnownSid(win32security.WinBuiltinAdministratorsSid)
394
+ )
395
+ for sid, attributes in win32security.GetTokenInformation(token, win32security.TokenGroups):
396
+ if to_str(sid) != admins:
397
+ continue
398
+ return bool(attributes & se_group_enabled) and not bool(
399
+ attributes & se_group_use_for_deny_only
400
+ )
401
+ return False
402
+ except Exception as exc:
403
+ LOG.warn("ipc_client_admin_probe_failed", pid=pid, error=str(exc))
404
+ return False
405
+ finally:
406
+ for obj in (token, handle):
407
+ if obj is not None:
408
+ with contextlib.suppress(Exception):
409
+ obj.Close()
410
+
411
+
412
+ # ------------------------------------------------------------ Unix 实现 ----
413
+ class UnixSocketServer(IpcServer):
414
+ """AF_UNIX 服务端(目录 0700,仅服务账户可访问)。"""
415
+
416
+ def __init__(self, *args: Any, path: str = "", **kwargs: Any) -> None:
417
+ super().__init__(*args, **kwargs)
418
+ self.path = path or default_socket_path()
419
+
420
+ def start(self) -> None:
421
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
422
+ with _suppress_os_error():
423
+ os.unlink(self.path)
424
+ server = socket.socket(AF_UNIX, socket.SOCK_STREAM)
425
+ server.bind(self.path)
426
+ os.chmod(os.path.dirname(self.path), 0o700)
427
+ server.listen(8)
428
+ server.settimeout(0.5)
429
+ self._server = server
430
+ self._threads.append(threading.Thread(target=self._accept_loop, daemon=True, name="ppx-ipc"))
431
+ self._threads[-1].start()
432
+
433
+ def stop(self) -> None:
434
+ self._stop.set()
435
+ server, self._server = self._server, None
436
+ if server is not None:
437
+ with _suppress_os_error():
438
+ server.close()
439
+ with _suppress_os_error():
440
+ os.unlink(self.path)
441
+
442
+ def _accept_loop(self) -> None:
443
+ while not self._stop.is_set():
444
+ server = self._server
445
+ if server is None:
446
+ return
447
+ try:
448
+ conn, _ = server.accept()
449
+ except TimeoutError:
450
+ continue
451
+ except OSError:
452
+ return
453
+ with conn:
454
+ try:
455
+ raw = conn.recv(MAX_REQUEST)
456
+ except OSError:
457
+ continue
458
+ if raw:
459
+ conn.sendall(self.handle_raw(raw))
460
+
461
+
462
+ def default_socket_path() -> str:
463
+ runtime = os.environ.get("PROCPROXY_RUNTIME_DIR")
464
+ base = runtime or (os.path.join(os.environ.get("PROGRAMDATA", r"C:\ProgramData"), "ProcProxy") if IS_WINDOWS else os.path.join(os.path.expanduser("~"), ".procproxy"))
465
+ return os.path.join(base, UNIX_SOCKET_NAME)
466
+
467
+
468
+ def make_server(
469
+ *,
470
+ handlers: dict[str, Callable[[IpcRequest], dict[str, Any]]] | None = None,
471
+ path: str = "",
472
+ allow_sids: Iterable[str] = (),
473
+ ) -> IpcServer:
474
+ """按平台造一个 IPC 服务端。
475
+
476
+ `allow_sids` 只对 Windows 命名管道有意义(额外放行哪些 SID 连管道);
477
+ Unix socket 靠目录 0700,天然只有服务账户能连。
478
+ """
479
+ if IS_WINDOWS:
480
+ return WindowsPipeServer(handlers, allow_sids=allow_sids)
481
+ return UnixSocketServer(handlers, path=path)
482
+
483
+
484
+ # ------------------------------------------------------------ 客户端 --------
485
+ class IpcClient:
486
+ """CLI 侧:连服务(Windows 走命名管道,Unix 走 socket)。"""
487
+
488
+ def __init__(self, *, path: str = "", timeout: float = 5.0) -> None:
489
+ self.path = path or (PIPE_NAME_WINDOWS if IS_WINDOWS else default_socket_path())
490
+ self.timeout = timeout
491
+
492
+ def call(self, cmd: str, **args: Any) -> dict[str, Any]:
493
+ request = IpcRequest(cmd=cmd, args=args, client_pid=current_pid())
494
+ if IS_WINDOWS:
495
+ return self._call_pipe(request)
496
+ return self._call_socket(request)
497
+
498
+ def _call_pipe(self, request: IpcRequest) -> dict[str, Any]: # pragma: no cover - Windows only
499
+ try:
500
+ import win32file
501
+ import win32pipe
502
+ except ImportError:
503
+ return {"ok": False, "error": "需要 pywin32 才能连接服务的命名管道"}
504
+ try:
505
+ handle = self._open_pipe(win32file, win32pipe)
506
+ except Exception as exc:
507
+ return {"ok": False, "error": f"连接服务失败:{exc}"}
508
+ try:
509
+ return _pipe_exchange(handle, request.dumps(), timeout=self.timeout)
510
+ finally:
511
+ with _suppress_os_error():
512
+ handle.Close()
513
+
514
+ def _open_pipe(self, win32file: Any, win32pipe: Any) -> Any: # pragma: no cover - Windows only
515
+ """打开管道;实例全忙时等到 `timeout`,而不是立刻报错。"""
516
+ deadline = time.monotonic() + max(0.05, self.timeout)
517
+ while True:
518
+ try:
519
+ return win32file.CreateFile(
520
+ self.path,
521
+ win32file.GENERIC_READ | win32file.GENERIC_WRITE,
522
+ 0,
523
+ None,
524
+ win32file.OPEN_EXISTING,
525
+ 0,
526
+ None,
527
+ )
528
+ except Exception as exc:
529
+ if getattr(exc, "winerror", None) != _ERROR_PIPE_BUSY:
530
+ raise
531
+ remaining = deadline - time.monotonic()
532
+ if remaining <= 0:
533
+ raise
534
+ win32pipe.WaitNamedPipe(self.path, max(1, int(remaining * 1000)))
535
+
536
+ def _call_socket(self, request: IpcRequest) -> dict[str, Any]:
537
+ try:
538
+ with socket.socket(AF_UNIX, socket.SOCK_STREAM) as sock:
539
+ sock.settimeout(self.timeout)
540
+ sock.connect(self.path)
541
+ sock.sendall(request.dumps())
542
+ raw = sock.recv(MAX_REQUEST)
543
+ except OSError as exc:
544
+ return {"ok": False, "error": f"连接服务失败:{exc}"}
545
+ try:
546
+ return json.loads(raw.decode("utf-8"))
547
+ except json.JSONDecodeError:
548
+ return {"ok": False, "error": "服务返回了非法 JSON"}
549
+
550
+
551
+ def _pipe_exchange(handle: Any, payload: bytes, *, timeout: float) -> dict[str, Any]:
552
+ """在后台线程里完成"写请求 + 读响应",超时用 CancelSynchronousIo 取消。
553
+
554
+ 命名管道没有 socket 那样的读超时;同步 `ReadFile` 一旦对端不响应就会
555
+ 永久阻塞。取消必须由另一个线程发起(`CancelSynchronousIo`),因此这里
556
+ 把整段交换放进 worker,主线程按 `timeout` 收口。
557
+ """
558
+ import win32file # pragma: no cover - Windows only
559
+
560
+ result: dict[str, Any] = {}
561
+
562
+ def worker() -> None: # pragma: no cover - Windows only
563
+ try:
564
+ win32file.WriteFile(handle, payload)
565
+ _data, raw = win32file.ReadFile(handle, MAX_REQUEST)
566
+ result["payload"] = json.loads(raw.decode("utf-8"))
567
+ except Exception as exc: # 取消/断连都归到调用方返回
568
+ result["error"] = f"与服务通信失败:{exc}"
569
+
570
+ thread = threading.Thread(target=worker, daemon=True, name="ppx-ipc-client")
571
+ thread.start()
572
+ thread.join(max(0.05, timeout))
573
+ if thread.is_alive():
574
+ _cancel_sync_io(thread.ident or 0)
575
+ thread.join(1.0)
576
+ return {"ok": False, "error": f"与服务通信超时({timeout:g}s)"}
577
+ if "payload" in result:
578
+ return result["payload"]
579
+ return {"ok": False, "error": result.get("error", "与服务通信失败:未收到响应")}
580
+
581
+
582
+ def _cancel_sync_io(thread_id: int) -> None:
583
+ """取消目标线程上挂起的同步 I/O(Windows)。失败只记日志,不抛。"""
584
+ if not IS_WINDOWS or thread_id <= 0:
585
+ return
586
+ try:
587
+ import ctypes
588
+ from ctypes import wintypes
589
+
590
+ k32 = ctypes.WinDLL("kernel32", use_last_error=True)
591
+ k32.OpenThread.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
592
+ k32.OpenThread.restype = wintypes.HANDLE
593
+ k32.CancelSynchronousIo.argtypes = [wintypes.HANDLE]
594
+ k32.CancelSynchronousIo.restype = wintypes.BOOL
595
+ k32.CloseHandle.argtypes = [wintypes.HANDLE]
596
+ k32.CloseHandle.restype = wintypes.BOOL
597
+ handle = k32.OpenThread(0x0001, False, thread_id) # THREAD_TERMINATE
598
+ if not handle:
599
+ return
600
+ try:
601
+ k32.CancelSynchronousIo(handle)
602
+ finally:
603
+ k32.CloseHandle(handle)
604
+ except Exception as exc: # 取消失败时由句柄关闭兜底
605
+ LOG.warn("ipc_cancel_sync_io_failed", thread_id=thread_id, error=str(exc))
606
+
607
+
608
+ def _suppress_os_error() -> Any:
609
+ import contextlib
610
+
611
+ return contextlib.suppress(OSError)
612
+
613
+
614
+ __all__ = [
615
+ "ADMIN_ONLY_COMMANDS",
616
+ "AF_UNIX",
617
+ "CMD_PANIC",
618
+ "CMD_RELOAD",
619
+ "CMD_RULES",
620
+ "CMD_SESSIONS",
621
+ "CMD_STATUS",
622
+ "CMD_STOP",
623
+ "PIPE_NAME_WINDOWS",
624
+ "PIPE_SDDL",
625
+ "READ_COMMANDS",
626
+ "VALID_COMMANDS",
627
+ "IpcClient",
628
+ "IpcRequest",
629
+ "IpcServer",
630
+ "UnixSocketServer",
631
+ "WindowsPipeServer",
632
+ "build_pipe_sddl",
633
+ "default_socket_path",
634
+ "encode_response",
635
+ "make_server",
636
+ "resolve_group_sid",
637
+ ]