debug-control-plane 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.
@@ -0,0 +1,245 @@
1
+ """Manual device registry — human-told fallback channel (R020-BF007).
2
+
3
+ Role (design §3.4 ManualRegistry / §4.2.1 register_device tool / §4.3 devices.json):
4
+ When auto-discovery (BF002 USB + BF005 LAN scan) misses a device the AI
5
+ knows about (e.g. the phone is on a different subnet, behind a VPN the
6
+ host can't see, or simply not yet probed), the human can explicitly tell
7
+ the bridge ``register_device(host=192.168.1.34)``. This service probes
8
+ that single host's ``/hello``, and on success registers a
9
+ :class:`DeviceRecord` with ``source="manual"`` in :class:`DevicePool`.
10
+
11
+ Three concerns kept separate (design §3.4 single-direction dependency):
12
+
13
+ * **Identity** (``device_id``) is host-derived — ``manual-<sha1(host)[:16]>``
14
+ via :func:`device_pool.manual_device_id`. **NEVER** sourced from
15
+ ``/hello.deviceId`` (R019 fixed string ``gmacro-virtual-iOS`` collides
16
+ across devices — backend D9, enforced by BF001 ``manual_device_id``).
17
+ * **Reachability** is verified by reusing :func:`endpoint.probe_hello`
18
+ (BF002, design D8 zero-rewrite). A failed probe does NOT pollute the
19
+ pool (analysis L3) — the caller sees ``RegisterResult(ok=False)`` with a
20
+ :class:`DeviceUnreachable` error (BF008 exception model, shared vocab).
21
+ * **Persistence** is delegated to :meth:`DevicePool.upsert` (BF001.3
22
+ identity-only — this module never touches devices.json directly).
23
+
24
+ Why inject ``LanScan`` if ``register`` only uses ``probe_hello``? The task
25
+ skeleton (tasks.md BF007) specifies ``__init__(self, pool, lan_scan)`` for
26
+ forward-compat with BF011 server wiring (the server already holds a LanScan
27
+ instance). Holding it is cheap; not calling ``scan()`` from ``register``
28
+ keeps single-host registration O(1) (no /24 sweep).
29
+
30
+ Design references:
31
+ - tasks: .dev-flow/R020/mcp-bridge-device-discovery-tasks.md BF007 (L472-497)
32
+ - design: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-backend.md
33
+ §3.4 ManualRegistry / §4.2.1 / §4.3
34
+ - test: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-test.md §2.1
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import time
40
+ from dataclasses import dataclass
41
+ from typing import TYPE_CHECKING
42
+
43
+ from ..device_pool import DevicePool, DeviceRecord, manual_device_id
44
+ from ..endpoint import Endpoint, UrlOpen, default_urlopen, probe_hello
45
+
46
+ # AD-B9: DeviceUnreachable 自 mcp_debug_bridge/bridge_client 下沉至 device_discovery/protocol.
47
+ from ..protocol import DeviceUnreachable
48
+
49
+ if TYPE_CHECKING: # pragma: no cover - typing only
50
+ from ..protocol import NetworkTarget
51
+ from .lan_scan import LanScan
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Constants
56
+ # ---------------------------------------------------------------------------
57
+
58
+ #: R019 debug plane HTTP port (mobile app listens here). Matches BF005/BF008.
59
+ DEFAULT_PORT = 18080
60
+
61
+ #: probe /hello timeout. Single-host probe — slightly more generous than
62
+ #: BF005's 2.5s sweep default, since a single explicit host deserves one
63
+ #: full timeout window before we declare it unreachable.
64
+ DEFAULT_PROBE_TIMEOUT = 3.0
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Result type
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class RegisterResult:
74
+ """Outcome of :meth:`ManualRegistry.register`.
75
+
76
+ Two-state result (never raises — :meth:`register` catches all probe
77
+ failures and returns ``ok=False``). The caller dispatches:
78
+
79
+ * ``ok=True`` → use ``record`` (already in the pool).
80
+ * ``ok=False`` → inspect ``error`` (always :class:`DeviceUnreachable`
81
+ for probe failures); the pool is untouched.
82
+
83
+ Why a result object instead of raising? Two reasons:
84
+ 1. The MCP ``register_device`` tool wants to translate probe failures
85
+ into the ``device_unreachable`` MCP error, not a Python traceback —
86
+ a result object makes that translation explicit at the call site.
87
+ 2. ``register`` must be total: a typo'd host or a powered-off phone is
88
+ a normal input, not an exceptional state (analysis L3 "人工告知也可能
89
+ 告知错误").
90
+ """
91
+
92
+ ok: bool
93
+ record: DeviceRecord | None = None
94
+ error: DeviceUnreachable | None = None
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Service
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ class ManualRegistry:
103
+ """Human-told device registration service (design §3.4 / analysis L3).
104
+
105
+ Wraps :func:`endpoint.probe_hello` + :meth:`DevicePool.upsert` into a
106
+ single ``register(host, port, label)`` call that:
107
+
108
+ 1. Probes ``http://{host}:{port}/hello`` (reuses BF002 ``probe_hello``,
109
+ D8 zero-rewrite). On failure returns ``RegisterResult(ok=False)``
110
+ with :class:`DeviceUnreachable` — the pool is NOT modified.
111
+ 2. Derives ``device_id = manual_device_id(host)`` (BF001 helper,
112
+ ``manual-<sha1(host)[:16]>``). Independent of ``/hello.deviceId``
113
+ (backend D9).
114
+ 3. Builds a :class:`DeviceRecord` with ``source="manual"`` and the
115
+ probe's runtime fields (hardware_name/machine_id/platform +
116
+ last_known_host/last_seen), then :meth:`DevicePool.upsert` it.
117
+ Persistence (devices.json) is delegated to DevicePool — this module
118
+ never writes the file directly (BF001.3 identity-only).
119
+
120
+ Args:
121
+ pool: BF001 DevicePool — identity store + persistence (never None).
122
+ lan_scan: BF005 LanScan — held for forward-compat with BF011 server
123
+ wiring; ``register`` does NOT call ``scan()`` (single-host probe
124
+ only). Accepting it here lets the server pass its existing
125
+ LanScan instance without a second constructor.
126
+ port: default port (18080, R019 debug plane). Per-call ``port``
127
+ override in :meth:`register` takes precedence.
128
+ probe_timeout: single-host probe timeout (default 3.0s).
129
+ urlopen: injectable urlopen for testing (defaults to
130
+ ``endpoint.default_urlopen`` = ``urllib.request.urlopen``).
131
+ """
132
+
133
+ def __init__(
134
+ self,
135
+ pool: DevicePool,
136
+ lan_scan: LanScan,
137
+ *,
138
+ port: int = DEFAULT_PORT,
139
+ probe_timeout: float = DEFAULT_PROBE_TIMEOUT,
140
+ urlopen: UrlOpen | None = None,
141
+ ) -> None:
142
+ self._pool = pool
143
+ self._lan_scan = lan_scan
144
+ self._port = port
145
+ self._probe_timeout = probe_timeout
146
+ self._urlopen: UrlOpen = urlopen if urlopen is not None else default_urlopen
147
+
148
+ def register(
149
+ self,
150
+ host: str,
151
+ *,
152
+ port: int | None = None,
153
+ label: str | None = None,
154
+ note: str | None = None,
155
+ ) -> RegisterResult:
156
+ """Probe ``host`` and register it as a manual device on success.
157
+
158
+ Args:
159
+ host: target IPv4 (or hostname) the human told us about.
160
+ port: override the registry's default port (default None → use
161
+ ``self._port``).
162
+ label: human-readable label. Defaults to ``host`` when None.
163
+ note: optional human annotation persisted with the identity
164
+ (BF001.3 ``note`` field; meaningful only for source="manual").
165
+
166
+ Returns:
167
+ :class:`RegisterResult`. ``ok=True`` + ``record`` on success;
168
+ ``ok=False`` + ``error`` (DeviceUnreachable) on any probe failure.
169
+ Never raises — all probe exceptions are caught and surfaced as
170
+ ``ok=False`` (analysis L3: 人工告知也可能告知错误).
171
+ """
172
+ effective_port = self._port if port is None else port
173
+ endpoint = Endpoint(host, effective_port)
174
+ target = self._safe_probe(endpoint)
175
+ if target is None:
176
+ return RegisterResult(
177
+ ok=False,
178
+ error=DeviceUnreachable(
179
+ f"probe /hello failed for {host}:{effective_port} "
180
+ f"(host unreachable, timed out, or returned invalid /hello)"
181
+ ),
182
+ )
183
+
184
+ record = self._build_record(host, target, label=label, note=note)
185
+ self._pool.upsert(record)
186
+ return RegisterResult(ok=True, record=record)
187
+
188
+ # ------------------------------------------------------------------
189
+ # Internal helpers
190
+ # ------------------------------------------------------------------
191
+
192
+ def _safe_probe(self, endpoint: Endpoint) -> NetworkTarget | None:
193
+ """Probe ``/hello`` and never raise.
194
+
195
+ ``probe_hello`` (BF002) already swallows OSError/URLError/TimeoutError
196
+ /JSONDecodeError → None. We additionally catch any unexpected
197
+ exception (defensive — a buggy urlopen shouldn't crash the registry)
198
+ and return None, so :meth:`register` always returns a
199
+ :class:`RegisterResult`.
200
+ """
201
+ try:
202
+ return probe_hello(
203
+ endpoint,
204
+ timeout=self._probe_timeout,
205
+ urlopen=self._urlopen,
206
+ )
207
+ except Exception: # noqa: BLE001 (defensive — never crash register)
208
+ return None
209
+
210
+ @staticmethod
211
+ def _build_record(
212
+ host: str,
213
+ target: NetworkTarget,
214
+ *,
215
+ label: str | None,
216
+ note: str | None,
217
+ ) -> DeviceRecord:
218
+ """Assemble the DeviceRecord from a successful probe.
219
+
220
+ Identity: ``device_id`` is host-derived (NEVER ``target.device_id``);
221
+ ``label`` falls back to ``host`` when the human didn't supply one.
222
+ Runtime: ``last_known_host``/``last_seen`` record the probe moment
223
+ (memory-only per BF001.3); ``hardware_name``/``machine_id``/
224
+ ``platform`` come straight from /hello (may be None on old phones).
225
+ """
226
+ return DeviceRecord(
227
+ device_id=manual_device_id(host),
228
+ label=label if label else host,
229
+ source="manual",
230
+ last_known_host=host,
231
+ last_seen=time.time(),
232
+ hardware_name=target.hardware_name,
233
+ machine_id=target.machine_id,
234
+ platform=target.platform,
235
+ network_target=target,
236
+ note=note,
237
+ )
238
+
239
+
240
+ __all__ = [
241
+ "DEFAULT_PORT",
242
+ "DEFAULT_PROBE_TIMEOUT",
243
+ "ManualRegistry",
244
+ "RegisterResult",
245
+ ]
@@ -0,0 +1,228 @@
1
+ """USB 设备身份源服务 (R020-BF004).
2
+
3
+ 职责 (design §3.4 + analysis iOS/Android §USB 通道): 通过 USB 通道 (Android
4
+ ``adb devices -l`` / iOS ``flutter devices``) 取设备**身份** (serial /
5
+ usbmuxd id / 机型 / LAN IP), 供下游 BF006 cross_identify 做 USB⊕LAN 交叉识别.
6
+
7
+ 设计要点:
8
+ * **USB 仅当身份源不当数据通道** (analysis backlog 红线 / BF004.3): 本服务
9
+ 只取身份字段, **绝不**做 ``adb forward`` / ``iproxy`` 数据通道转发.
10
+ ``android_lan_ip`` 是身份辅助 (供 BF006 交叉识别用), 不是数据通道.
11
+ * **分端策略** (memory ios16-device-devicectl-pitfall):
12
+ - Android: ``adb devices -l`` → serial + model; LAN IP 经
13
+ ``adb shell ip route get 1.1.1.1`` 精确拿 (供 BF006 交叉识别用).
14
+ - iOS: **只**走 ``flutter devices --machine`` (BF002.2
15
+ ``discover_ios_flutter_candidates``, 解析 usbmuxd id + 机型);
16
+ **绝不**调 ``xcrun devicectl`` (iPhone X iOS 16 永远 unavailable).
17
+ * **复用 device_candidates** (D8 + BF004 决策 1, 不改旧函数):
18
+ - Android: 复用 ``_android_device_ip(serial, run_command)`` 拿精确 LAN IP;
19
+ serial + model 自己解析 ``adb devices -l`` 输出 (device_candidates
20
+ ``_android_connected_devices`` 内部已做此解析但暴露的 ConnectedDeviceEndpoint
21
+ 丢失 serial, 故 BF004 自行解析以保留 device_id=serial).
22
+ - iOS: 直接调 ``discover_ios_flutter_candidates(run_command=...)`` 拿
23
+ ``IosDeviceCandidate`` (device_id=usbmuxd id, model), 转 UsbCandidate.
24
+ * **异常容忍** (与 device_candidates + BF003/BF005 同模式): adb/flutter 不可用
25
+ (命令不存在 / 超时 / 无设备) → 返回空列表, 不抛异常.
26
+
27
+ 设计来源:
28
+ - tasks: .dev-flow/R020/mcp-bridge-device-discovery-tasks.md BF004 节
29
+ - design: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-backend.md
30
+ §3.4 Discovery (UsbIdentity 角色) + D9 device_id 来源
31
+ - analysis: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery.md
32
+ §USB 通道 (USB 仅身份不数据通道) + §iOS/Android
33
+ - test: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-test.md §2.1
34
+ - memory: ios16-device-devicectl-pitfall (iOS 16 devicectl 永远 unavailable)
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from dataclasses import dataclass
40
+
41
+ # device_candidates 是 device_discovery 顶层模块 (BF006 自旧 network 子包迁入).
42
+ # 复用其解析函数 (D8 零重写), 不改其旧函数.
43
+ from ..device_candidates import (
44
+ CommandRunner,
45
+ _android_device_ip,
46
+ _metadata_value,
47
+ _run_command,
48
+ discover_ios_flutter_candidates,
49
+ )
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # UsbCandidate dataclass (BF004.2)
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class UsbCandidate:
58
+ """USB 身份候选 (单台已连接的物理设备).
59
+
60
+ 跨平台统一身份容器: Android 来自 ``adb`` serial, iOS 来自 ``flutter``
61
+ usbmuxd id. ``device_id`` 是稳定身份键 (BF006 cross_identify 用此匹配
62
+ LAN 响应, design D9); ``android_lan_ip`` 是 Android 独有的身份辅助字段
63
+ (经 ``adb shell ip route`` 精确拿, iOS 恒 None), 供 BF006 做交叉识别桥梁.
64
+
65
+ Attributes:
66
+ device_id: 稳定身份标识. Android = adb serial (如 ``R58M1234567``);
67
+ iOS = usbmuxd id (如 ``3992f440...``, ``flutter devices --machine``
68
+ 的 ``id`` 字段).
69
+ model: 机型显示名 (Android = ``model:`` 字段值如 ``SM-G991B``;
70
+ iOS = ``flutter devices --machine`` 的 ``name`` 字段如 ``iPhone X``).
71
+ 弱唯一, 仅用于交叉识别 + 显示.
72
+ platform: ``"android"`` 或 ``"ios"``.
73
+ android_lan_ip: Android 经 ``adb shell ip route get 1.1.1.1`` 精确拿的
74
+ LAN IP (供 BF006 交叉识别用); iOS 恒 ``None`` (USB 仅身份, iOS LAN
75
+ IP 由 BF005 LanScan 在 LAN 侧发现, 不在 USB 侧拿).
76
+ """
77
+
78
+ device_id: str
79
+ model: str
80
+ platform: str
81
+ android_lan_ip: str | None = None
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # UsbIdentity 服务 (BF004.1)
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ class UsbIdentity:
90
+ """USB 设备身份源服务 (Android ``adb`` / iOS ``flutter`` 分端).
91
+
92
+ 分端取 USB 已连接设备的身份 (serial / usbmuxd id / 机型 / LAN IP), 返回
93
+ ``UsbCandidate`` 列表. **仅身份, 不做数据通道** (BF004.3, 不调
94
+ ``adb forward`` / ``iproxy``).
95
+
96
+ Example::
97
+
98
+ svc = UsbIdentity()
99
+ android = svc.android() # [UsbCandidate(device_id="R58M...", ...)]
100
+ ios = svc.ios() # [UsbCandidate(device_id="3992f440...", ...)]
101
+ all_devs = svc.all_candidates() # android + ios 合并
102
+
103
+ Args:
104
+ run_command: 可注入的命令执行器 (默认 device_candidates._run_command,
105
+ 即 subprocess.run 封装); 测试时注入 mock runner (按 command 前缀
106
+ 返回预设输出或抛 OSError). 与 BF003/BF005/device_candidates 同模式.
107
+ """
108
+
109
+ def __init__(self, *, run_command: CommandRunner | None = None) -> None:
110
+ self._run_command: CommandRunner = run_command or _run_command
111
+
112
+ def android(self) -> list[UsbCandidate]:
113
+ """Android USB 身份候选 (经 ``adb devices -l`` + ``adb shell ip route``).
114
+
115
+ 解析 ``adb devices -l`` 输出取 ``device`` 状态的 serial + model; 对每个
116
+ serial 调 device_candidates ``_android_device_ip(serial)`` 经
117
+ ``adb shell ip route get 1.1.1.1`` 拿精确 LAN IP (供 BF006 交叉识别).
118
+
119
+ Returns:
120
+ ``UsbCandidate`` 列表 (platform="android"); 空 list 表示 adb 不可用 /
121
+ 无设备 / 无 LAN IP (拿不到 LAN IP 的设备跳过, 与 device_candidates
122
+ ``_android_connected_devices`` 一致). 永不抛异常.
123
+
124
+ Side effects:
125
+ 调用 ``adb devices -l`` + 每台设备一次 ``adb shell ip route``.
126
+ """
127
+ try:
128
+ output = self._run_command(["adb", "devices", "-l"], 5.0)
129
+ except OSError:
130
+ # adb 不可用 / 超时 / 命令失败 → 与 device_candidates 同模式返空
131
+ return []
132
+ candidates: list[UsbCandidate] = []
133
+ for serial, model in _parse_adb_devices_output(output):
134
+ lan_ip = _android_device_ip(serial, self._run_command)
135
+ if lan_ip is None:
136
+ # 拿不到 LAN IP 的设备跳过 (与 _android_connected_devices 一致:
137
+ # 没有可用 LAN IP 的 Android 设备对 BF006 交叉识别无意义)
138
+ continue
139
+ candidates.append(
140
+ UsbCandidate(
141
+ device_id=serial,
142
+ model=model,
143
+ platform="android",
144
+ android_lan_ip=lan_ip,
145
+ )
146
+ )
147
+ return candidates
148
+
149
+ def ios(self) -> list[UsbCandidate]:
150
+ """iOS USB 身份候选 (经 ``flutter devices --machine``, 不调 devicectl).
151
+
152
+ 复用 BF002.2 ``discover_ios_flutter_candidates`` 解析 ``flutter devices
153
+ --machine`` JSON, 取真机 (排除 simulator/emulator) 的 usbmuxd id + 机型;
154
+ 绝不调 ``xcrun devicectl`` (iPhone X iOS 16 永远 unavailable,
155
+ memory ios16-device-devicectl-pitfall).
156
+
157
+ Returns:
158
+ ``UsbCandidate`` 列表 (platform="ios", android_lan_ip=None); 空 list
159
+ 表示 flutter 不可用 / 无真机 / 解析失败. 永不抛异常.
160
+
161
+ Side effects:
162
+ 调用 ``flutter devices --machine`` (内部已处理多 flutter 候选命令
163
+ ``flutter`` / ``/usr/local/bin/flutter`` / ``fvm`` 等).
164
+ """
165
+ ios_candidates = discover_ios_flutter_candidates(run_command=self._run_command)
166
+ return [
167
+ UsbCandidate(
168
+ device_id=cand.device_id,
169
+ model=cand.model,
170
+ platform="ios",
171
+ android_lan_ip=None, # iOS LAN IP 由 BF005 LanScan 在 LAN 侧发现
172
+ )
173
+ for cand in ios_candidates
174
+ ]
175
+
176
+ def all_candidates(self) -> list[UsbCandidate]:
177
+ """所有 USB 已连接设备 (Android + iOS 合并, Android 在前).
178
+
179
+ Returns:
180
+ ``UsbCandidate`` 列表 (先 android() 后 ios()); 两个端都不抛异常,
181
+ 任一端失败只贡献空列表.
182
+ """
183
+ return [*self.android(), *self.ios()]
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # adb devices -l 解析 (BF004 自行解析以保留 serial, 不复用 _android_connected_devices
188
+ # 因其暴露的 ConnectedDeviceEndpoint 丢失 serial — BF004 device_id=serial 需保留)
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ #: ``adb devices -l`` 输出行的 ``device`` 状态标记 (其余 ``offline``/``unauthorized`` 跳过).
193
+ _ADB_DEVICE_STATE_CONNECTED = "device"
194
+
195
+
196
+ def _parse_adb_devices_output(output: str) -> list[tuple[str, str]]:
197
+ """解析 ``adb devices -l`` 输出, 返回 ``[(serial, model), ...]``.
198
+
199
+ 只取状态为 ``device`` (已授权连接) 的行; ``offline`` / ``unauthorized`` /
200
+ ``recovery`` 等状态跳过. model 来自 ``model:`` 元数据字段 (如
201
+ ``model:SM-G991B``); 缺失则回退 serial (与 device_candidates
202
+ ``_metadata_value(parts, "model") or serial`` 一致).
203
+
204
+ Args:
205
+ output: ``adb devices -l`` 的原始标准输出.
206
+
207
+ Returns:
208
+ ``[(serial, model), ...]`` 列表, 顺序与输入一致; 输出为空 / 全非 device
209
+ 状态 → 空列表.
210
+ """
211
+ results: list[tuple[str, str]] = []
212
+ # 第一行是 "List of devices attached" 表头, 但稳妥起见逐行判断状态字段
213
+ # (而非简单 splitlines()[1:], 防御空输出 / 单行输出).
214
+ for line in output.splitlines():
215
+ parts = line.split()
216
+ if len(parts) < 2 or parts[1] != _ADB_DEVICE_STATE_CONNECTED:
217
+ continue
218
+ serial = parts[0]
219
+ # 复用 device_candidates._metadata_value (D8 零重写, 同模块已 export).
220
+ model = _metadata_value(parts, "model") or serial
221
+ results.append((serial, model))
222
+ return results
223
+
224
+
225
+ __all__ = [
226
+ "UsbCandidate",
227
+ "UsbIdentity",
228
+ ]
@@ -0,0 +1,103 @@
1
+ """VPN-immune LAN CIDR service (R020-BF003).
2
+
3
+ 封装 :mod:`device_discovery.endpoint` 的路由表网段计算, 对上层
4
+ (LanScan / DevicePool) 暴露稳定的 ``lan_cidr()`` 接口, 同时记录 fallback
5
+ 事件 (路由表失败 → socket 出口法) 供上层日志 / 诊断.
6
+
7
+ 设计参考:
8
+ - design §5.4 VPN-immune 网段计算 (route -n get default → en0 → ipconfig
9
+ getifaddr → /24 CIDR; 失败 fallback socket 出口法, 仅兜底不主导)
10
+ - design §4.2 BF003 角色: VpnImmune 在 LanScan 上游提供网段来源
11
+ - tasks BF003: 封装 endpoint.vpn_immune_lan_cidrs + 暴露 fallback 可观测性
12
+
13
+ 隐蔽坑 (design §5.4): 全局 VPN (Surge/ClashX TUN 模式 ``utun1024=198.18.0.1``)
14
+ 劫持 ``socket.connect((1.1.1.1,80))`` 出口, 返回 198.18.x 而非真实 LAN. 本服务
15
+ 依赖 endpoint 的路由表法 (读 route 输出的 interface 行), 默认路径避开 TUN 污染;
16
+ fallback 路径会暴露 ``socket_fallback`` source + 诊断 note 提示上层注意.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+
23
+ from ..endpoint import (
24
+ CommandRunner,
25
+ _run_command_default,
26
+ vpn_immune_lan_cidrs_traced,
27
+ )
28
+
29
+ #: 路由表成功 source 标记 (design §5.4 主路径).
30
+ SOURCE_ROUTE_TABLE = "route_table"
31
+
32
+ #: socket 出口法 fallback source 标记 (design §5.4 兜底路径, 可能受 VPN TUN 污染).
33
+ SOURCE_SOCKET_FALLBACK = "socket_fallback"
34
+
35
+ #: fallback 路径的诊断 note (供上层日志提示运维者注意 VPN TUN 风险).
36
+ _FALLBACK_NOTE = (
37
+ "route table unavailable, socket fallback (may be VPN TUN polluted)"
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class FallbackEvent:
43
+ """VpnImmune 网段解析事件 (供上层日志 / 诊断).
44
+
45
+ 每次 :meth:`VpnImmune.lan_cidr` 调用后刷新 ``VpnImmune.last_event``.
46
+
47
+ Attributes:
48
+ source: ``"route_table"`` (路由表成功) 或 ``"socket_fallback"``
49
+ (回退 socket 出口法, 可能受 VPN TUN 污染).
50
+ cidrs: 本次返回的 CIDR 列表 (与 ``lan_cidr()`` 返回值一致, 拷贝).
51
+ note: 诊断说明; route_table 路径为空字符串, socket_fallback 路径
52
+ 含 VPN TUN 污染风险提示.
53
+ """
54
+
55
+ source: str
56
+ cidrs: list[str]
57
+ note: str = ""
58
+
59
+
60
+ @dataclass
61
+ class VpnImmune:
62
+ """VPN-immune LAN 网段服务 (design §5.4).
63
+
64
+ 封装 endpoint 的路由表法, 对上层暴露 ``lan_cidr() → list[str]``; 记录
65
+ fallback 事件 (路由表失败 → socket 出口法) 供诊断.
66
+
67
+ Example::
68
+
69
+ svc = VpnImmune()
70
+ cidrs = svc.lan_cidr() # ["192.168.1.0/24"]
71
+ event = svc.last_event # FallbackEvent(source="route_table", ...)
72
+ if event.source == "socket_fallback":
73
+ log.warning("LAN scan may be polluted by VPN TUN: %s", event.note)
74
+
75
+ Args:
76
+ run_command: 可注入命令执行器 (默认 ``_run_command_default`` 即 subprocess.run);
77
+ 测试时注入 mock runner (前缀匹配 command → 返回字符串或抛 OSError).
78
+ cidr_prefix_len: CIDR 前缀长度 (默认 24, 与 discover_default_endpoints 一致).
79
+ """
80
+
81
+ run_command: CommandRunner = _run_command_default
82
+ cidr_prefix_len: int = 24
83
+ last_event: FallbackEvent | None = field(default=None, init=False)
84
+
85
+ def lan_cidr(self) -> list[str]:
86
+ """返回真实 LAN 网段列表 (路由表法, VPN TUN 不污染).
87
+
88
+ Returns:
89
+ CIDR 字符串列表 (如 ``["192.168.1.0/24"]``); 空列表表示无 LAN 可达.
90
+ 路由表失败时回退 socket 出口法, 不抛异常 (返回可能含 198.18.x).
91
+
92
+ Side effects:
93
+ 刷新 :attr:`last_event` — 上层可读 ``source`` / ``note`` 做诊断.
94
+ """
95
+ cidrs, source = vpn_immune_lan_cidrs_traced(
96
+ run_command=self.run_command, cidr_prefix_len=self.cidr_prefix_len
97
+ )
98
+ note = "" if source == SOURCE_ROUTE_TABLE else _FALLBACK_NOTE
99
+ # 拷贝 cidrs 避免外部 mutate 污染内部 state (dataclass 默认可变默认值陷阱)
100
+ self.last_event = FallbackEvent(
101
+ source=source, cidrs=list(cidrs), note=note
102
+ )
103
+ return cidrs