procproxy 0.1.4__tar.gz → 0.1.4.2__tar.gz

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 (63) hide show
  1. {procproxy-0.1.4/src/procproxy.egg-info → procproxy-0.1.4.2}/PKG-INFO +24 -3
  2. {procproxy-0.1.4 → procproxy-0.1.4.2}/README.md +23 -2
  3. {procproxy-0.1.4 → procproxy-0.1.4.2}/docs/11-/350/260/203/350/257/225/346/216/222/351/224/231/346/211/213/345/206/214.md +10 -0
  4. {procproxy-0.1.4 → procproxy-0.1.4.2}/docs/15-/347/274/226/347/250/213/346/216/245/345/217/243/344/270/216SDK.md +151 -5
  5. {procproxy-0.1.4 → procproxy-0.1.4.2}/examples/verify_sdk.py +22 -0
  6. {procproxy-0.1.4 → procproxy-0.1.4.2}/pyproject.toml +1 -1
  7. {procproxy-0.1.4 → procproxy-0.1.4.2/src/procproxy.egg-info}/PKG-INFO +24 -3
  8. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/procproxy.egg-info/SOURCES.txt +2 -0
  9. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/__init__.py +1 -1
  10. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/api.py +54 -16
  11. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/__init__.py +17 -15
  12. procproxy-0.1.4.2/src/proxycore/background.py +461 -0
  13. procproxy-0.1.4.2/src/proxycore/errors.py +20 -0
  14. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/runtime.py +2 -0
  15. {procproxy-0.1.4 → procproxy-0.1.4.2}/LICENSE +0 -0
  16. {procproxy-0.1.4 → procproxy-0.1.4.2}/MANIFEST.in +0 -0
  17. {procproxy-0.1.4 → procproxy-0.1.4.2}/docs/00-/346/236/266/346/236/204/344/270/216/351/230/205/350/257/273/350/267/257/347/272/277.md" +0 -0
  18. {procproxy-0.1.4 → procproxy-0.1.4.2}/docs/07-/350/247/204/345/210/231/345/274/225/346/223/216/344/270/216Profile/346/240/274/345/274/217.md" +0 -0
  19. {procproxy-0.1.4 → procproxy-0.1.4.2}/docs/14-/346/212/212/350/277/233/347/250/213/346/265/201/351/207/217/350/275/254/345/217/221/347/273/231/346/212/223/345/214/205/345/267/245/345/205/267.md" +0 -0
  20. {procproxy-0.1.4 → procproxy-0.1.4.2}/examples/proxy_process.py +0 -0
  21. {procproxy-0.1.4 → procproxy-0.1.4.2}/setup.cfg +0 -0
  22. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/procproxy.egg-info/dependency_links.txt +0 -0
  23. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/procproxy.egg-info/entry_points.txt +0 -0
  24. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/procproxy.egg-info/requires.txt +0 -0
  25. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/procproxy.egg-info/top_level.txt +0 -0
  26. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/__main__.py +0 -0
  27. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/__init__.py +0 -0
  28. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/base.py +0 -0
  29. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/null.py +0 -0
  30. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/filter.py +0 -0
  31. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/pidmap.py +0 -0
  32. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/pkt.py +0 -0
  33. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/canproxy.py +0 -0
  34. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/checkroute.py +0 -0
  35. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/cli.py +0 -0
  36. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/config.py +0 -0
  37. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/config_init.py +0 -0
  38. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/config_source.py +0 -0
  39. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/data/default.yaml +0 -0
  40. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/dns/__init__.py +0 -0
  41. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/dns/cache.py +0 -0
  42. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/dns/modes.py +0 -0
  43. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/dns/udp.py +0 -0
  44. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/forward.py +0 -0
  45. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/ipc.py +0 -0
  46. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/log.py +0 -0
  47. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/pathsetup.py +0 -0
  48. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/probe.py +0 -0
  49. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/proc.py +0 -0
  50. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/rules.py +0 -0
  51. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/service/__init__.py +0 -0
  52. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/service/__main__.py +0 -0
  53. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/tunnel/__init__.py +0 -0
  54. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/tunnel/relay.py +0 -0
  55. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/tunnel/sessions.py +0 -0
  56. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/tunnel/sink.py +0 -0
  57. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/types.py +0 -0
  58. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/upstream/__init__.py +0 -0
  59. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/upstream/base.py +0 -0
  60. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/upstream/chain.py +0 -0
  61. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/upstream/http_connect.py +0 -0
  62. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/upstream/socks4.py +0 -0
  63. {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/upstream/socks5.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: procproxy
3
- Version: 0.1.4
3
+ Version: 0.1.4.2
4
4
  Summary: ProcProxy —— 进程级代理:按进程决定哪条连接走哪个上游代理
5
5
  Author: Longersking
6
6
  License-Expression: MIT
@@ -507,6 +507,24 @@ finally:
507
507
  `start_proxy()` 会启动后台维护线程;`run_forever()` 会阻塞到 Ctrl+C。
508
508
  请在主线程使用,并在退出时 `stop()`(或使用 `with`)。
509
509
 
510
+ ### 后台常驻(父进程可退出)
511
+
512
+ ```python
513
+ from proxycore.api import get_status, start_proxy, stop_proxy
514
+
515
+ bg = start_proxy("D:/cfg/chrome.yaml", background=True)
516
+ print(bg.pid, bg.config_path, bg.runtime_dir)
517
+ # 父进程可以退出;独立子进程继续运行
518
+
519
+ print(get_status(bg.runtime_dir)) # 跨进程读 state.json
520
+ print(stop_proxy(bg.runtime_dir)) # 写 control.json 并等待退出
521
+ ```
522
+
523
+ - 状态目录默认 `~/.procproxy-app`(可用 `runtime_dir=` 或 `PROCPROXY_RUNTIME_DIR` 覆盖)
524
+ - 文件协议:`state.json`(数据面写)、`control.json`(应用写 `stop`/`panic`)
525
+ - `start_proxy(background=True, replace=True)` 可替换同目录下已在跑的数据面
526
+ - 子进程继承父进程权限;`backend="windivert"` 仍需管理员启动
527
+
510
528
  ### 错误处理
511
529
 
512
530
  配置 / 后端 / 启动失败统一抛 `ProxyStartError`,`code` 是稳定的机器可读标识:
@@ -528,6 +546,9 @@ except ProxyStartError as exc:
528
546
  | `config_version_unsupported` | `version != 1` |
529
547
  | `rule_upstream_missing` 等 | 规则或上游校验失败(完整列表见文档) |
530
548
  | `E_BACKEND_UNAVAILABLE` | 后端不可用(未知后端、WinDivert 不可加载等) |
549
+ | `already_running` | 后台模式同一 runtime_dir 已有存活数据面 |
550
+ | `background_start_failed` / `background_start_timeout` | 后台子进程启动失败 / 超时 |
551
+ | `runtime_dir_unavailable` | 后台状态目录无法创建或写入 |
531
552
 
532
553
  `api.load_config()` 同样抛 `ProxyStartError`,不是 `ConfigError`。
533
554
 
@@ -590,7 +611,7 @@ src/proxycore/ 核心包
590
611
  samples/ mock 服务 + 示例配置
591
612
  examples/ 纯 Python 示例(proxy_process.py / verify_sdk.py)
592
613
  docs/ 文档(00 架构 / 07 规则 / 11 排错 / 14 抓包 / 15 SDK)
593
- tests/ 测试套件(624 passed / 4 skipped)
614
+ tests/ 测试套件(633 passed / 4 skipped)
594
615
  └── tools/ 验证脚本(run_demo / run_e2e / bench / …)
595
616
  ```
596
617
 
@@ -621,7 +642,7 @@ tests/ 测试套件(624 passed / 4 skipped)
621
642
 
622
643
  ```bash
623
644
  pip install -e ".[dev]"
624
- python -m pytest -q tests/ # 624 passed / 4 skipped(非管理员 Windows)
645
+ python -m pytest -q tests/ # 633 passed / 4 skipped(非管理员 Windows)
625
646
  python -m ruff check src tests
626
647
  python -m mypy src/proxycore
627
648
  ```
@@ -468,6 +468,24 @@ finally:
468
468
  `start_proxy()` 会启动后台维护线程;`run_forever()` 会阻塞到 Ctrl+C。
469
469
  请在主线程使用,并在退出时 `stop()`(或使用 `with`)。
470
470
 
471
+ ### 后台常驻(父进程可退出)
472
+
473
+ ```python
474
+ from proxycore.api import get_status, start_proxy, stop_proxy
475
+
476
+ bg = start_proxy("D:/cfg/chrome.yaml", background=True)
477
+ print(bg.pid, bg.config_path, bg.runtime_dir)
478
+ # 父进程可以退出;独立子进程继续运行
479
+
480
+ print(get_status(bg.runtime_dir)) # 跨进程读 state.json
481
+ print(stop_proxy(bg.runtime_dir)) # 写 control.json 并等待退出
482
+ ```
483
+
484
+ - 状态目录默认 `~/.procproxy-app`(可用 `runtime_dir=` 或 `PROCPROXY_RUNTIME_DIR` 覆盖)
485
+ - 文件协议:`state.json`(数据面写)、`control.json`(应用写 `stop`/`panic`)
486
+ - `start_proxy(background=True, replace=True)` 可替换同目录下已在跑的数据面
487
+ - 子进程继承父进程权限;`backend="windivert"` 仍需管理员启动
488
+
471
489
  ### 错误处理
472
490
 
473
491
  配置 / 后端 / 启动失败统一抛 `ProxyStartError`,`code` 是稳定的机器可读标识:
@@ -489,6 +507,9 @@ except ProxyStartError as exc:
489
507
  | `config_version_unsupported` | `version != 1` |
490
508
  | `rule_upstream_missing` 等 | 规则或上游校验失败(完整列表见文档) |
491
509
  | `E_BACKEND_UNAVAILABLE` | 后端不可用(未知后端、WinDivert 不可加载等) |
510
+ | `already_running` | 后台模式同一 runtime_dir 已有存活数据面 |
511
+ | `background_start_failed` / `background_start_timeout` | 后台子进程启动失败 / 超时 |
512
+ | `runtime_dir_unavailable` | 后台状态目录无法创建或写入 |
492
513
 
493
514
  `api.load_config()` 同样抛 `ProxyStartError`,不是 `ConfigError`。
494
515
 
@@ -551,7 +572,7 @@ src/proxycore/ 核心包
551
572
  samples/ mock 服务 + 示例配置
552
573
  examples/ 纯 Python 示例(proxy_process.py / verify_sdk.py)
553
574
  docs/ 文档(00 架构 / 07 规则 / 11 排错 / 14 抓包 / 15 SDK)
554
- tests/ 测试套件(624 passed / 4 skipped)
575
+ tests/ 测试套件(633 passed / 4 skipped)
555
576
  └── tools/ 验证脚本(run_demo / run_e2e / bench / …)
556
577
  ```
557
578
 
@@ -582,7 +603,7 @@ tests/ 测试套件(624 passed / 4 skipped)
582
603
 
583
604
  ```bash
584
605
  pip install -e ".[dev]"
585
- python -m pytest -q tests/ # 624 passed / 4 skipped(非管理员 Windows)
606
+ python -m pytest -q tests/ # 633 passed / 4 skipped(非管理员 Windows)
586
607
  python -m ruff check src tests
587
608
  python -m mypy src/proxycore
588
609
  ```
@@ -100,6 +100,16 @@ sc stop WinDivert # 极端情况:直接卸掉 WinDive
100
100
 
101
101
  > `version != 1` 仍然报 `config_version_unsupported`,与读取错误分开。
102
102
 
103
+ ### 2.9 升级 pydivert 4.0.0 后,改道成功但连接卡在 SYN_SENT
104
+
105
+ | 项 | 内容 |
106
+ |---|---|
107
+ | 症状 | 日志有 `redirect`,但应用一直 SYN_SENT、curl 超时、sink/Yakit 收不到包;上游 0 条 CONNECT |
108
+ | 根因 | pydivert 4.0.0 的 `send()` 默认调用 `recalculate_checksums()`,后者在 Windows 上先执行 `_populate_wd_addr()`,按**构造期缓存**重建 `WINDIVERT_ADDRESS`。旧实现先构造 `Packet` 再直接改 `wd_addr.Loopback/IfIdx`,重建时被清零 → 回环 SYN 被内核丢弃。3.1.3 不做这次重建,所以 3.x 正常 |
109
+ | 修复 | 0.1.4.1 起:`Loopback` / `IfIdx` / `direction` 通过 `Packet(...)` 构造参数传入,并且 `send(..., recalculate_checksum=False)`(校验和已由 `pkt.py` 算好);同时兼容 3.x / 4.x |
110
+ | 快速确认 | `python -c "import pydivert; print(pydivert.__version__)"`;4.x 且有上述症状就升级 proxycore |
111
+ | 临时绕过 | 不要长期 monkey patch;可在验证阶段把 pydivert 固定到 `3.1.3`,或直接使用含修复的 proxycore |
112
+
103
113
  ## 3. 抓包与验证技巧
104
114
 
105
115
  ```powershell
@@ -5,8 +5,8 @@
5
5
  > 发行包名是 `procproxy`,导入名是 `proxycore`。`pip install procproxy` 同时安装
6
6
  > 库和可选的 CLI;CLI 只是库的外壳,不使用它也能完成初始化、代理、查询和停止。
7
7
 
8
- 本文既是使用教程,也是**行为契约**。对应 `proxycore 0.1.4`;§10 的验收脚本
9
- `examples/verify_sdk.py`(25 项断言)可以直接用来检查一份实现是否与本文一致。
8
+ 本文既是使用教程,也是**行为契约**。对应 `proxycore 0.1.4.2`;§10 的验收脚本
9
+ `examples/verify_sdk.py`(31 项断言,含后台进程)可以直接用来检查一份实现是否与本文一致。
10
10
 
11
11
  ---
12
12
 
@@ -429,7 +429,111 @@ ppx.stop() # 或在 with 退出时自动停止
429
429
 
430
430
  停止后不再捕获新连接;已经建立的连接由关闭流程处理。
431
431
 
432
- ### 6.7 没有管理员怎么办
432
+ ### 6.7 后台常驻:父进程退出后继续运行
433
+
434
+ 适合"应用只负责拉起数据面,随后退出"的场景。
435
+
436
+ ```python
437
+ from proxycore.api import get_status, start_proxy, stop_proxy
438
+
439
+ bg = start_proxy("D:/cfg/chrome.yaml", background=True)
440
+ print(bg.pid) # 后台数据面 PID
441
+ print(bg.config_path) # 生效配置的绝对路径
442
+ print(bg.runtime_dir) # state.json / control.json 所在目录
443
+
444
+ # 父进程可以退出;数据面继续运行
445
+ state = get_status(bg.runtime_dir) # 读 state.json(跨进程、只读文件)
446
+ result = stop_proxy(bg.runtime_dir) # 写 control.json,等待子进程退出
447
+ print(result.to_dict())
448
+ ```
449
+
450
+ `start_proxy(background=True)` 的完整签名(其余参数与前台一致):
451
+
452
+ ```python
453
+ def start_proxy(
454
+ config=None,
455
+ *,
456
+ backend="",
457
+ sink_test_target="",
458
+ upstream="",
459
+ log_level="",
460
+ log_jsonl="",
461
+ background: bool = False,
462
+ runtime_dir: str | os.PathLike[str] | None = None,
463
+ wait_timeout_s: float = 10.0,
464
+ replace: bool = False,
465
+ ) -> ProxyHandle | BackgroundProxy: ...
466
+ ```
467
+
468
+ 返回的 `BackgroundProxy`:
469
+
470
+ | 字段 | 类型 | 说明 |
471
+ |---|---|---|
472
+ | `pid` | `int` | 后台数据面进程 PID |
473
+ | `config_path` | `str` | 子进程实际加载的配置绝对路径 |
474
+ | `runtime_dir` | `str` | 状态/控制文件目录 |
475
+ | `state_path` | `str` | `<runtime_dir>/state.json` |
476
+ | `control_path` | `str` | `<runtime_dir>/control.json` |
477
+ | `log_path` | `str` | `<runtime_dir>/daemon.log`(子进程 stdout/stderr) |
478
+ | `backend` | `str` | `null` / `windivert` |
479
+ | `started_at` | `float` | 启动时间戳 |
480
+
481
+ `BackgroundProxy.to_dict()` 返回上述字段的字典。
482
+
483
+ #### 文件协议(与 CLI 完全一致)
484
+
485
+ | 文件 | 方向 | 内容 |
486
+ |---|---|---|
487
+ | `state.json` | 数据面 → 应用 | 周期性状态;含 `pid`、`backend`、`started_at`、`config`、会话/计数等 |
488
+ | `control.json` | 应用 → 数据面 | `{"action": "stop"}` 或 `{"action": "panic", "ts": ...}`;数据面 1 秒内消费并删除 |
489
+ | `sessions.jsonl` | 数据面 → 应用 | 会话日志(CLI 默认路径) |
490
+ | `daemon.log` | 数据面 → 应用 | 子进程 stdout/stderr,启动失败时看这里 |
491
+
492
+ `runtime_dir` 解析顺序:
493
+
494
+ 1. `start_proxy(..., runtime_dir=...)` 显式参数;
495
+ 2. 环境变量 `PROCPROXY_RUNTIME_DIR`;
496
+ 3. `~/.procproxy-app`。
497
+
498
+ 子进程启动时会被显式设置 `PROCPROXY_RUNTIME_DIR=<解析后的绝对路径>`,
499
+ 所以父进程和应用侧的 `get_status` / `stop_proxy` 只要使用同一个目录即可。
500
+
501
+ #### 行为细节
502
+
503
+ - **父进程退出不影响子进程**:Windows 使用 `DETACHED_PROCESS`,POSIX 使用
504
+ `start_new_session=True`;子进程 stdout/stderr 重定向到 `daemon.log`;
505
+ - **配置对象会被落盘**:传 `Config` 对象时写到 `<runtime_dir>/config.yaml`,
506
+ 再由子进程加载;传路径时直接使用该绝对路径;
507
+ - **重复启动**:同一 `runtime_dir` 已有存活数据面时抛
508
+ `ProxyStartError(code="already_running")`;`replace=True` 会先停止旧进程;
509
+ - **启动失败**:子进程退出或超时分别抛 `background_start_failed` /
510
+ `background_start_timeout`,异常文本附带 `daemon.log` 最后 20 行;
511
+ - **权限**:子进程继承父进程权限;`backend="windivert"` 仍然要求父进程以管理员启动;
512
+ - **停止不强制杀进程**:`stop_proxy` 只写控制文件并等待;超时返回
513
+ `StopResult(stopped=False, reason="timeout")`,不会静默杀掉别人的进程。
514
+
515
+ #### 跨进程查询/停止的独立函数
516
+
517
+ ```python
518
+ from proxycore.api import get_status, is_running, resolve_runtime_dir, stop_proxy
519
+
520
+ get_status(runtime_dir=None) -> dict | None # 读 state.json;不存在/非法返回 None
521
+ is_running(runtime_dir=None) -> bool # state.json 的 pid 是否存活
522
+ stop_proxy(runtime_dir=None, *, timeout_s=10.0) -> StopResult
523
+ resolve_runtime_dir(runtime_dir=None) -> Path # 显式 → 环境变量 → ~/.procproxy-app
524
+ ```
525
+
526
+ `StopResult` 字段:`stopped: bool`、`pid: int`、`runtime_dir: str`、
527
+ `control_path: str`、`reason: str`(`stopped` / `not_running` / `timeout`)。
528
+
529
+ > 你现有的 `get_status` / `stop_proxy` 只要使用同一个 `runtime_dir` 和上述
530
+ > 文件名,就与 SDK 完全兼容;不需要改 state.json / control.json 的格式。
531
+ >
532
+ > CLI 的 `procproxy stop` 使用 CLI 自己的 runtime_dir;要停止 SDK 后台数据面,
533
+ > 请设置 `PROCPROXY_RUNTIME_DIR=<runtime_dir>` 后再执行,或直接调用
534
+ > `proxycore.api.stop_proxy(runtime_dir)`。
535
+
536
+ ### 6.8 没有管理员怎么办
433
537
 
434
538
  只能做联调:
435
539
 
@@ -629,6 +733,41 @@ class ProxyStartError(RuntimeError):
629
733
 
630
734
  `str(exc)` 是给人看的中文信息;程序判断请用 `exc.code`。
631
735
 
736
+ ### 7.8 后台常驻 API
737
+
738
+ ```python
739
+ def start_proxy_background(
740
+ config: Config | str | os.PathLike[str] | None = None,
741
+ *,
742
+ backend: str = "",
743
+ sink_test_target: str = "",
744
+ upstream: str = "",
745
+ log_level: str = "",
746
+ log_jsonl: str = "",
747
+ runtime_dir: str | os.PathLike[str] | None = None,
748
+ wait_timeout_s: float = 10.0,
749
+ replace: bool = False,
750
+ ) -> BackgroundProxy: ...
751
+
752
+ def get_status(runtime_dir=None) -> dict | None: ...
753
+ def is_running(runtime_dir=None) -> bool: ...
754
+ def stop_proxy(runtime_dir=None, *, timeout_s: float = 10.0) -> StopResult: ...
755
+ def resolve_runtime_dir(runtime_dir=None) -> Path: ...
756
+ ```
757
+
758
+ `start_proxy(..., background=True, runtime_dir=..., wait_timeout_s=..., replace=...)`
759
+ 是同一入口;`start_proxy_background()` 便于类型明确的调用方直接使用。
760
+
761
+ 异常码:
762
+
763
+ | code | 触发条件 |
764
+ |---|---|
765
+ | `already_running` | 同一 runtime_dir 已有存活数据面,且 `replace=False` |
766
+ | `background_start_failed` | 子进程在写出 state.json 前退出(附 daemon.log 末尾) |
767
+ | `background_start_timeout` | 等待 state.json 超时;子进程会被终止 |
768
+ | `runtime_dir_unavailable` | runtime_dir 无法创建/写入 |
769
+ | `config_not_found` 等 | 配置阶段失败,与前台一致 |
770
+
632
771
  ---
633
772
 
634
773
  ## 8. 配置参考
@@ -783,7 +922,7 @@ udp_policy:
783
922
  ## 10. 验收清单(给实现方/文档校验)
784
923
 
785
924
  仓库里的 [`examples/verify_sdk.py`](https://github.com/Longersking/procproxy/blob/master/examples/verify_sdk.py)
786
- 是一个 25 项断言的可执行契约:
925
+ 是一个 31 项断言的可执行契约:
787
926
 
788
927
  ```bash
789
928
  python examples/verify_sdk.py
@@ -802,7 +941,10 @@ python examples/verify_sdk.py
802
941
  9. `start_proxy(path)` 的 `config_source == "explicit"`;
803
942
  10. `status()` 含 `backend/rules/sessions/breaker/dns/udp/config_path/config_source/running`;
804
943
  11. `reload()` 返回递增的 generation;
805
- 12. 显式路径不存在 → `ProxyStartError.code == "config_not_found"`。
944
+ 12. 显式路径不存在 → `ProxyStartError.code == "config_not_found"`;
945
+ 13. `start_proxy(background=True)` 返回 pid/config_path/runtime_dir;
946
+ 14. `get_status()` 能跨进程读到同一个 pid;
947
+ 15. `is_running()` 为 True,`stop_proxy()` 写 control.json 后变为 False。
806
948
 
807
949
  参考实现:
808
950
 
@@ -833,6 +975,10 @@ python examples/verify_sdk.py
833
975
  | `profile_option_unsupported` | `handle_services` / `handle_other_users=true` |
834
976
  | `E_BACKEND_UNAVAILABLE` | 未知后端、非管理员、驱动不可加载 |
835
977
  | `config_path_missing` | 对内存配置调用无参 `reload()` |
978
+ | `already_running` | 后台模式同一 runtime_dir 已有存活数据面 |
979
+ | `background_start_failed` | 后台子进程在写出 state.json 前退出 |
980
+ | `background_start_timeout` | 后台子进程未在超时内写出 state.json |
981
+ | `runtime_dir_unavailable` | 后台 runtime_dir 无法创建/写入 |
836
982
 
837
983
  `config_unknown_field` 只出现在 `Config.warnings`,不阻止加载。
838
984
 
@@ -25,9 +25,12 @@ os.environ.pop("PROCPROXY_CONFIG", None)
25
25
  from proxycore import __version__ # noqa: E402
26
26
  from proxycore.api import ( # noqa: E402
27
27
  ProxyStartError,
28
+ get_status,
28
29
  init_config,
30
+ is_running,
29
31
  make_runtime,
30
32
  start_proxy,
33
+ stop_proxy,
31
34
  )
32
35
  from proxycore.config import Config, atomic_write # noqa: E402
33
36
  from proxycore.types import ConnRequest, Endpoint, ProcInfo, Proto # noqa: E402
@@ -143,6 +146,25 @@ def main() -> int:
143
146
  else:
144
147
  raise AssertionError("缺失配置未被拒绝")
145
148
 
149
+ # 7. 后台常驻:独立进程 + state.json / control.json
150
+ bg_config = write_config(work / "background.yaml", port=19135)
151
+ bg = start_proxy(
152
+ bg_config,
153
+ background=True,
154
+ runtime_dir=work / "background-rt",
155
+ wait_timeout_s=20.0,
156
+ )
157
+ try:
158
+ check("background pid", bg.pid > 0, str(bg.pid))
159
+ check("background config path", bg.config_path == str(bg_config.resolve()))
160
+ state = get_status(bg.runtime_dir)
161
+ check("background state pid", bool(state) and state.get("pid") == bg.pid)
162
+ check("background is_running", is_running(bg.runtime_dir))
163
+ finally:
164
+ result = stop_proxy(bg.runtime_dir, timeout_s=20.0)
165
+ check("background stopped", result.stopped, result.reason)
166
+ check("background not running", not is_running(bg.runtime_dir))
167
+
146
168
  print(f"\n全部通过:{len(PASSED)} 项检查")
147
169
  for name in PASSED:
148
170
  print(f" [OK] {name}")
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "procproxy"
7
- version = "0.1.4"
7
+ version = "0.1.4.2"
8
8
  description = "ProcProxy —— 进程级代理:按进程决定哪条连接走哪个上游代理"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: procproxy
3
- Version: 0.1.4
3
+ Version: 0.1.4.2
4
4
  Summary: ProcProxy —— 进程级代理:按进程决定哪条连接走哪个上游代理
5
5
  Author: Longersking
6
6
  License-Expression: MIT
@@ -507,6 +507,24 @@ finally:
507
507
  `start_proxy()` 会启动后台维护线程;`run_forever()` 会阻塞到 Ctrl+C。
508
508
  请在主线程使用,并在退出时 `stop()`(或使用 `with`)。
509
509
 
510
+ ### 后台常驻(父进程可退出)
511
+
512
+ ```python
513
+ from proxycore.api import get_status, start_proxy, stop_proxy
514
+
515
+ bg = start_proxy("D:/cfg/chrome.yaml", background=True)
516
+ print(bg.pid, bg.config_path, bg.runtime_dir)
517
+ # 父进程可以退出;独立子进程继续运行
518
+
519
+ print(get_status(bg.runtime_dir)) # 跨进程读 state.json
520
+ print(stop_proxy(bg.runtime_dir)) # 写 control.json 并等待退出
521
+ ```
522
+
523
+ - 状态目录默认 `~/.procproxy-app`(可用 `runtime_dir=` 或 `PROCPROXY_RUNTIME_DIR` 覆盖)
524
+ - 文件协议:`state.json`(数据面写)、`control.json`(应用写 `stop`/`panic`)
525
+ - `start_proxy(background=True, replace=True)` 可替换同目录下已在跑的数据面
526
+ - 子进程继承父进程权限;`backend="windivert"` 仍需管理员启动
527
+
510
528
  ### 错误处理
511
529
 
512
530
  配置 / 后端 / 启动失败统一抛 `ProxyStartError`,`code` 是稳定的机器可读标识:
@@ -528,6 +546,9 @@ except ProxyStartError as exc:
528
546
  | `config_version_unsupported` | `version != 1` |
529
547
  | `rule_upstream_missing` 等 | 规则或上游校验失败(完整列表见文档) |
530
548
  | `E_BACKEND_UNAVAILABLE` | 后端不可用(未知后端、WinDivert 不可加载等) |
549
+ | `already_running` | 后台模式同一 runtime_dir 已有存活数据面 |
550
+ | `background_start_failed` / `background_start_timeout` | 后台子进程启动失败 / 超时 |
551
+ | `runtime_dir_unavailable` | 后台状态目录无法创建或写入 |
531
552
 
532
553
  `api.load_config()` 同样抛 `ProxyStartError`,不是 `ConfigError`。
533
554
 
@@ -590,7 +611,7 @@ src/proxycore/ 核心包
590
611
  samples/ mock 服务 + 示例配置
591
612
  examples/ 纯 Python 示例(proxy_process.py / verify_sdk.py)
592
613
  docs/ 文档(00 架构 / 07 规则 / 11 排错 / 14 抓包 / 15 SDK)
593
- tests/ 测试套件(624 passed / 4 skipped)
614
+ tests/ 测试套件(633 passed / 4 skipped)
594
615
  └── tools/ 验证脚本(run_demo / run_e2e / bench / …)
595
616
  ```
596
617
 
@@ -621,7 +642,7 @@ tests/ 测试套件(624 passed / 4 skipped)
621
642
 
622
643
  ```bash
623
644
  pip install -e ".[dev]"
624
- python -m pytest -q tests/ # 624 passed / 4 skipped(非管理员 Windows)
645
+ python -m pytest -q tests/ # 633 passed / 4 skipped(非管理员 Windows)
625
646
  python -m ruff check src tests
626
647
  python -m mypy src/proxycore
627
648
  ```
@@ -18,12 +18,14 @@ src/procproxy.egg-info/top_level.txt
18
18
  src/proxycore/__init__.py
19
19
  src/proxycore/__main__.py
20
20
  src/proxycore/api.py
21
+ src/proxycore/background.py
21
22
  src/proxycore/canproxy.py
22
23
  src/proxycore/checkroute.py
23
24
  src/proxycore/cli.py
24
25
  src/proxycore/config.py
25
26
  src/proxycore/config_init.py
26
27
  src/proxycore/config_source.py
28
+ src/proxycore/errors.py
27
29
  src/proxycore/forward.py
28
30
  src/proxycore/ipc.py
29
31
  src/proxycore/log.py
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.4"
5
+ __version__ = "0.1.4.2"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -49,9 +49,19 @@ from dataclasses import dataclass
49
49
  from pathlib import Path
50
50
  from typing import Any
51
51
 
52
+ from .background import (
53
+ BackgroundProxy,
54
+ StopResult,
55
+ get_status,
56
+ is_running,
57
+ resolve_runtime_dir,
58
+ start_proxy_background,
59
+ stop_proxy,
60
+ )
52
61
  from .config import Config, ConfigError, ConfigManager
53
62
  from .config_init import create_config_file
54
63
  from .config_source import ORIGIN_EXPLICIT, ORIGIN_OBJECT, normalize_config_path, resolve_config_source
64
+ from .errors import ProxyStartError
55
65
  from .log import EventLogger, build_logger, get_logger
56
66
  from .runtime import Runtime
57
67
  from .types import ConnRequest, Decision
@@ -59,18 +69,6 @@ from .types import ConnRequest, Decision
59
69
  LOG = EventLogger(get_logger("proxycore.api"))
60
70
 
61
71
 
62
- class ProxyStartError(RuntimeError):
63
- """启动失败(配置非法 / 后端不可用 / sink 端口占用)。
64
-
65
- `code` 是稳定的机器可读标识:配置错误沿用 `ConfigError.code`
66
- (如 `config_not_found`),后端不可用为 `E_BACKEND_UNAVAILABLE`。
67
- """
68
-
69
- def __init__(self, message: str, *, code: str = "") -> None:
70
- super().__init__(message)
71
- self.code = code
72
-
73
-
74
72
  def _wrap_config_error(exc: ConfigError) -> ProxyStartError:
75
73
  return ProxyStartError(f"配置无效:[{exc.code}] {exc.message}", code=exc.code)
76
74
 
@@ -285,17 +283,45 @@ def start_proxy(
285
283
  upstream: str = "",
286
284
  log_level: str = "",
287
285
  log_jsonl: str = "",
288
- ) -> ProxyHandle:
289
- """启动 ProcProxy 数据面(后端 + 规则 + 会话表,必要时监听 sink)。
286
+ background: bool = False,
287
+ runtime_dir: str | os.PathLike[str] | None = None,
288
+ wait_timeout_s: float = 10.0,
289
+ replace: bool = False,
290
+ ) -> ProxyHandle | BackgroundProxy:
291
+ """启动 ProcProxy 数据面。
292
+
293
+ 前台(默认,`background=False`):在当前进程里启动,返回 `ProxyHandle`;
294
+ 进程退出即停止,适合 `with` / 嵌入程序 / 测试。
295
+
296
+ 后台(`background=True`):拉起独立子进程并立即返回 `BackgroundProxy`
297
+ (含 `pid` / `config_path` / `runtime_dir` / `state_path` / `control_path`)。
298
+ 父进程可以退出,数据面继续运行;用 `get_status()` 查询、`stop_proxy()` 停止。
299
+ 子进程的 `PROCPROXY_RUNTIME_DIR` 固定为解析后的 runtime_dir
300
+ (默认 `~/.procproxy-app`),状态/控制文件跨进程一致。
301
+
302
+ 其它参数:
290
303
 
291
304
  * `config=None`:按来源优先级自动选择;pip 安装后默认使用内置配置
292
305
  (`backend=null`,不抓包、不改道),且不会创建任何文件;
293
- * 返回的 `ProxyHandle` 支持 `with`,退出会自动 stop;
294
306
  * `backend=""` 用配置里的 backend;`backend="null"` 用于联调(不抓包);
295
307
  * `sink_test_target` 非空 = 测试模式:sink 把所有连接转发到该固定目标;
296
308
  注意 backend=null 且不传 `sink_test_target`/`upstream` 时**不会**监听 sink;
297
- * 后端不可用(WinDivert 不可加载/非管理员)→ 抛 `ProxyStartError`,不会静默直连。
309
+ * 后端不可用(WinDivert 不可加载/非管理员)→ 抛 `ProxyStartError`,不会静默直连;
310
+ * 后台模式的 `replace=True`:同一 runtime_dir 已有存活数据面时先停掉再启动,
311
+ 否则抛 `already_running`。
298
312
  """
313
+ if background:
314
+ return start_proxy_background(
315
+ config,
316
+ backend=backend,
317
+ sink_test_target=sink_test_target,
318
+ upstream=upstream,
319
+ log_level=log_level,
320
+ log_jsonl=log_jsonl,
321
+ runtime_dir=runtime_dir,
322
+ wait_timeout_s=wait_timeout_s,
323
+ replace=replace,
324
+ )
299
325
  runtime = make_runtime(
300
326
  config,
301
327
  backend=backend,
@@ -325,6 +351,11 @@ def start_proxy(
325
351
  def run_forever(config: Config | str | os.PathLike[str] | None = None, **kwargs: Any) -> int:
326
352
  """启动并阻塞(等价于 `cli run`),Ctrl+C 退出。返回进程退出码。"""
327
353
  handle = start_proxy(config, **kwargs)
354
+ if not isinstance(handle, ProxyHandle):
355
+ raise ProxyStartError(
356
+ "run_forever 不支持 background=True;后台模式请直接调用 start_proxy(background=True)",
357
+ code="invalid_argument",
358
+ )
328
359
  try:
329
360
  handle.runtime.serve_forever(reload_watch=True)
330
361
  except KeyboardInterrupt:
@@ -335,11 +366,18 @@ def run_forever(config: Config | str | os.PathLike[str] | None = None, **kwargs:
335
366
 
336
367
 
337
368
  __all__ = [
369
+ "BackgroundProxy",
338
370
  "ProxyHandle",
339
371
  "ProxyStartError",
372
+ "StopResult",
373
+ "get_status",
340
374
  "init_config",
375
+ "is_running",
341
376
  "load_config",
342
377
  "make_runtime",
378
+ "resolve_runtime_dir",
343
379
  "run_forever",
344
380
  "start_proxy",
381
+ "start_proxy_background",
382
+ "stop_proxy",
345
383
  ]
@@ -610,23 +610,25 @@ class WinDivertBackend(BaseBackend):
610
610
  if direction is None: # pragma: no cover - start() 里已缓存
611
611
  direction = pydivert.Direction.OUTBOUND if outbound else pydivert.Direction.INBOUND
612
612
 
613
- out = pydivert.Packet(raw, direction=direction)
613
+ # 没显式给就沿用原包的接口索引;生产路径两个方向都会显式传。
614
+ idx = int(self._ifidx(packet) if ifidx is None else ifidx)
615
+
616
+ # 元数据必须在构造期传入,不能事后改 wd_addr:
617
+ # pydivert 4.0.0 的 send() 默认会调用 recalculate_checksums(),而它在 Windows
618
+ # 上先执行 _populate_wd_addr(),按构造期缓存的 _loopback/_interface/_direction
619
+ # 重建 WINDIVERT_ADDRESS。直接写 out.wd_addr.Loopback/IfIdx 会被清零,
620
+ # 回环 SYN 被内核丢弃,应用卡在 SYN_SENT(3.x 不做这次重建,所以以前没暴露)。
621
+ out = pydivert.Packet(
622
+ raw,
623
+ interface=(idx, 0),
624
+ direction=direction,
625
+ loopback=outbound,
626
+ )
614
627
  # 用改写后的报文挑句柄(地址族与原始包一致,但这里的数据更权威)
615
628
  handle = self._handle_for(out) or handle
616
- addr = getattr(out, "wd_addr", None)
617
- if addr is not None:
618
- addr.Outbound = 1 if outbound else 0
619
- # 回环流量要置 Loopback 位:内核据此把它当本机回环报文处理
620
- addr.Loopback = 1 if outbound else 0
621
- addr.Sniffed = 0
622
- addr.Impostor = 0
623
- # 没显式给就沿用原包的接口索引(老行为,够用);生产路径两个方向都会显式传
624
- idx = self._ifidx(packet) if ifidx is None else ifidx
625
- if idx:
626
- addr.Network.IfIdx = int(idx)
627
- # 校验和交给 pydivert 重算兜底(wd_addr 上的 *Checksum 标志会影响 helper 的行为,
628
- # 让它自己算最稳);我们 pkt.py 的校验和另有单测保证正确性。
629
- handle.send(out)
629
+ # pkt.py 的 redirect/reflect 已经算好校验和;关掉 pydivert 的重算,
630
+ # 既避免重复计算,也避免 4.0.0 重建地址时依赖构造期缓存的隐式行为。
631
+ handle.send(out, recalculate_checksum=False)
630
632
 
631
633
  # ------------------------------------------------------------ 统计 -----
632
634
  def stats(self) -> dict[str, Any]: