Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions doc/tech/dns-mode-e2e-recipe.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,113 @@ cp /tmp/manifest-backup.json "$MANIFEST"

---

## 5.6 Scenario F — 系统 DNS 被外部改动(issue #153 不一致探测)

`probe_system_dns` 是前端**独立于 Rust 内存态**的第二个数据源。`get_dns_mode`
只返回 `state.dns_enabled` 这个 `AtomicBool`("mHost 认为自己是开是关"),
而本场景验证的是 OS 侧的实际配置("系统实际上指向哪里")。两者分歧时
Settings 页 DNS 卡片顶部出现 `data-testid="dns-discrepancy-banner"` 横幅。

### 5.6.1 not_pointing 方向(显示 Running,系统没指向 mHost)

```bash
# 1. UI 启用 DNS 模式,确认 Status = Running
# 2. UI 切到别的 app(或直接改 DNS 再切回 mHost 触发 window focus 探测)
# 切回 mHost 窗口本身就会触发探测(App.tsx focus listener,1s 冷却)

# 3. 外部把系统 DNS 改成 8.8.8.8(模拟用户/别的工具动了网络配置)
IFACE=$(networksetup -listnetworkserviceorder | grep -A2 "Hardware Port: Wi-Fi" | grep "Device" | awk '{print $2}')
sudo networksetup -setdnsservers "Wi-Fi" 8.8.8.8

# 4. 切回 mHost 窗口 → focus 触发探测
```

期望:

- Settings 页 DNS 卡片出现横幅,`data-discrepancy="not_pointing"`
- 标题 `System DNS does not point at mHost`
- 正文显示实际读到的 servers(`8.8.8.8`)和接口名(`Wi-Fi`)
- **没有** Restore 按钮 —— 这个方向不危险(DNS 还能用),修它要重启
enable 流程(两次 sudo + 重建 DnsServer)
- 此时 mHost 的 DNS 规则**确实不生效**(`dig` 打到 8.8.8.8 而不是 1053)

```bash
# 5. 验证规则确实没生效(用一个被 hosts profile 覆盖的域名)
dig +short <被覆盖的域名> @8.8.8.8
# 期望: 上游返回的真实 IP,不是 profile 里配的 IP
```

**恢复方式(UI)**:关闭再开启 DNS 模式。横幅应在 toggle 完成后自动消失
(`toggleDnsModeAtom` 成功分支会重新探测)。

### 5.6.2 stuck_at_loopback 方向(显示 Stopped,系统卡在 127.0.0.1)

这是 **#152 那个 bug 的样子** —— 用户完全无计可施的原因,当时前端只有内存态
这一个数据源。**危险**:DNS 指向一个没人监听的地址,解析直接失败。

```bash
# 1. UI 禁用 DNS 模式,确认 Status = Stopped

# 2. 外部把系统 DNS 指向 127.0.0.1(模拟 disable 部分失败 / proxy 崩溃残留)
sudo networksetup -setdnsservers "Wi-Fi" 127.0.0.1

# 3. 切走再切回 mHost 窗口 → focus 触发探测
# (也可以重启 mHost,启动时的 truth-fetch 会并行探测)
```

期望:

- 横幅出现,`data-discrepancy="stuck_at_loopback"`
- 标题 `System DNS still points at mHost`(标题不写死 127.0.0.1:IPv6-only 环境正文会显示 `::1`)
- 正文提示 `Domain resolution may be broken right now`
- **有** `data-testid="dns-restore-button"`(`Restore system DNS`)
- 此时 `dig any-domain` 应该失败/超时

```bash
# 4. 验证网络解析确实坏了
dig +short example.com
# 期望: 超时 / SERVFAIL(127.0.0.1:53 上没有监听者)

# 5. 点「Restore system DNS」→ 弹 sudo(走既有的 disable 事务)
```

期望:

- 系统 DNS 被还原(DhcpEmpty 场景写 `Empty`,Manual 场景写回原始 servers)
- 横幅自动消失
- `dig example.com` 恢复正常

```bash
# 6. 验证还原结果
networksetup -getdnsservers "Wi-Fi"
# 期望: "There aren't any DNS Servers set on Wi-Fi"(或用户原始手动配置)
```

