//! netup — configuración de red mínima Rust-nativa para el userland de takana (Etapa C). //! //! Reemplaza el `ip` de busybox + la IP estática hardcodeada del driver de rebuild por un binario //! propio: levanta la interfaz, negocia un lease DHCPv4 y aplica IP/ruta/DNS vía netlink — todo //! síncrono, sin tokio, dependiendo sólo de libc (ABI estable del kernel). Como hammerd/arje, es //! código takana-propio: no hay un cliente DHCP Rust "maduro" para adoptar al estilo ripgrep. //! //! Uso: `netup [interfaz]` (sin argumento, autodetecta la primera interfaz no-loopback). mod dhcp; mod netlink; use std::process::ExitCode; fn main() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("netup: {e}"); ExitCode::FAILURE } } } fn run() -> Result<(), Box> { let iface = match std::env::args().nth(1) { Some(a) => a, None => detect_iface().ok_or("no encuentro ninguna interfaz no-loopback en /sys/class/net")?, }; let ifindex = read_sysfs_int(&iface, "ifindex")? as i32; let mac = read_mac(&iface)?; println!("netup: interfaz {iface} (index {ifindex}, mac {})", fmt_mac(mac)); let mut nl = netlink::Netlink::open()?; // ── EL LOOPBACK, PRIMERO. Nadie más lo levanta. ────────────────────────────────────────── // En un sistema con systemd/OpenRC esto lo hace el init; con arje-zero no lo hacía NADIE, y // `lo` quedaba `DOWN` y sin dirección. Medido el 2026-09-10 en la caja de producción: caddy // escuchando en `0.0.0.0:80` y `curl http://127.0.0.1/` **con TIMEOUT de 30 s** — no // "connection refused", que se diagnostica en un minuto, sino un cuelgue que parece del // servidor. Cualquier servicio que hable con 127.0.0.1 (un repo local, un backend detrás de un // proxy, un socket TCP entre demonios) fallaba igual. // // Va acá porque `netup` es el que configura la red, y no es fatal: si ya estaba levantado, los // errores son EEXIST y no deben tumbar el arranque. if let Ok(lo) = read_sysfs_int("lo", "ifindex") { let lo = lo as i32; if let Err(e) = nl.link_up(lo) { eprintln!("netup: aviso, no pude levantar lo: {e}"); } if let Err(e) = nl.add_addr(lo, [127, 0, 0, 1], 8) { eprintln!("netup: aviso, no pude direccionar lo: {e}"); } } nl.link_up(ifindex)?; // Esperar el carrier (virtio/e1000 lo levantan en ms, pero damos margen). wait_carrier(&iface, std::time::Duration::from_secs(5)); let lease = dhcp::run(&iface, mac)?; println!( "netup: lease {} /{} gw {} dns {} ({}s) de {}", fmt_ip(lease.ip), lease.prefix, lease.router.map(fmt_ip).unwrap_or_else(|| "-".into()), if lease.dns.is_empty() { "-".into() } else { lease.dns.iter().map(|d| fmt_ip(*d)).collect::>().join(",") }, lease.lease_secs, fmt_ip(lease.server), ); nl.add_addr(ifindex, lease.ip, lease.prefix)?; if let Some(gw) = lease.router { // La puerta puede NO estar dentro del prefijo (Hetzner Cloud da /32 + gw 172.31.1.1). En ese // caso el default falla con ENETUNREACH salvo que antes exista una ruta on-link a la puerta. // Se intenta siempre: si ya era alcanzable, es una ruta más específica y no molesta. No es // fatal —un EEXIST no debe tumbar la red— pero el default SÍ lo es. if let Err(e) = nl.add_onlink_host_route(ifindex, gw) { eprintln!("netup: aviso, no pude crear la ruta on-link a {}: {e}", fmt_ip(gw)); } nl.add_default_route(ifindex, gw)?; } if !lease.dns.is_empty() { write_resolv_conf(&lease.dns)?; } println!("netup: red configurada en {iface}"); Ok(()) } /// Primera interfaz de /sys/class/net que no sea loopback (type 772 = ARPHRD_LOOPBACK). fn detect_iface() -> Option { let mut names: Vec = std::fs::read_dir("/sys/class/net") .ok()? .filter_map(|e| e.ok()) .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| n != "lo") .filter(|n| read_sysfs_int(n, "type").map(|t| t != 772).unwrap_or(true)) .collect(); names.sort(); // determinismo (eth0 < eth1) names.into_iter().next() } fn read_sysfs_int(iface: &str, attr: &str) -> Result> { let s = std::fs::read_to_string(format!("/sys/class/net/{iface}/{attr}"))?; Ok(s.trim().parse()?) } fn read_mac(iface: &str) -> Result<[u8; 6], Box> { let s = std::fs::read_to_string(format!("/sys/class/net/{iface}/address"))?; let bytes: Vec = s .trim() .split(':') .filter_map(|h| u8::from_str_radix(h, 16).ok()) .collect(); if bytes.len() != 6 { return Err(format!("MAC inesperada para {iface}: {}", s.trim()).into()); } Ok([bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]]) } /// Espera hasta `timeout` a que /sys/class/net//carrier sea 1 (link físico/virtio listo). fn wait_carrier(iface: &str, timeout: std::time::Duration) { let start = std::time::Instant::now(); while start.elapsed() < timeout { if let Ok(c) = std::fs::read_to_string(format!("/sys/class/net/{iface}/carrier")) { if c.trim() == "1" { return; } } std::thread::sleep(std::time::Duration::from_millis(100)); } } fn write_resolv_conf(dns: &[[u8; 4]]) -> std::io::Result<()> { let body: String = dns.iter().map(|d| format!("nameserver {}\n", fmt_ip(*d))).collect(); std::fs::write("/etc/resolv.conf", body) } fn fmt_ip(ip: [u8; 4]) -> String { format!("{}.{}.{}.{}", ip[0], ip[1], ip[2], ip[3]) } fn fmt_mac(m: [u8; 6]) -> String { format!("{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", m[0], m[1], m[2], m[3], m[4], m[5]) }