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
@@ -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())
@@ -0,0 +1,8 @@
1
+ """tunnel 层:sink(本地拦截端口)、relay(双向转发)、sessions(会话表)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .relay import relay_bidirectional
6
+ from .sessions import SessionTable
7
+
8
+ __all__ = ["SessionTable", "relay_bidirectional"]