**关键点**:此时 Rust 内存态 `dns_enabled` 已经是 `false`,但
`set_dns_mode_disable` **没有**「已禁用就短路」的分支 —— 它会真的执行
`disable_dns_mode()` 走完整个还原事务。这正是这条恢复路径成立的前提,
**不要**给它加短路优化。

### 5.6.3 探测不可用(不应显示横幅)

```bash
# 断开 Wi-Fi / 拔网线,然后切回 mHost 窗口
```

期望:

- `route -n get default` 失败 → 后端返回 `Err`
- 前端**静默**吞掉(不弹 toast、不写 `dnsErrorAtom`),`systemDnsAtom` 置 null
- **不显示横幅** —— 无从判断就不报警

日志里可见(`tracing::debug`,需 RUST_LOG=debug):

```
probe_system_dns: interface=Wi-Fi servers=["127.0.0.1"] points_at_loopback=true
```

---

## 6. 日志 grep 一览表

| 期望日志 | 含义 |
Expand All @@ -226,13 +333,15 @@ cp /tmp/manifest-backup.json "$MANIFEST"
| `try_recover_dns: disable recovery marker found at ...` | 下次启动兜底命中 |
| `force restore failed` | sudo 兜底也失败 |
| `recovery marker left at ...` | marker 保留,下次启动 retry |
| `probe_system_dns: interface=... points_at_loopback=...` | issue #153 探测结果(需 `RUST_LOG=debug`) |

| 不期望日志 | 含义 |
|------------|------|
| `bind: Address already in use` | port 53 被占(orphan proxy 没清干净) |
| `Failed to enable DNS mode` | enable 失败(一般配合 osascript 超时) |
| `dns-proxy failed to become ready within 5s` | ready 文件超时(proxy 启动失败) |
| `recovery marker found` 在 successful disable 后 | 误报(说明 marker 没被清) |
| `probe system dns failed` 的前端错误提示 | issue #153 的探测失败应该静默,出现说明契约被破坏 |

---

Expand Down Expand Up @@ -260,6 +369,7 @@ ls -la "$(find . -path '*/MacOS/mhost-dns-proxy' -type f | head -1)"
- **Wi-Fi 切换**: Scenario A 假设用户稳定连接 Wi-Fi;如果中途断网 / 切到有线,networksetup 输出会改变,建议在稳定的 home Wi-Fi 环境下测。
- **macOS 版本差异**: 早期 macOS(< 12)的 networksetup 输出格式略有差异,但只要是 DHCP-empty 状态,输出都是 `There aren't any DNS Servers set on Wi-Fi`。
- **TCC 缓存**: 第一次跑 Scenario A 之前用户可能需要授权一次 mhost(系统弹窗)。授权后 5 分钟内不再弹(macOS 默认缓存策略)。
- **Scenario F 的 focus 触发**: 探测依赖 `window` 的 `focus` 事件(1s 冷却)。如果用自动化手段(AppleScript / XCTest)切窗口,事件可能不触发 —— 此时改用重启 mHost 验证,启动时的 truth-fetch 会并行探测。

---

