tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

crates/netup: red mínima Rust-nativa (Etapa C pieza 5)

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 <noreply@anthropic.com>

6886f810bddd29481e1d84b982d98e783b578bdc · ruwaq sergio · (4 killa ñawpaq) · tayta 50960d3c3 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 67

crates/netup/src/dhcp.rs

crates/netup/src/main.rs

crates/netup/src/netlink.rs

scripts/drive-netup.py

9 qillqakuna · +858 −0

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<u32> {
+    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<u8>)]) -> Vec<u8> {
+    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<u8, Vec<u8>>)> {
+    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<Lease> {
+    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<std::collections::HashMap<u8, Vec<u8>>> {
+    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<dyn std::error::Error>> {
+    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::<Vec<_>>().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<String> {
+    let mut names: Vec<String> = 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<i64, Box<dyn std::error::Error>> {
+    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<dyn std::error::Error>> {
+    let s = std::fs::read_to_string(format!("/sys/class/net/{iface}/address"))?;
+    let bytes: Vec<u8> = 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/<if>/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<T>(p: &T) -> &[u8] {
+    std::slice::from_raw_parts(p as *const T as *const u8, size_of::<T>())
+}
+
+/// 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<Self> {
+        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::<libc::sockaddr_nl>() 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<u8>)]) -> io::Result<()> {
+        self.seq += 1;
+        let mut buf: Vec<u8> = Vec::with_capacity(256);
+        buf.resize(size_of::<libc::nlmsghdr>(), 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::<Rtattr>() + 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::<libc::nlmsghdr>()].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::<libc::sockaddr_nl>() 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::<libc::nlmsghdr>() {
+            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::<libc::nlmsghdr>()) 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 <if> 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 <if>`).
+    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 <gw> dev <if>`).
+    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)"