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
124 lines
4.7 KiB
Rust
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])
|
|
}
|