fix(sync): enumerate LAN addresses without netlink

OpenHarmony forbids apps from binding netlink sockets, so
local-ip-address failed and the whole Sync panel failed to load.
Enumerate interfaces through getifaddrs (if-addrs) on every platform,
the same source mdns-sd uses.

- One port call returns available and default addresses from a single
  snapshot.
- The default prefers running broadcast interfaces, then the default
  route, so full-tunnel VPNs and cellular links are not chosen.
- The pairing return address stays route-based and moves into the
  adapter, its only caller.
This commit is contained in:
Darkatse 2026-10-04 18:00:15 -04:00
parent 9329278c6d
commit b7c8b33558
9 changed files with 164 additions and 193 deletions

View file

@ -47,9 +47,11 @@ LAN 上传保留发起方的覆盖策略,但实际回拉使用目标设备的
本机广播随接收服务启停,只下载时可以独立发现。发现失败不删除配对或停用仍可用的 HTTPS 连接;停止接收服务不取消已接受的本地下载作业。
附近发现使用 mDNS / DNS-SD。iOS 与 macOS 使用系统 Bonjour,Android、Windows 与 Linux 使用 Rust mDNS;两套后端共享服务信息与设备目录,记录更新和过期由发现后端处理。
本机地址按用途分三类:**可用地址**列出全部非回环 IPv4,包括 VPN 与组网地址;**默认地址**用于配对链接,优先取运行中的广播接口上默认路由所在的地址,广播接口也包括虚拟网桥与 TAP;**回连地址**在配对时取本机到对端路由的源地址。
LAN 功能受宿主 `sync.lan` 能力门禁和平台权限约束。移动端权限、签名与生命周期要求见 [Android](../AndroidDevelopment.md) 和 [iOS](../iOSDevelopment.md)。
附近发现使用 mDNS / DNS-SD。iOS 受直接组播权限限制,使用系统 Bonjour,macOS 共用这一后端;Android、Windows、Linux 与鸿蒙使用 Rust mDNS。两套后端共享服务信息与设备目录,记录更新和过期由发现后端处理。
LAN 功能受宿主 `sync.lan` 能力门禁和平台权限约束。移动端权限、签名与生命周期要求见 [Android](../AndroidDevelopment.md)、[iOS](../iOSDevelopment.md) 和 [鸿蒙](../OpenHarmonyDevelopment.md)。
## 面板、事件与自动同步

View file

