procproxy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. procproxy-0.1.0.dist-info/METADATA +489 -0
  2. procproxy-0.1.0.dist-info/RECORD +45 -0
  3. procproxy-0.1.0.dist-info/WHEEL +5 -0
  4. procproxy-0.1.0.dist-info/entry_points.txt +2 -0
  5. procproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. procproxy-0.1.0.dist-info/top_level.txt +1 -0
  7. proxycore/__init__.py +7 -0
  8. proxycore/__main__.py +65 -0
  9. proxycore/api.py +226 -0
  10. proxycore/backend/__init__.py +49 -0
  11. proxycore/backend/base.py +159 -0
  12. proxycore/backend/null.py +51 -0
  13. proxycore/backend/windivert/__init__.py +657 -0
  14. proxycore/backend/windivert/filter.py +123 -0
  15. proxycore/backend/windivert/pidmap.py +289 -0
  16. proxycore/backend/windivert/pkt.py +424 -0
  17. proxycore/canproxy.py +229 -0
  18. proxycore/checkroute.py +247 -0
  19. proxycore/cli.py +605 -0
  20. proxycore/config.py +1010 -0
  21. proxycore/dns/__init__.py +46 -0
  22. proxycore/dns/cache.py +209 -0
  23. proxycore/dns/modes.py +451 -0
  24. proxycore/dns/udp.py +606 -0
  25. proxycore/forward.py +572 -0
  26. proxycore/ipc.py +637 -0
  27. proxycore/log.py +298 -0
  28. proxycore/pathsetup.py +112 -0
  29. proxycore/probe.py +168 -0
  30. proxycore/proc.py +342 -0
  31. proxycore/rules.py +730 -0
  32. proxycore/runtime.py +654 -0
  33. proxycore/service/__init__.py +536 -0
  34. proxycore/service/__main__.py +124 -0
  35. proxycore/tunnel/__init__.py +8 -0
  36. proxycore/tunnel/relay.py +393 -0
  37. proxycore/tunnel/sessions.py +315 -0
  38. proxycore/tunnel/sink.py +325 -0
  39. proxycore/types.py +165 -0
  40. proxycore/upstream/__init__.py +52 -0
  41. proxycore/upstream/base.py +150 -0
  42. proxycore/upstream/chain.py +92 -0
  43. proxycore/upstream/http_connect.py +148 -0
  44. proxycore/upstream/socks4.py +106 -0
  45. proxycore/upstream/socks5.py +266 -0
