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