Implement opt-in overlay TUN runtime
This commit is contained in:
parent
c2dee50dae
commit
d9728a326d
15 changed files with 2315 additions and 84 deletions
|
|
@ -6,6 +6,7 @@ rust-version.workspace = true
|
|||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
blake3.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
geth-types = { path = "../geth-types" }
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue