mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-03 09:35:41 +00:00
feat: stabilize mobile runtime and VPN portal (#2536)
This commit is contained in:
@@ -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
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
// 虚拟 IP,peer_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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user