420 lines
14 KiB
Rust
420 lines
14 KiB
Rust
use crate::model::{NetMap, Route, RouteKind};
|
|
use crate::netlink::{Netlink, RouteEntry};
|
|
use anyhow::{anyhow, Result};
|
|
use ipnet::IpNet;
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
pub struct RouteApplyConfig {
|
|
pub interface: String,
|
|
pub accept_exit_node: bool,
|
|
pub exit_node_id: Option<String>,
|
|
pub exit_node_name: Option<String>,
|
|
pub exit_node_policy: ExitNodePolicy,
|
|
pub exit_node_tag: Option<String>,
|
|
pub exit_node_metric_base: u32,
|
|
pub exit_node_uid_range: Option<UidRange>,
|
|
pub allow_conflicts: bool,
|
|
pub route_table: Option<u32>,
|
|
pub route_rule_priority: u32,
|
|
pub exit_rule_priority: u32,
|
|
pub exit_uid_rule_priority: u32,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct UidRange {
|
|
pub start: u32,
|
|
pub end: u32,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub enum ExitNodePolicy {
|
|
First,
|
|
Latest,
|
|
Multi,
|
|
}
|
|
|
|
pub async fn apply_advertised_routes(netmap: &NetMap, cfg: &RouteApplyConfig) -> Result<()> {
|
|
let netlink = Netlink::new().await?;
|
|
let interface_index = netlink
|
|
.link_index(&cfg.interface)
|
|
.await?
|
|
.ok_or_else(|| anyhow!("interface {} not found", cfg.interface))?;
|
|
let existing_routes = netlink.list_routes().await?;
|
|
let selected_exit_peers = select_exit_peers(netmap, cfg);
|
|
let selected_exit_ids: HashSet<String> =
|
|
selected_exit_peers.iter().map(|peer| peer.peer_id.clone()).collect();
|
|
let selected_exit_metrics: HashMap<String, u32> = selected_exit_peers
|
|
.iter()
|
|
.filter_map(|peer| peer.metric.map(|metric| (peer.peer_id.clone(), metric)))
|
|
.collect();
|
|
let exit_requested = cfg.exit_node_id.is_some() || cfg.exit_node_name.is_some();
|
|
let tag_filtered = cfg.exit_node_tag.is_some();
|
|
if exit_requested && selected_exit_peers.is_empty() {
|
|
eprintln!("requested exit node not found; skipping exit routes");
|
|
}
|
|
if tag_filtered && selected_exit_peers.is_empty() {
|
|
eprintln!("exit node tag filter matched no peers; skipping exit routes");
|
|
}
|
|
let allow_exit_routes = if exit_requested || tag_filtered {
|
|
!selected_exit_peers.is_empty()
|
|
} else {
|
|
true
|
|
};
|
|
let allow_multiple_exit = matches!(cfg.exit_node_policy, ExitNodePolicy::Multi);
|
|
let mut exit_v4_applied = false;
|
|
let mut exit_v6_applied = false;
|
|
let mut conflict_count = 0;
|
|
let mut skipped_exit = false;
|
|
let mut applied_routes: Vec<IpNet> = Vec::new();
|
|
let mut exit_uid_rule_v4 = false;
|
|
let mut exit_uid_rule_v6 = false;
|
|
|
|
for peer in &netmap.peers {
|
|
let is_exit_peer = selected_exit_ids.is_empty() || selected_exit_ids.contains(&peer.id);
|
|
let exit_metric = selected_exit_metrics.get(&peer.id).cloned();
|
|
for route in &peer.routes {
|
|
if !route.enabled {
|
|
continue;
|
|
}
|
|
let apply_prefix = match route_apply_prefix(route) {
|
|
Ok(prefix) => prefix,
|
|
Err(err) => {
|
|
eprintln!(
|
|
"skipping route {} for peer {}: {}",
|
|
route.prefix, peer.id, err
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
match route.kind {
|
|
RouteKind::Subnet => {
|
|
if route_conflicts(apply_prefix, &existing_routes, interface_index)
|
|
|| route_conflicts_with_applied(apply_prefix, &applied_routes)
|
|
{
|
|
conflict_count += 1;
|
|
if !cfg.allow_conflicts {
|
|
continue;
|
|
}
|
|
}
|
|
let net = apply_route(
|
|
apply_prefix,
|
|
interface_index,
|
|
&netlink,
|
|
None,
|
|
cfg.route_table,
|
|
)
|
|
.await?;
|
|
applied_routes.push(net);
|
|
if let Some(table) = cfg.route_table {
|
|
netlink
|
|
.add_rule_for_prefix(net, table, cfg.route_rule_priority)
|
|
.await?;
|
|
}
|
|
}
|
|
RouteKind::Exit => {
|
|
if !cfg.accept_exit_node || !allow_exit_routes {
|
|
continue;
|
|
}
|
|
if !is_exit_peer {
|
|
skipped_exit = true;
|
|
continue;
|
|
}
|
|
if is_ipv6(apply_prefix) {
|
|
if exit_v6_applied && !allow_multiple_exit {
|
|
continue;
|
|
}
|
|
let net = apply_route(
|
|
apply_prefix,
|
|
interface_index,
|
|
&netlink,
|
|
exit_metric,
|
|
cfg.route_table,
|
|
)
|
|
.await?;
|
|
applied_routes.push(net);
|
|
exit_v6_applied = true;
|
|
if let Some(table) = cfg.route_table {
|
|
if let Some(uid_range) = cfg.exit_node_uid_range {
|
|
if !exit_uid_rule_v6 {
|
|
netlink
|
|
.add_uid_rule_v6(
|
|
table,
|
|
cfg.exit_uid_rule_priority,
|
|
uid_range.start,
|
|
uid_range.end,
|
|
)
|
|
.await?;
|
|
exit_uid_rule_v6 = true;
|
|
}
|
|
} else {
|
|
netlink
|
|
.add_rule_for_prefix(net, table, cfg.exit_rule_priority)
|
|
.await?;
|
|
}
|
|
}
|
|
} else {
|
|
if exit_v4_applied && !allow_multiple_exit {
|
|
continue;
|
|
}
|
|
let net = apply_route(
|
|
apply_prefix,
|
|
interface_index,
|
|
&netlink,
|
|
exit_metric,
|
|
cfg.route_table,
|
|
)
|
|
.await?;
|
|
applied_routes.push(net);
|
|
exit_v4_applied = true;
|
|
if let Some(table) = cfg.route_table {
|
|
if let Some(uid_range) = cfg.exit_node_uid_range {
|
|
if !exit_uid_rule_v4 {
|
|
netlink
|
|
.add_uid_rule_v4(
|
|
table,
|
|
cfg.exit_uid_rule_priority,
|
|
uid_range.start,
|
|
uid_range.end,
|
|
)
|
|
.await?;
|
|
exit_uid_rule_v4 = true;
|
|
}
|
|
} else {
|
|
netlink
|
|
.add_rule_for_prefix(net, table, cfg.exit_rule_priority)
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if conflict_count > 0 {
|
|
eprintln!(
|
|
"skipped {} conflicting route(s) (use --allow-route-conflicts to force)",
|
|
conflict_count
|
|
);
|
|
}
|
|
if skipped_exit {
|
|
eprintln!(
|
|
"exit node selection active; routes from other exit nodes were skipped"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn selected_exit_peer_ids(netmap: &NetMap, cfg: &RouteApplyConfig) -> HashSet<String> {
|
|
if !cfg.accept_exit_node {
|
|
return HashSet::new();
|
|
}
|
|
let selected = select_exit_peers(netmap, cfg);
|
|
let exit_requested = cfg.exit_node_id.is_some() || cfg.exit_node_name.is_some();
|
|
let tag_filtered = cfg.exit_node_tag.is_some();
|
|
let allow_exit_routes = if exit_requested || tag_filtered {
|
|
!selected.is_empty()
|
|
} else {
|
|
true
|
|
};
|
|
if !allow_exit_routes {
|
|
return HashSet::new();
|
|
}
|
|
selected
|
|
.into_iter()
|
|
.map(|peer| peer.peer_id)
|
|
.collect()
|
|
}
|
|
|
|
fn route_apply_prefix(route: &Route) -> Result<&str> {
|
|
let Some(mapped) = route.mapped_prefix.as_deref() else {
|
|
return Ok(&route.prefix);
|
|
};
|
|
let real_net: IpNet = route.prefix.parse()?;
|
|
let mapped_net: IpNet = mapped.parse()?;
|
|
let real_v4 = matches!(real_net, IpNet::V4(_));
|
|
let mapped_v4 = matches!(mapped_net, IpNet::V4(_));
|
|
if real_v4 != mapped_v4 {
|
|
return Err(anyhow!("mapped prefix ip version mismatch"));
|
|
}
|
|
if real_net.prefix_len() != mapped_net.prefix_len() {
|
|
return Err(anyhow!("mapped prefix length mismatch"));
|
|
}
|
|
Ok(mapped)
|
|
}
|
|
|
|
struct ExitPeerSelection {
|
|
peer_id: String,
|
|
metric: Option<u32>,
|
|
}
|
|
|
|
fn select_exit_peers(netmap: &NetMap, cfg: &RouteApplyConfig) -> Vec<ExitPeerSelection> {
|
|
let mut candidates: Vec<&crate::model::PeerInfo> = netmap
|
|
.peers
|
|
.iter()
|
|
.filter(|peer| {
|
|
peer.routes
|
|
.iter()
|
|
.any(|route| matches!(route.kind, RouteKind::Exit))
|
|
})
|
|
.collect();
|
|
|
|
if let Some(tag) = cfg.exit_node_tag.as_ref() {
|
|
candidates.retain(|peer| peer.tags.iter().any(|peer_tag| peer_tag == tag));
|
|
}
|
|
|
|
if let Some(id) = cfg.exit_node_id.as_ref() {
|
|
return candidates
|
|
.into_iter()
|
|
.find(|peer| &peer.id == id)
|
|
.map(|peer| vec![ExitPeerSelection {
|
|
peer_id: peer.id.clone(),
|
|
metric: None,
|
|
}])
|
|
.unwrap_or_default();
|
|
}
|
|
|
|
if let Some(name) = cfg.exit_node_name.as_ref() {
|
|
return candidates
|
|
.into_iter()
|
|
.find(|peer| peer.name == *name)
|
|
.map(|peer| vec![ExitPeerSelection {
|
|
peer_id: peer.id.clone(),
|
|
metric: None,
|
|
}])
|
|
.unwrap_or_default();
|
|
}
|
|
|
|
match cfg.exit_node_policy {
|
|
ExitNodePolicy::Latest => {
|
|
candidates.sort_by_key(|peer| peer.last_seen);
|
|
candidates
|
|
.last()
|
|
.map(|peer| ExitPeerSelection {
|
|
peer_id: peer.id.clone(),
|
|
metric: None,
|
|
})
|
|
.into_iter()
|
|
.collect()
|
|
}
|
|
ExitNodePolicy::Multi => candidates
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(idx, peer)| ExitPeerSelection {
|
|
peer_id: peer.id.clone(),
|
|
metric: Some(cfg.exit_node_metric_base.saturating_add(idx as u32)),
|
|
})
|
|
.collect(),
|
|
ExitNodePolicy::First => candidates
|
|
.into_iter()
|
|
.next()
|
|
.map(|peer| ExitPeerSelection {
|
|
peer_id: peer.id.clone(),
|
|
metric: None,
|
|
})
|
|
.into_iter()
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
async fn apply_route(
|
|
prefix: &str,
|
|
interface_index: u32,
|
|
netlink: &Netlink,
|
|
metric: Option<u32>,
|
|
table: Option<u32>,
|
|
) -> Result<IpNet> {
|
|
let net: IpNet = prefix.parse()?;
|
|
match table {
|
|
Some(table) => {
|
|
netlink
|
|
.replace_route_with_metric_table(net, interface_index, metric, table)
|
|
.await?;
|
|
}
|
|
None => {
|
|
netlink
|
|
.replace_route_with_metric(net, interface_index, metric)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(net)
|
|
}
|
|
|
|
fn route_conflicts(prefix: &str, existing: &[RouteEntry], interface_index: u32) -> bool {
|
|
let Ok(net) = prefix.parse::<IpNet>() else {
|
|
return false;
|
|
};
|
|
existing.iter().any(|route| {
|
|
if route.oif == Some(interface_index) {
|
|
return false;
|
|
}
|
|
if route.prefix.prefix_len() == 0 {
|
|
return false;
|
|
}
|
|
nets_overlap(&net, &route.prefix)
|
|
})
|
|
}
|
|
|
|
fn nets_overlap(a: &IpNet, b: &IpNet) -> bool {
|
|
match (a, b) {
|
|
(IpNet::V4(a4), IpNet::V4(b4)) => ranges_overlap(v4_range(a4), v4_range(b4)),
|
|
(IpNet::V6(a6), IpNet::V6(b6)) => ranges_overlap(v6_range(a6), v6_range(b6)),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn v4_range(net: &ipnet::Ipv4Net) -> (u64, u64) {
|
|
let base = u64::from(u32::from(net.network()));
|
|
let host_bits = 32u32.saturating_sub(net.prefix_len() as u32);
|
|
let end = if host_bits == 32 {
|
|
u64::from(u32::MAX)
|
|
} else {
|
|
base + ((1u64 << host_bits) - 1)
|
|
};
|
|
(base, end)
|
|
}
|
|
|
|
fn v6_range(net: &ipnet::Ipv6Net) -> (u128, u128) {
|
|
let base = u128::from(net.network());
|
|
let host_bits = 128u32.saturating_sub(net.prefix_len() as u32);
|
|
let end = if host_bits == 128 {
|
|
u128::MAX
|
|
} else {
|
|
base + ((1u128 << host_bits) - 1)
|
|
};
|
|
(base, end)
|
|
}
|
|
|
|
fn ranges_overlap<T: Ord>(a: (T, T), b: (T, T)) -> bool {
|
|
a.0 <= b.1 && b.0 <= a.1
|
|
}
|
|
|
|
fn route_conflicts_with_applied(prefix: &str, applied: &[IpNet]) -> bool {
|
|
let Ok(net) = prefix.parse::<IpNet>() else {
|
|
return false;
|
|
};
|
|
applied.iter().any(|other| nets_overlap(&net, other))
|
|
}
|
|
|
|
fn is_ipv6(prefix: &str) -> bool {
|
|
prefix.contains(':')
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn overlaps_detected_for_subnets() {
|
|
let a: IpNet = "10.0.0.0/24".parse().unwrap();
|
|
let b: IpNet = "10.0.0.128/25".parse().unwrap();
|
|
assert!(nets_overlap(&a, &b));
|
|
}
|
|
|
|
#[test]
|
|
fn applied_conflict_detects_overlap() {
|
|
let applied: Vec<IpNet> = vec!["10.1.0.0/24".parse().unwrap()];
|
|
assert!(route_conflicts_with_applied("10.1.0.128/25", &applied));
|
|
}
|
|
}
|