[[user]] en la receta: la otra mitad del SDD 30 — un demonio que no corre como root necesita cuenta
El SDD 30 sacó las Cards de una constante de Rust y las puso en la receta. Los USUARIOS se quedaron
donde estaban las Cards: `/etc/passwd` de la imagen es `takana_bootstrap::PRODUCT_PASSWD`, un literal
con `root` y `sshd`, y añadir un tercero era editar Rust y recompilar takana.
No es simetría por elegancia, es un servicio que no arranca: `gitea` se niega a correr como root, su
Card hace `setuidgid gitea`, y sin la cuenta arranca, muere y reintenta para siempre con un log que
dice `unknown user` — no «a la imagen le falta una cuenta».
[[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`, medido: el hash de gitea no se movió (`b3:391a613e…` antes y después).
`takana users <recetas…> [--merge <rootfs>]` es el gemelo de `service-cards`, y fusiona **por clave,
no por línea entera** — componer dos veces no duplica, y `grep -q` de la línea completa no serviría
porque la misma cuenta con otro GECOS se leería como nueva. Tres decisiones con su control:
· Una cuenta ya presente con OTRA línea es 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.
· Se planea todo y sólo entonces se escribe. Fichero a fichero, un conflicto en `passwd` dejaba el
`group` ya escrito: media cuenta es peor que ninguna, porque parece que está. Comprobado con el
caso exacto — `group` sin la cuenta, `passwd` con otro uid — y los DOS ficheros quedan intactos.
· Un `passwd` ausente es un error, no un fichero a crear: crearlo dejaría una imagen SIN `root`.
La validación rechaza lo que rompe tarde: `root`/`sshd`/`nobody` y sus uids, uid fuera de
100..=65533, `home` relativo y un `:` en cualquier campo — que partiría la línea y fallaría lejos.
Y `--user-paths` NO es `--service-paths` con otro nombre: aquél lista los servicios HABILITADOS,
éste los paquetes INSTALADOS que declaran cuentas. `postgres` instalado y sin levantar necesita su
usuario igual, porque los ficheros de la imagen ya son suyos.
`scripts/servidor-image.sh` lo aplica entre hidratar el perfil y sellar la imagen, y aborta si hay
conflicto: un rootfs con el uid equivocado produce ficheros de un dueño que no existe.
Verde: 6 tests del módulo, core 234, cli 88, bootstrap 42, `targets.py --selftest` 7/7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn
This commit is contained in:
@@ -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 <perfil>` las lista.
|
||||
recipes: Vec<String>,
|
||||
/// 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<String>,
|
||||
},
|
||||
/// [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<takana_core::user::User> = 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<String> = users.iter().map(|u| u.passwd_line()).collect();
|
||||
let group: Vec<String> = 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)?;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -76,6 +76,20 @@ pub struct Recipe {
|
||||
/// solo `ArtifactHash`.
|
||||
#[serde(default, rename = "service", skip_serializing_if = "Vec::is_empty")]
|
||||
pub services: Vec<crate::service::Service>,
|
||||
/// **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<crate::user::User>,
|
||||
/// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u32>,
|
||||
/// Nombre del grupo primario. Ausente ⇒ `name`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String>, Vec<String>) {
|
||||
let clave = |l: &str| l.split(':').next().unwrap_or("").to_string();
|
||||
let existentes: std::collections::BTreeMap<String, String> = 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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 <recetas…> --merge <rootfs>` 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
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <rootfs>`. 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 <perfil>", 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 <perfil> (hay: {', '.join(sorted(perfiles))})",
|
||||
|
||||
Reference in New Issue
Block a user