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
@@ -723,7 +723,7 @@ mod tests {
wireguard_private_key: Some("server-private-key".to_owned()),
clients: vec![manage::VpnPortalClientConfig {
name: "alice".to_owned(),
virtual_ip: "10.144.144.10".to_owned(),
virtual_ip: "10.144.144.10/16".to_owned(),
groups: vec!["staff".to_owned()],
}],
}
@@ -751,7 +751,7 @@ mod tests {
Some("server-private-key")
);
assert_eq!(portal.clients[0].name, "alice");
assert_eq!(portal.clients[0].virtual_ip.to_string(), "10.144.144.10");
assert_eq!(portal.clients[0].virtual_ip.to_string(), "10.144.144.10/16");
assert_eq!(portal.clients[0].groups, vec!["staff".to_owned()]);
let output = NetworkConfig::new_from_config(&config).unwrap();
+2 -2
View File
@@ -473,7 +473,7 @@ impl std::fmt::Debug for VpnPortalConfig {
#[serde(deny_unknown_fields)]
pub struct VpnPortalClientConfig {
pub name: String,
pub virtual_ip: std::net::Ipv4Addr,
pub virtual_ip: cidr::Ipv4Inet,
#[serde(default)]
pub groups: Vec<String>,
}
@@ -1279,7 +1279,7 @@ wireguard_private_key = "wireguard-private-key"
[[vpn_portal_config.clients]]
name = "alice"
virtual_ip = "10.144.144.10"
virtual_ip = "10.144.144.10/24"
groups = ["staff"]
[acl.acl_v1.group]
+3 -4
View File
@@ -1,10 +1,9 @@
//! Protocol-neutral portal runtime and host adapter seam.
mod ipv4_translator;
mod runtime;
pub use runtime::{
DEFAULT_PORTAL_CLIENT_ADDRESS, MAX_VPN_PORTAL_CLIENTS, PortalClientConfig,
PortalClientConfigPlan, PortalClientInfoSnapshot, PortalClientState, PortalHost,
PortalInfoSnapshot, PortalListener, PortalModule, PortalRuntimeConfig, PortalSession,
MAX_VPN_PORTAL_CLIENTS, PortalClientConfig, PortalClientConfigPlan, PortalClientInfoSnapshot,
PortalClientState, PortalHost, PortalInfoSnapshot, PortalListener, PortalModule,
PortalRuntimeConfig, PortalSession,
};
@@ -1,974 +0,0 @@
use std::net::Ipv4Addr;
const IPV4_MIN_HEADER_LEN: usize = 20;
const TCP_MIN_HEADER_LEN: usize = 20;
const UDP_HEADER_LEN: usize = 8;
const ICMP_MIN_HEADER_LEN: usize = 8;
const IP_PROTOCOL_ICMP: u8 = 1;
const IP_PROTOCOL_TCP: u8 = 6;
const IP_PROTOCOL_UDP: u8 = 17;
const IPV4_CHECKSUM_OFFSET: usize = 10;
const IPV4_SOURCE_OFFSET: usize = 12;
const IPV4_DESTINATION_OFFSET: usize = 16;
const TCP_CHECKSUM_OFFSET: usize = 16;
const UDP_CHECKSUM_OFFSET: usize = 6;
const ICMP_CHECKSUM_OFFSET: usize = 2;
const ICMP_QUOTED_PACKET_OFFSET: usize = 8;
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub(crate) enum Ipv4TranslationError {
#[error("IPv4 packet is too short: expected at least 20 bytes, got {actual}")]
PacketTooShort { actual: usize },
#[error("unsupported IP version {version}; expected IPv4")]
UnsupportedIpVersion { version: u8 },
#[error("invalid IPv4 IHL {ihl_words}; expected at least 5 words")]
InvalidHeaderLength { ihl_words: u8 },
#[error("truncated IPv4 header: header is {header_len} bytes, packet is {actual} bytes")]
TruncatedHeader { header_len: usize, actual: usize },
#[error("IPv4 total length {declared} does not match payload length {actual}")]
TotalLengthMismatch { declared: usize, actual: usize },
#[error("unexpected IPv4 source: expected {expected}, got {actual}")]
UnexpectedSource {
expected: Ipv4Addr,
actual: Ipv4Addr,
},
#[error("unexpected IPv4 destination: expected {expected}, got {actual}")]
UnexpectedDestination {
expected: Ipv4Addr,
actual: Ipv4Addr,
},
#[error("unsupported IPv4 protocol {protocol}")]
UnsupportedProtocol { protocol: u8 },
#[error("truncated {protocol} header: expected at least {required} bytes, got {actual}")]
TruncatedTransportHeader {
protocol: &'static str,
required: usize,
actual: usize,
},
#[error("invalid TCP data offset {data_offset_words}; expected at least 5 words")]
InvalidTcpHeaderLength { data_offset_words: u8 },
#[error("truncated TCP header: header is {header_len} bytes, fragment carries {actual} bytes")]
TruncatedTcpHeader { header_len: usize, actual: usize },
#[error("invalid UDP length {declared} for an IPv4 payload carrying {actual} UDP bytes")]
InvalidUdpLength { declared: usize, actual: usize },
#[error("non-final IPv4 fragment carries {actual} bytes; expected a multiple of 8")]
InvalidFragmentLength { actual: usize },
#[error("ICMP error quotes only {actual} IPv4 bytes; expected at least 20")]
QuotedPacketTooShort { actual: usize },
#[error("ICMP error quotes IP version {version}; expected IPv4")]
UnsupportedQuotedIpVersion { version: u8 },
#[error("ICMP error quotes an invalid IPv4 IHL {ihl_words}; expected at least 5 words")]
InvalidQuotedHeaderLength { ihl_words: u8 },
#[error(
"ICMP error quotes a truncated IPv4 header: header is {header_len} bytes, quote is {actual} bytes"
)]
TruncatedQuotedHeader { header_len: usize, actual: usize },
#[error(
"ICMP error quotes an IPv4 total length {declared} smaller than its {header_len}-byte header"
)]
InvalidQuotedTotalLength { declared: usize, header_len: usize },
#[error("unsupported protocol {protocol} in translated ICMP IPv4 quote")]
UnsupportedQuotedProtocol { protocol: u8 },
}
#[derive(Clone, Copy)]
enum AddressField {
Source,
Destination,
}
impl AddressField {
fn offset(self) -> usize {
match self {
Self::Source => IPV4_SOURCE_OFFSET,
Self::Destination => IPV4_DESTINATION_OFFSET,
}
}
}
#[derive(Clone, Copy)]
struct Ipv4Layout {
header_len: usize,
protocol: u8,
fragment_offset: u16,
more_fragments: bool,
}
#[derive(Clone, Copy)]
struct ChecksumField {
offset: usize,
udp: bool,
}
#[derive(Clone, Copy)]
struct QuotedIpv4Plan {
header_offset: usize,
header_len: usize,
replace_source: bool,
replace_destination: bool,
transport_checksum: Option<ChecksumField>,
}
#[derive(Clone, Copy)]
enum TransportPlan {
HeaderOnly,
WithPseudoHeaderChecksum(ChecksumField),
Icmp {
checksum_offset: usize,
quoted: Option<QuotedIpv4Plan>,
},
}
pub(crate) fn rewrite_ipv4_source(
packet: &mut [u8],
old_address: Ipv4Addr,
new_address: Ipv4Addr,
) -> Result<(), Ipv4TranslationError> {
rewrite_ipv4_address(packet, AddressField::Source, old_address, new_address)
}
pub(crate) fn rewrite_ipv4_destination(
packet: &mut [u8],
old_address: Ipv4Addr,
new_address: Ipv4Addr,
) -> Result<(), Ipv4TranslationError> {
rewrite_ipv4_address(packet, AddressField::Destination, old_address, new_address)
}
fn rewrite_ipv4_address(
packet: &mut [u8],
field: AddressField,
old_address: Ipv4Addr,
new_address: Ipv4Addr,
) -> Result<(), Ipv4TranslationError> {
let layout = parse_complete_ipv4(packet)?;
let actual_address = read_ipv4_address(packet, field.offset());
if actual_address != old_address {
return Err(match field {
AddressField::Source => Ipv4TranslationError::UnexpectedSource {
expected: old_address,
actual: actual_address,
},
AddressField::Destination => Ipv4TranslationError::UnexpectedDestination {
expected: old_address,
actual: actual_address,
},
});
}
let transport_plan = analyze_transport(packet, layout, old_address)?;
match transport_plan {
TransportPlan::HeaderOnly => {}
TransportPlan::WithPseudoHeaderChecksum(checksum) => {
rewrite_pseudo_header_checksum(packet, checksum, old_address, new_address);
}
TransportPlan::Icmp {
checksum_offset,
quoted,
} => {
let mut fragmented_checksum = read_u16(packet, checksum_offset);
if let Some(quoted) = quoted {
rewrite_quoted_ipv4(
packet,
quoted,
old_address,
new_address,
&mut fragmented_checksum,
);
}
if layout.more_fragments {
write_u16(packet, checksum_offset, fragmented_checksum);
} else {
let icmp = &packet[layout.header_len..];
let checksum = checksum_with_zeroed_word(icmp, ICMP_CHECKSUM_OFFSET);
write_u16(packet, checksum_offset, checksum);
}
}
}
packet[field.offset()..field.offset() + 4].copy_from_slice(&new_address.octets());
let checksum = checksum_with_zeroed_word(&packet[..layout.header_len], IPV4_CHECKSUM_OFFSET);
write_u16(packet, IPV4_CHECKSUM_OFFSET, checksum);
Ok(())
}
fn parse_complete_ipv4(packet: &[u8]) -> Result<Ipv4Layout, Ipv4TranslationError> {
if packet.len() < IPV4_MIN_HEADER_LEN {
return Err(Ipv4TranslationError::PacketTooShort {
actual: packet.len(),
});
}
let version = packet[0] >> 4;
if version != 4 {
return Err(Ipv4TranslationError::UnsupportedIpVersion { version });
}
let ihl_words = packet[0] & 0x0f;
if ihl_words < 5 {
return Err(Ipv4TranslationError::InvalidHeaderLength { ihl_words });
}
let header_len = usize::from(ihl_words) * 4;
if header_len > packet.len() {
return Err(Ipv4TranslationError::TruncatedHeader {
header_len,
actual: packet.len(),
});
}
let declared = usize::from(read_u16(packet, 2));
if declared != packet.len() {
return Err(Ipv4TranslationError::TotalLengthMismatch {
declared,
actual: packet.len(),
});
}
let fragment = read_u16(packet, 6);
let layout = Ipv4Layout {
header_len,
protocol: packet[9],
fragment_offset: fragment & 0x1fff,
more_fragments: fragment & 0x2000 != 0,
};
let fragment_payload_len = packet.len() - header_len;
if layout.more_fragments && !fragment_payload_len.is_multiple_of(8) {
return Err(Ipv4TranslationError::InvalidFragmentLength {
actual: fragment_payload_len,
});
}
Ok(layout)
}
fn analyze_transport(
packet: &[u8],
layout: Ipv4Layout,
old_address: Ipv4Addr,
) -> Result<TransportPlan, Ipv4TranslationError> {
if !matches!(
layout.protocol,
IP_PROTOCOL_TCP | IP_PROTOCOL_UDP | IP_PROTOCOL_ICMP
) {
return Err(Ipv4TranslationError::UnsupportedProtocol {
protocol: layout.protocol,
});
}
if layout.fragment_offset != 0 {
return Ok(TransportPlan::HeaderOnly);
}
let transport_len = packet.len() - layout.header_len;
match layout.protocol {
IP_PROTOCOL_TCP => {
let required = if layout.more_fragments {
TCP_CHECKSUM_OFFSET + 2
} else {
TCP_MIN_HEADER_LEN
};
if transport_len < required {
return Err(Ipv4TranslationError::TruncatedTransportHeader {
protocol: "TCP",
required,
actual: transport_len,
});
}
let data_offset_words = packet[layout.header_len + 12] >> 4;
if data_offset_words < 5 {
return Err(Ipv4TranslationError::InvalidTcpHeaderLength { data_offset_words });
}
let tcp_header_len = usize::from(data_offset_words) * 4;
if !layout.more_fragments && tcp_header_len > transport_len {
return Err(Ipv4TranslationError::TruncatedTcpHeader {
header_len: tcp_header_len,
actual: transport_len,
});
}
Ok(TransportPlan::WithPseudoHeaderChecksum(ChecksumField {
offset: layout.header_len + TCP_CHECKSUM_OFFSET,
udp: false,
}))
}
IP_PROTOCOL_UDP => {
if transport_len < UDP_HEADER_LEN {
return Err(Ipv4TranslationError::TruncatedTransportHeader {
protocol: "UDP",
required: UDP_HEADER_LEN,
actual: transport_len,
});
}
let udp_len = usize::from(read_u16(packet, layout.header_len + 4));
let invalid = udp_len < UDP_HEADER_LEN
|| (!layout.more_fragments && udp_len != transport_len)
|| (layout.more_fragments && udp_len <= transport_len);
if invalid {
return Err(Ipv4TranslationError::InvalidUdpLength {
declared: udp_len,
actual: transport_len,
});
}
Ok(TransportPlan::WithPseudoHeaderChecksum(ChecksumField {
offset: layout.header_len + UDP_CHECKSUM_OFFSET,
udp: true,
}))
}
IP_PROTOCOL_ICMP => {
if transport_len < ICMP_MIN_HEADER_LEN {
return Err(Ipv4TranslationError::TruncatedTransportHeader {
protocol: "ICMP",
required: ICMP_MIN_HEADER_LEN,
actual: transport_len,
});
}
let icmp_offset = layout.header_len;
let quoted = if is_icmp_error(packet[icmp_offset]) {
Some(analyze_quoted_ipv4(
packet,
icmp_offset + ICMP_QUOTED_PACKET_OFFSET,
old_address,
)?)
} else {
None
};
Ok(TransportPlan::Icmp {
checksum_offset: icmp_offset + ICMP_CHECKSUM_OFFSET,
quoted,
})
}
_ => unreachable!("supported protocol checked above"),
}
}
fn analyze_quoted_ipv4(
packet: &[u8],
header_offset: usize,
old_address: Ipv4Addr,
) -> Result<QuotedIpv4Plan, Ipv4TranslationError> {
let quote = &packet[header_offset..];
if quote.len() < IPV4_MIN_HEADER_LEN {
return Err(Ipv4TranslationError::QuotedPacketTooShort {
actual: quote.len(),
});
}
let version = quote[0] >> 4;
if version != 4 {
return Err(Ipv4TranslationError::UnsupportedQuotedIpVersion { version });
}
let ihl_words = quote[0] & 0x0f;
if ihl_words < 5 {
return Err(Ipv4TranslationError::InvalidQuotedHeaderLength { ihl_words });
}
let header_len = usize::from(ihl_words) * 4;
if header_len > quote.len() {
return Err(Ipv4TranslationError::TruncatedQuotedHeader {
header_len,
actual: quote.len(),
});
}
let total_len = usize::from(read_u16(quote, 2));
if total_len < header_len {
return Err(Ipv4TranslationError::InvalidQuotedTotalLength {
declared: total_len,
header_len,
});
}
let replace_source = read_ipv4_address(quote, IPV4_SOURCE_OFFSET) == old_address;
let replace_destination = read_ipv4_address(quote, IPV4_DESTINATION_OFFSET) == old_address;
let fragment_offset = read_u16(quote, 6) & 0x1fff;
let visible_len = total_len.min(quote.len());
let protocol = quote[9];
let transport_checksum = if (!replace_source && !replace_destination) || fragment_offset != 0 {
None
} else {
let relative_checksum_offset = match protocol {
IP_PROTOCOL_TCP => header_len + TCP_CHECKSUM_OFFSET,
IP_PROTOCOL_UDP => header_len + UDP_CHECKSUM_OFFSET,
IP_PROTOCOL_ICMP => usize::MAX,
_ => {
return Err(Ipv4TranslationError::UnsupportedQuotedProtocol { protocol });
}
};
let checksum_visible =
relative_checksum_offset != usize::MAX && relative_checksum_offset + 2 <= visible_len;
checksum_visible.then_some(ChecksumField {
offset: header_offset + relative_checksum_offset,
udp: protocol == IP_PROTOCOL_UDP,
})
};
Ok(QuotedIpv4Plan {
header_offset,
header_len,
replace_source,
replace_destination,
transport_checksum,
})
}
fn rewrite_quoted_ipv4(
packet: &mut [u8],
plan: QuotedIpv4Plan,
old_address: Ipv4Addr,
new_address: Ipv4Addr,
outer_icmp_checksum: &mut u16,
) {
if !plan.replace_source && !plan.replace_destination {
return;
}
if let Some(checksum) = plan.transport_checksum {
let current = read_u16(packet, checksum.offset);
if !checksum.udp || current != 0 {
let mut updated = current;
if plan.replace_source {
updated = update_checksum_for_address(updated, old_address, new_address);
}
if plan.replace_destination {
updated = update_checksum_for_address(updated, old_address, new_address);
}
if checksum.udp && updated == 0 {
updated = u16::MAX;
}
write_tracked_word(packet, checksum.offset, updated, outer_icmp_checksum);
}
}
if plan.replace_source {
write_tracked_address(
packet,
plan.header_offset + IPV4_SOURCE_OFFSET,
new_address,
outer_icmp_checksum,
);
}
if plan.replace_destination {
write_tracked_address(
packet,
plan.header_offset + IPV4_DESTINATION_OFFSET,
new_address,
outer_icmp_checksum,
);
}
let inner_header = &packet[plan.header_offset..plan.header_offset + plan.header_len];
let checksum = checksum_with_zeroed_word(inner_header, IPV4_CHECKSUM_OFFSET);
write_tracked_word(
packet,
plan.header_offset + IPV4_CHECKSUM_OFFSET,
checksum,
outer_icmp_checksum,
);
}
fn rewrite_pseudo_header_checksum(
packet: &mut [u8],
checksum: ChecksumField,
old_address: Ipv4Addr,
new_address: Ipv4Addr,
) {
let current = read_u16(packet, checksum.offset);
if checksum.udp && current == 0 {
return;
}
let mut updated = update_checksum_for_address(current, old_address, new_address);
if checksum.udp && updated == 0 {
updated = u16::MAX;
}
write_u16(packet, checksum.offset, updated);
}
fn write_tracked_address(
packet: &mut [u8],
offset: usize,
address: Ipv4Addr,
enclosing_checksum: &mut u16,
) {
let octets = address.octets();
write_tracked_word(
packet,
offset,
u16::from_be_bytes([octets[0], octets[1]]),
enclosing_checksum,
);
write_tracked_word(
packet,
offset + 2,
u16::from_be_bytes([octets[2], octets[3]]),
enclosing_checksum,
);
}
fn write_tracked_word(
packet: &mut [u8],
offset: usize,
new_value: u16,
enclosing_checksum: &mut u16,
) {
let old_value = read_u16(packet, offset);
if old_value == new_value {
return;
}
*enclosing_checksum = update_checksum_word(*enclosing_checksum, old_value, new_value);
write_u16(packet, offset, new_value);
}
fn update_checksum_for_address(checksum: u16, old_address: Ipv4Addr, new_address: Ipv4Addr) -> u16 {
let old = old_address.octets();
let new = new_address.octets();
let checksum = update_checksum_word(
checksum,
u16::from_be_bytes([old[0], old[1]]),
u16::from_be_bytes([new[0], new[1]]),
);
update_checksum_word(
checksum,
u16::from_be_bytes([old[2], old[3]]),
u16::from_be_bytes([new[2], new[3]]),
)
}
fn update_checksum_word(checksum: u16, old_value: u16, new_value: u16) -> u16 {
let mut sum = u32::from(!checksum) + u32::from(!old_value) + u32::from(new_value);
while sum >> 16 != 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
!(sum as u16)
}
fn checksum_with_zeroed_word(bytes: &[u8], zero_offset: usize) -> u16 {
let mut sum = 0u32;
for (offset, chunk) in bytes.chunks(2).enumerate() {
let byte_offset = offset * 2;
let word = if byte_offset == zero_offset {
0
} else if let [high, low] = chunk {
u16::from_be_bytes([*high, *low])
} else {
u16::from(chunk[0]) << 8
};
sum += u32::from(word);
}
while sum >> 16 != 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
!(sum as u16)
}
fn is_icmp_error(icmp_type: u8) -> bool {
matches!(icmp_type, 3 | 4 | 5 | 11 | 12)
}
fn read_ipv4_address(bytes: &[u8], offset: usize) -> Ipv4Addr {
Ipv4Addr::new(
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
)
}
fn read_u16(bytes: &[u8], offset: usize) -> u16 {
u16::from_be_bytes([bytes[offset], bytes[offset + 1]])
}
fn write_u16(bytes: &mut [u8], offset: usize, value: u16) {
bytes[offset..offset + 2].copy_from_slice(&value.to_be_bytes());
}
#[cfg(test)]
mod tests {
use super::*;
const CLIENT_IP: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1);
const VIRTUAL_IP: Ipv4Addr = Ipv4Addr::new(10, 144, 144, 10);
const REMOTE_IP: Ipv4Addr = Ipv4Addr::new(10, 144, 144, 20);
fn build_ipv4(
source: Ipv4Addr,
destination: Ipv4Addr,
protocol: u8,
payload: &[u8],
options: &[u8],
fragment: u16,
) -> Vec<u8> {
assert_eq!(options.len() % 4, 0);
let header_len = IPV4_MIN_HEADER_LEN + options.len();
let mut packet = vec![0; header_len + payload.len()];
packet[0] = 0x40 | u8::try_from(header_len / 4).unwrap();
let packet_len = u16::try_from(packet.len()).unwrap();
write_u16(&mut packet, 2, packet_len);
write_u16(&mut packet, 4, 0x1234);
write_u16(&mut packet, 6, fragment);
packet[8] = 64;
packet[9] = protocol;
packet[IPV4_SOURCE_OFFSET..IPV4_SOURCE_OFFSET + 4].copy_from_slice(&source.octets());
packet[IPV4_DESTINATION_OFFSET..IPV4_DESTINATION_OFFSET + 4]
.copy_from_slice(&destination.octets());
packet[IPV4_MIN_HEADER_LEN..header_len].copy_from_slice(options);
packet[header_len..].copy_from_slice(payload);
let checksum = checksum_with_zeroed_word(&packet[..header_len], IPV4_CHECKSUM_OFFSET);
write_u16(&mut packet, IPV4_CHECKSUM_OFFSET, checksum);
packet
}
fn tcp_segment(source: Ipv4Addr, destination: Ipv4Addr, data: &[u8]) -> Vec<u8> {
let mut tcp = vec![0; TCP_MIN_HEADER_LEN + data.len()];
write_u16(&mut tcp, 0, 12345);
write_u16(&mut tcp, 2, 443);
tcp[12] = 5 << 4;
tcp[13] = 0x18;
write_u16(&mut tcp, 14, 4096);
tcp[TCP_MIN_HEADER_LEN..].copy_from_slice(data);
let checksum = transport_checksum(source, destination, IP_PROTOCOL_TCP, &tcp);
write_u16(&mut tcp, TCP_CHECKSUM_OFFSET, checksum);
tcp
}
fn udp_datagram(
source: Ipv4Addr,
destination: Ipv4Addr,
data: &[u8],
checksum_enabled: bool,
) -> Vec<u8> {
let mut udp = vec![0; UDP_HEADER_LEN + data.len()];
write_u16(&mut udp, 0, 5353);
write_u16(&mut udp, 2, 53);
let udp_len = u16::try_from(udp.len()).unwrap();
write_u16(&mut udp, 4, udp_len);
udp[UDP_HEADER_LEN..].copy_from_slice(data);
if checksum_enabled {
let checksum = transport_checksum(source, destination, IP_PROTOCOL_UDP, &udp);
write_u16(
&mut udp,
UDP_CHECKSUM_OFFSET,
if checksum == 0 { u16::MAX } else { checksum },
);
}
udp
}
fn icmp_message(icmp_type: u8, body: &[u8]) -> Vec<u8> {
let mut icmp = vec![0; ICMP_MIN_HEADER_LEN + body.len()];
icmp[0] = icmp_type;
icmp[1] = 0;
icmp[4..8].copy_from_slice(&[0x12, 0x34, 0, 1]);
icmp[ICMP_MIN_HEADER_LEN..].copy_from_slice(body);
let checksum = checksum_with_zeroed_word(&icmp, ICMP_CHECKSUM_OFFSET);
write_u16(&mut icmp, ICMP_CHECKSUM_OFFSET, checksum);
icmp
}
fn transport_checksum(
source: Ipv4Addr,
destination: Ipv4Addr,
protocol: u8,
transport: &[u8],
) -> u16 {
let mut bytes = Vec::with_capacity(12 + transport.len());
bytes.extend_from_slice(&source.octets());
bytes.extend_from_slice(&destination.octets());
bytes.push(0);
bytes.push(protocol);
bytes.extend_from_slice(&u16::try_from(transport.len()).unwrap().to_be_bytes());
bytes.extend_from_slice(transport);
checksum_with_zeroed_word(
&bytes,
12 + if protocol == IP_PROTOCOL_TCP {
TCP_CHECKSUM_OFFSET
} else {
UDP_CHECKSUM_OFFSET
},
)
}
fn assert_valid_ipv4_checksum(packet: &[u8]) {
let header_len = usize::from(packet[0] & 0x0f) * 4;
assert_eq!(
read_u16(packet, IPV4_CHECKSUM_OFFSET),
checksum_with_zeroed_word(&packet[..header_len], IPV4_CHECKSUM_OFFSET)
);
}
fn assert_valid_transport_checksum(packet: &[u8], protocol: u8) {
let header_len = usize::from(packet[0] & 0x0f) * 4;
let source = read_ipv4_address(packet, IPV4_SOURCE_OFFSET);
let destination = read_ipv4_address(packet, IPV4_DESTINATION_OFFSET);
let transport = &packet[header_len..];
let offset = if protocol == IP_PROTOCOL_TCP {
TCP_CHECKSUM_OFFSET
} else {
UDP_CHECKSUM_OFFSET
};
assert_eq!(
read_u16(transport, offset),
transport_checksum(source, destination, protocol, transport)
);
}
#[test]
fn rewrites_tcp_source_with_ipv4_options() {
let tcp = tcp_segment(CLIENT_IP, REMOTE_IP, b"tcp payload");
let mut packet = build_ipv4(
CLIENT_IP,
REMOTE_IP,
IP_PROTOCOL_TCP,
&tcp,
&[1, 1, 1, 0],
0,
);
rewrite_ipv4_source(&mut packet, CLIENT_IP, VIRTUAL_IP).unwrap();
assert_eq!(read_ipv4_address(&packet, IPV4_SOURCE_OFFSET), VIRTUAL_IP);
assert_eq!(
read_ipv4_address(&packet, IPV4_DESTINATION_OFFSET),
REMOTE_IP
);
assert_valid_ipv4_checksum(&packet);
assert_valid_transport_checksum(&packet, IP_PROTOCOL_TCP);
}
#[test]
fn rewrites_tcp_destination() {
let tcp = tcp_segment(REMOTE_IP, VIRTUAL_IP, b"reply");
let mut packet = build_ipv4(REMOTE_IP, VIRTUAL_IP, IP_PROTOCOL_TCP, &tcp, &[], 0);
rewrite_ipv4_destination(&mut packet, VIRTUAL_IP, CLIENT_IP).unwrap();
assert_eq!(read_ipv4_address(&packet, IPV4_SOURCE_OFFSET), REMOTE_IP);
assert_eq!(
read_ipv4_address(&packet, IPV4_DESTINATION_OFFSET),
CLIENT_IP
);
assert_valid_ipv4_checksum(&packet);
assert_valid_transport_checksum(&packet, IP_PROTOCOL_TCP);
}
#[test]
fn rewrites_udp_checksum_and_preserves_disabled_checksum() {
for checksum_enabled in [true, false] {
let udp = udp_datagram(CLIENT_IP, REMOTE_IP, b"dns", checksum_enabled);
let mut packet = build_ipv4(CLIENT_IP, REMOTE_IP, IP_PROTOCOL_UDP, &udp, &[], 0);
rewrite_ipv4_source(&mut packet, CLIENT_IP, VIRTUAL_IP).unwrap();
assert_valid_ipv4_checksum(&packet);
let udp_offset = IPV4_MIN_HEADER_LEN + UDP_CHECKSUM_OFFSET;
if checksum_enabled {
assert_valid_transport_checksum(&packet, IP_PROTOCOL_UDP);
assert_ne!(read_u16(&packet, udp_offset), 0);
} else {
assert_eq!(read_u16(&packet, udp_offset), 0);
}
}
}
#[test]
fn rewrites_icmp_echo_outer_address_and_checksum() {
let icmp = icmp_message(8, b"echo payload");
let original_icmp_checksum = read_u16(&icmp, ICMP_CHECKSUM_OFFSET);
let mut packet = build_ipv4(CLIENT_IP, REMOTE_IP, IP_PROTOCOL_ICMP, &icmp, &[], 0);
rewrite_ipv4_source(&mut packet, CLIENT_IP, VIRTUAL_IP).unwrap();
assert_valid_ipv4_checksum(&packet);
let translated_icmp = &packet[IPV4_MIN_HEADER_LEN..];
assert_eq!(
read_u16(translated_icmp, ICMP_CHECKSUM_OFFSET),
original_icmp_checksum
);
assert_eq!(
read_u16(translated_icmp, ICMP_CHECKSUM_OFFSET),
checksum_with_zeroed_word(translated_icmp, ICMP_CHECKSUM_OFFSET)
);
}
#[test]
fn rewrites_icmp_error_quoted_ipv4_and_visible_udp_checksum() {
let udp = udp_datagram(VIRTUAL_IP, REMOTE_IP, b"request", true);
let quoted = build_ipv4(VIRTUAL_IP, REMOTE_IP, IP_PROTOCOL_UDP, &udp, &[], 0);
let icmp = icmp_message(3, &quoted);
let mut packet = build_ipv4(REMOTE_IP, VIRTUAL_IP, IP_PROTOCOL_ICMP, &icmp, &[], 0);
rewrite_ipv4_destination(&mut packet, VIRTUAL_IP, CLIENT_IP).unwrap();
assert_valid_ipv4_checksum(&packet);
let outer_ihl = IPV4_MIN_HEADER_LEN;
let translated_icmp = &packet[outer_ihl..];
assert_eq!(
read_u16(translated_icmp, ICMP_CHECKSUM_OFFSET),
checksum_with_zeroed_word(translated_icmp, ICMP_CHECKSUM_OFFSET)
);
let translated_quote = &translated_icmp[ICMP_QUOTED_PACKET_OFFSET..];
assert_eq!(
read_ipv4_address(translated_quote, IPV4_SOURCE_OFFSET),
CLIENT_IP
);
assert_valid_ipv4_checksum(translated_quote);
assert_valid_transport_checksum(translated_quote, IP_PROTOCOL_UDP);
}
#[test]
fn rewrites_icmp_error_quoted_destination_and_visible_tcp_checksum() {
let tcp = tcp_segment(REMOTE_IP, CLIENT_IP, b"request");
let quoted = build_ipv4(REMOTE_IP, CLIENT_IP, IP_PROTOCOL_TCP, &tcp, &[], 0);
let icmp = icmp_message(11, &quoted);
let mut packet = build_ipv4(CLIENT_IP, REMOTE_IP, IP_PROTOCOL_ICMP, &icmp, &[], 0);
rewrite_ipv4_source(&mut packet, CLIENT_IP, VIRTUAL_IP).unwrap();
assert_valid_ipv4_checksum(&packet);
let translated_icmp = &packet[IPV4_MIN_HEADER_LEN..];
assert_eq!(
read_u16(translated_icmp, ICMP_CHECKSUM_OFFSET),
checksum_with_zeroed_word(translated_icmp, ICMP_CHECKSUM_OFFSET)
);
let translated_quote = &translated_icmp[ICMP_QUOTED_PACKET_OFFSET..];
assert_eq!(
read_ipv4_address(translated_quote, IPV4_DESTINATION_OFFSET),
VIRTUAL_IP
);
assert_valid_ipv4_checksum(translated_quote);
assert_valid_transport_checksum(translated_quote, IP_PROTOCOL_TCP);
}
#[test]
fn rewrites_fragmented_tcp_checksum_only_in_first_fragment() {
let tcp = tcp_segment(CLIENT_IP, REMOTE_IP, b"0123456789abcdef01234567");
let split = 24;
let mut first = build_ipv4(
CLIENT_IP,
REMOTE_IP,
IP_PROTOCOL_TCP,
&tcp[..split],
&[],
0x2000,
);
let mut second = build_ipv4(
CLIENT_IP,
REMOTE_IP,
IP_PROTOCOL_TCP,
&tcp[split..],
&[],
u16::try_from(split / 8).unwrap(),
);
let second_payload_before = second[IPV4_MIN_HEADER_LEN..].to_vec();
rewrite_ipv4_source(&mut first, CLIENT_IP, VIRTUAL_IP).unwrap();
rewrite_ipv4_source(&mut second, CLIENT_IP, VIRTUAL_IP).unwrap();
assert_valid_ipv4_checksum(&first);
assert_valid_ipv4_checksum(&second);
assert_eq!(&second[IPV4_MIN_HEADER_LEN..], second_payload_before);
let mut translated_tcp = first[IPV4_MIN_HEADER_LEN..].to_vec();
translated_tcp.extend_from_slice(&second[IPV4_MIN_HEADER_LEN..]);
assert_eq!(
read_u16(&translated_tcp, TCP_CHECKSUM_OFFSET),
transport_checksum(VIRTUAL_IP, REMOTE_IP, IP_PROTOCOL_TCP, &translated_tcp)
);
}
#[test]
fn rejects_truncated_and_length_mismatched_ipv4_packets() {
let mut short = vec![0; IPV4_MIN_HEADER_LEN - 1];
assert_eq!(
rewrite_ipv4_source(&mut short, CLIENT_IP, VIRTUAL_IP),
Err(Ipv4TranslationError::PacketTooShort {
actual: IPV4_MIN_HEADER_LEN - 1
})
);
let udp = udp_datagram(CLIENT_IP, REMOTE_IP, b"data", true);
let mut mismatched = build_ipv4(CLIENT_IP, REMOTE_IP, IP_PROTOCOL_UDP, &udp, &[], 0);
let declared = mismatched.len();
mismatched.push(0);
assert_eq!(
rewrite_ipv4_source(&mut mismatched, CLIENT_IP, VIRTUAL_IP),
Err(Ipv4TranslationError::TotalLengthMismatch {
declared,
actual: declared + 1,
})
);
let mut truncated_options = vec![0; IPV4_MIN_HEADER_LEN];
truncated_options[0] = 0x46;
write_u16(&mut truncated_options, 2, IPV4_MIN_HEADER_LEN as u16);
assert_eq!(
rewrite_ipv4_source(&mut truncated_options, CLIENT_IP, VIRTUAL_IP),
Err(Ipv4TranslationError::TruncatedHeader {
header_len: 24,
actual: IPV4_MIN_HEADER_LEN,
})
);
}
#[test]
fn rejects_unsupported_protocol_without_mutating_packet() {
let mut packet = build_ipv4(CLIENT_IP, REMOTE_IP, 47, &[0; 8], &[], 0);
let original = packet.clone();
assert_eq!(
rewrite_ipv4_source(&mut packet, CLIENT_IP, VIRTUAL_IP),
Err(Ipv4TranslationError::UnsupportedProtocol { protocol: 47 })
);
assert_eq!(packet, original);
}
#[test]
fn rejects_unexpected_source_and_destination_without_mutating_packet() {
let tcp = tcp_segment(CLIENT_IP, REMOTE_IP, b"payload");
let packet = build_ipv4(CLIENT_IP, REMOTE_IP, IP_PROTOCOL_TCP, &tcp, &[], 0);
let mut source_packet = packet.clone();
assert_eq!(
rewrite_ipv4_source(&mut source_packet, VIRTUAL_IP, CLIENT_IP),
Err(Ipv4TranslationError::UnexpectedSource {
expected: VIRTUAL_IP,
actual: CLIENT_IP,
})
);
assert_eq!(source_packet, packet);
let mut destination_packet = packet.clone();
assert_eq!(
rewrite_ipv4_destination(&mut destination_packet, VIRTUAL_IP, CLIENT_IP),
Err(Ipv4TranslationError::UnexpectedDestination {
expected: VIRTUAL_IP,
actual: REMOTE_IP,
})
);
assert_eq!(destination_packet, packet);
}
#[test]
fn rejects_truncated_transport_and_icmp_quote() {
let mut tcp = build_ipv4(
CLIENT_IP,
REMOTE_IP,
IP_PROTOCOL_TCP,
&[0; TCP_MIN_HEADER_LEN - 1],
&[],
0,
);
assert_eq!(
rewrite_ipv4_source(&mut tcp, CLIENT_IP, VIRTUAL_IP),
Err(Ipv4TranslationError::TruncatedTransportHeader {
protocol: "TCP",
required: TCP_MIN_HEADER_LEN,
actual: TCP_MIN_HEADER_LEN - 1,
})
);
let icmp = icmp_message(11, &[0; IPV4_MIN_HEADER_LEN - 1]);
let mut packet = build_ipv4(REMOTE_IP, VIRTUAL_IP, IP_PROTOCOL_ICMP, &icmp, &[], 0);
assert_eq!(
rewrite_ipv4_destination(&mut packet, VIRTUAL_IP, CLIENT_IP),
Err(Ipv4TranslationError::QuotedPacketTooShort {
actual: IPV4_MIN_HEADER_LEN - 1,
})
);
}
}
+138 -106
View File
@@ -2,7 +2,7 @@
//!
//! Native adapters authenticate clients and yield sessions. This module owns
//! configured client identities, attached-peer lifetimes, per-client
//! generations, and IPv4 address translation at the Host packet seam.
//! generations, and raw IPv4 packet forwarding at the Host packet seam.
use std::{
collections::{BTreeMap, BTreeSet},
@@ -29,15 +29,12 @@ use crate::{
socket::SocketListener,
};
use super::ipv4_translator::{rewrite_ipv4_destination, rewrite_ipv4_source};
pub const MAX_VPN_PORTAL_CLIENTS: usize = 64;
pub const DEFAULT_PORTAL_CLIENT_ADDRESS: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortalClientConfig {
pub name: String,
pub virtual_ip: Ipv4Addr,
pub virtual_ip: Ipv4Inet,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
}
@@ -428,7 +425,7 @@ impl PortalModule {
status.generation = status.generation.wrapping_add(1);
status.state = PortalClientState::Connecting;
status.endpoint = Some(session.endpoint.borrow_and_update().clone());
status.tunnel_ip = None;
status.tunnel_ip = Some(client.virtual_ip.address());
status.error = None;
status.generation
};
@@ -481,44 +478,15 @@ impl PortalModule {
let mut client_stream = session.from_client;
let endpoint = session.endpoint;
let client_sink = session.to_client;
let client_ip = Arc::new(Mutex::new(None::<Ipv4Addr>));
let client_to_mesh = {
let attached = attached.clone();
let client_ip = client_ip.clone();
let statuses = statuses.clone();
let name = client.name.clone();
let virtual_ip = client.virtual_ip;
let virtual_ip = client.virtual_ip.address();
tokio::spawn(async move {
while let Some(mut payload) = client_stream.recv().await {
let Some(source) = ipv4_source(&payload) else {
while let Some(payload) = client_stream.recv().await {
if !has_ipv4_source(&payload, virtual_ip) {
tracing::warn!(client = %name, expected = ?virtual_ip, "VPN client source does not match its assigned address");
continue;
};
match *client_ip.lock().await {
Some(expected) if expected != source => {
tracing::warn!(client = %name, ?expected, ?source, "VPN client source changed");
continue;
}
None | Some(_) => {}
}
if rewrite_ipv4_source(&mut payload, source, virtual_ip).is_err() {
continue;
}
let learned = {
let mut tunnel_ip = client_ip.lock().await;
if tunnel_ip.is_none() {
*tunnel_ip = Some(source);
true
} else {
false
}
};
if learned {
let mut statuses = statuses.write().await;
if let Some(status) = statuses.get_mut(&name)
&& status.generation == generation
{
status.tunnel_ip = Some(source);
}
}
if let Err(error) = attached.send_packet(&payload).await {
tracing::debug!(?error, client = %name, "attached peer send failed");
@@ -529,28 +497,12 @@ impl PortalModule {
};
let mesh_to_client = {
let attached = attached.clone();
let client_ip = client_ip.clone();
let statuses = statuses.clone();
let name = client.name.clone();
let virtual_ip = client.virtual_ip;
tokio::spawn(async move {
while let Some(packet) = attached.recv_packet().await {
let Some(tunnel_ip) = *client_ip.lock().await else {
continue;
};
let mut payload = packet.payload().to_vec();
if rewrite_ipv4_destination(&mut payload, virtual_ip, tunnel_ip).is_err() {
continue;
}
let payload = packet.payload().to_vec();
if client_sink.send(payload).await.is_err() {
break;
}
let mut statuses = statuses.write().await;
if let Some(status) = statuses.get_mut(&name)
&& status.generation == generation
{
status.tunnel_ip = Some(tunnel_ip);
}
}
})
};
@@ -706,10 +658,14 @@ impl PortalModule {
let status = statuses.get(&client.name).cloned().unwrap_or_default();
let client_config = match (self.host.as_ref(), listener_url.as_ref()) {
(Some(host), Some(listener_url)) => {
let mut client_allowed_ips = allowed_ips.clone();
client_allowed_ips.push(client.virtual_ip.network().to_string());
client_allowed_ips.sort();
client_allowed_ips.dedup();
host.render_client_config(&PortalClientConfigPlan {
name: client.name.clone(),
address: DEFAULT_PORTAL_CLIENT_ADDRESS,
allowed_ips: allowed_ips.clone(),
address: client.virtual_ip.address(),
allowed_ips: client_allowed_ips,
listener_url: listener_url.clone(),
})
}
@@ -717,7 +673,7 @@ impl PortalModule {
};
PortalClientInfoSnapshot {
name: client.name.clone(),
virtual_ip: client.virtual_ip,
virtual_ip: client.virtual_ip.address(),
groups: client.groups.clone(),
state: status.state,
peer_id: status.peer_id,
@@ -744,12 +700,6 @@ impl PortalModule {
for route in self.peer_manager.list_route_snapshots().await {
allowed.extend(route.proxy_cidrs);
}
if let Some(ipv4) = snapshot.peer.runtime.core.routes.ipv4.as_ref()
&& let IpAddr::V4(address) = ipv4.address
&& let Ok(inet) = Ipv4Inet::new(address, ipv4.prefix_len)
{
allowed.insert(inet.network().to_string());
}
for proxy in &snapshot.peer.runtime.core.routes.proxy_networks {
let mapped = proxy.mapped.as_ref().unwrap_or(&proxy.real);
allowed.insert(format!("{}/{}", mapped.address, mapped.prefix_len));
@@ -784,7 +734,7 @@ fn validate_clients(
if !names.insert(client.name.as_str()) {
anyhow::bail!("duplicate VPN portal client name: {}", client.name);
}
if !addresses.insert(client.virtual_ip) {
if !addresses.insert(client.virtual_ip.address()) {
anyhow::bail!("duplicate VPN portal virtual IP: {}", client.virtual_ip);
}
for group in &client.groups {
@@ -814,33 +764,28 @@ fn validate_runtime_compatibility(
{
anyhow::bail!("VPN portal requires an admin node with a non-empty network secret");
}
if snapshot.services.dhcp_ipv4 {
anyhow::bail!("VPN portal does not support DHCP IPv4 on the portal node");
}
let prefix = snapshot
let host_address = snapshot
.peer
.runtime
.core
.routes
.ipv4
.as_ref()
.ok_or_else(|| anyhow::anyhow!("VPN portal requires a static IPv4 address"))?;
let IpAddr::V4(portal_ip) = prefix.address else {
anyhow::bail!("VPN portal requires an IPv4 route prefix");
};
let network = Ipv4Inet::new(portal_ip, prefix.prefix_len)
.map_err(|error| anyhow::anyhow!("invalid portal IPv4 prefix: {error}"))?
.network();
.and_then(|prefix| match prefix.address {
IpAddr::V4(address) => Some(address),
IpAddr::V6(_) => None,
});
for client in &config.clients {
if client.virtual_ip == portal_ip
|| !network.contains(&client.virtual_ip)
|| client.virtual_ip == network.first_address()
|| client.virtual_ip == network.last_address()
let address = client.virtual_ip.address();
let network = client.virtual_ip.network();
if host_address == Some(address)
|| address == network.first_address()
|| address == network.last_address()
{
anyhow::bail!(
"VPN portal client {} has an unusable virtual IP {}",
client.name,
client.virtual_ip
address
);
}
}
@@ -879,6 +824,10 @@ fn ipv4_source(payload: &[u8]) -> Option<Ipv4Addr> {
))
}
fn has_ipv4_source(payload: &[u8], expected: Ipv4Addr) -> bool {
ipv4_source(payload) == Some(expected)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -937,7 +886,12 @@ mod tests {
}
fn render_client_config(&self, plan: &PortalClientConfigPlan) -> String {
format!("config:{}", plan.name)
format!(
"config:{}:{}:{}",
plan.name,
plan.address,
plan.allowed_ips.join(",")
)
}
}
@@ -1092,7 +1046,7 @@ mod tests {
fn client(name: &str, virtual_ip: Ipv4Addr, groups: &[&str]) -> PortalClientConfig {
PortalClientConfig {
name: name.to_owned(),
virtual_ip,
virtual_ip: Ipv4Inet::new(virtual_ip, 24).unwrap(),
groups: groups.iter().map(|group| (*group).to_owned()).collect(),
}
}
@@ -1108,6 +1062,21 @@ mod tests {
packet[20] = 8;
packet
}
#[test]
fn portal_client_packet_requires_its_assigned_source() {
let assigned = Ipv4Addr::new(10, 82, 0, 2);
assert!(has_ipv4_source(
&raw_ipv4(assigned, Ipv4Addr::new(10, 82, 0, 1)),
assigned
));
assert!(!has_ipv4_source(
&raw_ipv4(Ipv4Addr::new(10, 82, 0, 99), Ipv4Addr::new(10, 82, 0, 1)),
assigned
));
assert!(!has_ipv4_source(&[0u8; 8], assigned));
}
fn network_runtime() -> (Arc<PeerManagerCore>, CoreRuntimeConfigStore) {
network_runtime_with_secure_mode(false)
}
@@ -1213,6 +1182,22 @@ mod tests {
assert!(error.contains("VPN portal requires an admin node"));
}
#[test]
fn portal_client_cidr_is_independent_of_host_addressing() {
let runtime_config = runtime_config();
runtime_config.update_peer_with(|peer| peer.runtime.core.routes.ipv4 = None);
runtime_config.update_services(|services| services.dhcp_ipv4 = true);
let config = PortalRuntimeConfig {
clients: vec![PortalClientConfig {
name: "alice".to_owned(),
virtual_ip: "10.82.0.2/16".parse().unwrap(),
groups: vec!["ops".to_owned()],
}],
};
validate_clients(&config, runtime_config.snapshot().as_ref()).unwrap();
}
#[test]
fn portal_session_debug_redacts_identity_private_key() {
let identity_private_key = [173u8; 32];
@@ -1233,11 +1218,12 @@ mod tests {
}
#[tokio::test]
async fn portal_session_publishes_learned_tunnel_ip_without_mesh_reply() {
async fn portal_session_publishes_virtual_ip_before_first_client_packet() {
let (peer_manager, runtime_config) = network_runtime();
peer_manager.run().await.unwrap();
let virtual_ip = Ipv4Addr::new(10, 82, 0, 2);
let config = PortalRuntimeConfig {
clients: vec![client("alice", Ipv4Addr::new(10, 82, 0, 2), &["ops"])],
clients: vec![client("alice", virtual_ip, &["ops"])],
};
let statuses = Arc::new(RwLock::new(BTreeMap::from([(
"alice".to_owned(),
@@ -1248,7 +1234,7 @@ mod tests {
Arc::new(Mutex::new(())),
)])));
let (to_runtime, from_client) = mpsc::channel(1);
let (to_client, _from_runtime) = mpsc::channel(1);
let (to_client, mut from_runtime) = mpsc::channel(1);
let (endpoint_sender, endpoint) = tokio::sync::watch::channel("portal://alice".to_owned());
let session = PortalSession {
client_name: "alice".to_owned(),
@@ -1270,34 +1256,51 @@ mod tests {
cancel,
));
to_runtime
.send(raw_ipv4(
DEFAULT_PORTAL_CLIENT_ADDRESS,
Ipv4Addr::new(10, 82, 0, 1),
))
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let status = statuses.read().await.get("alice").cloned().unwrap();
if status.tunnel_ip == Some(DEFAULT_PORTAL_CLIENT_ADDRESS) {
if status.state == PortalClientState::Online && status.tunnel_ip == Some(virtual_ip)
{
return;
}
assert_ne!(
status.state,
PortalClientState::Error,
"portal session failed before learning tunnel IP: {:?}",
"portal session failed before publishing virtual IP: {:?}",
status.error
);
assert!(
!task.is_finished(),
"portal session ended before learning tunnel IP"
"portal session ended before publishing virtual IP"
);
tokio::task::yield_now().await;
}
})
.await
.expect("learned tunnel IP was not published");
.expect("configured virtual IP was not published before the first client packet");
let mesh_packet = raw_ipv4(Ipv4Addr::new(10, 82, 0, 1), virtual_ip);
let outbound = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let _ = peer_manager
.send_msg_by_ip(
crate::packet::ZCPacket::new_with_payload(&mesh_packet),
IpAddr::V4(virtual_ip),
false,
)
.await;
if let Ok(Some(packet)) =
tokio::time::timeout(std::time::Duration::from_millis(20), from_runtime.recv())
.await
{
break packet;
}
}
})
.await
.expect("mesh packet was not delivered before the first client packet");
assert_eq!(&outbound[16..20], virtual_ip.octets().as_slice());
endpoint_sender.send("portal://roamed".to_owned()).unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
@@ -1469,30 +1472,26 @@ mod tests {
));
to_runtime
.send(raw_ipv4(
DEFAULT_PORTAL_CLIENT_ADDRESS,
Ipv4Addr::new(10, 82, 0, 1),
))
.send(raw_ipv4(virtual_ip, Ipv4Addr::new(10, 82, 0, 1)))
.await
.unwrap();
let attached_peer_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let status = statuses.read().await.get("alice").cloned().unwrap();
if status.state == PortalClientState::Online
&& status.tunnel_ip == Some(DEFAULT_PORTAL_CLIENT_ADDRESS)
if status.state == PortalClientState::Online && status.tunnel_ip == Some(virtual_ip)
{
return status.peer_id.unwrap();
}
assert!(
!task.is_finished(),
"portal session ended before learning its tunnel address: {:?}",
"portal session ended before publishing its virtual address: {:?}",
status.error
);
tokio::task::yield_now().await;
}
})
.await
.expect("portal session did not learn its tunnel address");
.expect("portal session did not publish its virtual address");
drop(from_runtime);
let mesh_packet = raw_ipv4(Ipv4Addr::new(10, 82, 0, 1), virtual_ip);
@@ -1632,6 +1631,39 @@ mod tests {
peer_manager.clear_resources().await;
}
#[tokio::test]
async fn portal_client_config_routes_its_own_network_not_the_host_network() {
let (peer_manager, runtime_config) = network_runtime();
let module = PortalModule::new(
peer_manager.clone(),
runtime_config,
Some(PortalRuntimeConfig {
clients: vec![PortalClientConfig {
name: "alice".to_owned(),
virtual_ip: "10.90.0.2/16".parse().unwrap(),
groups: vec!["ops".to_owned()],
}],
}),
Some(StaticPortalHost::new(vec![Box::new(
PendingPortalListener {
url: "test://127.0.0.1:10004".parse().unwrap(),
accept_calls: Arc::new(AtomicUsize::new(0)),
},
)])),
Arc::new(()),
)
.unwrap();
module.start().await.unwrap();
let snapshot = module.info_snapshot().await;
assert!(snapshot.clients[0].client_config.contains("10.90.0.2"));
assert!(snapshot.clients[0].client_config.contains("10.90.0.0/16"));
assert!(!snapshot.clients[0].client_config.contains("10.82.0.0/24"));
module.stop().await;
peer_manager.clear_resources().await;
}
#[tokio::test]
async fn portal_restarts_after_listener_accept_failure() {
let (peer_manager, runtime_config) = network_runtime();
+7
View File
@@ -152,6 +152,13 @@ where
}
async fn stop_components(&self) {
if !self.peer_manager.withdraw_routes_before_stop().await {
tracing::warn!(
instance = %self.instance_name,
"not every direct route session acknowledged the shutdown withdrawal"
);
}
#[cfg(feature = "vpn-portal")]
self.vpn_portal.stop().await;
#[cfg(feature = "public-ipv6-provider")]
+225 -2
View File
@@ -344,7 +344,7 @@ mod portable_runtime {
config.vpn_portal = Some(crate::gateway::vpn_portal::PortalRuntimeConfig {
clients: vec![crate::gateway::vpn_portal::PortalClientConfig {
name: "alice".to_owned(),
virtual_ip: "10.82.0.2".parse().unwrap(),
virtual_ip: "10.82.0.2/24".parse().unwrap(),
groups: Vec::new(),
}],
});
@@ -476,6 +476,37 @@ mod portable_runtime {
Ok(())
}
}
#[cfg(feature = "vpn-portal")]
struct RejectingPortalHost;
#[cfg(feature = "vpn-portal")]
#[async_trait]
impl crate::gateway::vpn_portal::PortalHost for RejectingPortalHost {
async fn start_listeners(
&self,
) -> anyhow::Result<Vec<crate::gateway::vpn_portal::PortalListener>> {
anyhow::bail!("injected portal start failure")
}
fn name(&self) -> String {
"rejecting-test-portal".to_owned()
}
fn render_client_config(
&self,
_plan: &crate::gateway::vpn_portal::PortalClientConfigPlan,
) -> String {
String::new()
}
async fn update_clients(
&self,
_clients: &[crate::gateway::vpn_portal::PortalClientConfig],
) -> anyhow::Result<()> {
anyhow::bail!("injected portal update failure")
}
}
#[cfg(feature = "vpn-portal")]
#[tokio::test]
async fn runtime_update_rejects_portal_client_address_conflict() {
@@ -893,6 +924,198 @@ source = "web"
assert!(persisted[0].contains(&secret));
}
#[cfg(feature = "management")]
#[tokio::test]
async fn ordinary_config_patch_is_durable_before_commit() {
use easytier_proto::api::config::InstanceConfigPatch;
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
let config = TomlConfig::new_from_str(
r#"
instance_name = "durable-ordinary-patch"
hostname = "before"
[network_identity]
network_name = "durable-network"
network_secret = "network-secret"
[source]
source = "web"
"#,
)
.unwrap();
let instance =
CoreInstance::from_toml(config, adapters(None, Arc::new(packet_sink))).unwrap();
instance.start().await.unwrap();
let patch = InstanceConfigPatch {
hostname: Some("after".to_owned()),
..Default::default()
};
let persistence = RecordingConfigPatchPersistence {
writes: std::sync::Mutex::new(Vec::new()),
fail: AtomicBool::new(true),
};
let error =
crate::management::apply_config_patch(&instance, patch.clone(), Some(&persistence))
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("injected config persistence failure")
);
assert_eq!(instance.toml_config().unwrap().get_hostname(), "before");
persistence.fail.store(false, Ordering::Relaxed);
crate::management::apply_config_patch(&instance, patch, Some(&persistence))
.await
.unwrap();
assert_eq!(instance.toml_config().unwrap().get_hostname(), "after");
{
let persisted = persistence.writes.lock().unwrap();
assert_eq!(persisted.len(), 1);
assert!(persisted[0].contains("hostname = \"after\""));
}
instance.stop().await;
}
#[cfg(all(feature = "management", feature = "vpn-portal"))]
#[tokio::test]
async fn portal_client_patch_restores_durable_state_after_failures() {
use easytier_proto::api::{
config::{ConfigPatchAction, InstanceConfigPatch, VpnPortalClientPatch},
manage::VpnPortalClientConfig,
};
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
let mut host_adapters = adapters(None, Arc::new(packet_sink));
host_adapters.vpn_portal = Some(Arc::new(RejectingPortalHost));
let instance = CoreInstance::from_toml(
TomlConfig::new_from_str(
r#"
instance_name = "durable-portal-patch"
ipv4 = "10.82.0.1/24"
[network_identity]
network_name = "durable-portal-network"
network_secret = "network-secret"
[vpn_portal_config]
wireguard_listen = "0.0.0.0:51820"
[[vpn_portal_config.clients]]
name = "alice"
virtual_ip = "10.82.0.2/24"
[source]
source = "web"
"#,
)
.unwrap(),
host_adapters,
)
.unwrap();
instance.set_state(CoreInstanceState::Running);
let persistence = RecordingConfigPatchPersistence {
writes: std::sync::Mutex::new(Vec::new()),
fail: AtomicBool::new(true),
};
let error = crate::management::apply_config_patch(
&instance,
InstanceConfigPatch {
vpn_portal_clients: vec![
VpnPortalClientPatch {
action: ConfigPatchAction::Remove as i32,
client: Some(VpnPortalClientConfig {
name: "alice".to_owned(),
..Default::default()
}),
},
VpnPortalClientPatch {
action: ConfigPatchAction::Add as i32,
client: Some(VpnPortalClientConfig {
name: "bob".to_owned(),
virtual_ip: "10.82.0.3/24".to_owned(),
..Default::default()
}),
},
],
..Default::default()
},
Some(&persistence),
)
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("injected config persistence failure")
);
let clients = instance
.toml_config()
.unwrap()
.get_vpn_portal_config()
.unwrap()
.clients;
assert_eq!(clients.len(), 1);
assert_eq!(clients[0].name, "alice");
assert!(persistence.writes.lock().unwrap().is_empty());
persistence.fail.store(false, Ordering::Relaxed);
let error = crate::management::apply_config_patch(
&instance,
InstanceConfigPatch {
vpn_portal_clients: vec![
VpnPortalClientPatch {
action: ConfigPatchAction::Remove as i32,
client: Some(VpnPortalClientConfig {
name: "alice".to_owned(),
..Default::default()
}),
},
VpnPortalClientPatch {
action: ConfigPatchAction::Add as i32,
client: Some(VpnPortalClientConfig {
name: "bob".to_owned(),
virtual_ip: "10.82.0.3/24".to_owned(),
..Default::default()
}),
},
],
..Default::default()
},
Some(&persistence),
)
.await
.unwrap_err();
assert!(error.to_string().contains("injected portal update failure"));
crate::management::apply_config_patch(
&instance,
InstanceConfigPatch {
hostname: Some("after-rollback".to_owned()),
..Default::default()
},
Some(&persistence),
)
.await
.unwrap();
{
let persisted = persistence.writes.lock().unwrap();
assert_eq!(persisted.len(), 3);
assert!(persisted[0].contains("name = \"bob\""));
assert!(persisted[1].contains("name = \"alice\""));
assert!(persisted[2].contains("name = \"alice\""));
assert!(!persisted[2].contains("name = \"bob\""));
}
instance.peer_manager.clear_resources().await;
}
#[cfg(all(feature = "management", not(feature = "proxy-smoltcp-stack")))]
#[tokio::test]
async fn unavailable_gateway_patch_does_not_commit_shared_toml() {
@@ -985,7 +1208,7 @@ wireguard_listen = "0.0.0.0:51820"
[[vpn_portal_config.clients]]
name = "alice"
virtual_ip = "10.82.0.2"
virtual_ip = "10.82.0.2/24"
"#,
)
.unwrap(),
@@ -51,30 +51,32 @@ where
// sub-patches remain applied if a later sub-patch fails.
let patch_result: anyhow::Result<(bool, bool)> = async {
let result = patch_port_forwards(&candidate, patch.port_forwards);
validate_and_commit_candidate(instance, &config, &candidate)?;
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
let result = patch_acl(&candidate, patch.acl);
validate_and_commit_candidate(instance, &config, &candidate)?;
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
let result = patch_proxy_networks(&candidate, patch.proxy_networks);
validate_and_commit_candidate(instance, &config, &candidate)?;
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
let result = patch_routes(&candidate, patch.routes);
validate_and_commit_candidate(instance, &config, &candidate)?;
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
let result = patch_exit_nodes_config(&candidate, patch.exit_nodes);
let normalized = validate_and_commit_candidate(instance, &config, &candidate)?;
let normalized =
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence)
.await?;
result?;
instance
.update_exit_nodes(normalized.peer.exit_nodes.clone())
.await;
let result = patch_mapped_listeners(&candidate, patch.mapped_listeners);
validate_and_commit_candidate(instance, &config, &candidate)?;
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
patch_connectors(instance, patch.connectors)?;
@@ -117,29 +119,45 @@ where
// Runs last so client validation sees the fully patched candidate,
// including routes and the node IPv4 set earlier in this request.
if !patch.vpn_portal_clients.is_empty() {
let previous = config.detached_snapshot();
apply_vpn_portal_client_patches(&candidate, patch.vpn_portal_clients)?;
// Deep-validate and hot-apply before committing, so a rejected
// client set leaves neither the shared TOML model nor the live
// portal changed.
// Deep-validate and durably persist before hot-applying. A failed
// write leaves the live Portal untouched. If the host rejects the
// hot update, restore the previous durable snapshot before
// returning so a later patch cannot overwrite from stale shared
// state and a restart cannot apply a rejected client set.
let normalized = validate_candidate(instance, &candidate)?;
persist_candidate_if_changed(instance, &config, &candidate, persistence).await?;
#[cfg(feature = "vpn-portal")]
{
let portal = normalized
.vpn_portal
.clone()
.ok_or_else(|| anyhow::anyhow!("VPN portal is not configured"))?;
instance
if let Err(error) = instance
.update_vpn_portal_clients(
portal.clients,
&runtime_config_from_normalized(&normalized),
)
.await?;
.await
{
if let Some(persistence) = persistence
&& let Err(rollback_error) =
persistence.persist(instance.instance_id(), &previous).await
{
return Err(error.context(format!(
"failed to restore durable configuration after VPN portal update: \
{rollback_error:#}"
)));
}
return Err(error);
}
}
#[cfg(not(feature = "vpn-portal"))]
{
let _ = normalized;
}
validate_and_commit_candidate(instance, &config, &candidate)?;
config.replace_from_snapshot(&candidate);
}
if let Some(managed) = &managed_credentials {
@@ -177,17 +195,19 @@ where
.map_err(anyhow::Error::msg)?;
candidate.set_managed_credentials(entries);
validate_candidate(instance, &candidate)?;
// File-backed configs persist every successful patch, so the
// durable file and the shared TOML model can never diverge.
persistence
.ok_or_else(|| anyhow::anyhow!("durable config patching is unavailable"))?
.persist(instance.instance_id(), &candidate)
.await?;
// When durable storage is configured, persist before installing
// secret authority so a successful replacement survives restart.
if let Some(persistence) = persistence {
persistence
.persist(instance.instance_id(), &candidate)
.await?;
}
config.replace_from_snapshot(&candidate);
managed_credentials_changed =
CredentialManager::install_managed_credentials(replacement);
} else {
validate_and_commit_candidate(instance, &config, &candidate)?;
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence)
.await?;
}
let normalized = validate_candidate(instance, &candidate)?;
let runtime = runtime_config_from_normalized(&normalized);
@@ -241,19 +261,42 @@ where
Ok(normalized)
}
fn validate_and_commit_candidate<H>(
async fn validate_persist_and_commit_candidate<H>(
instance: &CoreInstance<H>,
shared: &TomlConfig,
candidate: &TomlConfig,
persistence: Option<&dyn ConfigPatchPersistence>,
) -> anyhow::Result<CoreInstanceConfig>
where
H: CoreInstanceHost,
{
let normalized = validate_candidate(instance, candidate)?;
shared.replace_from_snapshot(candidate);
if persist_candidate_if_changed(instance, shared, candidate, persistence).await? {
shared.replace_from_snapshot(candidate);
}
Ok(normalized)
}
async fn persist_candidate_if_changed<H>(
instance: &CoreInstance<H>,
shared: &TomlConfig,
candidate: &TomlConfig,
persistence: Option<&dyn ConfigPatchPersistence>,
) -> anyhow::Result<bool>
where
H: CoreInstanceHost,
{
if shared.dump() == candidate.dump() {
return Ok(false);
}
if let Some(persistence) = persistence {
persistence
.persist(instance.instance_id(), candidate)
.await?;
}
Ok(true)
}
fn runtime_config_from_toml<H>(
instance: &CoreInstance<H>,
config: &TomlConfig,
@@ -471,15 +514,16 @@ fn apply_vpn_portal_client_patches(
tracing::warn!("ignored VPN portal client add without client");
continue;
};
let virtual_ip = client
.virtual_ip
.parse::<std::net::Ipv4Addr>()
.with_context(|| {
format!(
"invalid VPN portal client virtual IP: {}",
client.virtual_ip
)
})?;
let virtual_ip =
client
.virtual_ip
.parse::<cidr::Ipv4Inet>()
.with_context(|| {
format!(
"invalid VPN portal client virtual CIDR: {}",
client.virtual_ip
)
})?;
portal
.clients
.push(crate::config::toml::VpnPortalClientConfig {
@@ -560,7 +604,7 @@ mod tests {
wireguard_private_key: None,
clients: vec![VpnPortalClientConfig {
name: "alice".to_owned(),
virtual_ip: "10.0.0.2".parse().unwrap(),
virtual_ip: "10.0.0.2/24".parse().unwrap(),
groups: Vec::new(),
}],
});
@@ -603,7 +647,7 @@ mod tests {
fn vpn_portal_client_patches_add_remove_and_clear() {
let config = portal_config();
apply_vpn_portal_client_patches(&config, vec![add("bob", "10.0.0.3")]).unwrap();
apply_vpn_portal_client_patches(&config, vec![add("bob", "10.0.0.3/24")]).unwrap();
assert_eq!(configured_names(&config), ["alice", "bob"]);
apply_vpn_portal_client_patches(&config, vec![remove("alice")]).unwrap();
@@ -636,7 +680,7 @@ mod tests {
assert!(
error
.to_string()
.contains("invalid VPN portal client virtual IP")
.contains("invalid VPN portal client virtual CIDR")
);
}
}
@@ -447,7 +447,7 @@ mod tests {
state: PortalClientState::Online,
peer_id: Some(42),
endpoint: Some("198.51.100.2:51820".to_owned()),
tunnel_ip: Some(Ipv4Addr::new(192, 0, 2, 1)),
tunnel_ip: Some(Ipv4Addr::new(10, 82, 0, 2)),
client_config: "[Interface]\nPrivateKey = secret\n".to_owned(),
error: None,
},
@@ -479,7 +479,7 @@ mod tests {
assert_eq!(online.state, VpnPortalClientState::Online as i32);
assert_eq!(online.peer_id, Some(42));
assert_eq!(online.endpoint.as_deref(), Some("198.51.100.2:51820"));
assert_eq!(online.tunnel_ip.as_deref(), Some("192.0.2.1"));
assert_eq!(online.tunnel_ip.as_deref(), Some("10.82.0.2"));
assert_eq!(online.client_config, "[Interface]\nPrivateKey = secret\n");
assert_eq!(online.error, None);
@@ -167,15 +167,10 @@ where
H: CoreInstanceHost,
{
pub fn new(manager: Arc<InstanceManager<F>>) -> Self {
#[cfg(feature = "web-client")]
let persistence = Arc::new(ManagerPathlessConfigPatchPersistence {
manager: manager.clone(),
_host: std::marker::PhantomData,
});
Self {
resolver: ManagerInstanceResolver { manager },
#[cfg(feature = "web-client")]
config_patch_persistence: Some(persistence),
config_patch_persistence: None,
}
}
#[cfg(feature = "web-client")]
@@ -225,40 +220,6 @@ impl ConfigPatchPersistence for InMemoryConfigPatchPersistence {
}
}
#[cfg(feature = "web-client")]
struct ManagerPathlessConfigPatchPersistence<F, H>
where
F: InstanceFactory,
H: CoreInstanceHost,
{
manager: Arc<InstanceManager<F>>,
_host: std::marker::PhantomData<fn() -> H>,
}
#[async_trait::async_trait]
#[cfg(feature = "web-client")]
impl<F, H> ConfigPatchPersistence for ManagerPathlessConfigPatchPersistence<F, H>
where
F: InstanceFactory<Instance = CoreInstance<H>>,
H: CoreInstanceHost,
{
async fn persist(&self, instance_id: uuid::Uuid, _config: &TomlConfig) -> anyhow::Result<()> {
let Some(control) = self.manager.config_control(instance_id) else {
return Ok(());
};
if control.is_read_only() {
anyhow::bail!("configuration file is read-only");
}
if let Some(path) = control.path {
anyhow::bail!(
"config file {} requires a durable config storage backend",
path.display()
);
}
Ok(())
}
}
#[cfg(feature = "web-client")]
struct ManagerConfigPatchPersistence<F, H>
where
+47 -38
View File
@@ -35,10 +35,10 @@ use crate::{
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachedPeerConfig {
pub name: String,
pub virtual_ip: Ipv4Addr,
pub virtual_ip: cidr::Ipv4Inet,
pub groups: Vec<String>,
/// Stable identity key supplied by the caller; changing it changes the
/// peer identity, so callers must persist and reuse it across restarts.
/// Identity key supplied by the caller. It stays stable for one attached
/// runtime; replacing the runtime may deliberately rotate the identity.
pub identity_private_key: [u8; 32],
}
@@ -393,28 +393,9 @@ fn build_peer_snapshot(
.as_deref()
.filter(|secret| !secret.is_empty())
.ok_or_else(|| anyhow::anyhow!("attached peers require a non-empty network secret"))?;
if network.services.dhcp_ipv4 {
anyhow::bail!("attached peers require a static network-manager IPv4 address");
}
let network_ipv4 = network
.peer
.runtime
.core
.routes
.ipv4
.as_ref()
.ok_or_else(|| anyhow::anyhow!("attached peers require a network-manager IPv4 prefix"))?;
let IpAddr::V4(network_address) = network_ipv4.address else {
anyhow::bail!("attached peers require a network-manager IPv4 prefix");
};
let network_prefix = cidr::Ipv4Inet::new(network_address, network_ipv4.prefix_len)
.map_err(|error| anyhow::anyhow!("invalid network-manager IPv4 prefix: {error}"))?;
let network_prefix = network_prefix.network();
if config.virtual_ip == network_address
|| !network_prefix.contains(&config.virtual_ip)
|| config.virtual_ip == network_prefix.first_address()
|| config.virtual_ip == network_prefix.last_address()
{
let address = config.virtual_ip.address();
let client_network = config.virtual_ip.network();
if address == client_network.first_address() || address == client_network.last_address() {
anyhow::bail!("unusable attached-peer IPv4 address: {}", config.virtual_ip);
}
@@ -423,8 +404,8 @@ fn build_peer_snapshot(
snapshot.runtime.core.node.instance_id = None;
snapshot.runtime.core.node.hostname = Some(config.name.clone());
snapshot.runtime.core.routes.ipv4 = Some(IpPrefix {
address: IpAddr::V4(config.virtual_ip),
prefix_len: network_ipv4.prefix_len,
address: IpAddr::V4(address),
prefix_len: config.virtual_ip.network_length(),
});
snapshot.runtime.core.routes.ipv6 = None;
snapshot.runtime.core.routes.advertised_routes.clear();
@@ -544,6 +525,10 @@ mod tests {
},
};
fn attached_ipv4(address: Ipv4Addr) -> cidr::Ipv4Inet {
cidr::Ipv4Inet::new(address, 24).unwrap()
}
fn peer_manager_with_acl(
tcp_whitelist: Vec<String>,
) -> (Arc<PeerManagerCore>, CoreRuntimeConfigStore) {
@@ -713,7 +698,7 @@ mod tests {
store.clone(),
AttachedPeerConfig {
name: "group-update".to_owned(),
virtual_ip: Ipv4Addr::new(10, 82, 0, 2),
virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 2)),
groups: vec!["ops".to_owned(), "audit".to_owned()],
identity_private_key: [2; 32],
},
@@ -745,7 +730,7 @@ mod tests {
store,
AttachedPeerConfig {
name: "direct-group-update".to_owned(),
virtual_ip: Ipv4Addr::new(10, 82, 0, 2),
virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 2)),
groups: vec!["ops".to_owned()],
identity_private_key: [5; 32],
},
@@ -831,7 +816,7 @@ mod tests {
let network = store.snapshot();
let config = AttachedPeerConfig {
name: "sanitized".to_owned(),
virtual_ip: Ipv4Addr::new(10, 82, 0, 2),
virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 2)),
groups: vec!["ops".to_owned()],
identity_private_key: [3; 32],
};
@@ -878,6 +863,30 @@ mod tests {
);
}
#[tokio::test]
async fn attached_peer_uses_its_own_cidr_when_host_uses_dhcp() {
let (_network_peer_manager, store) = peer_manager_with_acl_and_secure(Vec::new(), true);
store.update_peer_with(|peer| peer.runtime.core.routes.ipv4 = None);
store.update_services(|services| services.dhcp_ipv4 = true);
let network = store.snapshot();
let config = AttachedPeerConfig {
name: "wireguard-client".to_owned(),
virtual_ip: "10.90.0.2/16".parse().unwrap(),
groups: vec!["ops".to_owned()],
identity_private_key: [3; 32],
};
let (snapshot, _) = build_peer_snapshot(network.as_ref(), &config).unwrap();
assert_eq!(
snapshot.runtime.core.routes.ipv4,
Some(IpPrefix {
address: IpAddr::V4(Ipv4Addr::new(10, 90, 0, 2)),
prefix_len: 16,
})
);
}
#[tokio::test]
async fn secure_attached_peer_uses_credential_identity_and_granted_groups() {
let (network_peer_manager, store) = peer_manager_with_acl_and_secure(Vec::new(), true);
@@ -896,7 +905,7 @@ mod tests {
store,
AttachedPeerConfig {
name: "credential".to_owned(),
virtual_ip: Ipv4Addr::new(10, 82, 0, 2),
virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 2)),
groups: vec!["ops".to_owned()],
identity_private_key,
},
@@ -944,7 +953,7 @@ mod tests {
store.clone(),
AttachedPeerConfig {
name: "first".to_owned(),
virtual_ip: Ipv4Addr::new(10, 82, 0, 2),
virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 2)),
groups: vec!["ops".to_owned()],
identity_private_key: [1; 32],
},
@@ -956,7 +965,7 @@ mod tests {
store.clone(),
AttachedPeerConfig {
name: "second".to_owned(),
virtual_ip: Ipv4Addr::new(10, 82, 0, 3),
virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 3)),
groups: Vec::new(),
identity_private_key: [2; 32],
},
@@ -1030,7 +1039,7 @@ mod tests {
store,
AttachedPeerConfig {
name: "reconnected".to_owned(),
virtual_ip: Ipv4Addr::new(10, 82, 0, 4),
virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 4)),
groups: vec!["ops".to_owned()],
identity_private_key: [3; 32],
},
@@ -1059,7 +1068,7 @@ mod tests {
store,
AttachedPeerConfig {
name: "cancelled-close".to_owned(),
virtual_ip: Ipv4Addr::new(10, 82, 0, 4),
virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 4)),
groups: Vec::new(),
identity_private_key,
},
@@ -1126,7 +1135,7 @@ mod tests {
store,
AttachedPeerConfig {
name: "closed-receiver".to_owned(),
virtual_ip: Ipv4Addr::new(10, 82, 0, 4),
virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 4)),
groups: Vec::new(),
identity_private_key: [3; 32],
},
@@ -1158,7 +1167,7 @@ mod tests {
store,
AttachedPeerConfig {
name: "dropped".to_owned(),
virtual_ip: Ipv4Addr::new(10, 82, 0, 4),
virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 4)),
groups: Vec::new(),
identity_private_key: [3; 32],
},
@@ -1198,7 +1207,7 @@ mod tests {
store,
AttachedPeerConfig {
name: "dropped-secure".to_owned(),
virtual_ip: Ipv4Addr::new(10, 82, 0, 5),
virtual_ip: attached_ipv4(Ipv4Addr::new(10, 82, 0, 5)),
groups: vec!["ops".to_owned()],
identity_private_key,
},
+7
View File
@@ -1519,6 +1519,13 @@ impl PeerManagerCore {
self.route.clone()
}
pub(crate) async fn withdraw_routes_before_stop(&self) -> bool {
let Some(route) = self.route_algo_inst.ospf_route() else {
return true;
};
route.withdraw_self_conn_info().await
}
pub fn mark_recent_traffic(&self, dst_peer_id: PeerId) {
let flags = self.context.flags();
self.recent_traffic
@@ -2369,6 +2369,7 @@ struct PeerRouteServiceImpl {
interface_peers_generation: AtomicU64,
applied_interface_peers_generation: AtomicU64,
applied_interface_peers: std::sync::Mutex<BTreeSet<PeerId>>,
self_conn_info_withdrawn: AtomicBool,
last_update_my_foreign_network: AtomicCell<Option<Instant>>,
@@ -2442,6 +2443,7 @@ impl PeerRouteServiceImpl {
interface_peers_generation: AtomicU64::new(1),
applied_interface_peers_generation: AtomicU64::new(0),
applied_interface_peers: std::sync::Mutex::new(BTreeSet::new()),
self_conn_info_withdrawn: AtomicBool::new(false),
last_update_my_foreign_network: AtomicCell::new(None),
@@ -2606,6 +2608,10 @@ impl PeerRouteServiceImpl {
&self,
snapshot: &InterfacePeerSnapshot,
) -> BTreeSet<PeerId> {
if self.self_conn_info_withdrawn.load(Ordering::Acquire) {
return BTreeSet::new();
}
if !self.peer_relay_projection_enabled() {
return snapshot.peers.clone();
}
@@ -2780,7 +2786,9 @@ impl PeerRouteServiceImpl {
fn local_route_snapshot(&self) -> OspfRouteSnapshot {
let mut snapshot = self.synced_route_info.route_snapshot();
if !self.peer_relay_projection_enabled() {
if !self.peer_relay_projection_enabled()
&& !self.self_conn_info_withdrawn.load(Ordering::Acquire)
{
return snapshot;
}
@@ -4201,6 +4209,63 @@ impl Debug for PeerRoute {
}
impl PeerRoute {
pub(crate) async fn withdraw_self_conn_info(&self) -> bool {
if self.service_impl.stopped.load(Ordering::Acquire) {
return true;
}
if self.service_impl.interface.lock().await.is_none() {
return true;
}
self.service_impl
.self_conn_info_withdrawn
.store(true, Ordering::Release);
self.service_impl.mark_interface_peers_dirty();
let direct_peers = self.service_impl.interface_peer_snapshot().await;
self.service_impl.update_my_infos().await;
let Some(conn_info_version) = self
.service_impl
.synced_route_info
.conn_map
.read()
.get(&self.my_peer_id)
.map(|info| info.version.get())
else {
return false;
};
let sessions = direct_peers
.peers
.iter()
.filter_map(|peer_id| {
self.service_impl
.get_session(*peer_id)
.map(|session| (*peer_id, session))
})
.collect::<Vec<_>>();
if sessions.is_empty() {
return true;
}
let Some(peer_rpc) = self.peer_rpc.upgrade() else {
return false;
};
let synchronized = crate::foundation::time::timeout(Duration::from_secs(1), async {
futures::future::join_all(sessions.iter().map(|(peer_id, _)| {
self.service_impl
.sync_route_with_peer(*peer_id, peer_rpc.clone(), false)
}))
.await;
sessions.iter().all(|(_, session)| {
session.check_saved_conn_version_update_to_date(self.my_peer_id, conn_info_version)
})
})
.await;
matches!(synchronized, Ok(true))
}
pub fn new(
my_peer_id: PeerId,
context: ArcPeerContext,
@@ -5314,6 +5379,127 @@ mod tests {
assert_eq!(get_peer_identity_type_calls.load(Ordering::Relaxed), 4);
}
#[tokio::test]
async fn shutdown_withdrawal_preserves_local_physical_routes() {
let (route, _peer_rpc) =
test_route_with_admin_peer(Arc::new(NoopPeerContext::default())).await;
assert!(route.service_impl.update_my_infos().await);
assert_eq!(
route
.service_impl
.synced_route_info
.get_connected_peers::<BTreeSet<_>>(1),
Some(BTreeSet::from([2]))
);
let previous_version = route
.service_impl
.synced_route_info
.conn_map
.read()
.get(&1)
.unwrap()
.version
.get();
assert!(route.withdraw_self_conn_info().await);
{
let conn_map = route.service_impl.synced_route_info.conn_map.read();
let withdrawn = conn_map.get(&1).unwrap();
assert!(withdrawn.connected_peers.is_empty());
assert!(withdrawn.version.get() > previous_version);
}
let local_snapshot = route.service_impl.local_route_snapshot();
assert_eq!(
local_snapshot
.conn_map
.iter()
.find(|row| row.peer_id == 1)
.unwrap()
.connected_peers,
BTreeSet::from([2])
);
route.service_impl.mark_interface_peers_dirty();
assert!(!route.service_impl.update_my_conn_info().await);
assert!(
route
.service_impl
.synced_route_info
.get_connected_peers::<BTreeSet<_>>(1)
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn empty_self_conn_info_round_trips_through_list_and_bitmap() {
let source = test_service_impl(1);
*source.interface.lock().await = Some(Box::new(CountingInterface {
my_peer_id: 1,
peers: Arc::new(Mutex::new(vec![2])),
peer_identity_types: Arc::new(Mutex::new(HashMap::from([(
2,
Some(PeerIdentityType::Admin),
)]))),
list_peers_calls: Arc::new(AtomicU32::new(0)),
get_peer_identity_type_calls: Arc::new(AtomicU32::new(0)),
}));
assert!(source.update_my_infos().await);
source
.self_conn_info_withdrawn
.store(true, Ordering::Release);
source.mark_interface_peers_dirty();
assert!(source.update_my_infos().await);
let session = SyncRouteSession::new(1, 2);
let mut estimated_size = 0;
let peer_list = source
.build_conn_peer_list(&session, &mut estimated_size)
.expect("withdrawn row should remain in the peer list");
let listed = peer_list
.peer_conn_infos
.iter()
.find(|info| info.peer_id.is_some_and(|id| id.peer_id == 1))
.expect("peer list should carry the withdrawn self row");
assert!(listed.connected_peer_ids.is_empty());
let list_receiver = test_service_impl(2);
install_conn_row(&list_receiver, 1, [2]);
list_receiver
.synced_route_info
.update_conn_info_with_list(&peer_list);
assert!(
list_receiver
.synced_route_info
.get_connected_peers::<BTreeSet<_>>(1)
.unwrap()
.is_empty()
);
let bitmap = source.build_conn_bitmap();
let self_index = bitmap
.peer_ids
.iter()
.position(|id| id.peer_id == 1)
.expect("bitmap should carry the withdrawn self row");
assert!(bitmap.get_connected_peers(self_index).is_empty());
let bitmap_receiver = test_service_impl(2);
install_conn_row(&bitmap_receiver, 1, [2]);
bitmap_receiver
.synced_route_info
.update_conn_info_with_bitmap(&bitmap);
assert!(
bitmap_receiver
.synced_route_info
.get_connected_peers::<BTreeSet<_>>(1)
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn periodic_requery_without_peer_change_keeps_route_version_stable() {
let service_impl = test_peer_relay_service_impl(1);