@ -149,26 +149,32 @@ ArkWeb 的 `javaScriptOnDocumentStart` 按字典序执行多个脚本条目,
鸿蒙是[文件传输](CurrentState/FileTransfer.md)的一个平台实现,与 Android 共用 URI 复制代码。鸿蒙特有的约束:dialog fork 只为面板返回的 URI 授权,授权保存在 Ability 的内存中,进程结束即失效。
## 8. 权限
## 8. 局域网同步
鸿蒙与 Android、Windows、Linux 共用 Rust mDNS 与本机地址实现,地址规则见[同步](CurrentState/Sync.md#lan-发现与信任)。鸿蒙特有的约束:普通应用不能绑定 `NETLINK_ROUTE` 套接字;本机地址经 libc `getifaddrs` 获取,鸿蒙 musl 的实现不绑定该套接字,接口查询受限时用 ioctl 补齐接口名和标志。鸿蒙没有公开的组播锁接口。
## 9. 权限
| 权限 | 用途 |
| --- | --- |
| `INTERNET` | 模型请求、扩展下载等网络访问 |
| `INTERNET` | 模型请求、扩展下载、局域网同步等网络访问 |
| `CAMERA` | 条码插件扫码(ScanKit,需要支持的 HarmonyOS 设备) |
| `READ_PASTEBOARD` | 网页通过 `navigator.clipboard.readText` 主动读取剪贴板:`/clipboard-get`,以及 Quick Reply 的"从剪贴板添加"。复制和普通粘贴不依赖它。它属于受限权限(需要 ACL),ArkWeb 是否要求这个权限尚未实测 |
## 9. 未覆盖
## 10. 未覆盖
- **后台生成:** 鸿蒙没有对应的后台执行保护(`generation_background` 返回空),应用退到后台后,生成可能被系统挂起。
- **局域网同步:** 编译时走的是 Linux 路径(mdns-sd,用 netlink 获取本机地址),没有在设备上验证。
- **局域网同步:** 已在 x86_64 模拟器验证;真机上的 Wi-Fi、热点、VPN 与跨设备互通尚未验证。
- **系统通知、扫码、语音合成:** 尚未验证。
## 10. 设备验收清单
## 11. 设备验收清单
- ARM64 真机和 x86_64 模拟器都能进入主界面,`window.__TAURITAVERN_HOST__` 为 `{ platform: 'ohos', kind: 'mobile' }`。
- localStorage 和 IndexedDB 在强制结束进程后仍保留。
- 沙箱 iframe 无法通过任何一条 IPC 路径调用原生接口。
- 新建聊天、发送、保存、强制结束后重新打开,内容完整;超过单帧预算的聊天提交与上传成功。
- 原生选择器导入、保存面板导出可用,取消不报错;`<input type="file">` 可用。
- 同步面板(含 TT-Sync)可打开,接收服务能启动;与其他设备互相发现、配对并双向同步。
- Wi-Fi 与移动数据同时在线时,配对链接使用 Wi-Fi 地址;组网地址出现在可用地址中,可通过手动地址配对与同步。
- TT 设置页能打开;返回键逐层关闭弹窗和抽屉,最后退到后台。
- 复制、粘贴与 `/clipboard-get` 正常;外链、全屏、媒体 Range、键盘和安全区表现正常。

53
src-tauri/Cargo.lock generated
View file

@ -2735,17 +2735,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "getset"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "gif"
version = "0.14.2"
@ -4890,17 +4879,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "local-ip-address"
version = "0.6.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3"
dependencies = [
"libc",
"neli",
"windows-sys 0.61.2",
]
[[package]]
name = "lock_api"
version = "0.4.14"
@ -5348,35 +5326,6 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "neli"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87"
dependencies = [
"bitflags 2.13.1",
"byteorder",
"derive_builder",
"getset",
"libc",
"log",
"neli-proc-macros",
"parking_lot",
]
[[package]]
name = "neli-proc-macros"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609"
dependencies = [
"either",
"proc-macro2",
"quote",
"serde",
"syn 2.0.119",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@ -9477,7 +9426,7 @@ dependencies = [
"base64 0.23.1",
"filetime",
"futures-util",
"local-ip-address",
"if-addrs",
"mdns-sd",
"rand 0.10.3",
"reqwest",

View file

@ -9,7 +9,7 @@ async-trait = "0.1"
axum = { version = "0.8", default-features = false, features = ["http1", "http2", "json", "query", "tokio"] }
axum-server = { version = "0.8", features = ["tls-rustls"] }
filetime = "0.2"
local-ip-address = "0.6"
if-addrs = "0.15"
rand = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls", "stream"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }

View file

@ -9,7 +9,7 @@ use ttsync_contract::status::StatusResponse;
use ttsync_contract::sync::OverwritePolicy;
use super::LanStatusResponse;
use super::discovery::LocalLanAddressDiscovery;
use super::discovery::routed_advertise_address;
use crate::sync::http_client::{
SyncHttpClient, bearer_auth_value, ensure_dataset_scope_v1, ensure_success,
};
@ -25,7 +25,7 @@ use tt_domain::models::lan_sync::{
LanPairCompleteRequest, LanPairCompleteResponse, LanSyncPairedDevice,
};
use tt_ports::lan_discovery::LanDeviceDiscovery;
use tt_ports::lan_sync::{LanAddressDiscovery, LanPairingClient};
use tt_ports::lan_sync::LanPairingClient;
#[derive(Clone)]
pub struct LanSyncClient {
@ -264,9 +264,7 @@ impl LanPairingClient for HttpLanPairingClient {
})
.await
.map_err(|_| DomainError::Transient("Timed out locating LAN Sync device".to_string()))??;
let client_base_url = LocalLanAddressDiscovery
.routed_advertise_address(&base_url, local_device.port)
.await?;
let client_base_url = routed_advertise_address(&base_url, local_device.port).await?;
let request = LanPairCompleteRequest {
device_id: local_device.device_id.clone(),
device_name: local_device.device_name.clone(),

View file

@ -1,112 +1,150 @@
use async_trait::async_trait;
use local_ip_address::{list_afinet_netifas, local_ip};
use std::collections::BTreeMap;
use std::io;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
use if_addrs::IfAddr;
use url::Url;
use tt_domain::errors::DomainError;
use tt_ports::lan_sync::LanAddressDiscovery;
use tt_ports::lan_sync::{LanAddressDiscovery, LocalLanAddresses};
/// TEST-NET-1 (RFC 5737) is never on-link, so routing to it selects the default route.
const DEFAULT_ROUTE_PROBE: SocketAddr =
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(192, 0, 2, 1), 9));
pub struct LocalLanAddressDiscovery;
#[async_trait]
impl LanAddressDiscovery for LocalLanAddressDiscovery {
fn list_available_addresses(&self, port: u16) -> Result<Vec<String>, DomainError> {
let ifas =
list_afinet_netifas().map_err(|error| DomainError::InternalError(error.to_string()))?;
let mut addresses = ifas
.into_iter()
.filter_map(|(_name, ip)| match ip {
std::net::IpAddr::V4(ip) => {
if ip.is_loopback() || ip.is_unspecified() {
None
} else {
Some(format!("https://{}:{}", ip, port))
}
}
std::net::IpAddr::V6(_) => None,
})
.collect::<Vec<_>>();
addresses.sort();
addresses.dedup();
Ok(addresses)
}
fn default_advertise_address(
&self,
port: u16,
available_addresses: &[String],
) -> Option<String> {
let route_ip = local_ip().ok().and_then(|ip| match ip {
std::net::IpAddr::V4(v4) => Some(format!("https://{}:{}", v4, port)),
std::net::IpAddr::V6(_) => None,
});
route_ip
.filter(|addr| available_addresses.contains(addr))
.or_else(|| available_addresses.first().cloned())
}
async fn routed_advertise_address(
&self,
peer_base_url: &str,
local_port: u16,
) -> Result<String, DomainError> {
let peer_url = Url::parse(peer_base_url)
.map_err(|error| DomainError::InvalidData(error.to_string()))?;
if peer_url.scheme() != "https" {
return Err(DomainError::InvalidData(
"LAN Sync peer URL must use https".to_string(),
));
}
let peer_host = peer_url.host_str().ok_or_else(|| {
DomainError::InvalidData("LAN Sync peer URL is missing host".to_string())
fn local_addresses(&self, port: u16) -> Result<LocalLanAddresses, DomainError> {
let interfaces = if_addrs::get_if_addrs().map_err(|error| {
DomainError::InternalError(format!("Failed to enumerate LAN addresses: {error}"))
})?;
let peer_port = peer_url.port_or_known_default().ok_or_else(|| {
DomainError::InvalidData("LAN Sync peer URL is missing port".to_string())
})?;
let remote_addr = tokio::net::lookup_host((peer_host, peer_port))
.await
.map_err(|error| DomainError::InternalError(error.to_string()))?
.find(|addr| addr.is_ipv4())
.ok_or_else(|| {
DomainError::InvalidData("No IPv4 LAN Sync peer address resolved".to_string())
})?;
let socket = tokio::net::UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, 0))
.await
.map_err(|error| DomainError::InternalError(error.to_string()))?;
socket
.connect(remote_addr)
.await
.map_err(|error| DomainError::InternalError(error.to_string()))?;
let local_addr = socket
.local_addr()
.map_err(|error| DomainError::InternalError(error.to_string()))?;
match local_addr.ip() {
std::net::IpAddr::V4(ip) if !ip.is_unspecified() => {
Ok(format!("https://{}:{}", ip, local_port))
// Address -> whether it is on a running broadcast interface.
let mut addresses = BTreeMap::<Ipv4Addr, bool>::new();
for interface in &interfaces {
let IfAddr::V4(address) = &interface.addr else {
continue;
};
if address.ip.is_loopback() || address.ip.is_unspecified() {
continue;
}
_ => Err(DomainError::InvalidData(
"No routable IPv4 LAN Sync address".to_string(),
)),
*addresses.entry(address.ip).or_default() |=
interface.is_oper_up() && address.broadcast.is_some();
}
// The probe is only a preference; when it fails, candidate order decides.
let route = route_source(DEFAULT_ROUTE_PROBE)
.inspect_err(|error| tracing::debug!(%error, "LAN default route probe failed"))
.ok();
Ok(LocalLanAddresses {
default: select_default(&addresses, route).map(|ip| lan_url(ip, port)),
available: addresses.keys().map(|&ip| lan_url(ip, port)).collect(),
})
}
}
/// Candidates are the broadcast-interface addresses, or all addresses when there are none.
/// The route wins among candidates; otherwise the lowest address does.
fn select_default(
addresses: &BTreeMap<Ipv4Addr, bool>,
route: Option<Ipv4Addr>,
) -> Option<Ipv4Addr> {
let broadcast_only = addresses.values().any(|&broadcast| broadcast);
let is_candidate = |ip: &Ipv4Addr| {
addresses
.get(ip)
.is_some_and(|&broadcast| broadcast || !broadcast_only)
};
route
.filter(is_candidate)
.or_else(|| addresses.keys().copied().find(is_candidate))
}
fn lan_url(ip: Ipv4Addr, port: u16) -> String {
format!("https://{ip}:{port}")
}
/// A UDP connect sends nothing; the kernel only resolves the route and its source address.
fn route_source(remote: SocketAddr) -> io::Result<Ipv4Addr> {
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
socket.connect(remote)?;
match socket.local_addr()?.ip() {
IpAddr::V4(ip) if !ip.is_unspecified() => Ok(ip),
_ => Err(io::Error::new(
io::ErrorKind::AddrNotAvailable,
"No routable IPv4 LAN Sync address",
)),
}
}
pub(crate) async fn routed_advertise_address(
peer_base_url: &str,
local_port: u16,
) -> Result<String, DomainError> {
let peer_url =
Url::parse(peer_base_url).map_err(|error| DomainError::InvalidData(error.to_string()))?;
if peer_url.scheme() != "https" {
return Err(DomainError::InvalidData(
"LAN Sync peer URL must use https".to_string(),
));
}
let peer_host = peer_url
.host_str()
.ok_or_else(|| DomainError::InvalidData("LAN Sync peer URL is missing host".to_string()))?;
let peer_port = peer_url
.port_or_known_default()
.ok_or_else(|| DomainError::InvalidData("LAN Sync peer URL is missing port".to_string()))?;
let remote_addr = tokio::net::lookup_host((peer_host, peer_port))
.await
.map_err(|error| DomainError::InternalError(error.to_string()))?
.find(|addr| addr.is_ipv4())
.ok_or_else(|| {
DomainError::InvalidData("No IPv4 LAN Sync peer address resolved".to_string())
})?;
let ip = route_source(remote_addr).map_err(|error| {
DomainError::InternalError(format!(
"Failed to determine LAN Sync return address: {error}"
))
})?;
Ok(lan_url(ip, local_port))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_address_prefers_broadcast_interfaces_then_the_route() {
let wifi = Ipv4Addr::new(192, 168, 1, 20);
let ethernet = Ipv4Addr::new(192, 168, 1, 10);
let tunnel = Ipv4Addr::new(100, 64, 0, 1);
for (interfaces, route, expected) in [
(
vec![(wifi, true), (ethernet, true), (tunnel, false)],
Some(wifi),
Some(wifi),
),
(
vec![(wifi, true), (tunnel, false)],
Some(tunnel),
Some(wifi),
),
(vec![(wifi, false), (tunnel, false)], Some(wifi), Some(wifi)),
(vec![(wifi, true), (ethernet, true)], None, Some(ethernet)),
(vec![], None, None),
] {
assert_eq!(
select_default(&interfaces.into_iter().collect(), route),
expected
);
}
}
#[tokio::test]
async fn routed_lan_advertise_address_uses_peer_route() {
let address = LocalLanAddressDiscovery
.routed_advertise_address("https://127.0.0.1:50000", 56000)
let address = routed_advertise_address("https://127.0.0.1:50000", 56000)
.await
.expect("routed address");
assert_eq!(address, "https://127.0.0.1:56000");
}
}

View file

@ -93,15 +93,12 @@ impl LanSyncService {
None => (false, settings.port),
};
let available_addresses = self.addresses.list_available_addresses(port)?;
let address = self
.addresses
.default_advertise_address(port, &available_addresses);
let addresses = self.addresses.local_addresses(port)?;
Ok(LanSyncStatus {
device_name: identity.device_name,
running,
address,
available_addresses,
address: addresses.default,
available_addresses: addresses.available,
port,
sync_mode,
manual_default_mode,
@ -207,10 +204,10 @@ impl LanSyncService {
pub async fn get_pairing_info(&self) -> Result<LanSyncPairingInfo, DomainError> {
let server_info = self.ensure_server_running().await?;
let addresses = self.addresses.list_available_addresses(server_info.port)?;
let address = self
.addresses
.default_advertise_address(server_info.port, &addresses)
.local_addresses(server_info.port)?
.default
.ok_or_else(|| {
DomainError::InvalidData("No available LAN sync addresses".to_string())
})?;

View file

@ -6,7 +6,7 @@ use ttsync_contract::peer::DeviceId;
use ttsync_contract::sync::{OverwritePolicy, SyncMode};
use super::pairing_link::{default_lan_permissions, device_pubkey_b64url};
use super::ports::LanPairingApprovalRequest;
use super::ports::{LanPairingApprovalRequest, LocalLanAddresses};
use super::*;
use crate::services::data_change_reconciler::DataChangeReconciler;
use crate::services::sync_job_coordinator::{SyncJobEventPublisher, SyncJobExecutor};
@ -215,26 +215,12 @@ impl LanServerControl for MemoryServerControl {
struct NoopAddressDiscovery;
#[async_trait]
impl LanAddressDiscovery for NoopAddressDiscovery {
fn list_available_addresses(&self, _port: u16) -> Result<Vec<String>, DomainError> {
Ok(Vec::new())
}
fn default_advertise_address(
&self,
_port: u16,
_available_addresses: &[String],
) -> Option<String> {
None
}
async fn routed_advertise_address(
&self,
_peer_base_url: &str,
_local_port: u16,
) -> Result<String, DomainError> {
Err(DomainError::InternalError("not used".to_string()))
fn local_addresses(&self, _port: u16) -> Result<LocalLanAddresses, DomainError> {
Ok(LocalLanAddresses {
available: Vec::new(),
default: None,
})
}
}

View file

@ -60,19 +60,14 @@ pub trait LanServerEvents: Send + Sync {
fn pairing_completed(&self);
}
#[async_trait]
#[derive(Debug, Clone)]
pub struct LocalLanAddresses {
pub available: Vec<String>,
pub default: Option<String>,
}
pub trait LanAddressDiscovery: Send + Sync {
fn list_available_addresses(&self, port: u16) -> Result<Vec<String>, DomainError>;
fn default_advertise_address(
&self,
port: u16,
available_addresses: &[String],
) -> Option<String>;
async fn routed_advertise_address(
&self,
peer_base_url: &str,
local_port: u16,
) -> Result<String, DomainError>;
fn local_addresses(&self, port: u16) -> Result<LocalLanAddresses, DomainError>;
}
#[async_trait]