feat: stabilize mobile runtime and VPN portal (#2536)

This commit is contained in:
KKRainbow
2026-08-29 13:40:56 +08:00
committed by GitHub
parent 4a10d1c2b9
commit 25f6e2dc5e
31 changed files with 1415 additions and 1302 deletions
+2 -2
View File
@@ -106,8 +106,8 @@ core_clap:
en: "base64 WireGuard server private key (prefer ET_VPN_PORTAL_PRIVATE_KEY over command-line exposure)"
zh-CN: "Base64 WireGuard 服务端私钥(建议通过 ET_VPN_PORTAL_PRIVATE_KEY 传入,避免命令行暴露)"
vpn_portal_client:
en: "named VPN portal client in NAME=IP form; may be repeated"
zh-CN: "NAME=IP 格式的具名 VPN 门户客户端;可重复指定"
en: "named VPN portal client in NAME=CIDR form, for example alice=10.144.0.5/16; may be repeated"
zh-CN: "NAME=CIDR 格式的具名 VPN 门户客户端,例如 alice=10.144.0.5/16;可重复指定"
vpn_portal_client_group:
en: "VPN portal client group membership in NAME=GROUP form; may be repeated"
zh-CN: "NAME=GROUP 格式的 VPN 门户客户端组成员关系;可重复指定"
+21 -7
View File
@@ -896,7 +896,7 @@ impl NetworkOptions {
}
if !url.path().is_empty() {
anyhow::bail!(
"legacy VPN portal CIDR paths are no longer supported; use wg://host:port and configure --vpn-portal-client NAME=IP"
"legacy VPN portal CIDR paths are no longer supported; use wg://host:port and configure --vpn-portal-client NAME=CIDR"
);
}
if !url.username().is_empty()
@@ -924,15 +924,20 @@ impl NetworkOptions {
.iter()
.map(|value| {
let (name, virtual_ip) = value.split_once('=').ok_or_else(|| {
anyhow::anyhow!("invalid vpn portal client {value:?}; expected NAME=IP")
anyhow::anyhow!("invalid vpn portal client {value:?}; expected NAME=CIDR")
})?;
if name.is_empty() {
anyhow::bail!("vpn portal client name cannot be empty");
}
if !virtual_ip.contains('/') {
anyhow::bail!(
"invalid vpn portal client {value:?}; expected NAME=CIDR, for example alice=10.144.0.5/16"
);
}
Ok(VpnPortalClientConfig {
name: name.to_owned(),
virtual_ip: virtual_ip.parse().with_context(|| {
format!("invalid virtual IP for vpn portal client {name}: {virtual_ip}")
format!("invalid virtual CIDR for vpn portal client {name}: {virtual_ip}")
})?,
groups: Vec::new(),
})
@@ -1949,7 +1954,7 @@ wireguard_private_key = "existing-key"
[[vpn_portal_config.clients]]
name = "existing"
virtual_ip = "10.144.144.9"
virtual_ip = "10.144.144.9/24"
"#,
)
.unwrap();
@@ -1971,8 +1976,8 @@ virtual_ip = "10.144.144.9"
NetworkOptions {
vpn_portal_private_key: Some("replacement-key".to_owned()),
vpn_portal_clients: vec![
"alice=10.144.144.10".to_owned(),
"bob=10.144.144.11".to_owned(),
"alice=10.144.144.10/24".to_owned(),
"bob=10.144.144.11/24".to_owned(),
],
vpn_portal_client_groups: vec!["alice=staff".to_owned(), "alice=dev".to_owned()],
..Default::default()
@@ -2024,7 +2029,7 @@ virtual_ip = "10.144.144.9"
let unknown_client = NetworkOptions {
vpn_portal: Some("wg://0.0.0.0:51820".to_owned()),
vpn_portal_clients: vec!["alice=10.144.144.10".to_owned()],
vpn_portal_clients: vec!["alice=10.144.144.10/24".to_owned()],
vpn_portal_client_groups: vec!["bob=staff".to_owned()],
..Default::default()
}
@@ -2035,6 +2040,15 @@ virtual_ip = "10.144.144.9"
unknown_client.contains("unknown CLI client: bob"),
"{unknown_client}"
);
let bare_ip = NetworkOptions {
vpn_portal_clients: vec!["alice=10.144.144.10".to_owned()],
..Default::default()
}
.parse_vpn_portal_clients()
.unwrap_err()
.to_string();
assert!(bare_ip.contains("expected NAME=CIDR"), "{bare_ip}");
}
#[test]
+19 -4
View File
@@ -279,7 +279,7 @@ enum VpnPortalSubCommand {
AddClient {
#[arg(help = "client name")]
name: String,
#[arg(long, help = "client virtual IPv4 address inside the mesh network")]
#[arg(long, help = "client virtual IPv4 CIDR inside the mesh network")]
virtual_ip: String,
#[arg(long, help = "ACL groups assigned to the client")]
groups: Vec<String>,
@@ -612,6 +612,16 @@ fn is_missing_web_client_service(error: &RpcError) -> bool {
)
}
fn parse_vpn_portal_client_cidr(value: &str) -> anyhow::Result<cidr::Ipv4Inet> {
let value = value.trim();
if !value.contains('/') {
anyhow::bail!("client virtual IPv4 must include its network prefix");
}
value
.parse::<cidr::Ipv4Inet>()
.map_err(|error| anyhow::anyhow!("invalid client virtual IPv4 CIDR ({value}): {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -637,6 +647,13 @@ mod tests {
assert!(!is_missing_web_client_service(&error));
}
#[test]
fn vpn_portal_client_requires_a_complete_ipv4_cidr() {
let client = parse_vpn_portal_client_cidr("10.90.0.2/16").unwrap();
assert_eq!(client.to_string(), "10.90.0.2/16");
assert!(parse_vpn_portal_client_cidr("10.90.0.2").is_err());
}
#[test]
fn proxy_cidrs_are_displayed_one_per_line() {
assert_eq!(
@@ -2725,9 +2742,7 @@ impl<'a> CommandHandler<'a> {
virtual_ip: String,
groups: Vec<String>,
) -> Result<(), Error> {
virtual_ip
.parse::<std::net::Ipv4Addr>()
.map_err(|e| anyhow::anyhow!("invalid virtual ip ({virtual_ip}): {e}"))?;
let virtual_ip = parse_vpn_portal_client_cidr(&virtual_ip)?.to_string();
self.apply_to_instances(|handler| {
let name = name.clone();
let virtual_ip = virtual_ip.clone();
+29 -23
View File
@@ -1767,7 +1767,7 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) {
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
clients: vec![VpnPortalClientConfig {
name: "test-client".to_owned(),
virtual_ip: "10.144.144.4".parse().unwrap(),
virtual_ip: "10.144.144.4/24".parse().unwrap(),
groups: Vec::new(),
}],
});
@@ -1808,6 +1808,12 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) {
client_info.client_config.contains("198.51.100.0/24"),
"client config must include remote proxy CIDRs"
);
assert!(
client_info
.client_config
.contains("Address = 10.144.144.4/32"),
"client config must assign the attached peer virtual IP"
);
let (server_public, client_private) =
test_wireguard_keys(&portal_config, "test-client").unwrap();
run_wireguard_client(
@@ -1816,7 +1822,7 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) {
Key::try_from(server_public.as_slice()).unwrap(),
Key::try_from(client_private.as_slice()).unwrap(),
vec!["10.144.144.0/24".to_string()],
"192.0.2.42".to_string(),
"10.144.144.4".to_owned(),
)
.unwrap();
@@ -1867,12 +1873,12 @@ pub async fn wireguard_vpn_portal_multi_client() {
clients: vec![
VpnPortalClientConfig {
name: "client-a".to_owned(),
virtual_ip: "10.144.144.4".parse().unwrap(),
virtual_ip: "10.144.144.4/24".parse().unwrap(),
groups: Vec::new(),
},
VpnPortalClientConfig {
name: "client-b".to_owned(),
virtual_ip: "10.144.144.5".parse().unwrap(),
virtual_ip: "10.144.144.5/24".parse().unwrap(),
groups: Vec::new(),
},
],
@@ -1890,9 +1896,9 @@ pub async fn wireguard_vpn_portal_multi_client() {
.get_vpn_portal_config()
.unwrap();
for (ns, client_name, tunnel_ip) in [
("net_d", "client-a", "192.0.2.42"),
("net_f", "client-b", "192.0.2.43"),
for (ns, client_name, virtual_ip) in [
("net_d", "client-a", "10.144.144.4"),
("net_f", "client-b", "10.144.144.5"),
] {
let net_ns = NetNS::new(Some(ns.into()));
let _g = net_ns.guard();
@@ -1904,7 +1910,7 @@ pub async fn wireguard_vpn_portal_multi_client() {
Key::try_from(server_public.as_slice()).unwrap(),
Key::try_from(client_private.as_slice()).unwrap(),
vec!["10.144.144.0/24".to_string()],
tunnel_ip.to_string(),
virtual_ip.to_owned(),
)
.unwrap();
}
@@ -1923,9 +1929,8 @@ pub async fn wireguard_vpn_portal_multi_client() {
.await;
}
// 跨客户端互 ping 对方的虚拟 IP:一次流量同时覆盖源地址改写
// tunnel_ip -> virtual_ip)与目的地址改写(virtual_ip -> tunnel_ip),
// 回程再反向各执行一遍
// 跨客户端互 ping 对方的虚拟 IP,验证 WireGuard 地址与 attached
// peer 地址相同且双向数据包无需地址改写。
wait_for_condition(
|| async { ping_test("net_d", "10.144.144.5", None).await },
Duration::from_secs(10),
@@ -1938,7 +1943,7 @@ pub async fn wireguard_vpn_portal_multi_client() {
.await;
// TCP 数据面:node1 侧看到的连接源地址必须是 client-a 的虚拟 IP
// 并做一段随机数据回环,覆盖 TCP 增量校验和改写路径
// 并做一段随机数据回环,验证传输层校验和保持不变。
let mut buf = vec![0u8; 1024];
rand::thread_rng().fill(&mut buf[..]);
let expected = buf.clone();
@@ -1964,7 +1969,8 @@ pub async fn wireguard_vpn_portal_multi_client() {
}
echo_task.await.unwrap();
// portal 状态:两个客户端均在线,tunnel_ip 学习正确,peer_id 互不相同
// portal 状态:两个客户端均在线,隧道地址等于各自的 attached peer
// 虚拟 IPpeer_id 互不相同。
let portal_info = insts[2].get_core_instance().vpn_portal_info().await;
assert_eq!(portal_info.clients.len(), 2);
let client_a = portal_info
@@ -1982,8 +1988,8 @@ pub async fn wireguard_vpn_portal_multi_client() {
assert!(client.peer_id.is_some());
}
assert_ne!(client_a.peer_id, client_b.peer_id);
assert_eq!(client_a.tunnel_ip, Some("192.0.2.42".parse().unwrap()));
assert_eq!(client_b.tunnel_ip, Some("192.0.2.43".parse().unwrap()));
assert_eq!(client_a.tunnel_ip, Some("10.144.144.4".parse().unwrap()));
assert_eq!(client_b.tunnel_ip, Some("10.144.144.5".parse().unwrap()));
drop_insts(insts).await;
}
@@ -2006,7 +2012,7 @@ pub async fn wireguard_vpn_portal_client_roaming() {
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
clients: vec![VpnPortalClientConfig {
name: "roaming-client".to_owned(),
virtual_ip: "10.144.144.4".parse().unwrap(),
virtual_ip: "10.144.144.4/24".parse().unwrap(),
groups: Vec::new(),
}],
});
@@ -2033,7 +2039,7 @@ pub async fn wireguard_vpn_portal_client_roaming() {
Key::try_from(server_public.as_slice()).unwrap(),
Key::try_from(client_private.as_slice()).unwrap(),
vec!["10.144.144.0/24".to_string()],
"192.0.2.42".to_string(),
"10.144.144.4".to_owned(),
)
.unwrap();
}
@@ -2156,7 +2162,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() {
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
clients: vec![VpnPortalClientConfig {
name: "client-a".to_owned(),
virtual_ip: "10.144.144.4".parse().unwrap(),
virtual_ip: "10.144.144.4/24".parse().unwrap(),
groups: Vec::new(),
}],
});
@@ -2186,7 +2192,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() {
Key::try_from(server_public.as_slice()).unwrap(),
Key::try_from(client_private.as_slice()).unwrap(),
vec!["10.144.144.0/24".to_string()],
"192.0.2.42".to_string(),
"10.144.144.4".to_owned(),
)
.unwrap();
}
@@ -2204,7 +2210,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() {
action: ConfigPatchAction::Add as i32,
client: Some(VpnPortalClientConfigPb {
name: "client-b".to_owned(),
virtual_ip: "10.144.144.5".to_owned(),
virtual_ip: "10.144.144.5/24".to_owned(),
groups: Vec::new(),
}),
}],
@@ -2234,7 +2240,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() {
action: ConfigPatchAction::Add as i32,
client: Some(VpnPortalClientConfigPb {
name: "client-b".to_owned(),
virtual_ip: "10.144.144.9".to_owned(),
virtual_ip: "10.144.144.9/24".to_owned(),
groups: Vec::new(),
}),
}],
@@ -2274,7 +2280,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() {
Key::try_from(server_public.as_slice()).unwrap(),
Key::try_from(client_private.as_slice()).unwrap(),
vec!["10.144.144.0/24".to_string()],
"192.0.2.43".to_string(),
"10.144.144.5".to_owned(),
)
.unwrap();
}
@@ -2325,7 +2331,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() {
assert_eq!(info.clients[0].state, PortalClientState::Online);
assert_eq!(
info.clients[0].tunnel_ip,
Some("192.0.2.43".parse().unwrap())
Some("10.144.144.5".parse().unwrap())
);
// Release the held CoreInstance Arc so drop_insts can observe a clean
+1 -7
View File
@@ -262,12 +262,10 @@ impl WireGuardPortalHost {
fn derive_client(master: [u8; 32], client: &PortalClientConfig) -> anyhow::Result<DerivedClient> {
let wireguard_private = derive_named_key(&master, b"wireguard-client", &client.name)?;
let identity_private_key = derive_named_key(&master, b"attached-noise", &client.name)?;
Ok(DerivedClient {
config: client.clone(),
wireguard_private,
wireguard_public: PublicKey::from(&StaticSecret::from(wireguard_private)),
identity_private_key,
})
}
fn secondary_ipv6_bind_address(address: SocketAddr, primary_port: u16) -> Option<SocketAddr> {
@@ -394,17 +392,13 @@ mod tests {
use super::*;
#[test]
fn named_keys_are_domain_separated_and_stable() {
fn named_wireguard_keys_are_stable_and_client_scoped() {
let master = [7; 32];
let client = derive_named_key(&master, b"wireguard-client", "laptop").unwrap();
assert_eq!(
client,
derive_named_key(&master, b"wireguard-client", "laptop").unwrap()
);
assert_ne!(
client,
derive_named_key(&master, b"attached-noise", "laptop").unwrap()
);
assert_ne!(
client,
derive_named_key(&master, b"wireguard-client", "phone").unwrap()
+18 -4
View File
@@ -22,6 +22,7 @@ use easytier_core::{
gateway::vpn_portal::{PortalClientConfig, PortalSession},
socket::udp::VirtualUdpSocket,
};
use rand::rngs::OsRng;
use tokio::{
sync::{Mutex, mpsc, watch},
task::JoinSet,
@@ -41,7 +42,6 @@ pub(super) struct DerivedClient {
pub(super) config: PortalClientConfig,
pub(super) wireguard_private: [u8; 32],
pub(super) wireguard_public: PublicKey,
pub(super) identity_private_key: [u8; 32],
}
struct PortalChannels {
@@ -52,6 +52,7 @@ struct PortalChannels {
struct ClientSession {
generation: u64,
identity_private_key: [u8; 32],
endpoint: Option<Endpoint>,
endpoint_updates: watch::Sender<String>,
tunnel: Tunn,
@@ -356,7 +357,7 @@ impl PortalEngine {
let _ = self.accepted.send(PortalSession {
client_name: slot.client.config.name.clone(),
endpoint: channels.endpoint,
identity_private_key: slot.client.identity_private_key,
identity_private_key: session.identity_private_key,
from_client: channels.from_client,
to_client: channels.to_client,
});
@@ -402,6 +403,7 @@ impl PortalEngine {
});
ClientSession {
generation,
identity_private_key: new_attached_identity_private_key(),
endpoint: Some(Endpoint { socket, remote }),
endpoint_updates,
tunnel: Tunn::new(
@@ -521,6 +523,11 @@ impl PortalEngine {
}
}
}
fn new_attached_identity_private_key() -> [u8; 32] {
StaticSecret::random_from_rng(OsRng).to_bytes()
}
fn is_handshake_initiation(packet: &[u8]) -> bool {
packet.len() == 148 && packet.get(..4) == Some(&1u32.to_le_bytes())
}
@@ -542,15 +549,22 @@ mod tests {
DerivedClient {
config: PortalClientConfig {
name: name.to_owned(),
virtual_ip: "192.0.2.1".parse().unwrap(),
virtual_ip: "10.82.0.2/24".parse().unwrap(),
groups: Vec::new(),
},
wireguard_private: secret.to_bytes(),
wireguard_public: PublicKey::from(&secret),
identity_private_key: [seed.wrapping_add(1); 32],
}
}
#[test]
fn attached_identity_is_unique_to_each_live_session() {
assert_ne!(
new_attached_identity_private_key(),
new_attached_identity_private_key()
);
}
fn slot_index(engine: &PortalEngine, name: &str) -> Option<u32> {
engine
.slots