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>
This commit is contained in:
2026-06-20 16:20:22 -04:00
co-authored by Claude Opus 4.8
parent 50960d3c38
commit 6886f810bd
9 changed files with 858 additions and 0 deletions
Generated
+7
View File
@@ -884,6 +884,13 @@ dependencies = [
"autocfg",
]
[[package]]
name = "netup"
version = "0.0.1"
dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.30.1"
+1
View File
@@ -9,6 +9,7 @@ members = [
"crates/hammer-agent",
"crates/hammer-cli",
"crates/hammerd",
"crates/netup",
]
[workspace.package]
+18
View File
@@ -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"
+217
View File
@@ -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})")))
}
+116
View File
@@ -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])
}
+217
View File
@@ -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())],
)
}
}
+118
View File
@@ -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 {'' 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)
+88
View File
@@ -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
+76
View File
@@ -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)"