From 6886f810bddd29481e1d84b982d98e783b578bdc Mon Sep 17 00:00:00 2001 From: sergio Date: Sat, 20 Jun 2026 16:20:22 -0400 Subject: [PATCH] =?UTF-8?q?crates/netup:=20red=20m=C3=ADnima=20Rust-nativa?= =?UTF-8?q?=20(Etapa=20C=20pieza=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit netup = binario hammer-propio (sync, sólo libc, sin tokio) que configura la red: levanta el link (netlink RTM_NEWLINK), negocia un lease DHCPv4 a mano sobre UDP (DISCOVER/OFFER/REQUEST/ACK con flag broadcast), y aplica IP + ruta default + /etc/resolv.conf (RTM_NEWADDR/RTM_NEWROUTE). Hand-roll de netlink y DHCP porque no hay cliente DHCP Rust "maduro" para adoptar al estilo ripgrep, y el workspace es 100% sync (rtnetlink arrastraría tokio). Reemplaza el `ip` estático de busybox. Validado in-VM contra el DHCP de QEMU slirp: ✓ lease 10.0.2.15 + ruta + DNS + egress TCP. Autodetecta la NIC (primera no-loopback) y espera carrier por sysfs. Infra de prueba: - scripts/drive-netup.py: bootea la VM, corre netup y verifica lease/NAT/DNS. SLIRP=1 ⇒ red user-mode (cero setup de host); si no, tap del lab. - scripts/labnet.sh: lab de LAN real opcional (tap directo + dnsmasq + NAT) para fidelidad de hardware; no requerido para validar netup. - scripts/netdiag.sh: diagnóstico del camino DHCP del lab (tcpdump+nft+dnsmasq). Lección: para validar el CÓDIGO conviene slirp primero (cero plomería de host); la LAN real (firewall INPUT, sutilezas del bridge, perms) es fidelidad posterior. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 7 ++ Cargo.toml | 1 + crates/netup/Cargo.toml | 18 +++ crates/netup/src/dhcp.rs | 217 ++++++++++++++++++++++++++++++++++++ crates/netup/src/main.rs | 116 +++++++++++++++++++ crates/netup/src/netlink.rs | 217 ++++++++++++++++++++++++++++++++++++ scripts/drive-netup.py | 118 ++++++++++++++++++++ scripts/labnet.sh | 88 +++++++++++++++ scripts/netdiag.sh | 76 +++++++++++++ 9 files changed, 858 insertions(+) create mode 100644 crates/netup/Cargo.toml create mode 100644 crates/netup/src/dhcp.rs create mode 100644 crates/netup/src/main.rs create mode 100644 crates/netup/src/netlink.rs create mode 100644 scripts/drive-netup.py create mode 100644 scripts/labnet.sh create mode 100644 scripts/netdiag.sh diff --git a/Cargo.lock b/Cargo.lock index 566d7b1b..53d910c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -884,6 +884,13 @@ dependencies = [ "autocfg", ] +[[package]] +name = "netup" +version = "0.0.1" +dependencies = [ + "libc", +] + [[package]] name = "nix" version = "0.30.1" diff --git a/Cargo.toml b/Cargo.toml index 4858884c..d1f99bc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/hammer-agent", "crates/hammer-cli", "crates/hammerd", + "crates/netup", ] [workspace.package] diff --git a/crates/netup/Cargo.toml b/crates/netup/Cargo.toml new file mode 100644 index 00000000..1a0e2a5f --- /dev/null +++ b/crates/netup/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "netup" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Configuración de red mínima Rust-nativa: link up + cliente DHCPv4 + addr/route/resolv vía netlink." + +[[bin]] +name = "netup" +path = "src/main.rs" + +# Solo libc: netlink (RTM_NEWLINK/NEWADDR/NEWROUTE) y DHCP los hacemos a mano sobre la ABI estable del +# kernel, sin tokio ni crates de netlink (el workspace es 100% sync). Cero deps nuevas que vendorar ⇒ +# build hermético y reproducible, mismo estilo que hammerd/hammer-overlay. +[dependencies] +libc = "0.2" diff --git a/crates/netup/src/dhcp.rs b/crates/netup/src/dhcp.rs new file mode 100644 index 00000000..622044f4 --- /dev/null +++ b/crates/netup/src/dhcp.rs @@ -0,0 +1,217 @@ +//! Cliente DHCPv4 mínimo y SÍNCRONO: DISCOVER → OFFER → REQUEST → ACK sobre un UDP socket bindeado a +//! la interfaz (SO_BINDTODEVICE) con broadcast. El servidor responde en broadcast (ponemos el flag +//! BROADCAST), así no hace falta un raw/AF_PACKET socket para recibir sin IP todavía. + +use std::io; +use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket}; +use std::os::unix::io::AsRawFd; + +const OP_REQUEST: u8 = 1; +const HTYPE_ETHER: u8 = 1; +const HLEN_ETHER: u8 = 6; +const MAGIC: [u8; 4] = [99, 130, 83, 99]; // 0x63825363 +const FLAG_BROADCAST: u16 = 0x8000; + +// Opciones DHCP. +const OPT_SUBNET: u8 = 1; +const OPT_ROUTER: u8 = 3; +const OPT_DNS: u8 = 6; +const OPT_REQ_IP: u8 = 50; +const OPT_LEASE: u8 = 51; +const OPT_MSG_TYPE: u8 = 53; +const OPT_SERVER_ID: u8 = 54; +const OPT_PARAM_LIST: u8 = 55; +const OPT_END: u8 = 255; + +// Tipos de mensaje (opción 53). +const DHCPDISCOVER: u8 = 1; +const DHCPOFFER: u8 = 2; +const DHCPREQUEST: u8 = 3; +const DHCPACK: u8 = 5; + +/// El resultado de la negociación: lo que aplicaremos por netlink + resolv.conf. +pub struct Lease { + pub ip: [u8; 4], + pub prefix: u8, + pub router: Option<[u8; 4]>, + pub dns: Vec<[u8; 4]>, + pub lease_secs: u32, + pub server: [u8; 4], +} + +/// xid (transaction id) desde /dev/urandom: el servidor lo refleja, lo usamos para casar la respuesta. +fn random_xid() -> io::Result { + let mut b = [0u8; 4]; + let mut f = std::fs::File::open("/dev/urandom")?; + io::Read::read_exact(&mut f, &mut b)?; + Ok(u32::from_ne_bytes(b)) +} + +/// Construye un paquete DHCP (cabecera BOOTP de 236 B + magic cookie + opciones). +fn build(msg_type: u8, xid: u32, mac: [u8; 6], extra: &[(u8, Vec)]) -> Vec { + let mut p = vec![0u8; 236]; + p[0] = OP_REQUEST; + p[1] = HTYPE_ETHER; + p[2] = HLEN_ETHER; + // hops=0, secs=0, ciaddr/yiaddr/siaddr/giaddr=0 (ya en cero) + p[4..8].copy_from_slice(&xid.to_be_bytes()); + p[10..12].copy_from_slice(&FLAG_BROADCAST.to_be_bytes()); + p[28..34].copy_from_slice(&mac); // chaddr + p.extend_from_slice(&MAGIC); + // opción 53 (tipo de mensaje) primero. + p.extend_from_slice(&[OPT_MSG_TYPE, 1, msg_type]); + for (code, data) in extra { + p.push(*code); + p.push(data.len() as u8); + p.extend_from_slice(data); + } + // opción 55: lista de parámetros que pedimos. + p.extend_from_slice(&[OPT_PARAM_LIST, 4, OPT_SUBNET, OPT_ROUTER, OPT_DNS, OPT_LEASE]); + p.push(OPT_END); + p +} + +/// Recorre las opciones TLV tras la cabecera+cookie. Devuelve (msg_type, opciones por código). +fn parse_options(pkt: &[u8]) -> Option<(u8, std::collections::HashMap>)> { + if pkt.len() < 240 || pkt[236..240] != MAGIC { + return None; + } + let mut opts = std::collections::HashMap::new(); + let mut i = 240; + while i < pkt.len() { + let code = pkt[i]; + if code == OPT_END { + break; + } + if code == 0 { + i += 1; // pad + continue; + } + if i + 1 >= pkt.len() { + break; + } + let len = pkt[i + 1] as usize; + if i + 2 + len > pkt.len() { + break; + } + opts.insert(code, pkt[i + 2..i + 2 + len].to_vec()); + i += 2 + len; + } + let mt = opts.get(&OPT_MSG_TYPE).and_then(|v| v.first().copied())?; + Some((mt, opts)) +} + +fn ipv4(v: &[u8]) -> Option<[u8; 4]> { + if v.len() >= 4 { + Some([v[0], v[1], v[2], v[3]]) + } else { + None + } +} + +fn prefix_from_mask(mask: [u8; 4]) -> u8 { + mask.iter().map(|b| b.count_ones() as u8).sum() +} + +/// Negocia un lease en `ifname`. `mac` es la MAC de la interfaz (para chaddr). +pub fn run(ifname: &str, mac: [u8; 6]) -> io::Result { + let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 68))?; + sock.set_broadcast(true)?; + sock.set_read_timeout(Some(std::time::Duration::from_secs(5)))?; + // SO_BINDTODEVICE: amarra el socket a la interfaz (sin IP todavía no hay ruta que elija salida). + let cname = std::ffi::CString::new(ifname).unwrap(); + let r = unsafe { + libc::setsockopt( + sock.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_BINDTODEVICE, + cname.as_ptr() as *const libc::c_void, + (ifname.len() + 1) as libc::socklen_t, + ) + }; + if r < 0 { + return Err(io::Error::last_os_error()); + } + let bcast = SocketAddrV4::new(Ipv4Addr::BROADCAST, 67); + let xid = random_xid()?; + + // DISCOVER → OFFER (reintentos: las VMs/servidores pueden tardar en contestar el primer paquete). + let discover = build(DHCPDISCOVER, xid, mac, &[]); + let mut buf = [0u8; 2048]; + let offer = recv_matching(&sock, &discover, &bcast, xid, DHCPOFFER, &mut buf, 4)?; + // yiaddr (la IP ofrecida) vive en la cabecera BOOTP; recv_matching lo dejó en LAST_YIADDR. + let yi = LAST_YIADDR.with(|c| *c.borrow()); + let server = offer + .get(&OPT_SERVER_ID) + .and_then(|v| ipv4(v)) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "OFFER sin server-id"))?; + + // REQUEST (pidiendo el yiaddr ofrecido y citando el server-id) → ACK. + let request = build( + DHCPREQUEST, + xid, + mac, + &[(OPT_REQ_IP, yi.to_vec()), (OPT_SERVER_ID, server.to_vec())], + ); + let ack = recv_matching(&sock, &request, &bcast, xid, DHCPACK, &mut buf, 4)?; + let ack_ip = LAST_YIADDR.with(|c| *c.borrow()); + + let prefix = ack + .get(&OPT_SUBNET) + .and_then(|v| ipv4(v)) + .map(prefix_from_mask) + .unwrap_or(24); + let router = ack.get(&OPT_ROUTER).and_then(|v| ipv4(v)); + let dns = ack + .get(&OPT_DNS) + .map(|v| v.chunks_exact(4).map(|c| [c[0], c[1], c[2], c[3]]).collect()) + .unwrap_or_default(); + let lease_secs = ack + .get(&OPT_LEASE) + .and_then(|v| if v.len() >= 4 { Some(u32::from_be_bytes([v[0], v[1], v[2], v[3]])) } else { None }) + .unwrap_or(0); + + Ok(Lease { ip: ack_ip, prefix, router, dns, lease_secs, server }) +} + +// yiaddr (la IP ofrecida) vive en la cabecera BOOTP, no en las opciones; lo guardamos al parsear cada +// paquete para que `run` lo recupere sin reestructurar el flujo. +thread_local! { + static LAST_YIADDR: std::cell::RefCell<[u8; 4]> = const { std::cell::RefCell::new([0; 4]) }; +} + +/// Manda `out` y espera un paquete con el xid y el tipo esperados, reintentando `tries` veces. +fn recv_matching( + sock: &UdpSocket, + out: &[u8], + dst: &SocketAddrV4, + xid: u32, + want_type: u8, + buf: &mut [u8], + tries: u32, +) -> io::Result>> { + for _ in 0..tries { + sock.send_to(out, dst)?; + loop { + match sock.recv_from(buf) { + Ok((n, _)) => { + let pkt = &buf[..n]; + if n < 240 || u32::from_be_bytes([pkt[4], pkt[5], pkt[6], pkt[7]]) != xid { + continue; // no es nuestra transacción + } + if let Some((mt, opts)) = parse_options(pkt) { + if mt == want_type { + LAST_YIADDR.with(|c| c.borrow_mut().copy_from_slice(&pkt[16..20])); // yiaddr + return Ok(opts); + } + } + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut => { + break // timeout → reintentar el send + } + Err(e) => return Err(e), + } + } + } + Err(io::Error::new(io::ErrorKind::TimedOut, format!("sin respuesta DHCP (tipo {want_type})"))) +} diff --git a/crates/netup/src/main.rs b/crates/netup/src/main.rs new file mode 100644 index 00000000..8b70609f --- /dev/null +++ b/crates/netup/src/main.rs @@ -0,0 +1,116 @@ +//! netup — configuración de red mínima Rust-nativa para el userland de hammer (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 hammer-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()?; + 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 { + 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]) +} diff --git a/crates/netup/src/netlink.rs b/crates/netup/src/netlink.rs new file mode 100644 index 00000000..87989fa1 --- /dev/null +++ b/crates/netup/src/netlink.rs @@ -0,0 +1,217 @@ +//! Netlink (NETLINK_ROUTE) mínimo y SÍNCRONO sobre libc: levantar un link, añadir una dirección IPv4 y +//! una ruta por defecto. Hand-roll de los mensajes RTM_* (ABI estable del kernel) en vez de tirar de +//! `rtnetlink` (async/tokio) — el workspace es 100% sync y así no agregamos deps que vendorar. + +use std::io; +use std::mem::size_of; +use std::os::unix::io::RawFd; + +// --- constantes de la ABI netlink (estables) --- +const RTM_NEWLINK: u16 = 16; +const RTM_NEWADDR: u16 = 20; +const RTM_NEWROUTE: u16 = 24; +const NLM_F_REQUEST: u16 = 0x001; +const NLM_F_ACK: u16 = 0x004; +const NLM_F_REPLACE: u16 = 0x100; +const NLM_F_CREATE: u16 = 0x400; +const NLMSG_ERROR: u16 = 0x2; +const IFA_ADDRESS: u16 = 1; +const IFA_LOCAL: u16 = 2; +const RTA_OIF: u16 = 4; +const RTA_GATEWAY: u16 = 5; +const RT_SCOPE_UNIVERSE: u8 = 0; +const RT_TABLE_MAIN: u8 = 254; +const RTPROT_BOOT: u8 = 3; +const RTN_UNICAST: u8 = 1; + +// libc trae nlmsghdr/nlmsgerr/ifinfomsg pero NO estos tres de rtnetlink; los definimos (layout ABI). +#[repr(C)] +struct Rtattr { + rta_len: u16, + rta_type: u16, +} +#[repr(C)] +struct Ifaddrmsg { + ifa_family: u8, + ifa_prefixlen: u8, + ifa_flags: u8, + ifa_scope: u8, + ifa_index: u32, +} +#[repr(C)] +struct Rtmsg { + rtm_family: u8, + rtm_dst_len: u8, + rtm_src_len: u8, + rtm_tos: u8, + rtm_table: u8, + rtm_protocol: u8, + rtm_scope: u8, + rtm_type: u8, + rtm_flags: u32, +} + +fn nlmsg_align(len: usize) -> usize { + (len + 3) & !3 +} + +unsafe fn as_bytes(p: &T) -> &[u8] { + std::slice::from_raw_parts(p as *const T as *const u8, size_of::()) +} + +/// Socket netlink + número de secuencia para los requests. +pub struct Netlink { + fd: RawFd, + seq: u32, +} + +impl Drop for Netlink { + fn drop(&mut self) { + unsafe { libc::close(self.fd) }; + } +} + +impl Netlink { + pub fn open() -> io::Result { + let fd = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_RAW | libc::SOCK_CLOEXEC, + libc::NETLINK_ROUTE, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + let mut sa: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + sa.nl_family = libc::AF_NETLINK as u16; + let r = unsafe { + libc::bind( + fd, + &sa as *const _ as *const libc::sockaddr, + size_of::() as u32, + ) + }; + if r < 0 { + let e = io::Error::last_os_error(); + unsafe { libc::close(fd) }; + return Err(e); + } + Ok(Self { fd, seq: 0 }) + } + + /// Empaqueta nlmsghdr + cuerpo + atributos, lo manda al kernel y espera el ACK (NLMSG_ERROR con + /// error==0). `body` es el struct de familia (ifinfomsg/ifaddrmsg/rtmsg); `attrs` los rtattr. + fn request(&mut self, msg_type: u16, flags: u16, body: &[u8], attrs: &[(u16, Vec)]) -> io::Result<()> { + self.seq += 1; + let mut buf: Vec = Vec::with_capacity(256); + buf.resize(size_of::(), 0); // hueco para el header, se rellena al final + buf.extend_from_slice(body); + while buf.len() % 4 != 0 { + buf.push(0); + } + for (atype, data) in attrs { + let rta_len = (size_of::() + data.len()) as u16; + let rta = Rtattr { rta_len, rta_type: *atype }; + buf.extend_from_slice(unsafe { as_bytes(&rta) }); + buf.extend_from_slice(data); + let pad = nlmsg_align(rta_len as usize) - rta_len as usize; + buf.extend(std::iter::repeat(0u8).take(pad)); + } + let hdr = libc::nlmsghdr { + nlmsg_len: buf.len() as u32, + nlmsg_type: msg_type, + nlmsg_flags: flags, + nlmsg_seq: self.seq, + nlmsg_pid: 0, + }; + buf[..size_of::()].copy_from_slice(unsafe { as_bytes(&hdr) }); + + let mut k: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + k.nl_family = libc::AF_NETLINK as u16; // nl_pid 0 ⇒ el kernel + let sent = unsafe { + libc::sendto( + self.fd, + buf.as_ptr() as *const libc::c_void, + buf.len(), + 0, + &k as *const _ as *const libc::sockaddr, + size_of::() as u32, + ) + }; + if sent < 0 { + return Err(io::Error::last_os_error()); + } + self.recv_ack() + } + + fn recv_ack(&mut self) -> io::Result<()> { + let mut rbuf = [0u8; 8192]; + let n = unsafe { + libc::recv( + self.fd, + rbuf.as_mut_ptr() as *mut libc::c_void, + rbuf.len(), + 0, + ) + }; + if n < 0 { + return Err(io::Error::last_os_error()); + } + let n = n as usize; + if n < size_of::() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "respuesta netlink truncada")); + } + let hdr: libc::nlmsghdr = unsafe { std::ptr::read_unaligned(rbuf.as_ptr() as *const _) }; + if hdr.nlmsg_type == NLMSG_ERROR { + // nlmsgerr.error: 0 = éxito, negativo = -errno. + let err: libc::nlmsgerr = + unsafe { std::ptr::read_unaligned(rbuf.as_ptr().add(size_of::()) as *const _) }; + if err.error != 0 { + return Err(io::Error::from_raw_os_error(-err.error)); + } + } + Ok(()) + } + + /// Levanta el link (equivalente a `ip link set up`). + pub fn link_up(&mut self, ifindex: i32) -> io::Result<()> { + let mut ifi: libc::ifinfomsg = unsafe { std::mem::zeroed() }; + ifi.ifi_family = libc::AF_UNSPEC as u8; + ifi.ifi_index = ifindex; + ifi.ifi_flags = libc::IFF_UP as u32; + ifi.ifi_change = libc::IFF_UP as u32; + self.request(RTM_NEWLINK, NLM_F_REQUEST | NLM_F_ACK, unsafe { as_bytes(&ifi) }, &[]) + } + + /// Añade una dirección IPv4 con su prefijo (equivalente a `ip addr add ip/prefix dev `). + pub fn add_addr(&mut self, ifindex: i32, ip: [u8; 4], prefix: u8) -> io::Result<()> { + let mut ifa: Ifaddrmsg = unsafe { std::mem::zeroed() }; + ifa.ifa_family = libc::AF_INET as u8; + ifa.ifa_prefixlen = prefix; + ifa.ifa_scope = RT_SCOPE_UNIVERSE; + ifa.ifa_index = ifindex as u32; + self.request( + RTM_NEWADDR, + NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_REPLACE, + unsafe { as_bytes(&ifa) }, + &[(IFA_LOCAL, ip.to_vec()), (IFA_ADDRESS, ip.to_vec())], + ) + } + + /// Añade la ruta por defecto (equivalente a `ip route add default via dev `). + pub fn add_default_route(&mut self, ifindex: i32, gw: [u8; 4]) -> io::Result<()> { + let mut rtm: Rtmsg = unsafe { std::mem::zeroed() }; + rtm.rtm_family = libc::AF_INET as u8; + rtm.rtm_table = RT_TABLE_MAIN; + rtm.rtm_protocol = RTPROT_BOOT; + rtm.rtm_scope = RT_SCOPE_UNIVERSE; + rtm.rtm_type = RTN_UNICAST; + self.request( + RTM_NEWROUTE, + NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE, + unsafe { as_bytes(&rtm) }, + &[(RTA_GATEWAY, gw.to_vec()), (RTA_OIF, (ifindex as u32).to_ne_bytes().to_vec())], + ) + } +} diff --git a/scripts/drive-netup.py b/scripts/drive-netup.py new file mode 100644 index 00000000..aed5dca2 --- /dev/null +++ b/scripts/drive-netup.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +# drive-netup.py — bootea la VM con la NIC conectada al tap del lab (scripts/labnet.sh) y corre `netup` +# + chequeos de conectividad real (DHCP de dnsmasq, NAT, DNS). Driver no-interactivo: espera la shell +# de arje-zero, manda el test, captura hasta el marcador y emite veredicto. +# +# Prerequisito: sudo sh scripts/labnet.sh up (bridge hmbr0 + tap hmtap0 + dnsmasq + NAT) +# +# Variables: KERNEL (def store/*-linux/boot/bzImage) DISK (def work/hammer-disk.img) +# TAP (def hmtap0) NICDEV (def e1000) MEM (def 4096) KVM (1 si /dev/kvm) +# DEADLINE (def 180s) LOG (def work/netup-run.log) +import os, pty, select, subprocess, sys, time, re, glob + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + + +def absp(p): + return p if os.path.isabs(p) else os.path.join(ROOT, p) + + +KERNEL = os.environ.get("KERNEL") or (glob.glob(os.path.join(ROOT, "store/*-linux/boot/bzImage")) + [""])[0] +DISK = absp(os.environ.get("DISK", "work/hammer-disk.img")) +TAP = os.environ.get("TAP", "hmtap0") +NICDEV = os.environ.get("NICDEV", "e1000") +MEM = os.environ.get("MEM", "4096") +DEADLINE = int(os.environ.get("DEADLINE", "180")) +LOG = absp(os.environ.get("LOG", "work/netup-run.log")) +WANT_KVM = os.environ.get("KVM", "1") == "1" + +for p, what in [(KERNEL, "kernel"), (DISK, "imagen de disco")]: + if not p or not os.path.exists(p): + sys.exit(f"no existe {what}: {p}") + +accel = ["-cpu", "Broadwell"] +if WANT_KVM and os.access("/dev/kvm", os.W_OK): + accel = ["-enable-kvm", "-cpu", "host"] + +# SLIRP=1 ⇒ red user-mode de QEMU (DHCP/DNS/NAT virtuales, cero setup de host). Si no, tap del lab. +if os.environ.get("SLIRP", "0") == "1": + netdev = ["-netdev", "user,id=n0"] +else: + netdev = ["-netdev", f"tap,id=n0,ifname={TAP},script=no,downscript=no"] +QEMU = (["qemu-system-x86_64", "-m", MEM, "-no-reboot", "-nographic"] + accel + + ["-kernel", KERNEL, + "-drive", f"file={DISK},if=virtio,format=raw", + "-append", "console=ttyS0 root=/dev/vda1 rw rdinit=/sbin/init"] + + netdev + ["-device", f"{NICDEV},netdev=n0"]) + +# El test que corre dentro de la VM: netup (autodetecta la NIC) + conectividad NAT + DNS. +# Los marcadores se CONSTRUYEN en runtime (printf '%s') para que NO aparezcan literales en el eco del +# comando tecleado — si no, el driver los matchearía en el eco y saldría antes de que el test corra. +TEST = os.environ.get("TESTCMD") or ( + "netup; " + "ping -c2 -W2 8.8.8.8 >/dev/null 2>&1 && printf 'PING_%s\\n' OK || printf 'PING_%s\\n' FAIL; " + "nslookup google.com >/dev/null 2>&1 && printf 'DNS_%s\\n' OK || printf 'DNS_%s\\n' FAIL; " + "printf 'TEST_%s\\n' DONE" +) +TEST = TEST + "\n" + +print(f"==> kernel {KERNEL}\n==> disco {DISK}\n==> tap {TAP} ({NICDEV}) mem {MEM} kvm {'sí' if accel[0]=='-enable-kvm' else 'no'}") +ansi = re.compile(rb'\x1b\[[0-9;?]*[a-zA-Z]') +master, slave = pty.openpty() +p = subprocess.Popen(QEMU, stdin=slave, stdout=slave, stderr=slave, close_fds=True) +os.close(slave) +log = open(LOG, "wb") +buf = b"" +sent = False +start = time.time() + +while True: + if time.time() - start > DEADLINE: + log.write(b"\n[DRIVER] DEADLINE\n") + break + r, _, _ = select.select([master], [], [], 5) + if r: + try: + data = os.read(master, 8192) + except OSError: + break + if not data: + break + log.write(data) + log.flush() + buf = ansi.sub(b'', buf + data)[-20000:] + if not sent and (b"\n~ #" in buf or b"\r~ #" in buf): + time.sleep(2) + os.write(master, TEST.encode()) + sent = True + log.write(b"\n[DRIVER] enviado test netup\n") + if sent and b"TEST_DONE" in buf: + time.sleep(1) + try: + log.write(os.read(master, 8192)) + except Exception: + pass + break + elif p.poll() is not None: + break + +for fn in (p.terminate, p.kill): + try: + fn(); time.sleep(1) + except Exception: + pass +log.close() + +blob = ansi.sub(b'', open(LOG, "rb").read()) +configured = b"red configurada" in blob # netup imprime esto sólo si todo salió bien +ping = b"PING_OK" in blob +dns = b"DNS_OK" in blob +lease = re.search(rb"netup: lease (\d+\.\d+\.\d+\.\d+)", blob) +print(f"\nDRIVER done sent={sent} elapsed={int(time.time()-start)}s") +print(f" netup ok : {configured}") +print(f" lease : {lease.group(1).decode() if lease else '—'}") +print(f" ping NAT : {ping}") +print(f" DNS : {dns}") +ok = configured and lease and ping and dns +print("RESULTADO:", "✓ RED OK (lease + NAT + DNS)" if ok else "✗ revisá el log: " + LOG) +sys.exit(0 if ok else 1) diff --git a/scripts/labnet.sh b/scripts/labnet.sh new file mode 100644 index 00000000..a79200a6 --- /dev/null +++ b/scripts/labnet.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# labnet.sh — lab de red para probar `netup` (y luego OpenSSH) in-VM con DHCP/DNS/NAT REALES, sin poner +# la VM en la LAN física (el host sale por WiFi, que no se puede bridgear a L2). +# +# Modelo TAP DIRECTO (sin bridge): para UNA VM no hace falta bridge. El host pone su IP en el tap +# (192.168.100.1/24) y corre dnsmasq escuchando ahí; la VM y el host son pares L2 sobre el tap. Esto +# evita la sutileza del Linux bridge (broadcast entregado localmente desde un puerto con skb_iif = +# puerto, que el SO_BINDTODEVICE de dnsmasq al bridge no matchea ⇒ no recibía el DISCOVER). +# +# Da DHCP+DNS reales (dnsmasq) + salida a internet por NAT hacia el uplink. Ejercita exactamente los +# code paths de netup: netlink real, DHCP DISCOVER/OFFER/REQUEST/ACK reales, ruta + DNS reales. +# +# REQUIERE ROOT. Levantar: sudo sh scripts/labnet.sh up Bajar: sudo sh scripts/labnet.sh down +# El tap queda owned por $LAB_USER ⇒ QEMU (no-root) lo abre. +# +# Variables: TAP (def hmtap0) SUBNET (def 192.168.100) UPLINK (def wlan0) +# LAB_USER (dueño del tap, def $SUDO_USER) DNS_UP (upstream DNS, def 192.168.1.1) +set -eu +TAP="${TAP:-hmtap0}" +SUBNET="${SUBNET:-192.168.100}" +UPLINK="${UPLINK:-wlan0}" +LAB_USER="${LAB_USER:-${SUDO_USER:-root}}" +DNS_UP="${DNS_UP:-192.168.1.1}" +PIDF="/run/hmdnsmasq.pid" + +[ "$(id -u)" = 0 ] || { echo "labnet: requiere root (sudo sh scripts/labnet.sh $*)" >&2; exit 1; } + +fw_clean() { # quita las reglas "hmlab" que insertamos en el firewall del host + for ch in input forward; do + while h=$(nft -a list chain inet filter "$ch" 2>/dev/null | awk '/comment "hmlab"/{print $NF; exit}'); [ -n "${h:-}" ]; do + nft delete rule inet filter "$ch" handle "$h" 2>/dev/null || break + done + done +} + +up() { + echo "==> tap $TAP ($SUBNET.1/24, owner $LAB_USER) — modelo tap directo, sin bridge" + # auto-limpieza de estado viejo (bridge de versiones previas + tap que pudo quedar enslaved) + ip link del hmbr0 2>/dev/null || true + ip link del "$TAP" 2>/dev/null || true + ip tuntap add dev "$TAP" mode tap user "$LAB_USER" + ip addr replace "$SUBNET.1/24" dev "$TAP" + ip link set "$TAP" up + + echo "==> forwarding + NAT (nftables) $SUBNET.0/24 → $UPLINK" + sysctl -wq net.ipv4.ip_forward=1 + nft list table ip hmnat >/dev/null 2>&1 || nft add table ip hmnat + nft flush table ip hmnat + nft add chain ip hmnat hmpost '{ type nat hook postrouting priority 100 ; }' + nft add rule ip hmnat hmpost ip saddr "$SUBNET.0/24" oifname "$UPLINK" masquerade + + # El host tiene su PROPIO firewall (inet filter, policy drop en input/forward). Insertamos accepts + # ACOTADOS al tap del lab DENTRO de inet filter (al tope, vía `insert`), reversibles por su comment + # "hmlab". input: el DISCOVER llega a dnsmasq. forward: el NAT VM→internet pasa. + if nft list chain inet filter input >/dev/null 2>&1; then + echo "==> permitir $TAP en el firewall del host (inet filter input/forward)" + fw_clean + nft insert rule inet filter input iifname "$TAP" accept comment "hmlab" + nft insert rule inet filter forward iifname "$TAP" accept comment "hmlab" + nft insert rule inet filter forward oifname "$TAP" accept comment "hmlab" + fi + + echo "==> dnsmasq (DHCP $SUBNET.50-150 + DNS→$DNS_UP) en $TAP" + [ -f "$PIDF" ] && kill "$(cat "$PIDF")" 2>/dev/null || true + dnsmasq --interface="$TAP" --bind-interfaces --except-interface=lo \ + --dhcp-range="$SUBNET.50,$SUBNET.150,12h" --dhcp-authoritative \ + --dhcp-option=3,"$SUBNET.1" --dhcp-option=6,"$SUBNET.1" \ + --listen-address="$SUBNET.1" --no-resolv --server="$DNS_UP" \ + --dhcp-leasefile=/tmp/hmdnsmasq.leases \ + --pid-file="$PIDF" --log-dhcp --log-facility=/tmp/hmdnsmasq.log + chmod 644 /tmp/hmdnsmasq.log /tmp/hmdnsmasq.leases 2>/dev/null || true + echo "==> listo. QEMU: -netdev tap,id=n0,ifname=$TAP,script=no,downscript=no -device e1000,netdev=n0" +} + +down() { + echo "==> teardown" + [ -f "$PIDF" ] && kill "$(cat "$PIDF")" 2>/dev/null && rm -f "$PIDF" || true + nft delete table ip hmnat 2>/dev/null || true + fw_clean + ip link del "$TAP" 2>/dev/null || true + echo "==> red del lab desmontada" +} + +case "${1:-}" in + up) up ;; + down) down ;; + *) echo "uso: sudo sh scripts/labnet.sh {up|down}" >&2; exit 1 ;; +esac diff --git a/scripts/netdiag.sh b/scripts/netdiag.sh new file mode 100644 index 00000000..33997ca3 --- /dev/null +++ b/scripts/netdiag.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# netdiag.sh — diagnostica (y prueba un fix) el camino DHCP del lab de red, todo como root en una corrida. +# Corré: sudo bash scripts/netdiag.sh +# Deja un reporte legible en work/netdiag.txt (chmod 644) y lo imprime. +set -u +cd "$(dirname "$0")/.." || exit 1 +BR="${BR:-hmtap0}"; TAP="${TAP:-hmtap0}"; SUBNET="${SUBNET:-192.168.100}"; VMMAC="52:54:00:12:34:56" +OUT=work/netdiag.txt +DNSLOG=/tmp/hmdnsmasq.log +mkdir -p work +exec > >(tee "$OUT") 2>&1 + +[ "$(id -u)" = 0 ] || { echo "corré con: sudo bash scripts/netdiag.sh"; exit 1; } +echo "===== netdiag =====" + +run_test() { KVM=1 MEM=4096 DEADLINE=45 LOG=work/netdiag-run.log python3 scripts/drive-netup.py >/dev/null 2>&1; } + +echo; echo "### 1. firewall del host (nft) ###" +echo "-- base chains input + policies --" +nft list ruleset 2>/dev/null | grep -iE 'table |chain |hook (input|forward|prerouting)|policy|type ' | head -40 +INPUT_DROP=$(nft list ruleset 2>/dev/null | grep -iE 'hook input' | grep -ci 'policy drop') +echo "input-hook chains con policy drop: $INPUT_DROP" + +echo; echo "### 2. captura DHCP en $TAP (egress del VM) + $BR mientras manda DISCOVER ###" +command -v tcpdump >/dev/null || echo "(tcpdump ausente — salteo captura)" +: > /tmp/netdiag.pcap.txt; : > /tmp/netdiag.br.txt +echo "estado del tap/bridge ANTES (idle):"; ip -br link show "$TAP"; ip -br link show "$BR" +if command -v tcpdump >/dev/null; then + timeout 55 tcpdump -i "$TAP" -nnev 'udp port 67 or udp port 68' > /tmp/netdiag.pcap.txt 2>/dev/null & + TPID=$! + timeout 55 tcpdump -i "$BR" -nnev 'udp port 67 or udp port 68' > /tmp/netdiag.br.txt 2>/dev/null & + TPID2=$!; sleep 1 +fi +run_test +sleep 1; for pid in ${TPID:-} ${TPID2:-}; do kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; done +echo "-- DISCOVER/Offer en el TAP $TAP (lo que el VM realmente manda/recibe) --" +grep -icE 'Discover|Offer|Request|Reply|BOOTP' /tmp/netdiag.pcap.txt; grep -iE 'Discover|Offer|cksum' /tmp/netdiag.pcap.txt | head -6 +echo "-- paquetes DHCP capturados en $BR --" +grep -icE 'Discover|Offer|Request|Reply|BOOTP' /tmp/netdiag.pcap.txt +echo "-- muestra (Discover/Offer/checksum) --" +grep -iE 'Discover|Offer|cksum' /tmp/netdiag.pcap.txt | head -10 +echo "-- dnsmasq: ¿recibió/ofreció? --" +grep -iE 'DHCPDISCOVER|DHCPOFFER|DHCPACK|no address' "$DNSLOG" 2>/dev/null | tail -8 || echo "(sin entradas DHCP)" + +echo; echo "### 3. veredicto del diagnóstico ###" +SAW_DISC=$(grep -ic 'Discover' /tmp/netdiag.pcap.txt) +BAD_CKSUM=$(grep -ic 'bad udp cksum' /tmp/netdiag.pcap.txt) +DNS_SAW=$(grep -ic 'DHCPDISCOVER' "$DNSLOG" 2>/dev/null) +echo "DISCOVER en bridge=$SAW_DISC bad-cksum=$BAD_CKSUM dnsmasq-vio-DISCOVER=$DNS_SAW input-drop=$INPUT_DROP" +if [ "$SAW_DISC" = 0 ]; then echo ">> el DISCOVER NO llega al bridge (problema tap/QEMU)"; fi +if [ "$SAW_DISC" != 0 ] && [ "$BAD_CKSUM" != 0 ]; then echo ">> DISCOVER llega con CHECKSUM MALO (offload) ⇒ dnsmasq lo descarta"; fi +if [ "$SAW_DISC" != 0 ] && [ "$DNS_SAW" = 0 ] && [ "$BAD_CKSUM" = 0 ]; then echo ">> DISCOVER llega OK pero dnsmasq no lo procesa ⇒ firewall INPUT (drop=$INPUT_DROP)"; fi + +echo; echo "### 4. firewall INPUT: inspecciono, meto accepts CONTADOS y retesteo ###" +echo "-- inet filter input ANTES (con handles) --" +nft -a list chain inet filter input 2>/dev/null +echo "-- inserto accepts contados (por iif del bridge y por puerto DHCP/DNS) --" +nft insert rule inet filter input udp dport '{ 67, 68, 53 }' counter accept comment '"hmlab"' 2>&1 | head -2 +nft insert rule inet filter input iifname "$BR" counter accept comment '"hmlab"' 2>&1 | head -2 +run_test +echo "-- reglas hmlab DESPUÉS del retest (los counters dicen cuál matcheó) --" +nft -a list chain inet filter input 2>/dev/null | grep -i hmlab +echo "-- dnsmasq tras retest --" +grep -iE 'DHCPDISCOVER|DHCPOFFER|DHCPACK' "$DNSLOG" 2>/dev/null | tail -5 || echo "(nada)" +echo "-- netup tras retest --" +grep -aE 'netup: lease|netup: red configurada|sin respuesta DHCP|PING_OK|PING_FAIL|DNS_OK|DNS_FAIL' work/netdiag-run.log | tail -5 +if grep -qa 'netup: red configurada' work/netdiag-run.log; then echo "RESULTADO: ✓ RED OK"; else echo "RESULTADO: ✗ sigue fallando"; fi + +echo; echo "### 5. dnsmasq log COMPLETO (volcado para análisis) ###" +tail -30 "$DNSLOG" 2>/dev/null || echo "(no legible)" +echo; echo "### 6. netup salida COMPLETA del retest ###" +tr -d '\r' < work/netdiag-run.log 2>/dev/null | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g' | grep -aE 'netup:|e1000.*Link|PING_|DNS_' | tail -12 + +chmod 644 "$OUT" 2>/dev/null +chmod 666 work/netdiag-run.log 2>/dev/null +echo; echo "(reporte: $OUT)"