diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs index 0879089f..da418808 100644 --- a/crates/takana-cli/src/main.rs +++ b/crates/takana-cli/src/main.rs @@ -67,6 +67,27 @@ enum Cmd { #[arg(long, default_value = "system")] scope: String, }, + /// [SDD 30] Emite las **cuentas de sistema** que declaran estas recetas (`[[user]]`) como + /// líneas de `/etc/passwd` y `/etc/group`, o las FUSIONA en un rootfs con `--merge`. + /// + /// Es la otra mitad de `service-cards`: la Card de `gitea` hace `setuidgid gitea` porque el + /// demonio se niega a correr como root, y sin la cuenta el servicio muere en bucle. Hasta hoy + /// el `passwd` de la imagen era una constante de Rust (`PRODUCT_PASSWD`) y añadir un usuario + /// era recompilar takana. + /// + /// `--merge` fusiona **por clave, no por línea**, y es idempotente: componer una imagen dos + /// veces no deja la cuenta dos veces. Una cuenta que ya está con OTRA línea es un conflicto y + /// sale ≠0 sin tocar el fichero — pisarla es cambiarle el uid a ficheros que ya son suyos. + Users { + /// Rutas de recetas TOML. `scripts/targets.py --user-paths ` las lista. + recipes: Vec, + /// Qué emitir por stdout: `passwd` (default), `group`, `both` o `json`. + #[arg(long, default_value = "passwd")] + format: String, + /// Rootfs donde fusionar `etc/passwd` y `etc/group` en vez de imprimir. + #[arg(long)] + merge: Option, + }, /// [Fase 0] Calcula el `ArtifactHash` de una receta SIN construir — puro sobre las recetas /// (source_id + compiler/target/link + patches + flags + fases + hashes de deps). No baja /// fuentes ni compila. Responde "¿cuál es el hash VIGENTE de esta receta?", que el store no @@ -984,6 +1005,64 @@ fn main() -> anyhow::Result<()> { } println!("{}", serde_json::to_string_pretty(&cards)?); } + Cmd::Users { recipes, format, merge } => { + let mut users: Vec = Vec::new(); + for ruta in &recipes { + let r = takana_core::Recipe::load_from_path(ruta)?; + users.extend(r.users.iter().cloned()); + } + // Las recetas se validan una por una al cargar; esto caza lo que sólo se ve al juntarlas: + // dos paquetes distintos que reclaman el MISMO uid con nombres distintos. En una imagen + // eso deja ficheros de dos dueños con el mismo número y no lo nota nadie. + takana_core::user::validate_all(&users)?; + let passwd: Vec = users.iter().map(|u| u.passwd_line()).collect(); + let group: Vec = users.iter().map(|u| u.group_line()).collect(); + if let Some(rootfs) = merge { + let root = std::path::Path::new(&rootfs); + // DOS PASADAS a propósito: se planea todo y sólo entonces se escribe. Fusionar y + // escribir fichero a fichero deja estado a medias cuando el segundo conflictúa — + // un `group` con la cuenta y un `passwd` sin ella es peor que no haber hecho nada, + // porque parece que la cuenta está. + let mut plan = Vec::new(); + let mut conflictos_totales = Vec::new(); + for (rel, lineas) in [("etc/passwd", &passwd), ("etc/group", &group)] { + let p = root.join(rel); + // Un `passwd` ausente es un rootfs que no es un rootfs: no se crea de la nada, + // porque hacerlo dejaría una imagen SIN `root` y con el usuario del paquete. + let previo = std::fs::read_to_string(&p).map_err(|e| { + anyhow::anyhow!("no pude leer {}: {e} — ¿es un rootfs?", p.display()) + })?; + let (nuevo, anadidas, conflictos) = takana_core::user::fusionar(&previo, lineas); + for c in conflictos { + conflictos_totales.push(format!("{rel}: {c}")); + } + plan.push((rel, p, nuevo, anadidas)); + } + if !conflictos_totales.is_empty() { + anyhow::bail!( + "cuenta(s) ya presentes con OTRA línea — no se tocó ningún fichero:\n {}", + conflictos_totales.join("\n ") + ); + } + for (rel, p, nuevo, anadidas) in plan { + if !anadidas.is_empty() { + std::fs::write(&p, &nuevo)?; + } + eprintln!("{}: +{} cuenta(s)", rel, anadidas.len()); + for a in &anadidas { + eprintln!(" {a}"); + } + } + } else { + match format.as_str() { + "passwd" => println!("{}", passwd.join("\n")), + "group" => println!("{}", group.join("\n")), + "both" => println!("{}\n{}", passwd.join("\n"), group.join("\n")), + "json" => println!("{}", serde_json::to_string_pretty(&users)?), + otro => anyhow::bail!("--format inválido: {otro} (passwd | group | both | json)"), + } + } + } Cmd::Hash { recipe, check } => { let store = takana_core::Store::open(&cli.store)?; let recipe = takana_core::Recipe::load_from_path(&recipe)?; diff --git a/crates/takana-core/src/lib.rs b/crates/takana-core/src/lib.rs index 9fcfed0d..c1ceb510 100644 --- a/crates/takana-core/src/lib.rs +++ b/crates/takana-core/src/lib.rs @@ -21,6 +21,7 @@ pub mod service; pub mod sign; pub mod store; pub mod swm; +pub mod user; pub use caps::{AgentCapsConfig, CapRule}; pub use differs::{why_differs, Causa, Divergencia, Informe}; diff --git a/crates/takana-core/src/recipe.rs b/crates/takana-core/src/recipe.rs index 507807f1..3cefe448 100644 --- a/crates/takana-core/src/recipe.rs +++ b/crates/takana-core/src/recipe.rs @@ -76,6 +76,20 @@ pub struct Recipe { /// solo `ArtifactHash`. #[serde(default, rename = "service", skip_serializing_if = "Vec::is_empty")] pub services: Vec, + /// **Las cuentas que este paquete necesita para correr** (`[[user]]`). + /// + /// La otra mitad del SDD 30, y no es simetría por elegancia: un demonio que se niega a correr + /// como root —`gitea` lo hace explícitamente— tiene su Card haciendo `setuidgid`, y sin la + /// cuenta muere en bucle. Hasta hoy el `/etc/passwd` de la imagen era la constante + /// `takana_bootstrap::PRODUCT_PASSWD`: añadir un usuario era editar Rust, exactamente lo que + /// este SDD arregló para las Cards y dejó sin arreglar para las cuentas. + /// + /// El `uid` **se declara, no se asigna** — ver [`crate::user`]. + /// + /// **NO entra en `hash_inputs`**, como `license` y `[[service]]`: describe qué cuenta necesita + /// el artefacto para correr, no qué bytes tiene. + #[serde(default, rename = "user", skip_serializing_if = "Vec::is_empty")] + pub users: Vec, /// Directorio base contra el que se resuelven rutas relativas de la receta /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío. #[serde(skip, default)] @@ -523,6 +537,9 @@ impl Recipe { // después, en un rootfs que ya no reproduce y no dice por qué. Recetas sin `[[service]]` // pasan por una lista vacía, así que esto no toca a las 979 existentes. crate::service::validate_all(&r.services)?; + // Mismo criterio que los servicios: se valida al CARGAR. Un uid fuera de rango o un `:` en + // el nombre tienen que verse acá y no en el fichero `passwd` de una imagen ya compuesta. + crate::user::validate_all(&r.users)?; Ok(r) } diff --git a/crates/takana-core/src/user.rs b/crates/takana-core/src/user.rs new file mode 100644 index 00000000..3a46fa19 --- /dev/null +++ b/crates/takana-core/src/user.rs @@ -0,0 +1,294 @@ +//! `[[user]]` — **las cuentas que un paquete necesita para correr** (SDD 30, segunda mitad). +//! +//! ## Por qué existe +//! +//! El SDD 30 sacó las Cards de servicio de una constante de Rust y las puso en la receta: el +//! paquete que trae el demonio es el que dice cómo se supervisa. Los **usuarios** se quedaron donde +//! estaban las Cards — `takana_bootstrap::PRODUCT_PASSWD` es un literal con `root` y `sshd`, y +//! añadir un tercero seguía siendo editar Rust y recompilar takana. +//! +//! Eso no es simetría incompleta: es un servicio que no arranca. `gitea` **se niega a correr como +//! root** (`[F] Gitea is not supposed to be run as root`), así que su Card hace `setuidgid gitea` y +//! sin la cuenta el demonio muere en bucle. El mismo patrón tienen postgres, caddy cuando no quiere +//! privilegios, y cualquier cosa que haga `drop privileges` por su cuenta. +//! +//! ## El uid se DECLARA, no se asigna +//! +//! Igual que el ULID de una Card, y por la misma razón: un uid asignado al vuelo («el primero libre +//! a partir de 1000») hace que dos imágenes del mismo perfil salgan con dueños distintos, y el +//! rootfs **deja de reproducir sin que nada falle** — los ficheros se ven iguales, `ls -l` dice otro +//! número. Que sea estable es requisito, no detalle. +//! +//! ## Fuera de `hash_inputs` +//! +//! Como `license`, `slots`, `evidence` y `[[service]]`: describe qué cuenta necesita el artefacto +//! para correr, no qué bytes tiene. Declararlo en recetas YA SELLADAS no mueve un solo +//! `ArtifactHash`. +//! +//! ## Lo que este módulo NO hace +//! +//! No crea usuarios ni escribe `/etc/passwd`: emite las LÍNEAS y quien compone la imagen las +//! fusiona ([`User::passwd_line`], [`User::group_line`]). Tampoco pone contraseña: todas las cuentas +//! salen con `x` en el campo de password y sin entrada en `/etc/shadow`, o sea **sin login posible** +//! — una cuenta de servicio a la que alguien pueda entrar es una puerta que nadie declaró. + +use serde::{Deserialize, Serialize}; + +/// `root` y `sshd` los pone el producto (`takana_bootstrap::PRODUCT_PASSWD`); `nobody` es +/// convención universal. Una receta que los redeclare estaría pisando al producto con otro uid, y +/// el síntoma sería un rootfs cuyos ficheros pertenecen a quien no debe. +const RESERVADOS: &[(&str, u32)] = &[("root", 0), ("sshd", 74), ("nobody", 65534)]; + +/// Una cuenta de sistema que una receta declara necesitar. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct User { + /// Nombre de la cuenta (`gitea`). Es también el nombre del grupo salvo que `group` diga otro. + pub name: String, + /// **Se declara, no se asigna** — ver el doc del módulo. + pub uid: u32, + /// Grupo primario. Ausente ⇒ el mismo número que `uid` (el patrón usuario-grupo propio). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gid: Option, + /// Nombre del grupo primario. Ausente ⇒ `name`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group: Option, + /// Directorio personal, absoluto. Es donde el demonio guarda su estado (`/var/lib/gitea`). + pub home: String, + /// Shell. Default `/bin/false`: una cuenta de servicio no inicia sesión. + #[serde(default = "shell_por_defecto")] + pub shell: String, + /// Campo GECOS. Default: el nombre. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comment: Option, +} + +fn shell_por_defecto() -> String { + "/bin/false".to_string() +} + +impl User { + pub fn gid(&self) -> u32 { + self.gid.unwrap_or(self.uid) + } + + pub fn group(&self) -> &str { + self.group.as_deref().unwrap_or(&self.name) + } + + /// La línea de `/etc/passwd`, sin salto final. + pub fn passwd_line(&self) -> String { + let gecos = self.comment.as_deref().unwrap_or(&self.name); + format!( + "{}:x:{}:{}:{}:{}:{}", + self.name, + self.uid, + self.gid(), + gecos, + self.home, + self.shell + ) + } + + /// La línea de `/etc/group`, sin salto final. + pub fn group_line(&self) -> String { + format!("{}:x:{}:", self.group(), self.gid()) + } + + /// Valida la FORMA. No comprueba que el `home` exista: eso es del que compone la imagen. + pub fn validate(&self) -> crate::Result<()> { + let bad = |m: String| Err(crate::Error::Recipe(m)); + // El nombre entra en una línea separada por `:` y es también nombre de fichero en algunos + // consumidores: un `:` de más parte la línea en dos campos y el fallo aparece lejos. + let nombre_ok = !self.name.is_empty() + && self.name.len() <= 32 + && self + .name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') + && !self.name.starts_with('-'); + if !nombre_ok { + return bad(format!( + "user.name inválido: {:?} (minúsculas, dígitos, `_` y `-`; ≤32; sin `:`)", + self.name + )); + } + for (n, u) in RESERVADOS { + if self.name == *n || self.uid == *u { + return bad(format!( + "user[{}]: `{n}`/uid {u} lo reserva el producto o la convención — \ + redeclararlo pisaría al dueño de ficheros que ya existen", + self.name + )); + } + } + // 1..100 es territorio de las cuentas históricas de las distros; ≥65534 son `nobody` y el + // uid inválido (u32::MAX). El rango de cuentas de servicio es el de en medio. + if !(100..=65533).contains(&self.uid) { + return bad(format!( + "user[{}].uid = {} fuera de rango: usá 100..=65533 (cuentas de servicio)", + self.name, self.uid + )); + } + if let Some(g) = self.gid { + if !(100..=65533).contains(&g) { + return bad(format!( + "user[{}].gid = {g} fuera de rango: usá 100..=65533", + self.name + )); + } + } + if !self.home.starts_with('/') || self.home.contains(':') { + return bad(format!( + "user[{}].home debe ser ruta absoluta y sin `:`, es {:?}", + self.name, self.home + )); + } + if !self.shell.starts_with('/') || self.shell.contains(':') { + return bad(format!( + "user[{}].shell debe ser ruta absoluta y sin `:`, es {:?}", + self.name, self.shell + )); + } + if let Some(c) = &self.comment { + if c.contains(':') { + return bad(format!("user[{}].comment no puede contener `:`", self.name)); + } + } + Ok(()) + } +} + +/// Valida una lista y rechaza nombres y uids repetidos **dentro de la misma receta**. +pub fn validate_all(users: &[User]) -> crate::Result<()> { + let mut nombres = std::collections::BTreeSet::new(); + let mut uids = std::collections::BTreeSet::new(); + for u in users { + u.validate()?; + if !nombres.insert(&u.name) { + return Err(crate::Error::Recipe(format!("user.name duplicado: {:?}", u.name))); + } + if !uids.insert(u.uid) { + return Err(crate::Error::Recipe(format!( + "user.uid duplicado: {} (lo usan {:?} y otro)", + u.uid, u.name + ))); + } + } + Ok(()) +} + +/// Fusiona líneas en un fichero tipo `passwd`/`group` **por CLAVE, no por línea entera**. +/// +/// Devuelve `(contenido_nuevo, añadidas, conflictos)`. Un conflicto es una cuenta cuyo nombre ya +/// está y con otra línea: no se pisa nunca — se reporta. Pisar la entrada de un usuario existente +/// es cambiarle el uid a los ficheros que ya son suyos, y eso no se ve hasta que algo no puede leer +/// su propio estado. +/// +/// La idempotencia es el requisito: componer una imagen dos veces no puede dejar la cuenta dos +/// veces, y `grep -q` sobre la línea entera no alcanza porque la misma cuenta con el GECOS cambiado +/// se leería como nueva. +pub fn fusionar(contenido: &str, lineas: &[String]) -> (String, Vec, Vec) { + let clave = |l: &str| l.split(':').next().unwrap_or("").to_string(); + let existentes: std::collections::BTreeMap = contenido + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| (clave(l), l.to_string())) + .collect(); + let (mut anadidas, mut conflictos) = (Vec::new(), Vec::new()); + let mut out = contenido.trim_end_matches('\n').to_string(); + for l in lineas { + match existentes.get(&clave(l)) { + Some(viejo) if viejo == l => {} // ya está, idéntica ⇒ nada que hacer + Some(viejo) => conflictos.push(format!("{} ≠ {}", viejo, l)), + None => { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(l); + anadidas.push(l.clone()); + } + } + } + out.push('\n'); + (out, anadidas, conflictos) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gitea() -> User { + User { + name: "gitea".into(), + uid: 916, + gid: None, + group: None, + home: "/var/lib/gitea".into(), + shell: "/bin/false".into(), + comment: None, + } + } + + #[test] + fn lineas_con_el_formato_de_passwd_y_group() { + let u = gitea(); + assert_eq!(u.passwd_line(), "gitea:x:916:916:gitea:/var/lib/gitea:/bin/false"); + assert_eq!(u.group_line(), "gitea:x:916:"); + } + + #[test] + fn rechaza_lo_reservado_por_el_producto() { + let mut u = gitea(); + u.uid = 74; // el de `sshd` en PRODUCT_PASSWD + assert!(u.validate().unwrap_err().to_string().contains("reserva")); + let mut u = gitea(); + u.name = "root".into(); + assert!(u.validate().is_err()); + } + + #[test] + fn rechaza_nombre_con_dos_puntos_que_partiria_la_linea() { + let mut u = gitea(); + u.name = "git:ea".into(); + assert!(u.validate().unwrap_err().to_string().contains("user.name")); + } + + #[test] + fn rechaza_uid_fuera_de_rango_y_home_relativo() { + let mut u = gitea(); + u.uid = 42; + assert!(u.validate().unwrap_err().to_string().contains("fuera de rango")); + let mut u = gitea(); + u.home = "var/lib/gitea".into(); + assert!(u.validate().unwrap_err().to_string().contains("absoluta")); + } + + #[test] + fn duplicados_dentro_de_la_receta() { + let a = gitea(); + let mut b = gitea(); + b.name = "otro".into(); + assert!(validate_all(&[a.clone(), b]).unwrap_err().to_string().contains("uid duplicado")); + assert!(validate_all(&[a.clone(), a]).unwrap_err().to_string().contains("name duplicado")); + } + + #[test] + fn fusionar_es_idempotente_y_no_pisa() { + let base = "root:x:0:0:root:/root:/bin/sh\nsshd:x:74:74:sshd privsep:/var/empty:/bin/false\n"; + let l = vec![gitea().passwd_line()]; + let (uno, add1, conf1) = fusionar(base, &l); + assert_eq!(add1.len(), 1); + assert!(conf1.is_empty()); + // Segunda pasada: nada nuevo, nada duplicado. Es lo que hace que componer dos veces sea seguro. + let (dos, add2, conf2) = fusionar(&uno, &l); + assert_eq!(uno, dos); + assert!(add2.is_empty() && conf2.is_empty()); + assert_eq!(uno.lines().filter(|l| l.starts_with("gitea:")).count(), 1); + // Misma cuenta con otro uid: NO se pisa, se reporta. + let mut otro = gitea(); + otro.uid = 917; + let (tres, add3, conf3) = fusionar(&uno, &[otro.passwd_line()]); + assert_eq!(tres, uno, "un conflicto no cambia el fichero"); + assert!(add3.is_empty() && conf3.len() == 1, "{conf3:?}"); + } +} diff --git a/docs/30-servicios-de-paquete.md b/docs/30-servicios-de-paquete.md index 5b6e62d0..0371d695 100644 --- a/docs/30-servicios-de-paquete.md +++ b/docs/30-servicios-de-paquete.md @@ -298,6 +298,61 @@ Y las tres cosas de siempre: EXDEV. `store/` es bind-mount de `/dev/sdb` y `work `install-image-efi.sh` (configurable por `STAGE`). Hidratado, fundido y staging tienen que vivir bajo el mismo montaje. +## 4d. La otra mitad, que faltaba: `[[user]]` *(2026-09-14)* + +Declarar cómo se supervisa un demonio no alcanza cuando el demonio **se niega a correr como root**. +`gitea` lo dice y sale: + + [F] Gitea is not supposed to be run as root. If you need to use privileged TCP ports please + instead use `setcap` and the `cap_net_bind_service` permission. + +Su Card hace `setuidgid gitea`, y sin la cuenta el servicio arranca, muere y reintenta para siempre +con un log que dice `unknown user` — no «a la imagen le falta una cuenta». El `/etc/passwd` de la +imagen era **la constante `takana_bootstrap::PRODUCT_PASSWD`** (`root` y `sshd`, nada más): añadir +un usuario era editar Rust y recompilar takana, que es **exactamente el hueco del §1 de este +documento**, resuelto para las Cards y dejado abierto para las cuentas. Simetría, no elegancia. + +```toml +[[user]] +name = "gitea" +uid = 916 +home = "/var/lib/gitea" +``` + +**El `uid` se declara, no se asigna**, por la misma razón que el ULID de la Card: un «primero libre +a partir de 1000» hace que dos imágenes del mismo perfil salgan con dueños distintos y el rootfs +**deje de reproducir sin que nada falle** — los ficheros se ven iguales y `ls -l` dice otro número. + +Fuera de `hash_inputs`, como `[[service]]`: medido, el hash de `gitea` no se movió al declararlo. + +### La pregunta que NO es la misma que la de los servicios + +`--service-paths` lista las recetas de los servicios **habilitados**; `--user-paths` lista las de los +paquetes **instalados** que declaran cuentas. No es el mismo conjunto y confundirlos rompe el caso +normal: `postgres` instalado y sin levantar necesita su usuario igual, porque los ficheros que ya +están en la imagen **ya son suyos**. + +### Fusionar, no escribir + +`takana users --merge ` fusiona **por clave, no por línea entera**: componer la +imagen dos veces no deja la cuenta dos veces (y `grep -q` sobre la línea completa no serviría — la +misma cuenta con el GECOS cambiado se leería como nueva). Tres decisiones, las tres con su control: + +1. **Una cuenta ya presente con OTRA línea es un conflicto y no se pisa**: sale ≠0. Pisarla es + cambiarle el uid a ficheros que ya son de alguien, y eso se descubre dentro de la VM. +2. **Se planea todo y sólo entonces se escribe.** Fichero a fichero, un conflicto en `passwd` + dejaría el `group` ya escrito: media cuenta es peor que ninguna, porque parece que está. +3. **Un `passwd` ausente es un error, no un fichero a crear.** Crearlo dejaría una imagen **sin + `root`** y con el usuario del paquete. + +Y la validación rechaza lo que rompe tarde: `root`/`sshd`/`nobody` y sus uids (los reserva el +producto), uid fuera de `100..=65533`, `home` relativo, y un `:` en cualquier campo — que partiría +la línea en dos y fallaría lejos de acá. + +**Verificado**: 6 tests del módulo, merge idempotente (segunda pasada `+0`), y los dos controles +negativos con el fichero **intacto** después. `scripts/servidor-image.sh` lo aplica entre hidratar el +perfil y sellar la imagen. + ## 5. El hueco que no es nuestro: no hay readiness arje ordena el arranque por **capacidades**, con orden topológico real @@ -331,6 +386,11 @@ arriba en arje. | La cadena receta→sidecar→card→genesis→arje, **en arranque real** | ✅ §4b.2 (`PRODUCT_SSH_OK`) | | Que la imagen de ESCRITORIO los arranque y no el script `&` | ✅ §4c.1, con control (y arregló `colord`) | | `takana service-cards` + `--service-paths` + el inyector | ✅ §4c.1 | +| `[[user]]` en la receta + validación + fusión idempotente | ✅ `crates/takana-core/src/user.rs`, §4d | +| `takana users` (+`--merge`) y `targets.py --user-paths` | ✅ §4d | +| `gitea` declara su servicio **y** su cuenta (hash sin mover) | ✅ `recipes/gitea.toml` | +| El merge de cuentas en el camino de imagen | ✅ `scripts/servidor-image.sh` | +| Que el resto de imágenes (escritorios) llamen al merge | ◻ hoy sólo `servidor-image.sh` | | `escritorio-cosmic`: auditar su `cosmic-start.sh` y habilitar | ◻ hoy sale con 5 AVISO | **La primera lectura del vigía**, para que no haya que creerle a este documento: 0 errores y 18 diff --git a/recipes/gitea.toml b/recipes/gitea.toml index 34aa5c30..f8f7c578 100644 --- a/recipes/gitea.toml +++ b/recipes/gitea.toml @@ -59,6 +59,21 @@ go build -trimpath -tags 'bindata sqlite sqlite_unlock_notify' \ """ install = "true" +# ── LA CUENTA QUE ESTE PAQUETE NECESITA (SDD 30) ──────────────────────────────────────────────── +# Fuera de `hash_inputs`: declarar esto NO re-hashea gitea. +# +# El `uid` se DECLARA y no se asigna, igual que el ULID de la Card: un «primero libre a partir de +# 1000» hace que dos imágenes del mismo perfil salgan con dueños distintos y el rootfs deje de +# reproducir **sin que nada falle** — los ficheros se ven iguales y `ls -l` dice otro número. +# +# 916 es el uid que Alpine reserva para `git`/gitea en sus `setup-*`; no hace falta que coincida con +# nada, hace falta que no se mueva. `shell = /bin/false` por defecto: una cuenta de servicio a la que +# alguien pueda entrar es una puerta que nadie declaró. +[[user]] +name = "gitea" +uid = 916 +home = "/var/lib/gitea" + # ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ────────────────────────────────────────────────── # Fuera de `hash_inputs`: declarar esto NO re-hashea gitea. # diff --git a/scripts/servidor-image.sh b/scripts/servidor-image.sh index c27affb2..87d7c829 100755 --- a/scripts/servidor-image.sh +++ b/scripts/servidor-image.sh @@ -43,6 +43,12 @@ WORKDIR="${WORKDIR:-/mnt/cosecha/servidor-build}" IMG="${IMG:-$WORKDIR/servidor.img}" KERNELPKG="${KERNELPKG:-linux-generic}" ROOT_SIZE="${ROOT_SIZE:-6144}"; STORE_SIZE="${STORE_SIZE:-512}"; STATE_SIZE="${STATE_SIZE:-512}" +# El binario: en un hub de desarrollo está en `target/release`, en una caja instalada es +# `/usr/bin/takana` y `target/` ni existe. Es el bloqueo que ya costó una vez en la caja nueva +# («unhashable 875» con el lab bien): los scripts asumían árbol de desarrollo. +TAKANA="${TAKANA:-$ROOT/target/release/takana}" +[ -x "$TAKANA" ] || TAKANA="$(command -v takana || true)" +[ -n "$TAKANA" ] && [ -x "$TAKANA" ] || { echo "!! no encuentro el binario takana (probá TAKANA=/usr/bin/takana)" >&2; exit 2; } [ -n "${AUTHKEYS:-}" ] || { echo "!! falta AUTHKEYS: una imagen sin clave deja la caja INALCANZABLE" >&2; exit 2; } [ -r "$AUTHKEYS" ] || { echo "!! no puedo leer AUTHKEYS=$AUTHKEYS" >&2; exit 2; } @@ -74,6 +80,25 @@ cp -al "$PDIR/." "$RFS/" echo "==> hidratando perfil '$PERFIL'" python3 scripts/hydrate-profile.py "$PERFIL" --into "$RFS" --store "$STORE" --keep +# ── 2 bis. las CUENTAS que los paquetes del perfil declaran (`[[user]]`, SDD 30) ───────────────── +# El `/etc/passwd` del product-rootfs trae `root` y `sshd` y nada más, porque es una constante de +# Rust. Un paquete cuyo demonio se niega a correr como root —`gitea` lo hace explícitamente— tiene +# su Card haciendo `setuidgid`, así que SIN esta línea el servicio arranca, muere y reintenta para +# siempre, y el log dice «unknown user», no «a la imagen le falta una cuenta». +# +# `--merge` fusiona por clave y es idempotente: componer dos veces no duplica. Si una cuenta ya está +# con OTRA línea, sale ≠0 sin tocar ningún fichero — y acá eso ABORTA la imagen a propósito: un +# rootfs con el uid equivocado produce ficheros de un dueño que no existe, y eso se descubre dentro +# de la VM, varios pasos más tarde. +USERPATHS=$(python3 scripts/targets.py --user-paths "$PERFIL") +if [ -n "$USERPATHS" ]; then + echo "==> cuentas : $(echo "$USERPATHS" | wc -l) receta(s) declaran usuarios" + # shellcheck disable=SC2086 + "$TAKANA" --store "$STORE" users $USERPATHS --merge "$RFS" +else + echo "==> cuentas : ninguna receta del perfil declara [[user]]" +fi + # ── 3. reparar el init que la hidratación pisó (ver cabecera, punto 2) ─────────────────────────── antes=$(readlink "$RFS/sbin/init" 2>/dev/null || echo "(fichero)") ln -sf /usr/bin/arje-zero "$RFS/sbin/init" diff --git a/scripts/targets.py b/scripts/targets.py index c1f99821..9e0080b8 100755 --- a/scripts/targets.py +++ b/scripts/targets.py @@ -305,6 +305,34 @@ def main(): for r in vistas: print(r) return 0 + if args[0] == "--user-paths": + # Las RUTAS de receta de los paquetes DEL PERFIL que declaran `[[user]]`, para que el + # ensamblado se las pase a `takana users --merge `. La pregunta NO es la misma que + # la de `--service-paths`: una cuenta hace falta porque el paquete está INSTALADO, no porque + # el perfil arranque su servicio. `postgres` instalado y sin levantar necesita su usuario + # igual — sus ficheros ya son suyos. + if len(args) < 2 or args[1] not in perfiles: + print("uso: targets.py --user-paths ", file=sys.stderr) + return 1 + memb = _membresia() + raices = set(perfiles[args[1]]["raices"]) + vistas = [] + for f in sorted((ROOT / "recipes").rglob("*.toml")): + try: + doc = tomllib.load(open(f, "rb")) + except tomllib.TOMLDecodeError: + continue + if not doc.get("user"): + continue + receta = doc.get("name", f.stem) + if not (receta in raices or args[1] in memb.get(receta, [])): + continue + r = str(f.relative_to(ROOT)) + if r not in vistas: + vistas.append(r) + for r in vistas: + print(r) + return 0 if args[0] in ("--services", "-s"): if len(args) < 2 or args[1] not in perfiles: print(f"uso: targets.py --services (hay: {', '.join(sorted(perfiles))})",