proxycore/types.py ADDED
@@ -0,0 +1,165 @@
1
+ """ProcProxy 核心数据结构。
2
+
3
+ 类型名、字段名、默认值都是对外契约,不得改名;扩展只能加字段。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+ from typing import Any
12
+
13
+
14
+ class Proto(Enum):
15
+ TCP = "tcp"
16
+ UDP = "udp"
17
+
18
+
19
+ class Action(Enum):
20
+ DIRECT = "direct" # 直连(不走代理)
21
+ PROXY = "proxy" # 走代理
22
+ BLOCK = "block" # 丢弃(连接失败 / 数据报丢弃)
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class Endpoint:
27
+ addr: str # 点分/冒分格式,不含端口
28
+ port: int
29
+ is_v6: bool = False
30
+
31
+ def __str__(self) -> str:
32
+ if self.is_v6:
33
+ return f"[{self.addr}]:{self.port}"
34
+ return f"{self.addr}:{self.port}"
35
+
36
+ def to_dict(self) -> dict[str, Any]:
37
+ return {"addr": self.addr, "port": self.port, "is_v6": self.is_v6}
38
+
39
+ @classmethod
40
+ def from_dict(cls, doc: dict[str, Any]) -> Endpoint:
41
+ return cls(addr=str(doc["addr"]), port=int(doc["port"]), is_v6=bool(doc.get("is_v6", False)))
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class ProcInfo:
46
+ pid: int
47
+ name: str # "chrome.exe"(无路径)
48
+ path: str # "C:\\Program Files\\...\\chrome.exe"(可能为空:受保护进程)
49
+ session_id: int = 0 # Windows 会话 ID;Linux 为 0;macOS 为 0
50
+ user: str = "" # 可空
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ return {
54
+ "pid": self.pid,
55
+ "name": self.name,
56
+ "path": self.path,
57
+ "session_id": self.session_id,
58
+ "user": self.user,
59
+ }
60
+
61
+ @classmethod
62
+ def from_dict(cls, doc: dict[str, Any]) -> ProcInfo:
63
+ return cls(
64
+ pid=int(doc.get("pid", 0)),
65
+ name=str(doc.get("name", "")),
66
+ path=str(doc.get("path", "")),
67
+ session_id=int(doc.get("session_id", 0)),
68
+ user=str(doc.get("user", "")),
69
+ )
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class ConnRequest:
74
+ """后端交给核心的一条"待决策连接"。"""
75
+
76
+ proc: ProcInfo
77
+ proto: Proto
78
+ src: Endpoint
79
+ dst: Endpoint
80
+ backend_id: int = 0 # 后端上下文(路线 A: 客户端源端口)
81
+ redirect_supported: bool = True # UDP 在部分后端为 False
82
+
83
+ def to_dict(self) -> dict[str, Any]:
84
+ return {
85
+ "proc": self.proc.to_dict(),
86
+ "proto": self.proto.value,
87
+ "src": self.src.to_dict(),
88
+ "dst": self.dst.to_dict(),
89
+ "backend_id": self.backend_id,
90
+ "redirect_supported": self.redirect_supported,
91
+ }
92
+
93
+
94
+ @dataclass(slots=True)
95
+ class Decision:
96
+ action: Action
97
+ rule_id: str = "" # 命中的规则(用于日志/统计)
98
+ upstream: str = "" # 上游别名(PROXY 时必填)
99
+ reason: str = "" # "rule:proxy-chrome" / "self-pid" / "loop-breaker" ...
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ return {
103
+ "action": self.action.value,
104
+ "rule_id": self.rule_id,
105
+ "upstream": self.upstream,
106
+ "reason": self.reason,
107
+ }
108
+
109
+
110
+ @dataclass(slots=True)
111
+ class Session:
112
+ id: int
113
+ proc: ProcInfo
114
+ proto: Proto
115
+ original: Endpoint # 真实目标(未改道前的)
116
+ upstream: str
117
+ rule_id: str
118
+ started_at: float
119
+ bytes_up: int = 0 # 客户端 → 目标
120
+ bytes_down: int = 0
121
+ state: str = "connecting" # connecting|relaying|closed|failed
122
+ error: str = ""
123
+
124
+ @property
125
+ def duration_ms(self) -> float:
126
+ import time
127
+
128
+ return (time.time() - self.started_at) * 1000.0
129
+
130
+ def to_dict(self) -> dict[str, Any]:
131
+ import time
132
+
133
+ return {
134
+ "session_id": self.id,
135
+ "pid": self.proc.pid,
136
+ "proc": self.proc.name,
137
+ "proto": self.proto.value,
138
+ "original": str(self.original),
139
+ "upstream": self.upstream,
140
+ "rule": self.rule_id,
141
+ "state": self.state,
142
+ "bytes_up": self.bytes_up,
143
+ "bytes_down": self.bytes_down,
144
+ "started_at": self.started_at,
145
+ "duration_ms": round((time.time() - self.started_at) * 1000.0, 3),
146
+ "error": self.error,
147
+ }
148
+
149
+
150
+ @dataclass(slots=True)
151
+ class UdpAssociation:
152
+ """`UpstreamDialer.dial_udp()` 的返回值。"""
153
+
154
+ relay: Endpoint # 中继地址(BND.ADDR/BND.PORT)
155
+ control_sock: Any = None # 控制用 TCP 连接(SOCKS5 UDP ASSOCIATE 用)
156
+ upstream: str = ""
157
+ created_at: float = 0.0
158
+ extra: dict[str, Any] = field(default_factory=dict)
159
+
160
+ def close(self) -> None:
161
+ sock = self.control_sock
162
+ if sock is not None:
163
+ with contextlib.suppress(OSError): # 关闭失败无补救
164
+ sock.close()
165
+ self.control_sock = None
@@ -0,0 +1,52 @@
1
+ """上游客户端:SOCKS5(含 UDP ASSOCIATE)、SOCKS4/4a、HTTP CONNECT(含 Basic)、跳链。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .base import (
8
+ AuthFailed,
9
+ BaseDialer,
10
+ TargetRefused,
11
+ UnsupportedAuth,
12
+ UpstreamDialer,
13
+ UpstreamDown,
14
+ UpstreamError,
15
+ )
16
+
17
+ KIND_TO_DIALER = {
18
+ "socks5": ("proxycore.upstream.socks5", "Socks5Dialer"),
19
+ "socks4": ("proxycore.upstream.socks4", "Socks4Dialer"),
20
+ "http": ("proxycore.upstream.http_connect", "HttpConnectDialer"),
21
+ "https": ("proxycore.upstream.http_connect", "HttpConnectDialer"),
22
+ }
23
+
24
+
25
+ def make_dialer(upstream: Any, *, timeout: float = 15.0, resolver: Any = None) -> UpstreamDialer:
26
+ """按 `upstream.kind` 构造 dialer;`chain` 非空时套一层跳链。
27
+
28
+ `resolver(alias) -> Upstream` 用于跳链解析(调用方注入 `profile.upstreams`)。
29
+ """
30
+ kind = str(getattr(upstream, "kind", "socks5")).lower()
31
+ module_name, class_name = KIND_TO_DIALER.get(kind, KIND_TO_DIALER["socks5"])
32
+ module = __import__(module_name, fromlist=[class_name])
33
+ dialer_cls = getattr(module, class_name)
34
+ dialer = dialer_cls(upstream, timeout=timeout, resolver=resolver)
35
+ chain = list(getattr(upstream, "chain", []) or [])
36
+ if chain:
37
+ from .chain import ChainDialer
38
+
39
+ return ChainDialer(dialer, chain, timeout=timeout, resolver=resolver)
40
+ return dialer
41
+
42
+
43
+ __all__ = [
44
+ "AuthFailed",
45
+ "BaseDialer",
46
+ "TargetRefused",
47
+ "UnsupportedAuth",
48
+ "UpstreamDialer",
49
+ "UpstreamDown",
50
+ "UpstreamError",
51
+ "make_dialer",
52
+ ]
@@ -0,0 +1,150 @@
1
+ """上游抽象。
2
+
3
+ `UpstreamDialer` 的语义硬约束:
4
+ * `hostname` 非空时必须使用远端解析(SOCKS5 `ATYP=0x03` / SOCKS4a / HTTP CONNECT 原样传域名);
5
+ * 认证失败 / 目标不可达必须抛出区分明确的异常:`AuthFailed`、`TargetRefused`、`UpstreamDown`,
6
+ 统计口径不同(`E_UPSTREAM_AUTH` / `E_UPSTREAM_DOWN` / `E_TARGET_REFUSED`)。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import socket
13
+ from typing import Any, Protocol, runtime_checkable
14
+
15
+ from ..types import Endpoint
16
+
17
+
18
+ class UpstreamError(RuntimeError):
19
+ """上游相关错误的基类(带 `code`,便于统计与测试断言)。"""
20
+
21
+ code = "E_UPSTREAM_DOWN"
22
+
23
+
24
+ class AuthFailed(UpstreamError):
25
+ """认证失败(对应 `E_UPSTREAM_AUTH`)。"""
26
+
27
+ code = "E_UPSTREAM_AUTH"
28
+
29
+
30
+ class TargetRefused(UpstreamError):
31
+ """目标拒绝/不可达(对应 `E_TARGET_REFUSED`)。"""
32
+
33
+ code = "E_TARGET_REFUSED"
34
+
35
+
36
+ class UpstreamDown(UpstreamError):
37
+ """上游不可达(对应 `E_UPSTREAM_DOWN`)。禁止在此情况下改为直连。"""
38
+
39
+ code = "E_UPSTREAM_DOWN"
40
+
41
+
42
+ class UnsupportedAuth(UpstreamError):
43
+ """该平台/该协议不支持所选认证方式(例如非 Windows 上的 NTLM)。"""
44
+
45
+ code = "E_UPSTREAM_AUTH"
46
+
47
+
48
+ @runtime_checkable
49
+ class UpstreamDialer(Protocol):
50
+ async def dial_tcp(self, dst: Endpoint, hostname: str | None) -> socket.socket: ...
51
+
52
+ async def dial_udp(self, dst: Endpoint) -> Any: ...
53
+
54
+ def supports_udp(self) -> bool: ...
55
+
56
+ def dial_tcp_sync(self, dst: Endpoint, hostname: str | None = None) -> socket.socket: ...
57
+
58
+
59
+ class BaseDialer:
60
+ """公共逻辑:域名透传判定、错误映射、超时。"""
61
+
62
+ kind = "base"
63
+ supports_udp_flag = False
64
+
65
+ def __init__(self, upstream: Any, *, timeout: float = 15.0) -> None:
66
+ self.upstream = upstream
67
+ self.timeout = timeout
68
+
69
+ @property
70
+ def alias(self) -> str:
71
+ return str(getattr(self.upstream, "alias", ""))
72
+
73
+ def supports_udp(self) -> bool:
74
+ return bool(self.supports_udp_flag and getattr(self.upstream, "udp", False))
75
+
76
+ # ------------------------------------------------------------ 工具 -----
77
+ def _connect_upstream(self) -> socket.socket:
78
+ host = str(getattr(self.upstream, "host", ""))
79
+ port = int(getattr(self.upstream, "port", 0))
80
+ return tcp_connect(host, port, timeout=self.timeout)
81
+
82
+ def dial_tcp_sync(self, dst: Endpoint, hostname: str | None = None) -> socket.socket:
83
+ raise NotImplementedError
84
+
85
+ def tunnel_over(
86
+ self, sock: socket.socket, dst: Endpoint, hostname: str | None = None
87
+ ) -> socket.socket:
88
+ """在已建立的连接之上再开一条隧道(跳链用)。
89
+
90
+ 默认实现:直接返回原 socket(表示"这一跳不需要额外握手")。
91
+ """
92
+ return sock
93
+
94
+ async def dial_tcp(self, dst: Endpoint, hostname: str | None = None) -> socket.socket:
95
+ """契约里的 async 形式。
96
+
97
+ 底层是阻塞 socket:必须丢到线程里,否则调用方的事件循环会被握手
98
+ 和 DNS 阻塞(本仓库当前没有 await 调用者,但这是对外 API)。
99
+ """
100
+ return await asyncio.to_thread(self.dial_tcp_sync, dst, hostname)
101
+
102
+ async def dial_udp(self, dst: Endpoint) -> Any:
103
+ raise NotImplementedError("该上游不支持 UDP ASSOCIATE")
104
+
105
+
106
+ def tcp_connect(host: str, port: int, *, timeout: float = 15.0) -> socket.socket:
107
+ """连上游;把 OSError 映射成 `UpstreamDown`(绝不静默直连)。"""
108
+ try:
109
+ sock = socket.create_connection((host, port), timeout=timeout)
110
+ except TimeoutError as exc:
111
+ raise UpstreamDown(f"连接上游 {host}:{port} 超时({timeout}s)") from exc
112
+ except socket.gaierror as exc:
113
+ raise UpstreamDown(f"上游域名解析失败 {host}: {exc}") from exc
114
+ except OSError as exc:
115
+ raise UpstreamDown(f"连接上游 {host}:{port} 失败: {exc}") from exc
116
+ sock.settimeout(timeout)
117
+ return sock
118
+
119
+
120
+ def recv_exact(sock: socket.socket, n: int) -> bytes:
121
+ """读满 n 字节;不足即视为上游断开。"""
122
+ buf = bytearray()
123
+ while len(buf) < n:
124
+ try:
125
+ chunk = sock.recv(n - len(buf))
126
+ except TimeoutError as exc:
127
+ raise UpstreamDown("读取上游响应超时") from exc
128
+ except OSError as exc:
129
+ raise UpstreamDown(f"读取上游失败: {exc}") from exc
130
+ if not chunk:
131
+ raise UpstreamDown(f"上游提前关闭(期望 {n} 字节,实际 {len(buf)})")
132
+ buf.extend(chunk)
133
+ return bytes(buf)
134
+
135
+
136
+ def pack_port(port: int) -> bytes:
137
+ return port.to_bytes(2, "big")
138
+
139
+
140
+ def parse_port(raw: bytes) -> int:
141
+ return int.from_bytes(raw, "big")
142
+
143
+
144
+ def optional_hostname(dst: Endpoint, hostname: str | None) -> str | None:
145
+ """只有"确实是域名"时才透传(IP 字面量不算域名)。"""
146
+ if not hostname:
147
+ return None
148
+ if hostname == dst.addr:
149
+ return None
150
+ return hostname
@@ -0,0 +1,92 @@
1
+ """跳链(上游 A → 上游 B → 目标),逐跳认证。
2
+
3
+ 语义:
4
+ * `chain: [B, C]` 表示"先连到 B,再由 B 连 C,最后由 C 连目标";
5
+ * 每一跳都用自己那套凭据完成握手(不共享认证状态);
6
+ * 链成环或跳数 > 8 在配置校验阶段(V3)就被拒绝,本模块只做执行。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import socket
13
+ from typing import Any
14
+
15
+ from ..types import Endpoint
16
+ from .base import BaseDialer, UpstreamDown
17
+ from .http_connect import HttpConnectDialer
18
+ from .socks4 import Socks4Dialer
19
+ from .socks5 import Socks5Dialer
20
+
21
+ _DIALERS: dict[str, type[BaseDialer]] = {
22
+ "socks5": Socks5Dialer,
23
+ "socks4": Socks4Dialer,
24
+ "http": HttpConnectDialer,
25
+ "https": HttpConnectDialer,
26
+ }
27
+
28
+
29
+ class ChainDialer(BaseDialer):
30
+ """把"最后一跳"的拨号替换为"逐跳建立隧道"。"""
31
+
32
+ def __init__(
33
+ self,
34
+ dialer: BaseDialer,
35
+ chain: list[str],
36
+ *,
37
+ timeout: float = 15.0,
38
+ resolver: Any = None,
39
+ ) -> None:
40
+ super().__init__(dialer.upstream, timeout=timeout)
41
+ self._tail = dialer
42
+ self._chain = list(chain)
43
+ self._resolver = resolver if resolver is not None else getattr(dialer, "resolve_alias", None)
44
+
45
+ def supports_udp(self) -> bool:
46
+ return False # 链式 UDP 需要逐跳 ASSOCIATE,本期不做
47
+
48
+ def dial_tcp_sync(self, dst: Endpoint, hostname: str | None = None) -> socket.socket:
49
+ hops = self._resolve_hops()
50
+ if not hops:
51
+ return self._tail.dial_tcp_sync(dst, hostname)
52
+
53
+ # 逐跳:第一跳用自身 dialer,后续每跳用各自的 dialer 连"下一跳的地址"
54
+ first = hops[0]
55
+ sock = self._tail.dial_tcp_sync(
56
+ Endpoint(addr=first.host, port=first.port), hostname=None
57
+ )
58
+ try:
59
+ for hop in hops[1:]:
60
+ hop_dialer = self._make(hop)
61
+ # 在已建立的隧道之上,用下一跳的协议连它的地址
62
+ sock = hop_dialer.tunnel_over(sock, Endpoint(addr=hop.host, port=hop.port))
63
+ final = self._make(hops[-1]) if len(hops) > 1 else self._tail
64
+ return final.tunnel_over(sock, dst, hostname)
65
+ except BaseException:
66
+ # 不只 UpstreamError:`tunnel_over()` 里的 sendall/OSError 也要关掉,
67
+ # 否则半开的隧道会一直占着文件描述符。
68
+ _safe_close(sock)
69
+ raise
70
+
71
+ def _make(self, upstream: Any) -> BaseDialer:
72
+ cls = _DIALERS.get(str(getattr(upstream, "kind", "socks5")).lower())
73
+ if cls is None:
74
+ raise UpstreamDown(f"链式跳不支持的上游类型 {getattr(upstream, 'alias', '?')}")
75
+ return cls(upstream, timeout=self.timeout)
76
+
77
+ def _resolve_hops(self) -> list[Any]:
78
+ """把别名列表解析成 `Upstream` 对象(由上游工厂注入的解析器完成)。"""
79
+ if self._resolver is None:
80
+ raise UpstreamDown("链式代理缺少别名解析器(make_dialer 必须注入 resolver)")
81
+ hops = []
82
+ for alias in self._chain:
83
+ upstream = self._resolver(alias)
84
+ if upstream is None:
85
+ raise UpstreamDown(f"链式跳引用了未定义的上游 {alias!r}")
86
+ hops.append(upstream)
87
+ return hops
88
+
89
+
90
+ def _safe_close(sock: socket.socket) -> None:
91
+ with contextlib.suppress(OSError):
92
+ sock.close()
@@ -0,0 +1,148 @@
1
+ """HTTP CONNECT 上游(含 Basic;NTLM 尚未实现,配置校验期拒绝)。
2
+
3
+ NTLM 需要 407 挑战 + 多轮 SSPI,本仓库尚未接线。`auth: ntlm` 会在
4
+ `Config.load()` 阶段被拒绝;下面的 `_sspi_ntlm_token()` 只是防御性兜底
5
+ (手工构造 `Upstream` 时仍会明确抛 `UnsupportedAuth`,绝不假装成功)。
6
+
7
+ 本实现:
8
+ * `auth: basic` → `Proxy-Authorization: Basic ...`;
9
+ * `auth: ntlm` → `UnsupportedAuth`(配置加载期已拒绝);
10
+ * 任意非 200 响应 → 按状态码映射错误(407 → `AuthFailed`)。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import contextlib
17
+ import os
18
+ import socket
19
+ import threading
20
+ from typing import Any
21
+
22
+ from ..types import Endpoint
23
+ from .base import AuthFailed, BaseDialer, TargetRefused, UnsupportedAuth, UpstreamDown, optional_hostname
24
+
25
+ MAX_HEADER = 65536
26
+ NTLM_SIGNATURE = b"NTLMSSP\x00"
27
+
28
+
29
+ class HttpConnectDialer(BaseDialer):
30
+ kind = "http"
31
+ supports_udp_flag = False
32
+
33
+ def __init__(self, upstream: Any, *, timeout: float = 15.0, resolver: Any = None) -> None:
34
+ super().__init__(upstream, timeout=timeout)
35
+ self.resolve_alias = resolver
36
+ self._sspi_lock = threading.Lock()
37
+
38
+ # ------------------------------------------------------------ 认证 ----
39
+ def _auth_header(self, sock: socket.socket) -> bytes:
40
+ auth = str(getattr(self.upstream, "auth", "none") or "none").lower()
41
+ user = str(getattr(self.upstream, "user", "") or "")
42
+ password = str(getattr(self.upstream, "password", "") or "")
43
+ if auth == "basic" or (user and auth == "none"):
44
+ token = base64.b64encode(f"{user}:{password}".encode()).decode("ascii")
45
+ return f"Proxy-Authorization: Basic {token}\r\n".encode("ascii")
46
+ if auth == "ntlm":
47
+ return self._ntlm_auth(sock)
48
+ if auth == "unsupported":
49
+ raise UnsupportedAuth(f"上游 {self.alias}: 配置标注该认证方式不受支持")
50
+ return b""
51
+
52
+ def _ntlm_auth(self, sock: socket.socket) -> bytes:
53
+ if os.name != "nt":
54
+ raise UnsupportedAuth(
55
+ f"上游 {self.alias}: NTLM 仅支持 Windows(SSPI);"
56
+ "非 Windows 请在配置里标注 ntlm=unsupported 并改用 basic"
57
+ )
58
+ with self._sspi_lock:
59
+ token = _sspi_ntlm_token(
60
+ str(getattr(self.upstream, "user", "") or ""),
61
+ str(getattr(self.upstream, "password", "") or ""),
62
+ str(getattr(self.upstream, "host", "") or ""),
63
+ )
64
+ return b"Proxy-Authorization: NTLM " + base64.b64encode(token) + b"\r\n"
65
+
66
+ # ------------------------------------------------------------ 隧道 ----
67
+ def tunnel_over(self, sock: socket.socket, dst: Endpoint, hostname: str | None = None) -> socket.socket:
68
+ address = optional_hostname(dst, hostname) or dst.addr
69
+ sock.settimeout(self.timeout)
70
+ self._send_connect(sock, address, dst.port)
71
+ return sock
72
+
73
+ def _send_connect(self, sock: socket.socket, host: str, port: int) -> None:
74
+ target = f"{host}:{port}"
75
+ try:
76
+ target.encode("ascii")
77
+ except UnicodeEncodeError:
78
+ raise UpstreamDown(f"非 ASCII 域名无法用于 HTTP CONNECT: {host}") from None
79
+ head = (
80
+ f"CONNECT {target} HTTP/1.1\r\n"
81
+ f"Host: {target}\r\n"
82
+ f"Proxy-Connection: Keep-Alive\r\n"
83
+ ).encode("ascii")
84
+ sock.sendall(head + self._auth_header(sock) + b"\r\n")
85
+ status, _headers = _read_response(sock)
86
+ if status == 200:
87
+ return
88
+ if status == 407:
89
+ raise AuthFailed(f"上游认证失败(HTTP 407){target}")
90
+ if status in {403, 405, 502, 503}:
91
+ raise TargetRefused(f"目标不可达(经 HTTP 代理){target}: HTTP {status}")
92
+ raise UpstreamDown(f"HTTP CONNECT 失败 {target}: HTTP {status}")
93
+
94
+ def dial_tcp_sync(self, dst: Endpoint, hostname: str | None = None) -> socket.socket:
95
+ remote = optional_hostname(dst, hostname) or dst.addr
96
+ sock = self._connect_upstream()
97
+ try:
98
+ self._send_connect(sock, remote, dst.port)
99
+ except BaseException:
100
+ with contextlib.suppress(OSError):
101
+ sock.close()
102
+ raise
103
+ sock.settimeout(None)
104
+ return sock
105
+
106
+
107
+ def _read_response(sock: socket.socket) -> tuple[int, dict[str, str]]:
108
+ """读到空行为止;返回 `(status, headers)`。"""
109
+ buf = bytearray()
110
+ while b"\r\n\r\n" not in buf:
111
+ try:
112
+ chunk = sock.recv(4096)
113
+ except TimeoutError as exc:
114
+ raise UpstreamDown("读取 HTTP 代理响应超时") from exc
115
+ except OSError as exc:
116
+ raise UpstreamDown(f"读取 HTTP 代理响应失败: {exc}") from exc
117
+ if not chunk:
118
+ raise UpstreamDown("HTTP 代理在响应未完成时关闭连接")
119
+ buf.extend(chunk)
120
+ if len(buf) > MAX_HEADER:
121
+ raise UpstreamDown(f"HTTP 代理响应头超过 {MAX_HEADER} 字节")
122
+
123
+ head, _, _rest = bytes(buf).partition(b"\r\n\r\n")
124
+ lines = head.split(b"\r\n")
125
+ try:
126
+ parts = lines[0].split()
127
+ status = int(parts[1])
128
+ except (IndexError, ValueError) as exc:
129
+ raise UpstreamDown(f"HTTP 代理响应行非法: {lines[0]!r}") from exc
130
+ headers: dict[str, str] = {}
131
+ for line in lines[1:]:
132
+ key, _, value = line.partition(b":")
133
+ headers[key.decode("latin1").strip().lower()] = value.decode("latin1").strip()
134
+ return status, headers
135
+
136
+
137
+ def _sspi_ntlm_token(user: str, password: str, target: str = "") -> bytes:
138
+ """Windows SSPI NTLM 类型 1 消息。
139
+
140
+ 完整 NTLM 需要 `AcquireCredentialsHandle` + 多轮 `InitializeSecurityContext`;
141
+ 在接线完成前,本函数按契约要求明确标注 unsupported(绝不假装成功)。
142
+ """
143
+ if os.name != "nt": # pragma: no cover - 调用方已拦截
144
+ raise UnsupportedAuth("NTLM 需要 Windows SSPI")
145
+ raise UnsupportedAuth(
146
+ "NTLM 尚未接线:请在配置里使用 auth=basic,"
147
+ "或把该上游标注为 ntlm=unsupported"
148
+ )