mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-12 15:26:14 -08:00
feat(vpn): multi-client WireGuard portal with attached peers (#2502)
* feat(peer): support protocol-agnostic attached peers Add locally attached peers backed by independent, peer-level portable managers and authenticated in-process ring connections. Carry trusted connection provenance through packet admission so attached relay privileges cannot be forged through packet headers. Let every peer manager own ACL loading, sanitized policy updates, route refresh, and runtime cleanup. In Secure Mode, grant attached identities ephemeral credentials instead of sharing administrator and group secrets. * feat(vpn): add reusable attached-peer portal runtime Add a protocol-neutral portal runtime that converts authenticated client sessions into attached EasyTier peers. Own per-client generations, status, packet forwarding, address translation, and peer cleanup without knowing the transport protocol. Add transactional IPv4 source and destination rewriting with correct IPv4, TCP, UDP, ICMP, and quoted-packet checksum updates. Keep the old production portal path temporarily active until the WireGuard adapter is migrated in the next change. * feat(wireguard): attach named clients through peer portal Replace the monolithic WireGuard portal with a native adapter that owns key derivation, UDP demultiplexing, reauthentication, roaming, and bounded per-client packet queues. Hand authenticated sessions to the generic portal runtime for peer lifecycle and IPv4 translation. Move portal configuration into the core instance model, require a dedicated server key, and preserve existing listener, CLI, and runtime configuration behavior. Reject runtime address conflicts before publishing shared configuration. * feat(vpn): expose per-client portal status Project configured clients and their runtime state through the portal RPC, including generated client configuration, listener, peer identity, endpoint, tunnel address, ACL groups, and errors. Keep private client configuration out of the broad instance-info response and expose the explicit RPC through the CLI and Tauri bridge. * feat(vpn): add portal configuration to web clients Expose WireGuard portal listener, key, client, ACL group, and runtime status fields in the shared frontend library, Web dashboard, and Tauri client. Preserve UUID and uint64 values across protobuf JSON boundaries, keep dynamic client editor rows stable, and document the portal workflow. * test(vpn): cover multi-client and roaming WireGuard portals Add two three-node integration tests for the WireGuard VPN portal. The multi-client test connects two kernel WireGuard clients from separate network namespaces, verifies per-client connectivity to mesh nodes, and exercises cross-client traffic that runs the IPv4 source and destination translation in both directions. A TCP echo exchange through the portal additionally covers the TCP pseudo-header checksum rewrite path that ICMP-only ping tests miss, and portal status snapshots must report both clients online with distinct peer ids and correctly learned tunnel addresses. The roaming test swaps the client namespace address (delete the old address, then add the new one) so the kernel WireGuard source cache is invalidated and the client keeps sending under the same session from the new source, exactly like a real network change. The portal must update the client endpoint on the same peer id via the data path (same generation, no re-handshake, no detach/reconnect) while connectivity to mesh nodes is preserved. Supporting changes: run_wireguard_client now takes an interface name, and the shared namespace topology gains net_f (10.1.2.5) on the portal bridge for the second client.
This commit is contained in:
1 parent
57eb6908f4
commit
62e4fd15e9
63 files changed
+7759
-1216
No files matched your search
@@ -7,6 +7,8 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[cfg(feature = "wireguard")]
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use easytier_core::{
|
||||
connectivity::protocol::raw::TunnelDialer,
|
||||
foundation::stats::{LabelSet, LabelType, MetricName, MetricSnapshot},
|
||||
@@ -99,7 +101,13 @@ async fn set_foreign_network_refresh_interval(inst: &Instance, seconds: u64) {
|
||||
}
|
||||
|
||||
#[cfg(feature = "wireguard")]
|
||||
use crate::{common::config::VpnPortalConfig, vpn_portal::wireguard::get_wg_config_for_portal};
|
||||
use crate::{
|
||||
common::config::{VpnPortalClientConfig, VpnPortalConfig},
|
||||
vpn_portal::wireguard::test_wireguard_keys,
|
||||
};
|
||||
|
||||
#[cfg(feature = "wireguard")]
|
||||
use easytier_core::gateway::vpn_portal::PortalClientState;
|
||||
|
||||
pub fn prepare_linux_namespaces() {
|
||||
del_netns("net_a");
|
||||
@@ -107,12 +115,14 @@ pub fn prepare_linux_namespaces() {
|
||||
del_netns("net_c");
|
||||
del_netns("net_d");
|
||||
del_netns("net_e");
|
||||
del_netns("net_f");
|
||||
|
||||
create_netns("net_a", "10.1.1.1/24", "fd11::1/64");
|
||||
create_netns("net_b", "10.1.1.2/24", "fd11::2/64");
|
||||
create_netns("net_c", "10.1.2.3/24", "fd12::3/64");
|
||||
create_netns("net_d", "10.1.2.4/24", "fd12::4/64");
|
||||
create_netns("net_e", "10.1.1.3/24", "fd11::3/64");
|
||||
create_netns("net_f", "10.1.2.5/24", "fd12::5/64");
|
||||
|
||||
prepare_bridge("br_a");
|
||||
prepare_bridge("br_b");
|
||||
@@ -122,6 +132,7 @@ pub fn prepare_linux_namespaces() {
|
||||
add_ns_to_bridge("br_a", "net_e");
|
||||
add_ns_to_bridge("br_b", "net_c");
|
||||
add_ns_to_bridge("br_b", "net_d");
|
||||
add_ns_to_bridge("br_b", "net_f");
|
||||
}
|
||||
|
||||
pub fn get_inst_config(
|
||||
@@ -1676,7 +1687,17 @@ use defguard_wireguard_rs::{
|
||||
InterfaceConfiguration, WGApi, WireguardInterfaceApi, host::Peer, key::Key, net::IpAddrMask,
|
||||
};
|
||||
|
||||
fn wireguard_ifname(base: &str) -> String {
|
||||
if cfg!(target_os = "linux") || cfg!(target_os = "freebsd") {
|
||||
base.to_owned()
|
||||
} else {
|
||||
"utun3".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_wireguard_client(
|
||||
ifname: &str,
|
||||
endpoint: SocketAddr,
|
||||
peer_public_key: Key,
|
||||
client_private_key: Key,
|
||||
@@ -1684,12 +1705,7 @@ fn run_wireguard_client(
|
||||
client_ip: String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create new API object for interface
|
||||
let ifname: String = if cfg!(target_os = "linux") || cfg!(target_os = "freebsd") {
|
||||
"wg0".into()
|
||||
} else {
|
||||
"utun3".into()
|
||||
};
|
||||
let wgapi = WGApi::new(ifname.clone(), false)?;
|
||||
let wgapi = WGApi::new(ifname.to_owned(), false)?;
|
||||
|
||||
// create interface
|
||||
wgapi.create_interface()?;
|
||||
@@ -1707,7 +1723,7 @@ fn run_wireguard_client(
|
||||
|
||||
// interface configuration
|
||||
let interface_config = InterfaceConfiguration {
|
||||
name: ifname.clone(),
|
||||
name: ifname.to_owned(),
|
||||
prvkey: client_private_key.to_string(),
|
||||
address: client_ip,
|
||||
port: 12345,
|
||||
@@ -1730,10 +1746,25 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) {
|
||||
let insts = init_three_node_ex(
|
||||
"tcp",
|
||||
|config| {
|
||||
let identity = config.get_network_identity();
|
||||
config.set_network_identity(NetworkIdentity::new(
|
||||
identity.network_name,
|
||||
"wireguard-portal-test".to_owned(),
|
||||
));
|
||||
if config.get_inst_name() == "inst1" {
|
||||
config
|
||||
.add_proxy_cidr("198.51.100.0/24".parse().unwrap(), None)
|
||||
.unwrap();
|
||||
}
|
||||
if config.get_inst_name() == "inst3" {
|
||||
config.set_vpn_portal_config(VpnPortalConfig {
|
||||
wireguard_listen: "0.0.0.0:22121".parse().unwrap(),
|
||||
client_cidr: "10.14.14.0/24".parse().unwrap(),
|
||||
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(),
|
||||
groups: Vec::new(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
config
|
||||
@@ -1756,13 +1787,31 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) {
|
||||
|
||||
let net_ns = NetNS::new(Some("net_d".into()));
|
||||
let _g = net_ns.guard();
|
||||
let wg_cfg = get_wg_config_for_portal(&insts[2].get_global_ctx().get_network_identity());
|
||||
let portal_config = insts[2]
|
||||
.get_global_ctx()
|
||||
.config
|
||||
.get_vpn_portal_config()
|
||||
.unwrap();
|
||||
let portal_info = insts[2].get_core_instance().vpn_portal_info().await;
|
||||
assert_eq!(portal_info.clients.len(), 1);
|
||||
let client_info = portal_info
|
||||
.clients
|
||||
.iter()
|
||||
.find(|client| client.name == "test-client")
|
||||
.expect("configured client must be reported");
|
||||
assert!(
|
||||
client_info.client_config.contains("198.51.100.0/24"),
|
||||
"client config must include remote proxy CIDRs"
|
||||
);
|
||||
let (server_public, client_private) =
|
||||
test_wireguard_keys(&portal_config, "test-client").unwrap();
|
||||
run_wireguard_client(
|
||||
&wireguard_ifname("wg0"),
|
||||
dst_socket_addr,
|
||||
Key::try_from(wg_cfg.my_public_key()).unwrap(),
|
||||
Key::try_from(wg_cfg.peer_secret_key()).unwrap(),
|
||||
vec!["10.14.14.0/24".to_string(), "10.144.144.0/24".to_string()],
|
||||
"10.14.14.2".to_string(),
|
||||
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(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1788,6 +1837,302 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) {
|
||||
drop_insts(insts).await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "wireguard")]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
pub async fn wireguard_vpn_portal_multi_client() {
|
||||
use rand::Rng as _;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::{TcpListener, TcpStream},
|
||||
};
|
||||
|
||||
let insts = init_three_node_ex(
|
||||
"tcp",
|
||||
|config| {
|
||||
let identity = config.get_network_identity();
|
||||
config.set_network_identity(NetworkIdentity::new(
|
||||
identity.network_name,
|
||||
"wireguard-portal-multi-client-test".to_owned(),
|
||||
));
|
||||
if config.get_inst_name() == "inst3" {
|
||||
config.set_vpn_portal_config(VpnPortalConfig {
|
||||
wireguard_listen: "0.0.0.0:22121".parse().unwrap(),
|
||||
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(),
|
||||
groups: Vec::new(),
|
||||
},
|
||||
VpnPortalClientConfig {
|
||||
name: "client-b".to_owned(),
|
||||
virtual_ip: "10.144.144.5".parse().unwrap(),
|
||||
groups: Vec::new(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
config
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
let portal_config = insts[2]
|
||||
.get_global_ctx()
|
||||
.config
|
||||
.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"),
|
||||
] {
|
||||
let net_ns = NetNS::new(Some(ns.into()));
|
||||
let _g = net_ns.guard();
|
||||
let (server_public, client_private) =
|
||||
test_wireguard_keys(&portal_config, client_name).unwrap();
|
||||
run_wireguard_client(
|
||||
&wireguard_ifname("wg0"),
|
||||
"10.1.2.3:22121".parse().unwrap(),
|
||||
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(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 两个客户端各自 ping mesh 内节点
|
||||
for ns in ["net_d", "net_f"] {
|
||||
wait_for_condition(
|
||||
|| async { ping_test(ns, "10.144.144.1", None).await },
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
wait_for_condition(
|
||||
|| async { ping_test(ns, "10.144.144.2", None).await },
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 跨客户端互 ping 对方的虚拟 IP:一次流量同时覆盖源地址改写
|
||||
// (tunnel_ip -> virtual_ip)与目的地址改写(virtual_ip -> tunnel_ip),
|
||||
// 回程再反向各执行一遍
|
||||
wait_for_condition(
|
||||
|| async { ping_test("net_d", "10.144.144.5", None).await },
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
wait_for_condition(
|
||||
|| async { ping_test("net_f", "10.144.144.4", None).await },
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
|
||||
// TCP 数据面:node1 侧看到的连接源地址必须是 client-a 的虚拟 IP,
|
||||
// 并做一段随机数据回环,覆盖 TCP 增量校验和改写路径
|
||||
let mut buf = vec![0u8; 1024];
|
||||
rand::thread_rng().fill(&mut buf[..]);
|
||||
let expected = buf.clone();
|
||||
let echo_task = tokio::spawn(async move {
|
||||
let net_ns = NetNS::new(Some("net_a".into()));
|
||||
let _g = net_ns.guard();
|
||||
let socket = TcpListener::bind("0.0.0.0:22222").await.unwrap();
|
||||
let (mut st, addr) = socket.accept().await.unwrap();
|
||||
assert_eq!(addr.ip().to_string(), "10.144.144.4".to_string());
|
||||
let mut rbuf = vec![0u8; 1024];
|
||||
st.read_exact(&mut rbuf).await.unwrap();
|
||||
assert_eq!(rbuf, expected);
|
||||
st.write_all(&rbuf).await.unwrap();
|
||||
});
|
||||
{
|
||||
let net_ns = NetNS::new(Some("net_d".into()));
|
||||
let _g = net_ns.guard();
|
||||
let mut stream = TcpStream::connect("10.144.144.1:22222").await.unwrap();
|
||||
stream.write_all(&buf).await.unwrap();
|
||||
let mut rbuf = vec![0u8; 1024];
|
||||
stream.read_exact(&mut rbuf).await.unwrap();
|
||||
assert_eq!(rbuf, buf);
|
||||
}
|
||||
echo_task.await.unwrap();
|
||||
|
||||
// portal 状态:两个客户端均在线,tunnel_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
|
||||
.clients
|
||||
.iter()
|
||||
.find(|client| client.name == "client-a")
|
||||
.expect("client-a must be reported");
|
||||
let client_b = portal_info
|
||||
.clients
|
||||
.iter()
|
||||
.find(|client| client.name == "client-b")
|
||||
.expect("client-b must be reported");
|
||||
for client in [client_a, client_b] {
|
||||
assert_eq!(client.state, PortalClientState::Online);
|
||||
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()));
|
||||
|
||||
drop_insts(insts).await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "wireguard")]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
pub async fn wireguard_vpn_portal_client_roaming() {
|
||||
let insts = init_three_node_ex(
|
||||
"tcp",
|
||||
|config| {
|
||||
let identity = config.get_network_identity();
|
||||
config.set_network_identity(NetworkIdentity::new(
|
||||
identity.network_name,
|
||||
"wireguard-portal-roaming-test".to_owned(),
|
||||
));
|
||||
if config.get_inst_name() == "inst3" {
|
||||
config.set_vpn_portal_config(VpnPortalConfig {
|
||||
wireguard_listen: "0.0.0.0:22121".parse().unwrap(),
|
||||
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(),
|
||||
groups: Vec::new(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
config
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
let portal_config = insts[2]
|
||||
.get_global_ctx()
|
||||
.config
|
||||
.get_vpn_portal_config()
|
||||
.unwrap();
|
||||
{
|
||||
let net_ns = NetNS::new(Some("net_d".into()));
|
||||
let _g = net_ns.guard();
|
||||
let (server_public, client_private) =
|
||||
test_wireguard_keys(&portal_config, "roaming-client").unwrap();
|
||||
run_wireguard_client(
|
||||
&wireguard_ifname("wg0"),
|
||||
"10.1.2.3:22121".parse().unwrap(),
|
||||
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(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 客户端在 net_d(源地址 10.1.2.4)上线
|
||||
wait_for_condition(
|
||||
|| async { ping_test("net_d", "10.144.144.1", None).await },
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
let peer_id = {
|
||||
let info = insts[2].get_core_instance().vpn_portal_info().await;
|
||||
let client = info
|
||||
.clients
|
||||
.iter()
|
||||
.find(|client| client.name == "roaming-client")
|
||||
.expect("roaming client must be reported");
|
||||
assert_eq!(client.state, PortalClientState::Online);
|
||||
assert!(
|
||||
client
|
||||
.endpoint
|
||||
.as_deref()
|
||||
.is_some_and(|endpoint| endpoint.starts_with("10.1.2.4:")),
|
||||
"unexpected endpoint before roaming: {:?}",
|
||||
client.endpoint
|
||||
);
|
||||
client.peer_id
|
||||
};
|
||||
assert!(peer_id.is_some());
|
||||
|
||||
// 模拟客户端换网络:net_d 把地址从 10.1.2.4 换成 10.1.2.9。内核
|
||||
// WireGuard 为 peer endpoint 缓存的源地址随旧地址一起失效,客户端
|
||||
// 不重建 peer、不重新握手,直接用原 session 从新源继续发数据包,
|
||||
// portal 应在数据路径上更新 endpoint
|
||||
for args in [
|
||||
vec![
|
||||
"netns".to_owned(),
|
||||
"exec".to_owned(),
|
||||
"net_d".to_owned(),
|
||||
"ip".to_owned(),
|
||||
"addr".to_owned(),
|
||||
"del".to_owned(),
|
||||
"10.1.2.4/24".to_owned(),
|
||||
"dev".to_owned(),
|
||||
get_guest_veth_name("net_d").to_owned(),
|
||||
],
|
||||
vec![
|
||||
"netns".to_owned(),
|
||||
"exec".to_owned(),
|
||||
"net_d".to_owned(),
|
||||
"ip".to_owned(),
|
||||
"addr".to_owned(),
|
||||
"add".to_owned(),
|
||||
"10.1.2.9/24".to_owned(),
|
||||
"dev".to_owned(),
|
||||
get_guest_veth_name("net_d").to_owned(),
|
||||
],
|
||||
] {
|
||||
let ret = std::process::Command::new("ip")
|
||||
.args(&args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
ret.status.success(),
|
||||
"ip {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&ret.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
// 驱动流量(ping 经内核 WireGuard 加密后从新源发出),portal 应在
|
||||
// 同一个 peer 上更新 endpoint:peer_id 不变说明是同代漫游,客户端没有掉线重连
|
||||
wait_for_condition(
|
||||
|| async {
|
||||
ping_test("net_d", "10.144.144.1", None).await;
|
||||
let info = insts[2].get_core_instance().vpn_portal_info().await;
|
||||
info.clients.iter().any(|client| {
|
||||
client.name == "roaming-client"
|
||||
&& client.state == PortalClientState::Online
|
||||
&& client.peer_id == peer_id
|
||||
&& client
|
||||
.endpoint
|
||||
.as_deref()
|
||||
.is_some_and(|endpoint| endpoint.starts_with("10.1.2.9:"))
|
||||
})
|
||||
},
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await;
|
||||
|
||||
// 漫游后连通性保持
|
||||
wait_for_condition(
|
||||
|| async { ping_test("net_d", "10.144.144.1", None).await },
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
wait_for_condition(
|
||||
|| async { ping_test("net_d", "10.144.144.3", None).await },
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
|
||||
drop_insts(insts).await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "wireguard")]
|
||||
#[rstest::rstest]
|
||||
#[tokio::test]
|
||||
|
||||
Reference in new issue
Block a user