Implement opt-in overlay TUN runtime

This commit is contained in:
Eric Wendland 2026-05-23 02:08:51 +02:00
commit d9728a326d
15 changed files with 2315 additions and 84 deletions

View file

@ -34,6 +34,7 @@ pub struct OverlayPlan {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayJoinPlan {
pub plan: OverlayPlan,
pub network: OverlayNetworkStatus,
pub enabled: bool,
pub note: String,
}
@ -57,6 +58,59 @@ pub struct OverlayNetworkStatus {
pub note: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayInterfacePlan {
pub name: String,
pub platform: String,
pub interface_name: String,
pub cidr: String,
pub virtual_ip: Option<String>,
pub requires_privileges: bool,
pub commands: Vec<String>,
pub notes: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayRuntimeStatus {
pub name: String,
pub interface_name: String,
pub virtual_ip: String,
pub cidr: String,
pub mtu: u16,
pub started_at_ms: i64,
pub packets_from_tun: u64,
pub packets_to_tun: u64,
pub packets_to_peers: u64,
pub last_error: Option<String>,
pub note: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayPacket {
pub id: String,
pub network: String,
pub source_node: String,
pub destination_node: String,
pub packet_base64: String,
pub size_bytes: usize,
pub received_at_ms: i64,
pub note: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayMembership {
pub name: String,
pub resource: ResourceId,
pub cidr: String,
pub state: OverlayState,
pub local_node_id: String,
pub virtual_ip: Option<String>,
pub secret_fingerprint: String,
pub joined_at_ms: i64,
pub updated_at_ms: i64,
pub packet_runtime: String,
}
#[derive(Debug, thiserror::Error)]
pub enum OverlayError {
#[error("invalid overlay name `{0}`")]
@ -65,6 +119,12 @@ pub enum OverlayError {
InvalidCidr(String),
#[error("overlay join requires a non-empty resource secret")]
EmptySecret,
#[error("overlay CIDR `{0}` has no usable host addresses")]
NoUsableHostAddress(String),
#[error("invalid IPv4 packet: {0}")]
InvalidIpv4Packet(String),
#[error("unsupported overlay platform `{0}`")]
UnsupportedPlatform(String),
}
pub fn validate_overlay_name(name: &str) -> Result<(), OverlayError> {
@ -104,11 +164,173 @@ pub fn validate_overlay_secret(secret: &str) -> Result<(), OverlayError> {
}
}
pub fn validate_ipv4_packet(packet: &[u8]) -> Result<(), OverlayError> {
if packet.len() < 20 {
return Err(OverlayError::InvalidIpv4Packet(
"packet shorter than IPv4 header".to_owned(),
));
}
if packet[0] >> 4 != 4 {
return Err(OverlayError::InvalidIpv4Packet(
"packet version is not IPv4".to_owned(),
));
}
let header_len = usize::from(packet[0] & 0x0f) * 4;
if header_len < 20 || header_len > packet.len() {
return Err(OverlayError::InvalidIpv4Packet(
"invalid IPv4 header length".to_owned(),
));
}
let total_len = u16::from_be_bytes([packet[2], packet[3]]) as usize;
if total_len < header_len || total_len > packet.len() {
return Err(OverlayError::InvalidIpv4Packet(
"invalid IPv4 total length".to_owned(),
));
}
Ok(())
}
pub fn ipv4_source_destination(packet: &[u8]) -> Result<(String, String), OverlayError> {
validate_ipv4_packet(packet)?;
Ok((
Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]).to_string(),
Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]).to_string(),
))
}
#[must_use]
pub fn overlay_state_key(name: &str) -> String {
format!("overlay:{name}")
}
#[must_use]
pub fn overlay_packet_key(network: &str, received_at_ms: i64, packet_id: &str) -> String {
format!("overlay-packet:{network}:{received_at_ms}:{packet_id}")
}
#[must_use]
pub fn overlay_resource_id(name: &str) -> ResourceId {
ResourceId::new(format!("resource:overlay:{name}"))
}
#[must_use]
pub fn overlay_secret_fingerprint(secret: &str) -> String {
format!("blake3:{}", blake3::hash(secret.as_bytes()))
}
#[must_use]
pub fn overlay_packet_id(
network: &str,
source_node: &str,
destination_node: &str,
packet: &[u8],
received_at_ms: i64,
) -> String {
format!(
"overlay-packet:{}",
blake3::hash(
format!("{network}\0{source_node}\0{destination_node}\0{received_at_ms}\0").as_bytes()
)
.to_hex()
) + &blake3::hash(packet).to_hex()[..16]
}
pub fn joined_overlay_membership(
name: &str,
cidr: Option<&str>,
local_node_id: &str,
secret: &str,
joined_at_ms: i64,
) -> Result<OverlayMembership, OverlayError> {
validate_overlay_secret(secret)?;
let cidr = cidr.unwrap_or(DEFAULT_OVERLAY_CIDR);
validate_overlay_name(name)?;
validate_overlay_cidr(cidr)?;
Ok(OverlayMembership {
name: name.to_owned(),
resource: overlay_resource_id(name),
cidr: cidr.to_owned(),
state: OverlayState::Joined,
local_node_id: local_node_id.to_owned(),
virtual_ip: Some(deterministic_virtual_ip(cidr, local_node_id)?),
secret_fingerprint: overlay_secret_fingerprint(secret),
joined_at_ms,
updated_at_ms: joined_at_ms,
packet_runtime:
"joined: run `geth overlay up <name>` to start the explicit TUN/Wintun runtime"
.to_owned(),
})
}
pub fn stopped_overlay_membership(
mut membership: OverlayMembership,
updated_at_ms: i64,
) -> OverlayMembership {
membership.state = OverlayState::Stopped;
membership.updated_at_ms = updated_at_ms;
membership.packet_runtime = "inactive: overlay membership stopped".to_owned();
membership
}
#[must_use]
pub fn membership_status(membership: &OverlayMembership) -> OverlayNetworkStatus {
OverlayNetworkStatus {
name: membership.name.clone(),
resource: membership.resource.clone(),
cidr: membership.cidr.clone(),
state: membership.state.clone(),
virtual_ip: membership.virtual_ip.clone(),
peers: Vec::new(),
note: membership.packet_runtime.clone(),
}
}
pub fn interface_plan(
plan: &OverlayPlan,
platform: Option<&str>,
virtual_ip: Option<&str>,
) -> Result<OverlayInterfacePlan, OverlayError> {
let platform = platform.unwrap_or(std::env::consts::OS);
let interface_name = format!("geth-{}", interface_slug(&plan.name));
let prefix = cidr_prefix(&plan.cidr)?;
let virtual_ip = virtual_ip.map(ToOwned::to_owned);
let addr = virtual_ip
.as_ref()
.map(|ip| format!("{ip}/{prefix}"))
.unwrap_or_else(|| format!("<virtual-ip>/{prefix}"));
let commands = match platform {
"linux" => vec![
format!("ip tuntap add dev {interface_name} mode tun user <user>"),
format!("ip addr add {addr} dev {interface_name}"),
format!("ip link set {interface_name} up"),
],
"macos" | "darwin" => vec![
"open a utun device from the geth daemon process".to_owned(),
format!("ifconfig <utunN> inet {} {} up", addr, plan.cidr),
],
"windows" => vec![
"install or open a Wintun adapter for the current user context".to_owned(),
format!("assign {addr} to the Wintun adapter"),
],
other => return Err(OverlayError::UnsupportedPlatform(other.to_owned())),
};
Ok(OverlayInterfacePlan {
name: plan.name.clone(),
platform: platform.to_owned(),
interface_name,
cidr: plan.cidr.clone(),
virtual_ip,
requires_privileges: true,
commands,
notes: vec![
"generated plan only; run `geth overlay up <name>` to activate host networking"
.to_owned(),
"activation is explicit and user-scoped".to_owned(),
"all overlay packets remain carried over /geth/overlay/1".to_owned(),
],
})
}
pub fn plan_overlay(
name: &str,
cidr: Option<&str>,
@ -128,7 +350,7 @@ pub fn plan_overlay(
CAPABILITY_ADMIN.to_owned(),
],
discovery: "future overlay discovery may use mDNS, peer exchange, and resource metadata; discovery remains untrusted".to_owned(),
runtime: "planned only in this prototype; no TUN/Wintun interface is created".to_owned(),
runtime: "explicit opt-in runtime available with `geth overlay up <name>`; creates a TUN/Wintun-style L3 interface".to_owned(),
security: vec![
"all overlay packets must be carried over daemon-owned Iroh connections".to_owned(),
"knowing an EndpointID or overlay name must not grant overlay access".to_owned(),
@ -137,15 +359,73 @@ pub fn plan_overlay(
],
implementation_notes: vec![
"inspired by iroh-lan's Iroh-carried packet overlay".to_owned(),
"future packet runtime should register /geth/overlay/1 on the shared geth Iroh router".to_owned(),
"future host integration may need TUN/Wintun privileges and must remain explicitly opt-in".to_owned(),
"packet runtime registers /geth/overlay/1 on the shared geth Iroh router".to_owned(),
"host integration may need TUN/Wintun privileges and remains explicitly opt-in".to_owned(),
],
})
}
#[must_use]
pub fn overlay_status_note() -> &'static str {
"overlay runtime is scaffolded but inactive; use `geth overlay plan <name>` to inspect the intended resource and capabilities"
"overlay membership is persisted locally; run `geth overlay up <name>` to start the explicit TUN/Wintun runtime"
}
pub fn deterministic_virtual_ip(cidr: &str, seed: &str) -> Result<String, OverlayError> {
let (network, prefix) = parse_ipv4_cidr(cidr)?;
let host_bits = 32_u8.saturating_sub(prefix);
let address = if host_bits == 0 {
network
} else {
let host_space = 1_u64 << host_bits;
if host_space <= 2 {
return Err(OverlayError::NoUsableHostAddress(cidr.to_owned()));
}
let hash = blake3::hash(seed.as_bytes());
let mut bytes = [0_u8; 8];
bytes.copy_from_slice(&hash.as_bytes()[..8]);
let value = u64::from_le_bytes(bytes);
let host_offset = 1 + (value % (host_space - 2));
network + host_offset as u32
};
Ok(Ipv4Addr::from(address).to_string())
}
pub fn cidr_prefix(cidr: &str) -> Result<u8, OverlayError> {
parse_ipv4_cidr(cidr).map(|(_, prefix)| prefix)
}
fn parse_ipv4_cidr(cidr: &str) -> Result<(u32, u8), OverlayError> {
let (addr, prefix) = cidr
.split_once('/')
.ok_or_else(|| OverlayError::InvalidCidr(cidr.to_owned()))?;
let addr = addr
.parse::<Ipv4Addr>()
.map_err(|_| OverlayError::InvalidCidr(cidr.to_owned()))?;
let prefix = prefix
.parse::<u8>()
.map_err(|_| OverlayError::InvalidCidr(cidr.to_owned()))?;
if prefix > 32 {
return Err(OverlayError::InvalidCidr(cidr.to_owned()));
}
let mask = if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix)
};
Ok((u32::from(addr) & mask, prefix))
}
fn interface_slug(name: &str) -> String {
let slug = name
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.take(8)
.collect::<String>();
if slug.is_empty() {
"net".to_owned()
} else {
slug
}
}
#[cfg(test)]
@ -177,6 +457,73 @@ mod tests {
assert_eq!(plan.cidr, DEFAULT_OVERLAY_CIDR);
assert!(plan.capabilities.contains(&CAPABILITY_JOIN.to_owned()));
assert!(plan.security.iter().any(|note| note.contains("Iroh")));
assert!(plan.runtime.contains("planned only"));
assert!(plan.runtime.contains("overlay up"));
}
#[test]
fn joined_membership_derives_virtual_ip_without_storing_secret() {
let membership = joined_overlay_membership(
"home",
Some("172.22.0.0/24"),
"node:local",
"invite-token",
42,
)
.expect("membership");
assert_eq!(
membership.resource,
ResourceId::new("resource:overlay:home")
);
assert_eq!(membership.virtual_ip, Some("172.22.0.168".to_owned()));
assert_ne!(membership.secret_fingerprint, "invite-token");
assert_eq!(membership.joined_at_ms, 42);
let status = membership_status(&membership);
assert_eq!(status.state, OverlayState::Joined);
assert_eq!(status.virtual_ip, membership.virtual_ip);
}
#[test]
fn tiny_overlay_cidrs_do_not_assign_hosts() {
assert!(
joined_overlay_membership("tiny", Some("10.0.0.0/31"), "node:local", "secret", 1)
.is_err()
);
}
#[test]
fn validates_ipv4_packets() {
let packet = [
0x45, 0x00, 0x00, 0x14, 0x00, 0x00, 0x40, 0x00, 64, 1, 0, 0, 172, 22, 0, 1, 172, 22, 0,
2,
];
validate_ipv4_packet(&packet).expect("valid packet");
assert!(validate_ipv4_packet(&packet[..10]).is_err());
let mut bad = packet;
bad[0] = 0x65;
assert!(validate_ipv4_packet(&bad).is_err());
let (_, destination) = ipv4_source_destination(&packet).expect("addresses");
assert_eq!(destination, "172.22.0.2");
}
#[test]
fn interface_plan_is_generated_without_mutating_host_networking() {
let plan = plan_overlay("home", None, OVERLAY_ALPN).expect("plan");
let interface =
interface_plan(&plan, Some("linux"), Some("172.22.0.10")).expect("interface plan");
assert_eq!(interface.interface_name, "geth-home");
assert!(interface.requires_privileges);
assert!(
interface
.commands
.iter()
.any(|cmd| cmd.contains("ip tuntap"))
);
assert!(
interface
.notes
.iter()
.any(|note| note.contains("generated"))
);
}
}