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/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
|
+
]
|