Expand All @@ -271,3 +381,5 @@ ls -la "$(find . -path '*/MacOS/mhost-dns-proxy' -type f | head -1)"
- Issue #152 讨论历史 — 完整 regression diff + 候选 root cause 分析
- `src-tauri/crates/mhost-dns/src/platform.rs` — `verify_dns_restored_against_loopback` 实现 + tests
- `src-tauri/crates/mhost-core/src/models.rs` — `OriginalDns::restore_argv` 防御层 + tests
- Issue #153 — 系统 DNS 不一致探测(`probe_system_dns` IPC + Settings 横幅)
- `src-tauri/crates/mhost-dns/src/platform.rs` — `probe_system_dns_state` / `probe_snapshot_from`(注意与 `networksetup_get_dns` 的过滤语义**故意相反**)
14 changes: 14 additions & 0 deletions src-tauri/crates/mhost-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ pub enum MhostError {
#[error("invalid input: {0}")]
InvalidInput(String),

/// The requested OS-level capability doesn't exist on this platform.
///
/// Distinct from [`MhostError::InvalidInput`] because the caller did
/// nothing wrong: `probe_system_dns` (issue #153) returns this on
/// non-macOS, where DNS mode itself isn't available yet (#67 tracks
/// Windows / Linux). Reporting a platform limitation as "invalid input"
/// would be a lie that surfaces verbatim if any future code path ever
/// renders the message to the user.
///
/// Serializes as `{ Unsupported: "<reason>" }`; `extractErrorMessage`
/// renders it as `unsupported on this platform: <reason>`.
#[error("unsupported on this platform: {0}")]
Unsupported(String),

/// A quick apply requested with `require_safe` was rejected because the
/// change is destructive (conflicts, would disable another profile, or a
/// bulk change over the threshold). The caller must fall back to the
Expand Down
181 changes: 181 additions & 0 deletions src-tauri/crates/mhost-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,59 @@ pub struct DnsStatus {
pub cache_capacity: usize,
}

// ---------------------------------------------------------------------------
// SystemDnsSnapshot (issue #153)
// ---------------------------------------------------------------------------

/// **issue #153**:系统 DNS 的**只读真相快照**,由 `probe_system_dns` IPC
/// 直接从 OS 读出(`networksetup -getdnsservers`),不经过任何 Rust 内存
/// 状态。
///
/// 与 [`DnsStatus`] 的区别是本 issue 的核心:
///
/// - `DnsStatus` 是 **mHost 自己视角**的运行态(`DnsServer` 是否在跑、
/// `state.dns_enabled` 捕获的 original…)。它只反映「mHost 认为自己是
/// 什么状态」。
/// - `SystemDnsSnapshot` 是 **OS 视角**的实际配置。两者可能不一致 ——
/// #152(DNS 关掉后卡在 127.0.0.1)就是这类分歧的实例,而当时前端
/// 根本没有第二个数据源能发现它。
///
/// `servers` **保留原始未过滤内容**(含 `127.0.0.1`),仅供 UI 展示;
/// 判定 `points_at_loopback` 一律走 [`is_local_resolver`]。
///
/// **不要用这个类型替换 `capture_dns_state()` 的返回类型**:后者服务于
/// 「用户的原始 DNS 是什么」并**必须**过滤掉 loopback(#152 root cause 2:
/// 把 mHost 自己注入的 `127.0.0.1` 当成用户原始值持久化,会永久污染后续
/// 还原)。两者语义相反,任何「顺手合并」都会重新引入那个 bug。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SystemDnsSnapshot {
/// 默认路由对应的 hardware port(如 `Wi-Fi`、`USB 10/100/1000 LAN`)。
pub interface: String,
/// `networksetup -getdnsservers` 的原始条目,**未过滤**。
/// 空 = 用户没手动配(DHCP 默认)。
pub servers: Vec<String>,
/// 任一条目指向 loopback / unspecified。
pub points_at_loopback: bool,
}

