Files
takana/crates/netup/src/main.rs
T
SergioandClaude Opus 5 be383ea485 netup: la puerta puede estar FUERA del prefijo — sin ruta on-link la caja queda con IP y sin salida
Medido en la caja `takana` (Hetzner Cloud, hel1) el 2026-09-10. Hetzner entrega la IPv4 así:

    inet 2.29.29.217/32 scope global eth0
    default via 172.31.1.1 dev eth0
    172.31.1.1 dev eth0 scope link      <-- ESTA es la que faltaba

La dirección es un /32 y la puerta no pertenece a ninguna red conectada. `add_default_route` mandaba
`RTA_GATEWAY` con scope UNIVERSE y nada más, así que el kernel rechazaba la ruta.

**Y el modo de fallo es el peor**: la máquina arranca, `netup` consigue el lease, se pone la IP,
arje-zero levanta sshd... y no se puede entrar, porque las respuestas no tienen por dónde salir. Un
`ssh` desde fuera da `Connection timed out` y desde dentro no hay nada que se vea roto.

Se agrega `add_onlink_host_route` (dst=/32, scope LINK) y se llama ANTES del default. Es lo que hace
`ip route add <gw> dev <if> scope link`, y es literalmente lo que el propio rescue de Hetzner tiene
en su tabla. No es fatal si falla —un EEXIST no debe tumbar la red—; el default sí lo sigue siendo.

**Control causal**, en un netns con una dummy y una /32:

    ip route add default via 172.31.1.1 dev dummy0   => Error: Nexthop has invalid gateway.
    ip route add 172.31.1.1 dev dummy0 scope link    => ok
    ip route add default via 172.31.1.1 dev dummy0   => ACEPTADO

O sea: no es que "ayude", es que era exactamente eso. netup se validaba contra el DHCP de QEMU slirp,
que da un /24 con la puerta dentro — por eso nunca apareció hasta tocar una nube de verdad.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-10 20:09:10 +00:00

124 lines
4.7 KiB
Rust

//! netup — configuración de red mínima Rust-nativa para el userland de takana (Etapa C).
//!
//! Reemplaza el `ip` de busybox + la IP estática hardcodeada del driver de rebuild por un binario
//! propio: levanta la interfaz, negocia un lease DHCPv4 y aplica IP/ruta/DNS vía netlink — todo
//! síncrono, sin tokio, dependiendo sólo de libc (ABI estable del kernel). Como hammerd/arje, es
//! código takana-propio: no hay un cliente DHCP Rust "maduro" para adoptar al estilo ripgrep.
//!
//! Uso: `netup [interfaz]` (sin argumento, autodetecta la primera interfaz no-loopback).
mod dhcp;
mod netlink;
use std::process::ExitCode;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("netup: {e}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), Box<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 {
// La puerta puede NO estar dentro del prefijo (Hetzner Cloud da /32 + gw 172.31.1.1). En ese
// caso el default falla con ENETUNREACH salvo que antes exista una ruta on-link a la puerta.
// Se intenta siempre: si ya era alcanzable, es una ruta más específica y no molesta. No es
// fatal —un EEXIST no debe tumbar la red— pero el default SÍ lo es.
if let Err(e) = nl.add_onlink_host_route(ifindex, gw) {
eprintln!("netup: aviso, no pude crear la ruta on-link a {}: {e}", fmt_ip(gw));
}
nl.add_default_route(ifindex, gw)?;
}
if !lease.dns.is_empty() {
write_resolv_conf(&lease.dns)?;
}
println!("netup: red configurada en {iface}");
Ok(())
}
/// Primera interfaz de /sys/class/net que no sea loopback (type 772 = ARPHRD_LOOPBACK).
fn detect_iface() -> Option<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])
}