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,536 @@
|
|
|
1
|
+
"""服务化:Windows 服务 / systemd 单元 / systemd-like 前台。
|
|
2
|
+
|
|
3
|
+
设计:
|
|
4
|
+
* 数据面只跑在服务进程里:服务持有 WinDivert 句柄、sink 监听、上游连接;
|
|
5
|
+
* CLI/GUI 只通过 IPC 读状态与改配置;
|
|
6
|
+
* 服务在 Session 0 运行,因此"其他用户/服务"的流量也能被统一处理。
|
|
7
|
+
|
|
8
|
+
崩溃自愈:进程一死,内核句柄随之释放,拦截自动失效、流量恢复直连
|
|
9
|
+
这是 WinDivert 方案天然具备的性质,不需要额外写"全量放行"逻辑。
|
|
10
|
+
服务重启后由本模块重新装载配置与句柄。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import os
|
|
17
|
+
import signal
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ..config import Config, ConfigError, ConfigManager
|
|
25
|
+
from ..ipc import (
|
|
26
|
+
CMD_PANIC,
|
|
27
|
+
CMD_RELOAD,
|
|
28
|
+
CMD_RULES,
|
|
29
|
+
CMD_SESSIONS,
|
|
30
|
+
CMD_STATUS,
|
|
31
|
+
CMD_STOP,
|
|
32
|
+
IpcRequest,
|
|
33
|
+
make_server,
|
|
34
|
+
resolve_group_sid,
|
|
35
|
+
)
|
|
36
|
+
from ..log import EventLogger, build_logger, get_logger
|
|
37
|
+
from ..proc import IS_LINUX, IS_WINDOWS
|
|
38
|
+
|
|
39
|
+
LOG = EventLogger(get_logger("proxycore.service"))
|
|
40
|
+
|
|
41
|
+
SERVICE_NAME = "procproxysvc" # 服务名(自研,避免与其它产品冲突)
|
|
42
|
+
SERVICE_DISPLAY = "ProcProxy Service"
|
|
43
|
+
SERVICE_DESC = "ProcProxy —— 进程级代理(拦截与转发数据面)"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class ServiceContext:
|
|
48
|
+
"""服务运行时状态(IPC handler 从这里读)。"""
|
|
49
|
+
|
|
50
|
+
manager: ConfigManager
|
|
51
|
+
runtime: Any = None
|
|
52
|
+
ipc: Any = None
|
|
53
|
+
started_at: float = field(default_factory=time.time)
|
|
54
|
+
restarts: int = 0
|
|
55
|
+
stop_requested: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ProcProxyService:
|
|
59
|
+
"""服务主体:装配 Runtime + IPC,并处理生命周期。"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, config: Config, *, backend_name: str = "") -> None:
|
|
62
|
+
self.config = config
|
|
63
|
+
self.manager = ConfigManager(config)
|
|
64
|
+
self.backend_name = backend_name or config.service.backend
|
|
65
|
+
self._stop = threading.Event()
|
|
66
|
+
self._ctx: ServiceContext | None = None
|
|
67
|
+
self._threads: list[threading.Thread] = []
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------ 生命周期 --
|
|
70
|
+
def start(self) -> ServiceContext:
|
|
71
|
+
from ..backend import BackendUnavailable, make_backend
|
|
72
|
+
from ..runtime import Runtime
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
backend = make_backend(self.backend_name, config=self.config)
|
|
76
|
+
except BackendUnavailable as exc:
|
|
77
|
+
# 绝不静默直连,服务启动失败,并给出可操作提示
|
|
78
|
+
LOG.error(
|
|
79
|
+
"service_backend_unavailable",
|
|
80
|
+
backend=self.backend_name,
|
|
81
|
+
code=getattr(exc, "code", "E_BACKEND_UNAVAILABLE"),
|
|
82
|
+
error=str(exc),
|
|
83
|
+
)
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
runtime = Runtime(self.manager, backend)
|
|
87
|
+
runtime.start()
|
|
88
|
+
ipc = make_server(allow_sids=self._allowed_ipc_sids())
|
|
89
|
+
self._register_handlers(ipc, runtime)
|
|
90
|
+
ipc.start()
|
|
91
|
+
|
|
92
|
+
self._ctx = ServiceContext(manager=self.manager, runtime=runtime, ipc=ipc)
|
|
93
|
+
self._threads.append(
|
|
94
|
+
threading.Thread(target=self._watchdog, daemon=True, name="ppx-watchdog")
|
|
95
|
+
)
|
|
96
|
+
self._threads[-1].start()
|
|
97
|
+
LOG.info(
|
|
98
|
+
"service_started",
|
|
99
|
+
service=SERVICE_NAME,
|
|
100
|
+
backend=backend.name,
|
|
101
|
+
sink=f"{self.config.service.sink.host}:{self.config.service.sink.port}",
|
|
102
|
+
session_id=os.getpid(),
|
|
103
|
+
)
|
|
104
|
+
return self._ctx
|
|
105
|
+
|
|
106
|
+
def stop(self, *, reason: str = "stop") -> None:
|
|
107
|
+
self._stop.set()
|
|
108
|
+
ctx = self._ctx
|
|
109
|
+
if ctx is None:
|
|
110
|
+
return
|
|
111
|
+
if ctx.ipc is not None:
|
|
112
|
+
ctx.ipc.stop()
|
|
113
|
+
if ctx.runtime is not None:
|
|
114
|
+
ctx.runtime.stop(reason=reason)
|
|
115
|
+
LOG.info("service_stopped", reason=reason, uptime_s=round(time.time() - ctx.started_at, 1))
|
|
116
|
+
|
|
117
|
+
def serve_forever(self) -> None:
|
|
118
|
+
self._install_signal_handlers()
|
|
119
|
+
try:
|
|
120
|
+
while not self._stop.wait(1.0):
|
|
121
|
+
pass
|
|
122
|
+
finally:
|
|
123
|
+
self.stop(reason="signal")
|
|
124
|
+
|
|
125
|
+
def _install_signal_handlers(self) -> None:
|
|
126
|
+
def handler(signum: int, _frame: Any) -> None: # pragma: no cover - 信号路径
|
|
127
|
+
LOG.warn("service_signal", signum=signum)
|
|
128
|
+
self._stop.set()
|
|
129
|
+
|
|
130
|
+
for name in ("SIGINT", "SIGTERM"):
|
|
131
|
+
sig = getattr(signal, name, None)
|
|
132
|
+
if sig is None:
|
|
133
|
+
continue
|
|
134
|
+
with contextlib.suppress(ValueError, OSError): # pragma: no cover
|
|
135
|
+
signal.signal(sig, handler)
|
|
136
|
+
|
|
137
|
+
def _watchdog(self) -> None:
|
|
138
|
+
"""每 5s 检查后端是否还活着;后端失效 → 明确停止代理(不静默直连)。"""
|
|
139
|
+
ctx = self._ctx
|
|
140
|
+
if ctx is None: # pragma: no cover
|
|
141
|
+
return
|
|
142
|
+
while not self._stop.wait(5.0):
|
|
143
|
+
backend = getattr(ctx.runtime, "backend", None)
|
|
144
|
+
if backend is None:
|
|
145
|
+
continue
|
|
146
|
+
if not getattr(backend, "running", False):
|
|
147
|
+
LOG.error(
|
|
148
|
+
"backend_stopped_unexpectedly",
|
|
149
|
+
backend=getattr(backend, "name", "?"),
|
|
150
|
+
hint="后端句柄失效;不静默直连,服务将停止代理",
|
|
151
|
+
)
|
|
152
|
+
ctx.runtime.stop(reason="backend-lost")
|
|
153
|
+
self._stop.set()
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------ IPC ------
|
|
157
|
+
def _allowed_ipc_sids(self) -> tuple[str, ...]:
|
|
158
|
+
"""把 `service.ipc_allow_group` 解析成 SID 列表(解析不了就不放行)。
|
|
159
|
+
|
|
160
|
+
默认配置(空)→ 返回空 → 管道 ACL 保持"只允许管理员 + SYSTEM"。
|
|
161
|
+
配了组名但解析失败(组不存在 / 拼错 / 无权限查询)→ 记 ERROR 并返回空,
|
|
162
|
+
即退回管理员专属,而不是"配了但静默不放行"或更糟的"放宽成 Everyone"。
|
|
163
|
+
"""
|
|
164
|
+
group = getattr(self.config.service, "ipc_allow_group", "") or ""
|
|
165
|
+
if not group:
|
|
166
|
+
return ()
|
|
167
|
+
sid = resolve_group_sid(group)
|
|
168
|
+
if not sid:
|
|
169
|
+
LOG.error(
|
|
170
|
+
"ipc_allow_group_invalid",
|
|
171
|
+
group=group,
|
|
172
|
+
code="E_IPC_GROUP_UNRESOLVED",
|
|
173
|
+
hint="组名解析失败 → IPC 保持仅管理员可用(不放宽)",
|
|
174
|
+
)
|
|
175
|
+
return ()
|
|
176
|
+
LOG.warn(
|
|
177
|
+
"ipc_allow_group_active",
|
|
178
|
+
group=group,
|
|
179
|
+
sid=sid,
|
|
180
|
+
hint="该组的成员可连 IPC 读状态;reload/stop/panic 仍需管理员令牌",
|
|
181
|
+
)
|
|
182
|
+
return (sid,)
|
|
183
|
+
|
|
184
|
+
def _register_handlers(self, ipc: Any, runtime: Any) -> None:
|
|
185
|
+
def status(_request: IpcRequest) -> dict[str, Any]:
|
|
186
|
+
snapshot = runtime.snapshot()
|
|
187
|
+
ctx = self._ctx
|
|
188
|
+
return {
|
|
189
|
+
"service": SERVICE_NAME,
|
|
190
|
+
"pid": os.getpid(),
|
|
191
|
+
"backend": snapshot["backend"],
|
|
192
|
+
"rules": snapshot["rules"],
|
|
193
|
+
"sessions": snapshot["sessions"],
|
|
194
|
+
"breaker": snapshot["breaker"],
|
|
195
|
+
"uptime_s": round(time.time() - (ctx.started_at if ctx else time.time()), 1),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
def sessions(request: IpcRequest) -> dict[str, Any]:
|
|
199
|
+
limit = int(request.args.get("limit", 50))
|
|
200
|
+
rows = [s.to_dict() for s in runtime.sessions.all()][-limit:]
|
|
201
|
+
return {"sessions": rows, "active": runtime.sessions.count()}
|
|
202
|
+
|
|
203
|
+
def rules(_request: IpcRequest) -> dict[str, Any]:
|
|
204
|
+
profile = runtime.config.profile
|
|
205
|
+
return {
|
|
206
|
+
"profile": profile.name,
|
|
207
|
+
"rules": [r.to_dict() for r in profile.rules],
|
|
208
|
+
"upstreams": sorted(profile.upstreams),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
def reload(request: IpcRequest) -> dict[str, Any]:
|
|
212
|
+
path = str(request.args.get("path") or runtime.config.path)
|
|
213
|
+
try:
|
|
214
|
+
new = self.manager.reload(path)
|
|
215
|
+
except ConfigError as exc:
|
|
216
|
+
LOG.error("ipc_reload_rejected", code=exc.code, message=exc.message)
|
|
217
|
+
return {"ok": False, "error": f"[{exc.code}] {exc.message}"}
|
|
218
|
+
# 与文件监视器 / api.ProxyHandle.reload() 走同一个收口:
|
|
219
|
+
# DNS、UDP 策略、守卫、粘性迁移都必须一起换,否则重载只生效一半。
|
|
220
|
+
runtime.apply_profile()
|
|
221
|
+
LOG.info("ipc_reload_ok", rules=len(new.profile.rules), generation=self.manager.generation)
|
|
222
|
+
return {"generation": self.manager.generation, "rules": len(new.profile.rules)}
|
|
223
|
+
|
|
224
|
+
def stop(_request: IpcRequest) -> dict[str, Any]:
|
|
225
|
+
ctx = self._ctx
|
|
226
|
+
if ctx is not None:
|
|
227
|
+
ctx.stop_requested = True
|
|
228
|
+
threading.Thread(target=self.stop, kwargs={"reason": "ipc-stop"}, daemon=True).start()
|
|
229
|
+
return {"stopping": True}
|
|
230
|
+
|
|
231
|
+
def panic(_request: IpcRequest) -> dict[str, Any]:
|
|
232
|
+
"""紧急停止:尽快停掉数据面,恢复直连。
|
|
233
|
+
|
|
234
|
+
注意:WinDivert 后端没有"驱动层开关"可拨,它靠句柄存活,
|
|
235
|
+
进程一退句柄就释放、拦截随之失效。所以这里只需把服务停掉。
|
|
236
|
+
"""
|
|
237
|
+
LOG.warn("ipc_panic", hint="紧急停止:停掉数据面并退出")
|
|
238
|
+
threading.Thread(target=self.stop, kwargs={"reason": "panic"}, daemon=True).start()
|
|
239
|
+
return {"panic": True, "hint": "服务正在停止;进程退出后 WinDivert 句柄会自动释放"}
|
|
240
|
+
|
|
241
|
+
ipc.register(CMD_STATUS, status)
|
|
242
|
+
ipc.register(CMD_SESSIONS, sessions)
|
|
243
|
+
ipc.register(CMD_RULES, rules)
|
|
244
|
+
ipc.register(CMD_RELOAD, reload)
|
|
245
|
+
ipc.register(CMD_STOP, stop)
|
|
246
|
+
ipc.register(CMD_PANIC, panic)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------- 多用户 / Session 0 -
|
|
250
|
+
@dataclass(slots=True)
|
|
251
|
+
class ServiceProfileDecision:
|
|
252
|
+
"""`handle_services` / `handle_other_users` 的判定结果。"""
|
|
253
|
+
|
|
254
|
+
handled: bool
|
|
255
|
+
reason: str
|
|
256
|
+
profile_name: str
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def should_handle(
|
|
260
|
+
proc_session_id: int,
|
|
261
|
+
proc_user: str,
|
|
262
|
+
*,
|
|
263
|
+
service_session_id: int,
|
|
264
|
+
handle_services: bool,
|
|
265
|
+
handle_other_users: bool,
|
|
266
|
+
) -> ServiceProfileDecision:
|
|
267
|
+
"""决定一条"服务进程 / 其他用户"的连接要不要按本 Profile 处理。
|
|
268
|
+
|
|
269
|
+
对应 Windows 的 `ProcessServices` / `ProcessOtherUsers` 选项:
|
|
270
|
+
* Session 0 的进程 = 服务;`handle_services=false` 时不代理(只直连);
|
|
271
|
+
* 非本服务账户 = 其他用户;`handle_other_users=false` 时不代理。
|
|
272
|
+
"""
|
|
273
|
+
if proc_session_id == service_session_id == 0:
|
|
274
|
+
return ServiceProfileDecision(
|
|
275
|
+
handled=handle_services,
|
|
276
|
+
reason="service-session" if handle_services else "service-session-skipped",
|
|
277
|
+
profile_name="ServiceProfile" if handle_services else "default",
|
|
278
|
+
)
|
|
279
|
+
if proc_session_id < 0:
|
|
280
|
+
return ServiceProfileDecision(handled=False, reason="unknown-session", profile_name="default")
|
|
281
|
+
if proc_session_id == 0:
|
|
282
|
+
return ServiceProfileDecision(
|
|
283
|
+
handled=handle_services,
|
|
284
|
+
reason="session0" if handle_services else "session0-skipped",
|
|
285
|
+
profile_name="ServiceProfile" if handle_services else "default",
|
|
286
|
+
)
|
|
287
|
+
if not proc_user or proc_user.lower().endswith("system"):
|
|
288
|
+
return ServiceProfileDecision(
|
|
289
|
+
handled=handle_services,
|
|
290
|
+
reason="system-user" if handle_services else "system-user-skipped",
|
|
291
|
+
profile_name="ServiceProfile" if handle_services else "default",
|
|
292
|
+
)
|
|
293
|
+
return ServiceProfileDecision(
|
|
294
|
+
handled=handle_other_users,
|
|
295
|
+
reason="other-user" if handle_other_users else "other-user-skipped",
|
|
296
|
+
profile_name="UserProfile" if handle_other_users else "default",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------- Windows 服务封装 -------
|
|
301
|
+
def _write_service_crash(config_path: str, exc: BaseException) -> None:
|
|
302
|
+
"""把服务启动失败的完整 traceback 落盘。
|
|
303
|
+
|
|
304
|
+
服务在 Session 0 跑、没有控制台,而日志系统这时可能还没配好,异常就这么
|
|
305
|
+
凭空消失,外部只看到 SCM 报一句 `1066 服务特定错误`,完全不知道原因。
|
|
306
|
+
|
|
307
|
+
本函数把它写到 `<运行目录>/service-error.log`(`%ProgramData%\\ProcProxy\\`),
|
|
308
|
+
让"为什么起不来"变成可读的。
|
|
309
|
+
(同一个教训:失败必须写在可读的地方,不能只 DbgPrint。)
|
|
310
|
+
"""
|
|
311
|
+
import traceback
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
from ..cli import runtime_dir
|
|
315
|
+
|
|
316
|
+
path = runtime_dir() / "service-error.log"
|
|
317
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
319
|
+
fh.write(f"\n===== {time.strftime('%Y-%m-%d %H:%M:%S')} config={config_path} =====\n")
|
|
320
|
+
fh.write("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
try: # pragma: no cover - 只在 Windows + pywin32 下有意义
|
|
326
|
+
import win32service
|
|
327
|
+
import win32serviceutil
|
|
328
|
+
|
|
329
|
+
class ProcProxyServiceFramework(win32serviceutil.ServiceFramework):
|
|
330
|
+
"""SCM 宿主入口:`service install` 注册的就是这个类。
|
|
331
|
+
|
|
332
|
+
为什么必须有它:SCM 要求服务映像自己按 SCM 协议握手
|
|
333
|
+
(`StartServiceCtrlDispatcher` → `ReportServiceStatus(SERVICE_RUNNING)`)。
|
|
334
|
+
直接把 `python.exe -m proxycore.service run ...` 注册成服务是不行的
|
|
335
|
+
那是个前台程序、不握手,SCM 等约 30 秒后按 **1053"服务没有及时响应启动
|
|
336
|
+
或控制请求"**把它杀掉(本机实测过)。pywin32 的 `pythonservice.exe`
|
|
337
|
+
负责握手,再回调到本类。
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
_svc_name_ = SERVICE_NAME
|
|
341
|
+
_svc_display_name_ = SERVICE_DISPLAY
|
|
342
|
+
_svc_description_ = SERVICE_DESC
|
|
343
|
+
|
|
344
|
+
def __init__(self, args: Any) -> None:
|
|
345
|
+
win32serviceutil.ServiceFramework.__init__(self, args)
|
|
346
|
+
self._service: ProcProxyService | None = None
|
|
347
|
+
self._stop_requested = threading.Event()
|
|
348
|
+
|
|
349
|
+
def SvcStop(self) -> None:
|
|
350
|
+
# 不能在 SCM 的回调里阻塞:立刻进 STOP_PENDING,再把停止丢给线程
|
|
351
|
+
self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
|
|
352
|
+
self._stop_requested.set()
|
|
353
|
+
service = self._service
|
|
354
|
+
if service is not None:
|
|
355
|
+
threading.Thread(
|
|
356
|
+
target=service.stop, kwargs={"reason": "service-stop"}, daemon=True
|
|
357
|
+
).start()
|
|
358
|
+
|
|
359
|
+
def SvcDoRun(self) -> None:
|
|
360
|
+
config_path = str(
|
|
361
|
+
win32serviceutil.GetServiceCustomOption(SERVICE_NAME, "config") or ""
|
|
362
|
+
)
|
|
363
|
+
try:
|
|
364
|
+
config = Config.load(config_path)
|
|
365
|
+
build_logger(
|
|
366
|
+
"proxycore",
|
|
367
|
+
level=config.service.log_level,
|
|
368
|
+
jsonl_path=config.service.log_jsonl or None,
|
|
369
|
+
console=False, # 服务里没有控制台
|
|
370
|
+
)
|
|
371
|
+
service = ProcProxyService(config)
|
|
372
|
+
self._service = service
|
|
373
|
+
service.start()
|
|
374
|
+
self.ReportServiceStatus(win32service.SERVICE_RUNNING)
|
|
375
|
+
if self._stop_requested.is_set(): # 起的过程中就被要求停了
|
|
376
|
+
service.stop(reason="service-stop-before-run")
|
|
377
|
+
return
|
|
378
|
+
service.serve_forever()
|
|
379
|
+
except Exception as exc:
|
|
380
|
+
LOG.error("service_framework_failed", config=config_path, error=str(exc))
|
|
381
|
+
_write_service_crash(config_path, exc)
|
|
382
|
+
# 必须显式告诉 SCM 失败了,不报的话它会一直以为还在启动,
|
|
383
|
+
# 最后仍以 1053 收场,而真正的错误反而看不到。
|
|
384
|
+
self.ReportServiceStatus(win32service.SERVICE_STOPPED, win32ExitCode=1066)
|
|
385
|
+
except ImportError: # pragma: no cover - 没装 pywin32
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def install_windows_service(*, python_exe: str = "", config_path: str = "") -> tuple[bool, str]:
|
|
390
|
+
"""注册 Windows 服务(需要管理员;用 `pywin32`)。
|
|
391
|
+
|
|
392
|
+
第一个参数是 `pythonClassString`(pywin32 的位置参数),注册的是
|
|
393
|
+
`ProcProxyServiceFramework`;由 `pythonservice.exe` 承载 SCM 握手。
|
|
394
|
+
原先写的是 `pythonClassString=None` + `exeName=python.exe` + `exeArgs='-m ... run'`
|
|
395
|
+
,那等于把前台运行器注册成服务,`sc start` 必返 1053。
|
|
396
|
+
"""
|
|
397
|
+
if not IS_WINDOWS:
|
|
398
|
+
return False, "仅 Windows 支持"
|
|
399
|
+
try:
|
|
400
|
+
import win32service
|
|
401
|
+
import win32serviceutil
|
|
402
|
+
except ImportError:
|
|
403
|
+
return False, "需要 pywin32(pip install pywin32)"
|
|
404
|
+
target = os.path.abspath(config_path or "samples/profiles/dev.yaml")
|
|
405
|
+
if not os.path.isfile(target):
|
|
406
|
+
return False, f"配置文件不存在:{target}"
|
|
407
|
+
try:
|
|
408
|
+
win32serviceutil.InstallService(
|
|
409
|
+
"proxycore.service.ProcProxyServiceFramework",
|
|
410
|
+
SERVICE_NAME,
|
|
411
|
+
SERVICE_DISPLAY,
|
|
412
|
+
# `SERVICE_DEMAND_START` 在 `win32service` 里,不在 `win32serviceutil`
|
|
413
|
+
# (原先写的是后者 → AttributeError,即安装路径从来没跑通过)
|
|
414
|
+
startType=win32service.SERVICE_DEMAND_START,
|
|
415
|
+
description=SERVICE_DESC,
|
|
416
|
+
)
|
|
417
|
+
# 配置路径走注册表:pythonservice.exe 只从注册表读 `PythonClass`,
|
|
418
|
+
# 额外参数用 CustomOption 带最稳(不依赖命令行/argv 的传递约定)。
|
|
419
|
+
win32serviceutil.SetServiceCustomOption(SERVICE_NAME, "config", target)
|
|
420
|
+
except Exception as exc:
|
|
421
|
+
return False, f"安装失败:{exc}(是否以管理员运行?)"
|
|
422
|
+
return True, f"已注册服务 {SERVICE_NAME}(配置:{target})"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def uninstall_windows_service() -> tuple[bool, str]:
|
|
426
|
+
if not IS_WINDOWS:
|
|
427
|
+
return False, "仅 Windows 支持"
|
|
428
|
+
try:
|
|
429
|
+
import win32serviceutil
|
|
430
|
+
except ImportError:
|
|
431
|
+
return False, "需要 pywin32"
|
|
432
|
+
try:
|
|
433
|
+
win32serviceutil.RemoveService(SERVICE_NAME)
|
|
434
|
+
except Exception as exc:
|
|
435
|
+
return False, f"卸载失败:{exc}"
|
|
436
|
+
return True, f"已删除服务 {SERVICE_NAME}"
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def service_status() -> tuple[bool, str]:
|
|
440
|
+
"""查询服务状态(不依赖 pywin32:直接用 `sc query`)。"""
|
|
441
|
+
if not IS_WINDOWS:
|
|
442
|
+
return False, "仅 Windows 支持"
|
|
443
|
+
import subprocess
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
result = subprocess.run(
|
|
447
|
+
["sc", "query", SERVICE_NAME],
|
|
448
|
+
capture_output=True,
|
|
449
|
+
text=True,
|
|
450
|
+
timeout=5,
|
|
451
|
+
check=False,
|
|
452
|
+
)
|
|
453
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
454
|
+
return False, f"查询失败:{exc}"
|
|
455
|
+
out = (result.stdout or "") + (result.stderr or "")
|
|
456
|
+
running = "RUNNING" in out.upper()
|
|
457
|
+
return running, out.strip().splitlines()[0] if out.strip() else "无输出"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
SYSTEMD_UNIT = """\
|
|
461
|
+
[Unit]
|
|
462
|
+
Description={desc}
|
|
463
|
+
After=network-online.target
|
|
464
|
+
Wants=network-online.target
|
|
465
|
+
|
|
466
|
+
[Service]
|
|
467
|
+
Type=simple
|
|
468
|
+
ExecStart={python} -m proxycore.service run --config {config}
|
|
469
|
+
Restart=on-failure
|
|
470
|
+
RestartSec=2
|
|
471
|
+
NoNewPrivileges=true
|
|
472
|
+
PrivateTmp=true
|
|
473
|
+
|
|
474
|
+
[Install]
|
|
475
|
+
WantedBy=multi-user.target
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def render_systemd_unit(*, python_exe: str = "", config_path: str = "") -> str:
|
|
480
|
+
return SYSTEMD_UNIT.format(
|
|
481
|
+
desc=SERVICE_DESC,
|
|
482
|
+
python=python_exe or sys.executable,
|
|
483
|
+
config=config_path or "/etc/procproxy/config.yaml",
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def write_systemd_unit(path: str = "/etc/systemd/system/procproxy.service", **kwargs: Any) -> tuple[bool, str]:
|
|
488
|
+
if not IS_LINUX:
|
|
489
|
+
return False, "仅 Linux 支持"
|
|
490
|
+
try:
|
|
491
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
492
|
+
handle.write(render_systemd_unit(**kwargs))
|
|
493
|
+
except OSError as exc:
|
|
494
|
+
return False, f"写入失败:{exc}(是否 root?)"
|
|
495
|
+
return True, f"已写入 {path};执行 `systemctl daemon-reload && systemctl enable --now procproxy`"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ------------------------------------------------------------- 入口 --------
|
|
499
|
+
def run_service(config_path: str, *, backend: str = "", log_jsonl: str = "") -> int:
|
|
500
|
+
try:
|
|
501
|
+
config = Config.load(config_path)
|
|
502
|
+
except ConfigError as exc:
|
|
503
|
+
print(f"配置无效:[{exc.code}] {exc.message}", file=sys.stderr)
|
|
504
|
+
return 2
|
|
505
|
+
build_logger(
|
|
506
|
+
"proxycore",
|
|
507
|
+
level=config.service.log_level,
|
|
508
|
+
jsonl_path=log_jsonl or config.service.log_jsonl or None,
|
|
509
|
+
console=True,
|
|
510
|
+
)
|
|
511
|
+
service = ProcProxyService(config, backend_name=backend)
|
|
512
|
+
try:
|
|
513
|
+
service.start()
|
|
514
|
+
except Exception as exc:
|
|
515
|
+
print(f"服务启动失败:{exc}", file=sys.stderr)
|
|
516
|
+
return 2
|
|
517
|
+
service.serve_forever()
|
|
518
|
+
return 0
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
__all__ = [
|
|
522
|
+
"SERVICE_DESC",
|
|
523
|
+
"SERVICE_DISPLAY",
|
|
524
|
+
"SERVICE_NAME",
|
|
525
|
+
"ProcProxyService",
|
|
526
|
+
"ProcProxyServiceFramework",
|
|
527
|
+
"ServiceContext",
|
|
528
|
+
"ServiceProfileDecision",
|
|
529
|
+
"install_windows_service",
|
|
530
|
+
"render_systemd_unit",
|
|
531
|
+
"run_service",
|
|
532
|
+
"service_status",
|
|
533
|
+
"should_handle",
|
|
534
|
+
"uninstall_windows_service",
|
|
535
|
+
"write_systemd_unit",
|
|
536
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""`python -m proxycore.service` 入口(Windows 服务 / systemd / 前台)。
|
|
2
|
+
|
|
3
|
+
用法:
|
|
4
|
+
python -m proxycore.service run --config samples/profiles/dev.yaml
|
|
5
|
+
python -m proxycore.service install --config samples/profiles/dev.yaml # Windows
|
|
6
|
+
python -m proxycore.service status
|
|
7
|
+
python -m proxycore.service uninstall
|
|
8
|
+
python -m proxycore.service unit --config /etc/procproxy/config.yaml # Linux systemd
|
|
9
|
+
python -m proxycore.service ipc-status # 通过 IPC 查询
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from proxycore.ipc import CMD_PANIC, CMD_RELOAD, CMD_STATUS, IpcClient
|
|
18
|
+
from proxycore.service import (
|
|
19
|
+
SERVICE_NAME,
|
|
20
|
+
install_windows_service,
|
|
21
|
+
run_service,
|
|
22
|
+
service_status,
|
|
23
|
+
uninstall_windows_service,
|
|
24
|
+
write_systemd_unit,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_ipc_group(config_path: str) -> None:
|
|
29
|
+
"""装之前先校验一遍 `service.ipc_allow_group`。
|
|
30
|
+
|
|
31
|
+
组名写错要当场报出来,而不是等服务启动后去日志里翻,那时表现是
|
|
32
|
+
"IPC 仍然只有管理员能连",很容易被当成"配了没生效"。
|
|
33
|
+
空值 = 只允许管理员(默认行为),此时不打印任何东西。
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
from proxycore.config import Config
|
|
37
|
+
|
|
38
|
+
config = Config.load(config_path)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
print(f"[!] 读配置失败,跳过 IPC 放行组校验:{exc}", file=sys.stderr)
|
|
41
|
+
return
|
|
42
|
+
group = getattr(config.service, "ipc_allow_group", "") or ""
|
|
43
|
+
if not group:
|
|
44
|
+
return
|
|
45
|
+
from proxycore.ipc import resolve_group_sid
|
|
46
|
+
|
|
47
|
+
sid = resolve_group_sid(group)
|
|
48
|
+
if sid:
|
|
49
|
+
print(f"[i] IPC 放行组:{group} → {sid}")
|
|
50
|
+
print(" 该组成员可读状态;reload / stop / panic 仍需管理员令牌。")
|
|
51
|
+
else:
|
|
52
|
+
print(
|
|
53
|
+
f"[!] IPC 放行组 {group!r} 解析失败(组不存在?)→ 服务将只允许管理员连接",
|
|
54
|
+
file=sys.stderr,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(argv: list[str] | None = None) -> int:
|
|
59
|
+
parser = argparse.ArgumentParser(prog="proxycore.service", description="ProcProxy 服务管理")
|
|
60
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
61
|
+
|
|
62
|
+
p_run = sub.add_parser("run", help="前台运行服务(调试/容器用)")
|
|
63
|
+
p_run.add_argument("--config", default="samples/profiles/dev.yaml")
|
|
64
|
+
p_run.add_argument("--backend", default="", choices=["", "null", "windivert"])
|
|
65
|
+
p_run.add_argument("--log-jsonl", default="")
|
|
66
|
+
|
|
67
|
+
p_install = sub.add_parser("install", help="注册 Windows 服务(需管理员)")
|
|
68
|
+
p_install.add_argument("--config", default="samples/profiles/dev.yaml")
|
|
69
|
+
|
|
70
|
+
sub.add_parser("uninstall", help="删除 Windows 服务(需管理员)")
|
|
71
|
+
sub.add_parser("status", help="查询服务状态")
|
|
72
|
+
|
|
73
|
+
p_unit = sub.add_parser("unit", help="生成 Linux systemd 单元")
|
|
74
|
+
p_unit.add_argument("--config", default="/etc/procproxy/config.yaml")
|
|
75
|
+
p_unit.add_argument("--write", action="store_true", help="直接写入 /etc/systemd/system/")
|
|
76
|
+
|
|
77
|
+
p_ipc = sub.add_parser("ipc-status", help="通过 IPC 查询运行中的服务")
|
|
78
|
+
p_ipc.add_argument("--cmd", default=CMD_STATUS, choices=[CMD_STATUS, CMD_RELOAD, CMD_PANIC])
|
|
79
|
+
p_ipc.add_argument("--path", default="")
|
|
80
|
+
|
|
81
|
+
args = parser.parse_args(argv)
|
|
82
|
+
|
|
83
|
+
if args.command == "run":
|
|
84
|
+
return run_service(args.config, backend=args.backend, log_jsonl=args.log_jsonl)
|
|
85
|
+
|
|
86
|
+
if args.command == "install":
|
|
87
|
+
_check_ipc_group(args.config)
|
|
88
|
+
ok, detail = install_windows_service(config_path=args.config)
|
|
89
|
+
print(detail)
|
|
90
|
+
return 0 if ok else 1
|
|
91
|
+
|
|
92
|
+
if args.command == "uninstall":
|
|
93
|
+
ok, detail = uninstall_windows_service()
|
|
94
|
+
print(detail)
|
|
95
|
+
return 0 if ok else 1
|
|
96
|
+
|
|
97
|
+
if args.command == "status":
|
|
98
|
+
running, detail = service_status()
|
|
99
|
+
print(f"{SERVICE_NAME}: {'RUNNING' if running else 'NOT RUNNING'}")
|
|
100
|
+
print(detail)
|
|
101
|
+
return 0 if running else 1
|
|
102
|
+
|
|
103
|
+
if args.command == "unit":
|
|
104
|
+
if args.write:
|
|
105
|
+
ok, detail = write_systemd_unit(config_path=args.config)
|
|
106
|
+
print(detail)
|
|
107
|
+
return 0 if ok else 1
|
|
108
|
+
from proxycore.service import render_systemd_unit
|
|
109
|
+
|
|
110
|
+
print(render_systemd_unit(config_path=args.config))
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
if args.command == "ipc-status":
|
|
114
|
+
result = IpcClient(path=args.path).call(args.cmd)
|
|
115
|
+
import json
|
|
116
|
+
|
|
117
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
118
|
+
return 0 if result.get("ok") else 1
|
|
119
|
+
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__": # pragma: no cover
|
|
124
|
+
sys.exit(main())
|