impl SystemDnsSnapshot {
/// 从接口名 + 原始 server 列表构造,`points_at_loopback` 由
/// [`is_local_resolver`] 推导。
///
/// 语义是 **any**(不是 all):enable 路径把系统 DNS 设成单个
/// `127.0.0.1`,所以 any 命中即代表「mHost 在接管」。混排
/// (`127.0.0.1, 8.8.8.8`)按接管处理 —— 保守方向,与
/// `platform::any_local_resolver`(#152 hardening Step 3)一致。
pub fn new(interface: impl Into<String>, servers: Vec<String>) -> Self {
let points_at_loopback = servers.iter().any(|s| is_local_resolver(s));
Self {
interface: interface.into(),
servers,
points_at_loopback,
}
}
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1455,4 +1508,132 @@ mod tests {
assert_eq!(q, ApplyMode::QuickApply);
assert_eq!(r, ApplyMode::RequirePreview);
}

// -----------------------------------------------------------------------
// SystemDnsSnapshot tests (issue #153)
// -----------------------------------------------------------------------

/// `points_at_loopback` 判定是 issue #153 整个特性的地基:前端拿它和
/// `dnsEnabledAtom` 对比来决定是否报不一致。判定错 = 用户要么被假警报
/// 骚扰,要么在真卡住时看不到横幅。
///
/// 表格驱动覆盖:
/// - `127.0.0.1` / `::1` / `0.0.0.0` → true(loopback + unspecified)
/// - `host:port` / `[v6]:port` 形式也走 is_local_resolver 的解析
/// - 公共 DNS → false
/// - 空列表(DHCP 默认)→ false
/// - **混排 any 语义**:`127.0.0.1, 8.8.8.8` → true
#[test]
fn test_system_dns_snapshot_points_at_loopback() {
struct Case {
name: &'static str,
servers: Vec<&'static str>,
expected: bool,
}
let cases = vec![
Case {
name: "single_ipv4_loopback",
servers: vec!["127.0.0.1"],
expected: true,
},
Case {
name: "ipv6_loopback",
servers: vec!["::1"],
expected: true,
},
Case {
name: "ipv4_unspecified",
servers: vec!["0.0.0.0"],
expected: true,
},
Case {
name: "loopback_with_port",
servers: vec!["127.0.0.1:53"],
expected: true,
},
Case {
name: "bracketed_ipv6_loopback_with_port",
servers: vec!["[::1]:53"],
expected: true,
},
Case {
name: "public_resolvers",
servers: vec!["8.8.8.8", "1.1.1.1"],
expected: false,
},
Case {
name: "empty_dhcp_default",
servers: vec![],
expected: false,
},
// any 语义:只要有一条指向 loopback 就算接管。enable 路径把
// 系统 DNS 设成单个 127.0.0.1,混排是异常状态但仍要按接管
// 判定(保守方向,与 platform::any_local_resolver 一致)。
Case {
name: "mixed_loopback_first_is_any_semantics",
servers: vec!["127.0.0.1", "8.8.8.8"],
expected: true,
},
// 畸形字符串按「非本地」处理(is_local_resolver 的既有契约)。
Case {
name: "malformed_entry_is_not_loopback",
servers: vec!["not-an-ip"],
expected: false,
},
];

for c in cases {
let snapshot =
SystemDnsSnapshot::new("Wi-Fi", c.servers.iter().map(|s| s.to_string()).collect());
assert_eq!(snapshot.points_at_loopback, c.expected, "case: {}", c.name);
assert_eq!(snapshot.interface, "Wi-Fi", "case: {}", c.name);
assert_eq!(snapshot.servers.len(), c.servers.len(), "case: {}", c.name);
}
}

/// `servers` 必须**原样保留**(含 loopback),只用于 UI 展示。
///
/// 如果这里有人「顺手」加个 filter(因为 #152 在 `capture_dns_state`
/// 路径上加过一次),探测会永远返回空列表,`points_at_loopback` 恒为
/// false,issue #153 静默退化成 no-op —— 且不会有任何测试失败。
/// 这个断言就是那堵墙。
#[test]
fn test_system_dns_snapshot_preserves_loopback_in_servers() {
let snapshot = SystemDnsSnapshot::new("Wi-Fi", vec!["127.0.0.1".to_string()]);
assert_eq!(
snapshot.servers,
vec!["127.0.0.1".to_string()],
"servers must be the RAW networksetup output — filtering here silently \
disables issue #153's probe"
);
assert!(snapshot.points_at_loopback);
}

#[test]
fn test_system_dns_snapshot_serde_roundtrip() {
let cases = vec![
(
"loopback",
SystemDnsSnapshot::new("Wi-Fi", vec!["127.0.0.1".into()]),
),
(
"public",
SystemDnsSnapshot::new("Ethernet", vec!["1.1.1.1".into()]),
),
("empty", SystemDnsSnapshot::new("Wi-Fi", vec![])),
];

for (name, snapshot) in cases {
let json = serde_json::to_string(&snapshot).unwrap();
let restored: SystemDnsSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(snapshot, restored, "case: {}", name);
// points_at_loopback 必须在线上(前端直接读这个字段决定横幅)。
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
parsed.get("points_at_loopback").is_some(),
"case {}: points_at_loopback missing from wire format",
name
);
}
}
}
Loading
Loading