Apagados en gioser (relay, serve y willay-crosscheck), encendidos en la caja: `tejido` en :4102 y `willay-crosscheck` en :4103, los dos alcanzables desde fuera. La prueba de que la identidad VIAJÓ no es que el servicio arranque —eso pasaría igual con semillas nuevas, que es el fallo silencioso que esto existe para evitar— sino que la semilla sea la misma: `device.seed` y `roster.postcard` con sha256 IDÉNTICO a los dos lados. El PeerId sale de ahí, así que sigue siendo 12D3KooWBw2u…. ⚠ LO QUE SÍ CAMBIA: el multiaddr que los clientes tienen configurado lleva la IP. antes /ip4/204.168.193.248/tcp/4102/p2p/12D3KooWBw2u… ahora /ip4/2.29.29.217/tcp/4102/p2p/12D3KooWBw2u… (mismo PeerId, otra IP) 🧱 SIN EL CORTAFUEGOS HABRÍA SIDO UN VERDE FALSO: el reglaset abría 80/443/1137/2345/22022 y nada más. Con el relay encendido y el 4102 filtrado, `status` diría «corriendo · 0 reinicios» y la flota no llegaría. Abiertos 4102 y 4103 con su ban por tasa; NO el 33097, que era el puerto efímero de un `tejido serve` cliente. Y el fichero quedó IDEMPOTENTE (crear+borrar la tabla antes de definirla), que era deuda del §6.33: 15 reglas dport antes, 21 después — no 36. ⚠ La política que genera ese reglaset NO EXISTE en ningún disco: ni la ruta que su cabecera nombra ni el binario `cortafuegos`. El generado sobrevivió a su fuente, así que hoy se edita a mano. 🆔 Y takana acepta ULIDs que arje-zero RECHAZA: la card no encarnaba por `invalid character`, y era la `L` de «RELAY» — carácter excluido del alfabeto Crockford. `service-cards` lo dio por bueno («26 alfanuméricos») y el consumidor lo tiró. El validador del productor más laxo que el del consumidor siempre termina en un fallo lejos de donde se escribió. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2592 lines
157 KiB
Markdown
2592 lines
157 KiB
Markdown
# SDD 28 — El servidor de producción: instalar takana puro en una caja remota y mudarse a ella
|
||
|
||
Escrito 2026-09-09 a pedido del usuario («qué tan lejos estamos de montar un server takana en
|
||
Hetzner y que sea producción, y mover allá todo lo que tengo acá, y de una vez mantenerlo por repo
|
||
takana»). Todo número marcado *(medido)* salió de un comando corrido ese día sobre `gioser`.
|
||
|
||
Continúa [SDD 19](19-lanzamiento-publico.md) (lanzamiento público) y
|
||
[SDD 27](27-perfiles-instalables-y-el-instalador.md) (metapaquetes e instalador). **No repite el
|
||
instalador**: SDD 27 decide QUÉ se instala y con qué verbo; esto decide DÓNDE, y cómo se llega.
|
||
|
||
---
|
||
|
||
## 0. La corrección de encuadre: ya estamos en Hetzner
|
||
|
||
`gioser` **es** un servidor Hetzner (hcloud id `126324694`, hel1, 4 cores / 7,6 GiB) *(medido)*. La
|
||
pregunta no era «cómo llegar a Hetzner» sino **cómo tener una caja de takana** en vez de ser un
|
||
inquilino más de una máquina compartida que:
|
||
|
||
- sirve **19 dominios** por Caddy,
|
||
- tiene `/mnt/vvv` al **89 %** (27 G libres) *(medido)*,
|
||
- es **fija y sin backup** por pedido explícito del usuario (blindaje anti-borrado en `deadman.sh` y
|
||
`farm-down.sh`, ver [ADR 0014](adr/0014-distribucion-multiorigen.md) y la memoria del proyecto),
|
||
- y **no publica nada de takana**: cero entradas de takana en el Caddyfile *(medido)*.
|
||
|
||
### 0.1 El hallazgo que cambia el tamaño del problema
|
||
|
||
```
|
||
$ ps -p 1 -o comm=,args=
|
||
arje-zero /usr/local/sbin/arje-zero
|
||
$ cat /proc/cmdline
|
||
BOOT_IMAGE=/vmlinuz-linux root=UUID=54de62df-… rw net.ifnames=0 splash \
|
||
init=/usr/local/sbin/arje-zero loglevel=4
|
||
```
|
||
|
||
**El init de takana ya gobierna un servidor de producción real.** Caddy, gitea, sshd, qdrant y
|
||
act-runner cuelgan directo de PID 1 *(medido: `ps -eo pid,ppid,comm | awk '$2==1'`)*.
|
||
|
||
Pero **eso no es takana puro**: es Artix con nuestro init inyectado por `init=` en el cmdline. El
|
||
kernel, la libc y los 19 servicios son ajenos. Se probó la mitad difícil sobre userland prestado.
|
||
|
||
### 0.2 ⚠ La declaración del arranque no existe en ningún sitio legible
|
||
|
||
`rc-status` de OpenRC dice `stopped` para **caddy, gitea, sshd, cronie y act-runner**, y los cinco
|
||
están **vivos** *(medido, los dos comandos)*. `/etc/arje/cards.d` tiene **4 tarjetas** (`acpid`,
|
||
`openrc-openclaw`, `squid`, `zram-swap`) y ninguna de ellas los levanta.
|
||
|
||
⇒ **La secuencia de arranque de esta caja no está escrita.** Es lo menos reproducible de toda la
|
||
mudanza y lo primero que hay que capturar — ver §4.
|
||
|
||
---
|
||
|
||
## 1. Inventario de lo que hay que mudar *(medido 2026-09-09)*
|
||
|
||
### Datos en `/mnt/vvv` (217 G de 255)
|
||
|
||
| | |
|
||
|---|---|
|
||
| `tawasuyu` | **124 G** |
|
||
| `takana` | **91 G** — incluye el store de 55 G bind-mounteado desde el otro volumen ⇒ ~36 G propios |
|
||
| `rustup` | 29 G |
|
||
| `act-runner` · `gioserv` · `_respaldo` | 6,5 · 5,4 · 4,1 G |
|
||
| ~20 dirs `*-standalone` | agora, card, chasqui, cosmos, dominium, khipu, minga, mirada, nakui, nahual, pata, pineal, pluma, shuma, takiy, llimphi… |
|
||
|
||
### El volumen `harkaq-cosecha` (250 G, 148 G usados)
|
||
|
||
| | |
|
||
|---|---|
|
||
| `store` | **55 G**, **1327 artefactos** — lo irreemplazable |
|
||
| `work-sources` + `cargo-home` + `gopath` | ~85 G — **caché regenerable**, no se muda |
|
||
| `work-tarballs` · `devfs-cache` · `work-out` | 3,2 G · 4,5 G · 397 M |
|
||
|
||
**El store no se copia por red.** Los dos volúmenes están en `hel1`; un volumen hcloud se desengancha
|
||
de una caja y se engancha a otra en la misma ubicación. La mudanza del store son dos comandos.
|
||
|
||
### Servicios vivos, todos hijos de arje-zero
|
||
|
||
`caddy` :80/:443 · `gitea` :3002 · `sshd` :2345 · `act_runner` (el CI de tawasuyu) · `qdrant`
|
||
:6333/:6334 · `openclaw` · `zeroclaw` · `shuma-gateway` :7378 · `tejido` :4102 y :33097 ·
|
||
`puerta-f6e393ff` · `uvicorn` :8771 · `python3` :8770 · `squid` :1137 · `fail2ban` · `glances` ·
|
||
`php-fpm` · `metalog` · **`adb` :5037**.
|
||
|
||
### Lo que no está en ningún repo
|
||
|
||
`/var/lib/gitea` (**2,2 G**: SQLite + los repos de git de todo, **incluido el `origin` de takana**) ·
|
||
`/var/www` (287 M) · `/home/sergio/gioser-web/` · `/etc/caddy/Caddyfile` **+ 20 ficheros `.bak`** ·
|
||
los certificados de letsencrypt · los crontabs · `/etc/arje/cards.d` · las 3 credenciales de
|
||
`~/.config/hammer/*.env` · las llaves `~/.ssh/{github5,sergio}` · el token hcloud · y **la memoria
|
||
del agente en `~/.claude/`**.
|
||
|
||
### 1.1 La basura, contada — porque una mudanza fiel copia también la basura
|
||
|
||
De los 19 dominios del Caddyfile *(probados uno por uno desde la propia caja)*:
|
||
|
||
| responden 200 | rotos |
|
||
|---|---|
|
||
| gioser.net · tawasuyu.net · git.gioser.net · summa.gioser.net · sergio.gioser.net | **api.gioser.net → 502** (backend caído) |
|
||
| | **aura · sigma · kosmofono · terapeuta.ec → sin DNS** |
|
||
|
||
Y sus raíces **no existen en disco**: `/var/www` sólo tiene `terapeuta`, `goaccess`, `ogisoer`,
|
||
`git-tawasuyu`, `letsencrypt` y un `.bak` — no hay `aura_frontend`, ni `sigma`, ni `summa/frontend`,
|
||
ni `kosmofono`. **La mitad del Caddyfile describe un servidor que ya no existe.**
|
||
|
||
⇒ **Regla de la mudanza: no se muda lo que no responde.** Lo que no tiene DNS ni ficheros se anota y
|
||
se deja morir con la caja vieja. La utilidad del §4 tiene que saber decir *declarado / vivo / fósil*.
|
||
|
||
---
|
||
|
||
## 2. La caja: qué, cuánto y por qué
|
||
|
||
Precios consultados a la API de hcloud el 2026-09-09 (hel1, x86, tipos no deprecados) *(medido)*:
|
||
|
||
| tipo | recursos | €/mes |
|
||
|---|---|---:|
|
||
| **`cx53`** | **16 c / 32 G / 320 G** | **29,49** |
|
||
| `cpx41` | 8 c / 16 G / 240 G | 32,49 |
|
||
| `ccx23` (dedicado) | 4 c / 16 G / 160 G | 85,99 |
|
||
|
||
**Elegido `cx53`**: es el mejor punto de la tabla y entra holgado el conjunto tawasuyu + takana +
|
||
rustup (~244 G) si algún día se muda todo.
|
||
|
||
**Decisiones del usuario (2026-09-09):**
|
||
|
||
1. **La caja PUBLICA; el LXC prestado sigue moliendo.** `dev.gioser.net` cuesta €0 y es la máquina
|
||
con más RAM (6 c / 16 G + 8 G swap). Separar *servir* de *compilar* mantiene la caja chica y
|
||
barata. La caja nueva puede además compilar, con el LXC enchufado como segundo worker.
|
||
2. **«Producción» = operativo pero privado.** Sin descarga pública todavía ⇒ **no** se activan los
|
||
bloqueantes legales del [SDD 19 §2](19-lanzamiento-publico.md) (espejo de fuentes público por
|
||
HTTP, marca de terceros). Lo que sí hace falta igual es la **clave de release estable** (§5).
|
||
3. **gioser se BORRA** cuando la mudanza esté comprobada. Eso convierte esto en un experimento con
|
||
fecha de caducidad, y cambia el diseño entero: ver §6.
|
||
|
||
⚠ **El blindaje anti-borrado de gioser es deliberado y hay que levantarlo a mano y a propósito.**
|
||
`deadman.sh` y `farm-down.sh` lo protegen por lista negra de nombre **y** por ausencia del label
|
||
`role=hammer-worker`. Ninguna automatización debe poder borrarlo: cuando llegue el día, se borra a
|
||
mano, con la comprobación del §6 en verde y no antes.
|
||
|
||
---
|
||
|
||
## 3. Instalar takana puro en una caja remota
|
||
|
||
### 3.1 Lo que ya está, y por qué está más cerca de lo que parece
|
||
|
||
**El dato que lo destraba: Hetzner Cloud arranca por BIOS, no por EFI.** *(medido en gioser: no
|
||
existe `/sys/firmware/efi`; GPT + GRUB; `/boot` es vfat de 1 G.)* Y `scripts/takana-install.sh`
|
||
escribe **exactamente ese layout**: GPT + GRUB BIOS, `SeaBIOS → GRUB → kernel → arje-zero PID1`.
|
||
El camino EFI —que fue el que más trabajo costó ([ADR 0010](adr/0010-arranque-grafo-mirada.md))— es
|
||
el que Hetzner **no** necesita.
|
||
|
||
| pieza | estado *(medido)* |
|
||
|---|---|
|
||
| kernel con virtio | `recipes/linux.toml` compila `VIRTIO`, `VIRTIO_PCI`, `VIRTIO_BLK`, `VIRTIO_NET`, `EXT4_FS` **`=y`** ⇒ sin initramfs |
|
||
| red | `crates/netup` = cliente **DHCPv4 propio** en Rust sobre netlink, autodetecta interfaz y espera carrier. gioser toma su IP por **DHCP en `eth0`** (`/32`, `net.ifnames=0`, DNS `185.12.64.2`) ⇒ es justo su caso |
|
||
| acceso | `recipes/openssh.toml` sellado; el `product-rootfs` vigente **ya trae `sshd`**; `takana-install.sh` acepta `AUTHKEYS` |
|
||
| servir | `recipes/caddy.toml` existe |
|
||
| escritura a disco | `scripts/takana-install.sh` (no interactivo) y `scripts/takana-live-install.sh` (TUI), probado por `scripts/install-tui-test.sh` |
|
||
|
||
### 3.2 El camino remoto: rescue → `dd` → reboot
|
||
|
||
Hetzner Cloud tiene un **sistema de rescate** (Debian por SSH) que se activa desde la API sin tocar
|
||
la caja. Desde ahí se escribe la imagen al disco y se reinicia. **No hace falta ISO, ni consola, ni
|
||
medio físico** — que es justo lo que hace innecesario el 90 % del trabajo de ISO/USB para este caso.
|
||
|
||
### 3.3 Los cuatro huecos, nombrados
|
||
|
||
1. **`perfil.servidor`** — **HECHO hoy** en `docs/state/targets.toml`. Ver §3.4.
|
||
2. **`console=ttyS0` en el cmdline** — el único salvavidas si el arranque falla y no hay teclado.
|
||
Hetzner da consola web; sin serial en el kernel, esa consola no muestra nada útil.
|
||
3. **IPv6** — `netup` hace DHCPv4 y Hetzner entrega un `/64` **estático**. La caja arrancaría con
|
||
IPv4 sola. Aceptable para empezar, pero hay que saberlo **antes**, no descubrirlo.
|
||
4. **Un `.img` del perfil servidor** que el rescue pueda escribir. Es `hydrate-profile.py` +
|
||
`install-image.sh`, los dos ya existentes — ver §7.
|
||
|
||
### 3.4 El perfil, y la deuda que declara *(hecho 2026-09-09)*
|
||
|
||
`[perfil.servidor]` hereda `cli` (que hereda `base`, [SDD 27 §7.1](27-perfiles-instalables-y-el-instalador.md))
|
||
y añade **5 raíces que existen** (`openssh`, `caddy`, `curl`, `wget`, `tmux`) y **5 que NO**:
|
||
|
||
| raíz que no resuelve | por qué está declarada igual |
|
||
|---|---|
|
||
| **`takana`** | **el propio takana no tiene receta.** El corpus construye 869 recetas y no la suya ⇒ el binario sólo existe como `cargo build` sobre un clon. Es EL bloqueante del §5 |
|
||
| `chrony` | sin NTP, un reloj que deriva rompe TLS y las firmas — y falla diciendo «firma inválida», no «la hora» |
|
||
| `nftables` | `dev.gioser.net` está hoy expuesto con fuerza bruta a root en el journal; una caja nueva no debería nacer así |
|
||
| `cronie` | el latido son tres líneas de crontab, y sin systemd no hay timers: o hay cron, o el latido es una tarjeta de arje-zero. **Decisión pendiente** |
|
||
| `logrotate` | en una caja que sirve, el log de acceso crece hasta llenar el disco |
|
||
|
||
Quedan **`wanted`** en el grafo, y **`wanted` no es `debt`** ⇒ `drenaje.json` seguirá diciendo
|
||
`deuda=0`. Se declaran igual porque **una deuda escrita se ve y una omitida no**: sin ellas el perfil
|
||
saldría N/N —completo y verde— describiendo un servidor sin hora, sin cortafuegos y sin latido. Es
|
||
la lección de `foot` en `escritorio-sway` (121/121 sellado y sin emulador de terminal), pagada por
|
||
adelantado. **Cuenta esperada: 5.** Un sexto `wanted` es un error de tipeo, no una receta nueva.
|
||
|
||
### 3.5 La caja, creada *(2026-09-10)*
|
||
|
||
| | |
|
||
|---|---|
|
||
| nombre / id | `takana` / `165447050` |
|
||
| tipo | **`cx33`** — 4 c / 7,6 GiB / 76,3 G — **€8,49/mes** |
|
||
| ubicación | **hel1** — la misma que los volúmenes `vvv` y `harkaq-cosecha` y que el Storage Box |
|
||
| IPv4 / IPv6 | `2.29.29.217` · `2a01:4f9:c014:2f9c::/64` |
|
||
| imagen inicial | `debian-13` — **desechable**: existe sólo para recibir ficheros hasta el `dd` del §3.2 |
|
||
| label | `role=takana-server` |
|
||
| protección de borrado | **NO**, a propósito: es la caja de sacrificio |
|
||
|
||
⚠ **El label importa**: `farm-down.sh` y `deadman.sh` borran **sólo** lo que lleva
|
||
`role=hammer-worker`. Esta caja no lo tiene ⇒ ninguna automatización de la granja puede tocarla.
|
||
Es la misma capa fuerte que protege a gioser, aplicada al revés: no se protege por nombre, se
|
||
protege por no haber nacido de `farm-up`.
|
||
|
||
⚠ **`cx53` no se pudo comprar: sale `Available: no` en las TRES ubicaciones.** Y la disponibilidad
|
||
**cambia por día** — `cx33` salió `no` en hel1 el 2026-09-09 y `yes` el 2026-09-10, con la misma
|
||
consulta. ⇒ **el precio de la tabla no dice que la máquina exista**: `hcloud server-type list`
|
||
devuelve precios para tipos que no se pueden crear. Consultar `Available` por ubicación, y volver a
|
||
consultarlo el día que se compra.
|
||
|
||
#### Lo que la caja confirma, medido EN ELLA y no inferido de gioser
|
||
|
||
1. **Arranca por BIOS** — no existe `/sys/firmware/efi`… **y sin embargo la imagen trae un ESP**
|
||
(`sda15`, 244 M vfat en `/boot/efi`). O sea: **la presencia de una partición EFI no indica
|
||
arranque EFI**. Si el instalador decidiera la rama mirando particiones en vez de
|
||
`/sys/firmware/efi`, elegiría mal en esta caja exacta. `takana-live-install.sh` mira lo correcto.
|
||
2. **`console=tty1 console=ttyS0` viene en el cmdline de Debian.** Confirma que el serial es lo que
|
||
Hetzner espera, y que el hueco 2 del §3.3 es real: nuestra imagen tiene que traerlo o la consola
|
||
web no muestra nada.
|
||
3. **`eth0` con `/32` dinámico por DHCP** — idéntico a gioser ⇒ es exactamente el caso de `netup`.
|
||
|
||
#### Dos cosas pendientes en la caja, anotadas
|
||
|
||
- **7,6 GiB de RAM y CERO swap** — la misma clase de máquina que ya provocó `global_oom` en gioser.
|
||
Coherente con la decisión de §2.1 (la caja **publica**, el LXC muele), pero significa que **no se
|
||
construye pesado acá** mientras siga así.
|
||
- **`sshd` trae `PasswordAuthentication yes`** (con `PermitRootLogin without-password`, así que root
|
||
por clave igual). El journal del LXC muestra fuerza bruta a root desde IPs random; esta caja está
|
||
igual de expuesta. Se cierra cuando se instale takana puro, o antes si se queda días con Debian.
|
||
|
||
### 3.6 La imagen del `perfil.servidor`, armada y validada *(2026-09-10)*
|
||
|
||
**Receta del ensamblado** (todo con scripts que ya existían, §7):
|
||
|
||
```sh
|
||
# 1. base con init: el product-rootfs sellado (trae /sbin/init -> /usr/bin/arje-zero)
|
||
cp -al /mnt/cosecha/store/5011955a…-product-rootfs/. $D/
|
||
# 2. userland del perfil encima
|
||
scripts/hydrate-profile.py servidor --into $D --store /mnt/cosecha/store --keep
|
||
# 3. ⚠ restaurar el init (ver abajo) y meter la clave
|
||
ln -sf /usr/bin/arje-zero $D/sbin/init ; cp github5.pub $D/root/.ssh/authorized_keys
|
||
# 4. imagen
|
||
ROOTFS=$D STAGE=/mnt/cosecha/.install-stage KERNEL=store/23f1cc43…-linux-generic/boot/bzImage \
|
||
CMDLINE="console=ttyS0,115200 console=tty0 root=/dev/sda2 rw init=/usr/bin/arje-zero" \
|
||
./scripts/install-image.sh
|
||
```
|
||
|
||
Resultado: **86 nodos, 30.500 ficheros, 2,0 G de rootfs → imagen de 7.172 M (2,1 G en disco, sparse)**.
|
||
|
||
#### El kernel correcto es `linux-generic`, y `linux.toml` NO sirve para hcloud
|
||
|
||
El disco de una caja hcloud lo maneja **`virtio_scsi`** (driver `sd`, disco `sda`) — no `virtio_blk`.
|
||
`recipes/linux.toml` **apaga SCSI explícitamente** (`-d SCSI`) ⇒ su kernel arranca y **no ve el
|
||
disco**. `linux-generic`, `linux-metal` y `linux-metal-dual` sí sirven.
|
||
|
||
⚠ **Y ninguna receta nombra `SCSI_VIRTIO`.** Está encendido igual, porque `make defconfig` lo trae y
|
||
`-e SCSI -e BLK_DEV_SD` lo mantiene. Se supo mirando el `.config` que `linux-generic` **publica en su
|
||
artefacto** (`/boot/config-7.1.2-generic`), no leyendo la receta. La receta dice lo que se cambió;
|
||
sólo el `.config` sellado dice lo que quedó.
|
||
|
||
#### ⚠ La hidratación PISA `/sbin/init`, y el resultado arranca igual
|
||
|
||
`busybox` entra en la clausura del perfil y su `/sbin/init -> ../bin/busybox` **gana por llegar
|
||
después**, borrando el `-> /usr/bin/arje-zero` del product-rootfs. La imagen habría arrancado
|
||
perfecta **con el init de busybox**: no takana puro, y sin un solo error. Se tapa por las dos puntas
|
||
— restaurando el symlink al ensamblar **y** con `init=/usr/bin/arje-zero` explícito en el cmdline,
|
||
que es lo que hace gioser y es autoritativo pase lo que pase con el symlink.
|
||
|
||
**Y la guarda que existía para esto estaba INVERTIDA** (arreglada en el mismo commit): `[ -e
|
||
"$ROOTFS/sbin/init" ]` sigue el symlink y lo resuelve contra el *host*, así que el symlink ABSOLUTO
|
||
a arje-zero daba falso y **el rootfs correcto era rechazado**, mientras que el relativo a busybox
|
||
—el equivocado— pasaba. Ver [[guardian-que-nunca-fallo]]: una guarda que nunca falló no se sabe si
|
||
sirve; ésta fallaba al revés.
|
||
|
||
#### EXDEV, dos veces en la misma tarde
|
||
|
||
`store` y `work/out` son bind-mounts de `/dev/sdb`; `work/` está en `/dev/sdc`. Un hardlink no cruza
|
||
un montaje **aunque sea el mismo disco**, así que reventaron (1) `hydrate-profile.py` con el destino
|
||
fuera del montaje del store —que avisa RUIDOSO y bien— y (2) el staging de `install-image.sh`, que
|
||
lo tenía cableado a `work/`. Ahora `STAGE` es env. Ver [[hardlink-cruza-mount-exdev]].
|
||
|
||
#### La validación: arrancó en QEMU reproduciendo virtio-scsi, y se entró por SSH
|
||
|
||
```
|
||
SeaBIOS → GRUB → kernel 7.1.2 propio → EXT4-fs (sda2): mounted → Pivoted into new rootfs
|
||
→ Run /usr/bin/arje-zero as init process → "arje-zero: despierta como PID 1"
|
||
→ netup (DHCP) → ssh-keygen -A → sshd
|
||
```
|
||
|
||
Y desde fuera: `ssh -p 2299 root@127.0.0.1` **entra**, `/proc/1/comm` dice `arje-zero`, `ip addr` da
|
||
la IP que negoció `netup`. Es la puerta 1 del §6 ensayada en local antes de gastar la caja.
|
||
|
||
El card `sshd` de `/ente/seed.card.json` ya hacía el trabajo entero sin que hubiera que tocar nada:
|
||
`sh -c "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"` — red, claves de host y
|
||
demonio en una línea. Lo único que hubo que inyectar fue `authorized_keys`.
|
||
|
||
#### Un resto del renombre, encontrado por el arranque
|
||
|
||
```
|
||
arje-zero: hammer no corre, sin menú de arranque: No such file or directory (os error 2)
|
||
```
|
||
|
||
**`arje-zero` invoca el binario por el nombre VIEJO (`hammer`).** No es fatal —sigue arrancando— pero
|
||
el menú de arranque por grafo del [ADR 0010](adr/0010-arranque-grafo-mirada.md) queda muerto. Y viene
|
||
de tawasuyu, pineado por `commit` en `recipes/arje-zero.toml`, así que **no se arregla desde este
|
||
repo**: hay que subirlo allá y re-pinear. Se suma a que la imagen tampoco trae `takana` (§3.4).
|
||
|
||
### 3.7 ⚠ El muro real: la puerta de enlace de Hetzner está FUERA del prefijo
|
||
|
||
La imagen validada en QEMU se escribió a la caja, arrancó… y **no se pudo entrar**. `hcloud` decía
|
||
`running`, el puerto 22 cerrado, y desde fuera `Connection timed out` — el modo de fallo más caro de
|
||
todos, porque no hay consola serie en hcloud para mirar.
|
||
|
||
La causa, medida entrando al rescue de la propia caja:
|
||
|
||
```
|
||
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 IPv4 viene con prefijo `/32` y la puerta (`172.31.1.1`) no pertenece a ninguna red conectada.**
|
||
`netup` mandaba el default con `RTA_GATEWAY` y scope UNIVERSE y nada más, así que el kernel lo
|
||
rechazaba con `ENETUNREACH`: la máquina se quedaba **con IP y sin salida**, contestando el DHCP y
|
||
levantando sshd, sin nada visiblemente roto por dentro.
|
||
|
||
**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
|
||
```
|
||
|
||
Arreglado con `add_onlink_host_route` (dst `/32`, scope LINK) antes del default.
|
||
|
||
**Por qué no había aparecido nunca**: `netup` se validaba contra el DHCP de QEMU slirp, que entrega
|
||
un `/24` con la puerta dentro de la subred. El escenario que rompe sólo existe en una nube de verdad.
|
||
Es [[cache-congela-regresiones]] en su otra forma: no es que el test estuviera mal, es que el ÚNICO
|
||
entorno donde se probaba no contenía el caso.
|
||
|
||
**Y destapó un segundo problema de fondo**: el binario de `netup` venía dentro del `product-rootfs`,
|
||
o sea **congelado en un artefacto sellado del bootstrap** ⇒ arreglar netup no llegaba a la imagen.
|
||
Por eso `netup` pasa a ser **raíz de `perfil.servidor`**: así la hidratación lo proyecta encima y la
|
||
imagen lleva el vigente. Verificado por sha256 (imagen `a23df20e…`, product-rootfs `a84b0a0d…`).
|
||
|
||
### 3.8 ✅ Puerta 1, pasada *(2026-09-10)*
|
||
|
||
```
|
||
PID 1 : arje-zero kernel : 7.1.2 (linux-generic propio)
|
||
IP : 2.29.29.217 rutas : default via 172.31.1.1 + 172.31.1.1 scope link
|
||
montajes : /dev/sda3 -> /store /dev/sda4 -> /var/lib/hammer
|
||
salida : ping 1.1.1.1 0% pérdida · curl http://1.1.1.1 -> 301 · resolv.conf escrito por netup
|
||
```
|
||
|
||
**Una caja Hetzner Cloud corriendo takana puro**: nuestro kernel, nuestro init, nuestro cliente DHCP,
|
||
nuestro sshd — sin una línea de Debian debajo. El camino entero es
|
||
`scripts/servidor-image.sh` + rescue + `dd`, y la escritura de los 7,1 G comprimidos tarda **~17 s**
|
||
(gioser y la caja están las dos en hel1).
|
||
|
||
Lo que la caja **todavía no** tiene, y es el paso 2: `takana` (sin receta, §3.4), el store real (el
|
||
volumen no se engancha hasta acá) y las otras cuatro raíces `wanted`.
|
||
|
||
---
|
||
|
||
## 4. La utilidad de migración: absorber lo VIVO, no lo declarado
|
||
|
||
`arje-absorb` ya existe, está sellado (`recipes/arje-absorb.toml`) y su `--help` dice: lee
|
||
`sysvinit | runit | dinit | openrc | auto` y **emite una Tarjeta Semilla** con cada servicio del init
|
||
ajeno como hija de arje-zero. Tiene parsers para los cinco (`systemd.rs`, `openrc.rs`, `runit.rs`,
|
||
`dinit.rs`, `sysvinit.rs`).
|
||
|
||
**Pero lee la declaración, y en gioser la declaración miente** (§0.2). Un `arje-absorb --from openrc`
|
||
sobre esta caja produciría una Semilla que **no levanta el servidor**, y lo haría en silencio: la
|
||
Semilla sería sintácticamente perfecta.
|
||
|
||
⇒ Lo que falta es un **segundo lector**: absorber del **proceso vivo** (`/proc`: `ppid==1`, `cmdline`,
|
||
`cwd`, entorno, fds en LISTEN) y **reconciliar** ambas lecturas marcando dónde discrepan. Eso es lo
|
||
que el usuario nombró como «lo aprendido de matilda» — absorber lo que hay, no lo que está escrito.
|
||
|
||
La utilidad debe clasificar en tres, no en dos:
|
||
|
||
| clase | criterio | qué se hace |
|
||
|---|---|---|
|
||
| **vivo** | proceso corriendo **y** declarado | se muda |
|
||
| **declarado, muerto** | en la config, sin proceso ni ficheros (los 4 dominios sin DNS) | **fósil: se anota y se deja morir** |
|
||
| **vivo, no declarado** | proceso con `ppid==1` que ningún init declara (caddy, gitea, sshd, act-runner) | **se escribe la tarjeta que faltaba** — es el trabajo real |
|
||
|
||
**Se construye al final**, estrenándose con la migración gioser → caja nueva, con el caso del
|
||
`rc-status` que miente como test de aceptación. Su especificación es el §1 de este documento.
|
||
|
||
---
|
||
|
||
## 5. Que el servidor sirva sus propios paquetes a su propio host
|
||
|
||
Existe el camino entero: `scripts/build-repo.sh` (corpus → `.swm` firmados + índice),
|
||
`takana install --repo` que **ya acepta lista HTTP multi-origen** y verifica cada paquete contra el
|
||
`digest` del índice firmado ([ADR 0014](adr/0014-distribucion-multiorigen.md)), `takana upgrade` con
|
||
rollback, y caddy para servir `dist/repo` con un `file_server`.
|
||
|
||
Lo que falta **no es servir**, son dos cosas:
|
||
|
||
1. **La clave.** `build-repo.sh` firma hoy con una **clave efímera** por defecto (`keygen release` si
|
||
no se le pasa `KEY=`) *(medido, línea 33)*. Sin clave estable, «firmado» no significa nada. Es el
|
||
[SDD 19 §3.2](19-lanzamiento-publico.md) y es requisito aunque el repo sea privado.
|
||
2. **La receta de `takana`** (§3.4). El host necesita `takana` instalado para consumir los paquetes,
|
||
y hoy no puede instalarlo desde el repo porque takana no está en el repo.
|
||
|
||
### 5.1 Hecho *(2026-09-10)*: clave estable, repo firmado, y la caja sirviéndolo
|
||
|
||
- **`trust/`** — la clave PÚBLICA de release en el repo; la privada en `~/.config/takana/keys/`
|
||
(0600, fuera de git). `scripts/repo-perfil.sh` **aborta si no la encuentra** en vez de inventar una
|
||
efímera, y tras firmar **verifica** el índice contra `trust/`.
|
||
- **88/88** paquetes del perfil `servidor` publicados con `expected_hash` anclado.
|
||
- **La caja los sirve** con `caddy file-server` sobre `/srv/repo`.
|
||
|
||
**El control de la firma, en los dos sentidos**, contra el repo servido por la caja:
|
||
|
||
| `--trust` | resultado |
|
||
|---|---|
|
||
| `./trust` | `release: trusted (by release)` ⇒ instala |
|
||
| un directorio vacío | `release: unknown-key … clave no confiada` ⇒ **ABORTA** |
|
||
|
||
### 5.2 ⚠ Lo que el lazo destapó: `strip_debug` no viajaba en el paquete
|
||
|
||
La primera instalación real de `takana` desde su propio repo falló:
|
||
|
||
```
|
||
Error: expected_hash no coincide:
|
||
declarado = b3:941d1857… obtenido = b3:2727050e…
|
||
```
|
||
|
||
`why-differs` lo nombró: los dos binarios pesaban **exactamente lo mismo** y sólo divergían en
|
||
`.shstrtab` — la firma de un `strip` que corrió una vez y la otra no. `swm_bridge` re-inyecta con
|
||
cuidado `flags`, `phases`, `zig_version`, `strip_components`, `patches`, `deps`… y **se olvidaba de
|
||
`strip_debug`**, que ni siquiera existía en `SwmBuild`. Como **entra en `hash_inputs`**, el receptor
|
||
reconstruía en otra dirección.
|
||
|
||
**Alcance: 40 recetas del corpus, 18 de las 88 de este perfil.** Una quinta parte del repo no se
|
||
podía instalar, y nadie lo sabía porque **nunca se había cerrado el lazo**. Arreglado, con test de
|
||
regresión y su control. Tras el arreglo, `install takana` da cache-hit en el hash anclado.
|
||
|
||
### 5.3 ⚠ Y el loopback nunca se levantaba
|
||
|
||
Al intentar que la caja instalara de sí misma por `http://127.0.0.1`: **timeout de 30 s**, con caddy
|
||
escuchando en `0.0.0.0:80`. `lo` estaba **`DOWN` y sin dirección** — con systemd o OpenRC lo levanta
|
||
el init; con arje-zero no lo hacía **nadie**. El síntoma no era «connection refused», que se
|
||
diagnostica en un minuto: era un cuelgue que parecía del servidor. Arreglado en `netup` (es quien
|
||
configura la red) y verificado desde la imagen, sin tocar la caja a mano.
|
||
|
||
### 5.4 🚧 Lo que NO se puede todavía, y por qué importa
|
||
|
||
Con todo lo anterior en verde, la caja **sigue sin poder instalar de su propio repo**:
|
||
|
||
```
|
||
release: trusted (by release) ← la firma verifica
|
||
repo: bajados 2 .swm de http://127.0.0.1 ← descarga de sí misma
|
||
Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed.
|
||
El toolchain entra en el ArtifactHash, así que sin rootfs no se puede
|
||
calcular un hash comparable
|
||
```
|
||
|
||
O sea: **la mitad de servir está cerrada y probada; la de consumir, no** — y no por un detalle de
|
||
instalación, sino porque `install` **reproduce desde fuente** y eso exige el LAB ENTERO en el
|
||
cliente. No es «falta un compilador»: el lab entra en el `ArtifactHash`, así que sin él no se puede
|
||
ni calcular el hash a comparar.
|
||
|
||
Es exactamente el punto que [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md) dejó planteado
|
||
y sin decidir —*reproducir o hidratar*— visto desde el otro lado: para un servidor, o para
|
||
cualquiera que no sea un hub de build, **reproducir no es una opción**. El camino normal tiene que
|
||
ser hidratar artefactos firmados, con `--reproduce` como camino auditable. Mientras esa decisión no
|
||
se tome, el repo firmado sirve para distribuir **a hubs**, no a usuarios.
|
||
|
||
⚠ **Y una caja que se sirve a sí misma es un solo origen** — justo lo que el ADR 0014 existe para no
|
||
tener. El camino normal puede ser el local, pero la lista de orígenes del host debe incluir también
|
||
el Storage Box o gioser. Si no, el primer origen roto es el último.
|
||
|
||
**Lo que esto vale**: cerrar el bucle es el ensayo de «actualización en sitio probada de verdad»
|
||
([SDD 19 §5.2](19-lanzamiento-publico.md)), hecho sobre una máquina que importa pero que **no es el
|
||
laptop de nadie**.
|
||
|
||
---
|
||
|
||
## 6. La mudanza como experimento: el criterio de borrado
|
||
|
||
El usuario decidió que **gioser se borra** al final. Eso no es un detalle de calendario: es lo que
|
||
convierte la mudanza en un experimento con criterio de aceptación, y hay que escribirlo **antes**.
|
||
|
||
**Regla base**: un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue
|
||
bien. Aplicado acá: *«lo copié»* no es prueba de nada. **La prueba es que el servicio responde en la
|
||
caja nueva y que el dato se verifica por contenido.**
|
||
|
||
### 6.1 Las puertas, en orden. Ninguna se salta.
|
||
|
||
| # | puerta | cómo se comprueba |
|
||
|---|---|---|
|
||
| 1 | la caja nueva arranca **takana puro** y se entra por SSH | `ssh` a la IP nueva tras el reboot del rescue |
|
||
| 2 | el **store** está entero | `1327` artefactos **y ninguno vacío** — `respaldo-storagebox.sh --listar` separa los vacíos; un directorio vacío **no** es un artefacto |
|
||
| 3 | los **grafos** salen idénticos | `build-state.py` en las dos máquinas ⇒ los cuatro grafos **byte a byte**. Es la prueba de que un hub está bien montado, no que «funcione» |
|
||
| 4 | la **granja** late en la caja nueva | una cosecha completa con el LXC enchufado por `.fleet` y artefactos que vuelven |
|
||
| 5 | el **repo** se sirve y el host se actualiza de sí mismo | §5 en verde, con la clave estable |
|
||
| 6 | cada dominio **vivo** responde 200 desde fuera | la tabla del §1.1, repetida contra la IP nueva |
|
||
| 7 | el **respaldo** al Storage Box corre desde la caja nueva | una corrida completa, no un `--seco` |
|
||
| 8 | lo **fósil** está anotado y decidido | lista del §1.1 con «muere» o «revive» al lado de cada uno |
|
||
|
||
⚠ **Puerta 3 tiene una trampa conocida**: `sealed_remoto` dependía de la máquina (laptop 700, gioser
|
||
751 sobre el MISMO corpus) y por eso se sacó del JSON. Si los grafos difieren, mirar primero si la
|
||
diferencia es «cuánto de esto tengo en disco» y no «qué existe».
|
||
|
||
### 6.3 Estado de las puertas *(2026-09-10)*
|
||
|
||
| # | puerta | estado |
|
||
|---|---|---|
|
||
| 1 | arranca takana puro y se entra por SSH | ✅ §3.8 |
|
||
| 2 | el store está entero | ✅ **1362 artefactos reales, 0 vacíos** |
|
||
| 3 | los cuatro grafos idénticos | ✅ **byte a byte** — §6.7 |
|
||
| 4 | la granja late en la caja nueva | ✅ **cosecha completa: 1369→1575, 0 vacíos** — §6.8 |
|
||
| 5 | el repo se sirve y el host se actualiza de sí mismo | ⬖ mitad: sirve (§5.1), no consume (§5.4) |
|
||
| 6 | cada dominio vivo responde 200 | ⬖ sondeados: **6 vivos de 19** — §6.9 |
|
||
| 7 | el respaldo corre desde la caja | ⬖ alcanza el box y lista 3140; arreglados 3 bloqueos — §6.10 |
|
||
| 8 | lo fósil, anotado y decidido | ✅ **decidido** — §6.9 |
|
||
|
||
**Puerta 2, medida acotando al patrón `<hash>-<nombre>`**: 1362 artefactos, **0 vacíos**. Los 3 sin
|
||
`.hammer/recipe.toml` son `stage1-rootfs`, `product-rootfs` y `seed-zig` — productos de bootstrap,
|
||
que por diseño no salen de una receta. (El primer conteo dio «3 vacíos» y eran `.mirror-tmp`,
|
||
`.bootstrap-tmp` y `.divergen`: directorios de trabajo con punto inicial, no artefactos. El chequeo
|
||
tiene que acotar por el patrón, no listar el directorio.)
|
||
|
||
**De paso, el disco**: la imagen ocupaba 7 G de los 76,3 G de la caja y los otros 69 eran
|
||
inalcanzables. Arreglado en `install-image.sh` — el store pasa a ser la ÚLTIMA partición (la única
|
||
que puede crecer sin mover datos) y el wrapper de `/sbin/init` la extiende en el primer arranque.
|
||
Medido en la caja: **`/store` = 68,7 G**, contra los 487 M de antes. Con eso el store de 55 G entra
|
||
en el disco local y **la mudanza no depende de mover el volumen de gioser**.
|
||
|
||
### 6.4 ⚠ El muro de la puerta 3: la imagen trae 23 binarios que NO PUEDEN CORRER
|
||
|
||
Al ir a computar los grafos en la caja:
|
||
|
||
```
|
||
$ python3 -c "print(1)"
|
||
sh: python3: not found ← y `command -v python3` decía /usr/bin/python3
|
||
```
|
||
|
||
No es que falte: **está y no arranca**. `/usr/bin/python3.12` son 23 MB y es un ELF **dinámico**:
|
||
|
||
```
|
||
Requesting program interpreter: /lib/ld-musl-x86_64.so.1
|
||
NEEDED libc.so
|
||
```
|
||
|
||
**Y en toda la imagen no hay ningún cargador dinámico.** Tampoco en el store: el artefacto `musl`
|
||
publica sólo lo estático (`libc.a`, `crt*.o`, headers) — **no hay `libc.so` ni
|
||
`ld-musl-x86_64.so.1` en ningún artefacto del corpus**. Lo que hace que estos binarios funcionen en
|
||
gioser es el **rootfs Alpine del LAB**, que sí lo trae (`.dev-fs/alpine/lib/ld-musl-x86_64.so.1`).
|
||
|
||
Es la fuga de [[needed-colgante-libstdcxx]] otra vez, y esta vez en el piso de abajo: *sella,
|
||
reproduce y no corre*, porque pide algo que sólo existe en el laboratorio.
|
||
|
||
**Contado sobre la imagen: 23 binarios inertes de 726 (703 estáticos están bien).** Son la suite
|
||
`binutils` entera (`ar as ld nm objcopy objdump ranlib readelf strip addr2line c++filt elfedit gprof
|
||
size ld.bfd`), más **`perl`**, **`python3`**, **`sqlite3`**, **`flex`** y **`nft`**.
|
||
|
||
**Por qué frena la mudanza y no es un detalle**: *todo* el instrumental del proyecto es Python —
|
||
`build-state.py`, `targets.py`, `yupana.py`, `hydrate-profile.py`, `drenar.py`, `triaje.py`. Un
|
||
servidor takana no puede correr ni una de sus propias herramientas. La puerta 3 (los cuatro grafos)
|
||
y la 4 (la granja) dependen de eso.
|
||
|
||
**Y no se arregla de paso.** Las salidas son tres y ninguna es barata:
|
||
|
||
1. **Publicar el cargador** — que `musl` emita `libc.so` + `ld-musl-x86_64.so.1`. Es lo correcto y lo
|
||
más chico en concepto, pero toca un componente de **Stage 1**: mueve el baseline `of_tree` del
|
||
selfhost y hay que rehacerlo a propósito, no de rebote.
|
||
2. **Construir python/perl estáticos** — el camino que evita el cargador, y es un frente propio:
|
||
Python estático con módulos de extensión es notoriamente arisco.
|
||
3. **Aceptar que el instrumental vive en el HUB** y que el servidor sólo sirve. Es la respuesta
|
||
honesta a corto plazo, pero contradice «mover allá todo lo que tengo acá»: el hub seguiría siendo
|
||
gioser, que es justo lo que se quiere apagar.
|
||
|
||
Es decisión de ADR, y de las que conviene tomar mirando también el [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md):
|
||
las dos preguntas —«¿el cliente reproduce o hidrata?» y «¿el cliente puede correr lo que le
|
||
mandamos?»— son la misma pregunta sobre qué es un sistema takana **sin laboratorio**.
|
||
|
||
### 6.5 ✅ El muro, derribado: el corpus publica su propio cargador *(2026-09-11)*
|
||
|
||
De las tres salidas del §6.4 el usuario eligió la 1 y la 2. Hecha la **1**, que resultó ser más barata
|
||
de lo que parecía y desbloquea la 2 en vez de competir con ella.
|
||
|
||
**`recipes/musl-shared.toml`** — variante dinámica de musl que publica `/usr/lib/libc.so` (4,2 M) y
|
||
`/lib/ld-musl-x86_64.so.1`. En musl el cargador Y la libc son el mismo objeto.
|
||
|
||
**Por qué una variante y no `--enable-shared` en la canónica**, con su control: `musl` es componente
|
||
de **Stage 1** y su `of_tree` es el baseline de `selfhost-verify`. Tocarla obliga a rehacer ese
|
||
baseline a propósito. Con el patrón `*-shared` —que el corpus ya usa 23 veces— la canónica no se
|
||
mueve: `takana hash recipes/musl.toml` sigue dando `b3:ce952f72…`, el mismo sellado de Stage 1. Y
|
||
además **`musl` no es dep de nadie** (medido: sólo de sí misma; el enlace estático lo resuelve el
|
||
musl que trae zig), así que esto **suma sin mover un solo ArtifactHash del corpus**.
|
||
|
||
#### ⚠ Y hubo que cambiar de compilador, medido
|
||
|
||
Con `zig-cc` el `libc.so` sale con **1586 símbolos dinámicos** contra los **1654** del musl de Alpine,
|
||
y los **68 que faltan son exactamente** los que musl implementa en ensamblador x86_64 — `memset`,
|
||
`memcpy`, `memmove`, `memcmp`, `strlen` y toda la familia matemática (`ceil floor sqrt fmod exp log
|
||
sin cos tan fma round trunc`) — más los `__stack_chk_*`.
|
||
|
||
No es que no se compilen: en el `libc.a` del musl canónico **están**. Lo que pasa es que **zig los
|
||
resuelve con su propio musl y los deja `FUNC LOCAL HIDDEN` de tamaño 0**, fuera de la tabla dinámica.
|
||
El síntoma es un cargador que arranca, reloca y muere con `memset: symbol not found` — que se lee
|
||
como «el binario está roto», no como «a la libc le faltan símbolos».
|
||
|
||
Con `compiler = "gcc"`: **1651 símbolos**, `memset` y `ceil` presentes.
|
||
|
||
#### El resultado, en la caja
|
||
|
||
| antes | después |
|
||
|---|---|
|
||
| `sh: python3: not found` (con `command -v` contestando `/usr/bin/python3`) | `Python 3.12.10` |
|
||
| 23 binarios inertes de 726 | **1** |
|
||
|
||
`perl 5.040002`, `readelf`/`objdump`/`nm` (GNU Binutils 2.45.1) y el resto corren. El único que
|
||
quedaba era **`sqlite3`**, con `Error loading shared library libz.so.1`: la zlib canónica también es
|
||
`--disable-shared` y nadie publicaba ese soname. `zlib-shared` ya existía sellado en el corpus —
|
||
sólo faltaba declararla en `base`.
|
||
|
||
**La salida 2 (python/perl estáticos) sigue viva y ahora es opcional, no urgente**: con el cargador
|
||
publicado el sistema ya no depende del rootfs del lab. Estático seguiría siendo mejor (un binario
|
||
menos que puede quedar colgado de un soname), pero es un frente propio y ya no bloquea la mudanza.
|
||
|
||
### 6.6 Sembrar el hub: un hub no es repo+store, es repo+store+LAB
|
||
|
||
Con `python3` vivo, `takana hash` en la caja **seguía fallando**:
|
||
|
||
```
|
||
Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed.
|
||
El toolchain entra en el ArtifactHash ⇒ sin rootfs no hay hash comparable.
|
||
```
|
||
|
||
**El lab es parte de la IDENTIDAD, no del entorno.** Una máquina sin él no puede ni preguntar «¿cuál
|
||
es el hash vigente de esta receta?», así que no puede computar el grafo ni decidir qué falta. Sembrado
|
||
el lab (tarball pineado + `tools/`, bajo `/store/dev-fs` porque la raíz son 6 G), el **hash testigo
|
||
coincide byte a byte**: `takana hash recipes/zlib.toml` da `b3:dc363f26…` en las dos máquinas.
|
||
|
||
⚠ **Y la caja no podía desempacar su propio lab**: el `tar` del rootfs es el de busybox y contesta
|
||
`tar: unrecognized option: zstd`. La distro comprime todo con zstd —la imagen del lab, el respaldo,
|
||
el `dd` remoto— y la imagen no traía el binario. `zstd` ya tenía receta sellada; declarado en `base`.
|
||
|
||
#### ⚠⚠ Copiar un store con `rsync -a` es una fábrica de vacíos, y ahora se sabe POR QUÉ
|
||
|
||
La primera copia murió con `No space left on device` en un `.so` de Qt, dejando el destino al 100 %
|
||
y **1367 de 1368 artefactos VACÍOS**. La puerta 2 corrida EN EL DESTINO lo cazó en el acto.
|
||
|
||
La causa, medida:
|
||
|
||
| medición sobre el store de gioser | |
|
||
|---|---:|
|
||
| `du -sh` (hardlinks contados UNA vez) | **60 G** |
|
||
| `du -sh --count-links` (hardlinks EXPANDIDOS) | **85 G** |
|
||
| `.dmerge` (la caché que hardlinkea al store) | **11 G** |
|
||
|
||
**`rsync` sin `-H` no preserva hardlinks: los expande en copias enteras.** Así que «el store son 60 G»
|
||
—que es lo que dice `du` y lo que uno planifica— se convierte en 85 G + lo que `.dmerge` expanda al
|
||
otro lado. La partición de 68,7 G no tenía ninguna chance, y el modo de fallar es el peor: el disco se
|
||
llena a mitad y quedan cientos de nombres de artefacto sin contenido, que el store da por presentes.
|
||
|
||
La copia correcta es **`rsync -aH --exclude='.dmerge'`**: `-H` preserva los enlaces (60 G reales) y
|
||
`.dmerge` no se copia porque es caché regenerable — la misma que ya llenó un disco en el worker.
|
||
|
||
**La lección de método**: `du -sh` sobre un árbol con hardlinks **no** dice cuánto ocupa copiarlo.
|
||
Para dimensionar una copia hay que medir con `--count-links`, o preservar los enlaces.
|
||
|
||
#### Y el instrumental apunta al binario de DESARROLLO, no al instalado
|
||
|
||
La primera corrida de `build-state.py` en la caja devolvió **875 recetas `unhashable`** — todas —
|
||
con el lab bien sembrado y `takana hash` funcionando a mano. La causa:
|
||
|
||
```python
|
||
HAMMER = os.environ.get("HAMMER", str(ROOT / "target/release/takana"))
|
||
```
|
||
|
||
Los scripts asumen un **árbol de desarrollo**: `target/release/takana`. En una caja **instalada** el
|
||
binario es `/usr/bin/takana` y `target/` ni existe (la siembra lo excluye a propósito). No falla
|
||
ruidosamente: cada `hash` falla y el grafo sale entero como `unhashable`, que se lee como «este
|
||
corpus no se puede hashear» y no como «no encontré el binario».
|
||
|
||
Se destraba con `HAMMER=/usr/bin/takana STORE=/store`, que la propia cabecera del script documenta.
|
||
Pero la deuda queda anotada: **el instrumental debería caer a `command -v takana`** antes de rendirse,
|
||
porque el caso «hub instalado» es justamente el que esta mudanza quiere que exista.
|
||
|
||
### 6.7 ✅ PUERTA 3 EN VERDE: los dos grafos, idénticos byte a byte
|
||
|
||
```
|
||
gioser: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
|
||
caja: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
|
||
```
|
||
|
||
`base 61/61 · cli 84/84 · escritorio-mirada 41/41 · servidor 96/96 · falta 0`, `wanted 3`
|
||
(`chrony`, `cronie`, `logrotate` — la deuda declarada), grafo CIERRA, topo-sort OK. Las dos máquinas.
|
||
|
||
**Costó tres intentos, y los dos fallos son la parte que vale.**
|
||
|
||
**Intento 1 — `unhashable 875`**, todas. Ver arriba: los scripts apuntan a `target/release/takana`.
|
||
|
||
**Intento 2 — dos nodos divergentes**: `aichat` y `zola`, `sealed` en gioser y `never` en la caja. No
|
||
era deriva ni pérdida: **ninguno de los dos está en disco en gioser tampoco**. Son los «2 sellados
|
||
que no están en esta máquina, avalados por los manifiestos» que el propio resumen imprime.
|
||
`build-state.py` lee `work/farm-sellados.txt` y `work/respaldo-sellados.txt` — y **`work/` está
|
||
gitignored**, así que un hub recién sembrado no los tiene y degrada esos nodos a `never` sin que nada
|
||
falle.
|
||
|
||
Es la misma familia que `scripts/farm/.fleet`: **un fichero fuera de git del que depende una medición
|
||
compartida**. La regla que sale: *sembrar un hub no es clonar el repo — es repo + store + lab +
|
||
los manifiestos*. Copiados los dos ficheros, los grafos coinciden exactamente.
|
||
|
||
### 6.8 Puerta 4: el camino de la granja, PROBADO; la cosecha completa, sin disco
|
||
|
||
**Probado desde la caja** (sin tocar el latido de gioser, todo de sólo lectura salvo el último paso):
|
||
|
||
- llevada la clave de la granja (`~/.ssh/github5` → `/root/.ssh/github5`, 0600) y escrito
|
||
`scripts/farm/.fleet` — los dos están fuera de git a propósito y hay que copiarlos a mano;
|
||
- la caja **alcanza al worker**: `PruebasIA`, load 3, 775 artefactos;
|
||
- `estado-granja.sh` corre allá y lee bien el worker y el avance KDE (198/199);
|
||
- y el paso que cierra el lazo: se **cosechó un artefacto real del worker** (`speedtest-go`, 12 M)
|
||
con el mismo transporte de la granja, llegó **con su `.hammer/recipe.toml`** y **su binario CORRE
|
||
en la caja** (`speedtest-go v1.7.10`). Worker construye → hub cosecha → binario ejecuta.
|
||
|
||
#### ✅ CERRADA (2026-09-11) con el volumen `takana-store`
|
||
|
||
Resuelto el disco (§6.12), la cosecha completa corrió: **1369 → 1575 artefactos, 0 vacíos**, que son
|
||
exactamente los 206 que faltaban. 12,4 G por la red para 39 G lógicos —`speedup 3.14`, el ahorro de
|
||
`-H`— y `/store` queda en 73,7 G de 97,9 (79 %).
|
||
|
||
⚠ El enlace con el worker va a **11 MB/s**, no a los 90 MB/s de gioser↔caja: el LXC vive en el
|
||
Proxmox de gioser, fuera de la red de Hetzner. Media hora para una cosecha de este tamaño.
|
||
|
||
⚠ **Lo que faltaba no era mecanismo: era DISCO.**
|
||
|
||
| | |
|
||
|---|---:|
|
||
| artefactos en el worker que la caja no tiene | **206** |
|
||
| a ~113 MB de media (el artefacto medio del corpus) | **~23 G** |
|
||
| libre en `/store` de la caja | **3,7 G** (61,5 de 68,7 · 94 %) |
|
||
|
||
Una cosecha completa **no entra**, y forzarla repetiría exactamente el llenado que dejó 1367
|
||
artefactos vacíos hace un rato. La puerta 4 queda pendiente de una decisión de CAPACIDAD, no de una
|
||
de ingeniería:
|
||
|
||
1. **Enganchar `harkaq-cosecha` (250 G) a la caja.** Es el plan original y no cuesta nada extra —
|
||
pero hay que DESENGANCHARLO de gioser, y ahí vive el store, `work/sources`, los tarballs y el
|
||
`CARGO_HOME` de la granja. O sea: **es el cutover**. gioser deja de construir en ese mismo
|
||
instante, y hoy está moliendo el árbol KDE.
|
||
2. **Un volumen nuevo para la caja** (€0,052/GB/mes: 100 G ≈ €5,2/mes). No interrumpe nada y
|
||
destraba la cosecha ya; se paga por duplicado hasta que gioser muera, y entonces se suelta.
|
||
3. **Podar el store de la caja** — deshace parte de la mudanza. No.
|
||
|
||
### 6.9 Puerta 8: los 19 dominios, sondeados uno por uno *(2026-09-11)*
|
||
|
||
El Caddyfile de gioser declara 19 sitios. Sondeados por DNS **y** por HTTP desde fuera, sólo **6**
|
||
los sirve gioser de verdad:
|
||
|
||
| dominio | DNS | HTTP | qué es |
|
||
|---|---|---|---|
|
||
| `gioser.net` · `tawasuyu.net` | gioser | **200** | **VIVO** — las dos landings |
|
||
| `git.gioser.net` · `git.tawasuyu.net` | gioser | **200** | **VIVO** — el gitea (aloja el `origin` de takana) |
|
||
| `sergio.gioser.net` · `api.sergio.gioser.net` | gioser | **200** | **VIVO** |
|
||
| `summa` · `api.summa` · `dev.summa` | **154.197.1.2** | 200 | **YA MUDADOS** a otra máquina: el bloque de Caddy en gioser es fósil aunque el sitio viva |
|
||
| `api.gioser.net` | gioser | **502** | backend caído |
|
||
| `mail.sigma.gioser.net` | gioser | **502** | backend caído |
|
||
| `aura` · `api.aura` · `sigma` · `kosmofono` · `api.kosmofono` | **SIN DNS** | — | fósil: ni DNS ni ficheros en `/var/www` |
|
||
| `terapeuta.ec` · `andino.ec` | **SIN DNS** | — | fósil con datos: 279 M en `/var/www/terapeuta` |
|
||
| `gitea.gioser.net` | **SIN DNS** | — | era un `redir` a `git.gioser.net`; inofensivo |
|
||
|
||
**Lo que esto cambia respecto del §1.1**: no son «4 fósiles» sino **13 de 19 entradas que no hay que
|
||
mudar** — 8 sin DNS, 2 con el backend caído y 3 que ya viven en otra máquina. La superficie real a
|
||
mudar son **6 sitios**, y de esos el que pesa es el **gitea**, porque aloja el `origin` de takana.
|
||
|
||
#### ✅ DECIDIDO (usuario, 2026-09-11)
|
||
|
||
| fósil | decisión |
|
||
|---|---|
|
||
| `aura` · `api.aura` · `sigma` · `kosmofono` · `api.kosmofono` | **mueren** — ni DNS ni ficheros |
|
||
| `gitea.gioser.net` | **muere** — era un `redir`, y `git.gioser.net` lo cubre |
|
||
| `api.gioser.net` · `mail.sigma.gioser.net` | **mueren** — backend caído, sin dueño |
|
||
| `summa` · `api.summa` · `dev.summa` | **no se mudan**: ya viven en 154.197.1.2. Sólo hay que sacar sus bloques del Caddyfile al apagar gioser |
|
||
| **`terapeuta.ec` · `andino.ec`** | **MUEREN** — decisión explícita del usuario. Los 279 M de `/var/www/terapeuta` se van con la caja; **no se archivan** |
|
||
|
||
⇒ De las 19 entradas del Caddyfile, **13 no se mudan**. La superficie real es de **6 sitios**, y el
|
||
que pesa es el gitea porque aloja el `origin` de takana.
|
||
|
||
⚠ Que esto quede escrito ES la puerta: el día que se borre gioser, `terapeuta.ec` no va a poder
|
||
recuperarse, y la diferencia entre «lo decidimos» y «se nos pasó» es este párrafo.
|
||
|
||
### 6.10 Puerta 7: el respaldo desde la caja — y tres bloqueos que sólo se ven corriéndolo
|
||
|
||
Llevadas las credenciales (`~/.config/hammer/*.env`, 0600) y la clave, **la caja alcanza el Storage
|
||
Box** por el puerto 23 y lista su contenido. El manifiesto se refresca desde allá:
|
||
**3140 artefactos**, y de paso el script detecta **2 VACÍOS en el respaldo** (`gnome-desktop` ×2) que
|
||
excluye del manifiesto — su propia guarda, funcionando.
|
||
|
||
Para llegar ahí hubo que arreglar **cinco** cosas, y todas son de la misma familia: *el instrumental
|
||
asume que el hub es gioser*. El `--seco` ahora completa los tres pasos desde la caja y lee la
|
||
ocupación del box (111 G de 1 T).
|
||
|
||
1. **La raíz cableada.** `RAIZ="${RAIZ:-/mnt/vvv/takana}"` ⇒ desde cualquier otra máquina el script
|
||
moría con `cd: /mnt/vvv/takana: No such file or directory`. Ahora se deriva de dónde está el
|
||
script, como el resto de `scripts/`.
|
||
2. **El binario de desarrollo.** `HAMMER = ROOT/target/release/takana` (§6.7).
|
||
3. **El store no estaba donde el script creía.** Usaba `$RAIZ/store` cableado: en gioser es un
|
||
bind-mount DENTRO del repo, pero en una caja instalada es una partición en `/store` ⇒
|
||
`change_dir "/opt/takana/store" failed`. Y rsync devuelve **23**, que está en la lista de
|
||
reintentables, así que el bucle insistía — el cuadro que la propia cabecera del script documenta
|
||
(«un error reintentable que se repite 40 veces no es un corte de red: es algo estructural»).
|
||
Ahora `STORE` es env, con una guarda que aborta ANTES del bucle si la ruta no existe.
|
||
4. **🚨 Y una que habría costado el historial.** El paso [2/3] sube el repo **con `--delete`** —
|
||
correcto, para eso está git. Pero el `/opt/takana` de la caja llegó por `rsync --exclude=.git`:
|
||
es una COPIA, no un clon. **Una corrida real desde ahí habría borrado `.git` del Storage Box**, o
|
||
sea el historial entero, y en silencio: rsync haría exactamente lo que se le pidió. Añadida una
|
||
guarda que aborta si la raíz no tiene `.git` (escotilla `REPO_INCOMPLETO=1`), probada en los tres
|
||
sentidos.
|
||
|
||
**La regla que sale**: un `--delete` convierte «respaldar» en «sincronizar», y sincronizar desde
|
||
un origen incompleto no sube menos — BORRA. Cualquier respaldo con `--delete` necesita una guarda
|
||
de completitud del ORIGEN, no sólo del destino.
|
||
5. **El rsync del corpus no puede correr el respaldo del corpus.** El script exige
|
||
`--compress-choice=zstd` y `recipes/rsync.toml` lo construía con `--disable-zstd` — porque cuando
|
||
se escribió, `zstd` no estaba en el catálogo. Hoy sí. Peor aún: el error 4 que devuelve rsync el
|
||
script lo clasifica como «no de red» y **no reintenta**, así que el respaldo simplemente no se
|
||
hacía. Arreglado por los dos lados: la receta enciende zstd (control: `zstd zlibx zlib none` vs
|
||
`zlibx zlib none`; radio cero, `rsync` no es dep de nadie) y **el script degrada a zlib avisando**
|
||
en vez de morir.
|
||
|
||
### 6.11 ⚠ La distro traía un `git` que NO PODÍA CLONAR
|
||
|
||
Para cerrar la puerta 7 la caja necesita un **clon de verdad** (el `--delete` del respaldo exige un
|
||
origen completo, §6.10). Llevada la clave del gitea —que es `~/.ssh/tawasuyu`, **no** la de la
|
||
granja— `git ls-remote` contesta… y `git clone` muere:
|
||
|
||
```
|
||
fatal: fetch-pack: invalid index-pack output
|
||
```
|
||
|
||
El informe completo, que la traza corta escondía:
|
||
|
||
```
|
||
panic: load of misaligned address 0x… for type 'const uint32_t',
|
||
which requires 4 byte alignment
|
||
in sha1_compression_states → sha1_process → SHA1DCUpdate → git_SHA1DCUpdate
|
||
→ git_hash_update → unpack_entry_data → cmd_index_pack
|
||
```
|
||
|
||
**Dos causas, encadenadas:**
|
||
|
||
1. **`-fsanitize=undefined` estaba en `CFLAGS`**, no sólo en `LDFLAGS`. El motivo original era
|
||
legítimo —las `libz.a` materializadas traen referencias `__ubsan_handle_*` que bajo `-static` no
|
||
se resuelven solas, y el flag *al enlazar* trae el runtime de zig— pero en `CFLAGS` **instrumenta
|
||
el código de git**. Un arreglo de ENLACE convertido en una mina de RUNTIME, justo en la ruta de
|
||
hash: por donde pasa todo lo que git recibe.
|
||
2. **`sha1collisiondetection` lee palabras de 32 bits SIN alinear.** Es el backend SHA1 por defecto
|
||
de git, el que detecta SHAttered. En x86 la lectura desalineada funciona y por eso nadie lo nota
|
||
jamás; según el estándar es UB, y basta con que el runtime ubsan esté enlazado para que aborte.
|
||
`-DSHA1DC_FORCE_ALIGNED_ACCESS` lo hace leer byte a byte: quita el UB **en la fuente** en vez de
|
||
esconderlo.
|
||
|
||
Con las dos: `git clone --depth 1` del propio repo desde la caja ⇒ **877 recetas**. Radio cero
|
||
(`git` no es dep de nadie), pero **sí es raíz de `perfil.base`** ⇒ esto arregla la distro entera.
|
||
|
||
**La lección**: `ls-remote` andaba, así que el fallo parecía de red. Lo que lo destapó fue leer el
|
||
informe COMPLETO en vez de la última línea — la primera decía exactamente qué y dónde.
|
||
|
||
### 6.12 ⚠ La caja dejó de ser desechable — y `hydrate` no cruza el store
|
||
|
||
Dos consecuencias de que la caja ya sea un hub, y las dos cambian el procedimiento:
|
||
|
||
**1. Re-escribir la imagen con `dd` ya NO es una actualización, es una pérdida.** El `dd` sobrescribe
|
||
el disco local, y ahí viven ahora `/opt/takana` (el clon), `/root/.ssh` (las dos claves),
|
||
`/root/.config/hammer` (las credenciales del respaldo), `/srv/repo` y la configuración de caddy. El
|
||
store se salva sólo porque está en el volumen. **Y hay una trampa peor**: la imagen etiqueta su
|
||
partición local como `hammer-store`, así que tras un `dd` habría **dos filesystems con esa etiqueta**
|
||
y el `findfs` del arranque elegiría cualquiera — con suerte el volumen, con mala suerte una partición
|
||
vacía. ⇒ el `dd` es para PROVISIONAR; actualizar es `takana upgrade` (que existe, con generaciones y
|
||
rollback) y está sin probar en esta caja.
|
||
|
||
**2. `takana hydrate` no puede proyectar al root vivo.** Hidratar enlaza en DURO, y en una caja
|
||
instalada el store es **siempre** otra partición que `/`:
|
||
|
||
```
|
||
Error: store: hardlink /store/51aa5e13…-git/.hammer/recipe.toml → /.hammer/…: Cross-device link
|
||
```
|
||
|
||
No es culpa del volumen: en el layout original `/store` es `sda4` y `/` es `sda2`, también distintos.
|
||
**Hidratar al FHS vivo nunca fue posible en una caja instalada** — funciona en el hub porque ahí
|
||
store y destino están en el mismo filesystem. Es la misma familia de EXDEV que ya mordió dos veces
|
||
(§6.6), ahora a nivel de sistema y no de herramienta.
|
||
|
||
Para instalar el `git` arreglado se copió el artefacto a mano. Eso **funciona y no es el camino**: el
|
||
camino es `takana upgrade`, y probarlo es su propia unidad de trabajo.
|
||
|
||
### 6.13 ✅ `takana upgrade`, probado de verdad — y el bug de durabilidad que destapó
|
||
|
||
§6.12 dejó dicho que el camino de actualización de una caja instalada es `takana upgrade`, no `dd` ni
|
||
`hydrate`. Probado con un paquete que la caja **realmente necesitaba**: `zstd`.
|
||
|
||
| paso | resultado |
|
||
|---|---|
|
||
| `upgrade apply …-zstd-cli` | `✓ generación 2 aplicada · + 1 añadidos, ~ 1 pisados, - 8 retirados` |
|
||
| la prueba de uso | `zstd -dc lab-image.tar.zst \| tar -xf -` ⇒ **la caja desempaca su propio lab** |
|
||
| `upgrade rollback` | `✓ generación 2 revertida · 9 restaurados, 1 borrados` — `zstd` se va, `libzstd.a` vuelve |
|
||
| re-`apply` + **corte duro sin `sync`** | sobrevive: generación viva 1, manifiesto íntegro |
|
||
|
||
✔ **Y cruza la frontera que `hydrate` no puede**: el store está en el volumen y `/` en el disco local,
|
||
y `upgrade` copia en vez de enlazar. Es, de hecho, el único camino que funciona en una caja instalada.
|
||
|
||
#### ⚠ El bug: `write_atomic` era atómico pero NO DURABLE
|
||
|
||
El primer intento pareció funcionar y **se perdió al reiniciar**. La caja volvió con:
|
||
|
||
```
|
||
pending.json 0 bytes
|
||
generations/2/manifest.json 0 bytes
|
||
upgrade status → Error: json: EOF while parsing a value
|
||
upgrade recover → Error: json: EOF while parsing a value
|
||
```
|
||
|
||
`write_atomic` hacía temporal + `rename`: atómico frente a otros PROCESOS, no frente a un CORTE.
|
||
`rename` sobre un fichero cuyos datos siguen en caché deja, tras el corte, la entrada apuntando a
|
||
bloques nunca escritos — cero bytes. Y `hcloud server reset` **es un corte duro**, no un apagado
|
||
limpio: ahí está la mitad operativa de la lección.
|
||
|
||
Lo grave no es perder un upgrade: es que **`recover` —que existe exactamente para «un apply
|
||
interrumpido por un corte»— abortaba con la huella más probable de ese corte**.
|
||
|
||
Arreglado: `fsync` del temporal antes del `rename` y `fsync` del directorio después (hacen falta los
|
||
dos), y un `pending.json` vacío se reporta como `PendingCorrupt`, nombrando el corte y apuntando al
|
||
árbol de respaldos. Con tests y su control.
|
||
|
||
**Verificado en la máquina, en los dos sentidos**: con el binario viejo, apply + reset duro ⇒ estado
|
||
en 0 bytes; con el nuevo, la misma secuencia ⇒ estado íntegro y el paquete en su sitio.
|
||
|
||
#### ⚠⚠ `upgrade apply` NO es un gestor de paquetes: cada generación ES UN ÁRBOL
|
||
|
||
Lo destapó usarlo dos veces seguidas. Tras aplicar `zstd-cli` y después `rsync`:
|
||
|
||
```
|
||
✓ generación 2 aplicada
|
||
+ 0 añadidos, ~ 8 pisados, - 1 retirados ← el `zstd` de la generación 1
|
||
$ zstd --version
|
||
AUSENTE
|
||
```
|
||
|
||
**Aplicar un árbol nuevo RETIRA los ficheros del anterior**, porque una generación es el estado
|
||
completo, no un incremento. Es coherente con lo que la ayuda dice —«aplica un árbol Stage1/**producto**
|
||
del store»— y con que exista `rollback`: lo que se revierte es un sistema, no un paquete.
|
||
|
||
⇒ Usarlo para instalar paquetes sueltos, como hice acá, funciona una vez y se deshace a la siguiente.
|
||
**La forma correcta de poner una caja al día es componer el árbol del perfil —lo que ya hace
|
||
`servidor-image.sh`— sellarlo, y aplicar ESO como una generación.** Eso además sería, de verdad, la
|
||
«actualización en sitio probada» del [SDD 19 §5.2](19-lanzamiento-publico.md): una imagen entera, con
|
||
rollback, sobre una caja viva.
|
||
|
||
Mientras tanto la caja quedó con `zstd` y `rsync` puestos a mano — funciona y **no es el camino**,
|
||
igual que el `git`.
|
||
|
||
⚠ Lo que NO se arregló: si el manifiesto se pierde, `recover` no puede deshacer solo — no sabe qué se
|
||
tocó. El árbol de respaldos tiene la información; reconstruir desde ahí es su propia unidad.
|
||
|
||
### 6.2 Lo que la mudanza tiene que producir, además de la mudanza
|
||
|
||
El usuario lo pidió explícito: **que este experimento saque recetas y las pruebe**. La caja vieja es
|
||
un catálogo de software real en producción, y cada servicio que hoy corre sobre binarios de Artix es
|
||
una receta candidata con un caso de uso comprobado detrás:
|
||
|
||
| lo que corre hoy en Artix | receta |
|
||
|---|---|
|
||
| `caddy` | ✅ existe (`recipes/caddy.toml`) |
|
||
| `openssh` | ✅ existe |
|
||
| `gitea` | ✅ **SÍ existe** (`recipes/gitea.toml`) **y está sellada** (`35bb4f04…`) — ver la corrección abajo |
|
||
| `qdrant`, `squid`, `fail2ban`, `php-fpm`, `metalog`, `glances` | ✗ |
|
||
| `chrony`, `nftables`, `cronie`, `logrotate` | ✗ — ya declaradas `wanted` en `perfil.servidor` |
|
||
|
||
> **CORRECCIÓN (2026-09-11).** Esta tabla decía que `gitea` NO tenía receta y que «es la que más
|
||
> peso tiene». **Las dos cosas eran falsas**: `recipes/gitea.toml` existe desde el 2026-09-09 y su
|
||
> artefacto está sellado. Lo escribí de memoria, sin mirar el catálogo.
|
||
>
|
||
> Lo destapó `planear.py` al cruzar cada binario contra `recipes/`: de los servicios de gioser, **4
|
||
> ya tienen receta takana y están sellados** (`caddy`, `gitea`, `python3`…), 4 vienen de un paquete
|
||
> ajeno y **11 son binarios sueltos sin dueño**. Ésos últimos son el trabajo real, no gitea.
|
||
>
|
||
> Es exactamente para esto que la herramienta existe: yo afirmé de memoria y el programa fue a mirar.
|
||
|
||
**Cada una que se cierre se prueba sola**: el servicio o levanta en la caja nueva o no, y eso se ve
|
||
el mismo día. Es la diferencia entre una receta «sellada» y una receta *usada*.
|
||
|
||
---
|
||
|
||
### 6.11 🗄️ La imagen del perfil, con gitea, ARRANCA y SIRVE *(2026-09-14)*
|
||
|
||
`servidor-image.sh` + QEMU: **PID 1 = `arje-zero`, gitea encarnado por él (`ppid=1`), corriendo como
|
||
`uid=916`, escuchando en :3000 y devolviendo `200` con `<title>takana git</title>`**, con su
|
||
`gitea.db` creada por él mismo. Es el primer servicio de PAQUETE que arranca en una imagen de takana:
|
||
los que había venían horneados en el bootstrap.
|
||
|
||
La cadena entera, eslabón por eslabón: `[[user]]` → `/etc/passwd` de la imagen · `[[service]]` →
|
||
`takana service-cards` → `genesis` de la seed → arje lo encarna → `setuidgid` → sirve.
|
||
|
||
#### Los tres fallos que sólo aparecieron AL PROBAR LA IMAGEN
|
||
|
||
Los tres pasan el sellado, el `hash`, el resolutor de servicios y el armado. Ninguno se ve sin
|
||
arrancar la imagen.
|
||
|
||
1. **⚠⚠ Escribir en el rootfs hidratado es escribir DENTRO del store.** `takana users --merge` hacía
|
||
`fs::write` sobre `<rootfs>/etc/passwd`, y ese fichero y el del artefacto `product-rootfs` son
|
||
**el mismo inode** (medido: 1225824, `2 links`, modo `444`) — el rootfs se arma con hardlinks.
|
||
Habría mutado un artefacto sellado, y toda imagen futura habría salido con la cuenta metida dentro
|
||
del producto. Se salvó porque el store es de sólo lectura y salió `Permission denied`: confiar en
|
||
eso es confiar en un permiso. Ahora escribe por temporal + `rename`, con un control que comprueba
|
||
que el fichero del store conserva su contenido y baja a 1 link.
|
||
|
||
2. **La imagen traía el binario, la cuenta… y nadie lo arrancaba.** El `genesis` sólo tenía lo que
|
||
hornea `takana-bootstrap` (`sshd`, `console-getty`, `hammerd`): `servidor-image.sh` **no inyectaba
|
||
las Cards** — eso sólo existía en el camino de las imágenes de escritorio. Y ninguna métrica lo
|
||
dice: `targets.py --services` responde que el perfil lo habilita, y lo habilita; lo que faltaba era
|
||
el paso que lleva esa declaración a la imagen.
|
||
|
||
3. **⚡ `trap invalid opcode`: el binario sólo corría en la CPU que lo compiló.** Con la card ya en el
|
||
genesis, gitea moría al instante dentro de QEMU-TCG. El sandbox exporta `CC` apuntando a un wrapper
|
||
con `-mcpu=baseline` (y su comentario ya avisaba: «de paso cierra el SIGILL de AVX en qemu64»), y
|
||
**la fase Go del propio builder lo pisaba** con `CC="zig cc"` a secas, que es `-mcpu=native`. No
|
||
falla al compilar ni al sellar: falla al ejecutar en otra CPU. Y el `ArtifactHash` no puede
|
||
cazarlo, porque la CPU del builder no entra en `hash_inputs` — **dos workers distintos sellan bytes
|
||
distintos bajo la misma dirección**. Arreglado en el builder y en la receta; re-hashea las cinco
|
||
recetas `cgo = true` (`gitea`, `usql`, `sq`, `gocryptfs`, `naabu`).
|
||
|
||
#### Lo que NO está probado, y falta para la mudanza
|
||
|
||
El `app.ini`, el usuario del sitio y los datos se pusieron **a mano** dentro de la VM para llegar al
|
||
200. Eso es exactamente lo que el plan de mudanza tiene que traer de gioser — `/etc/gitea` ya está en
|
||
`scripts/mudanza/rutas-fuera-de-git.txt`.
|
||
|
||
Y **la imagen no trae `arjectl`**: tras poner la config hubo que REINICIAR para que arje volviera a
|
||
intentarlo. El backoff de `restart` se agota y no hay forma de pedirle a PID 1 que relance un ente en
|
||
caliente. Para una caja de producción eso es una pieza que falta, no una comodidad.
|
||
|
||
### 6.12 🎛️ `arjectl` en la imagen: control en caliente *(2026-09-14)*
|
||
|
||
El §6.11 cerró con «la imagen no trae `arjectl`, hubo que reiniciar la máquina para arrancar un
|
||
servicio». Ya lo trae, y **no hubo que escribir nada**: el cliente existía en tawasuyu y se llama
|
||
**`arje-ctl`** (el crate; el binario es `arjectl`). Buscarlo por `arjectl` no lo encontraba, y de ahí
|
||
salió la conclusión falsa de que faltaba implementarlo — el protocolo (`arje-bus::BusRequest`) ya
|
||
traía `ListEntes`, `SpawnCardFromDisk`, `StopCardFromDisk`, `KillEnte` y `EnteStatus`.
|
||
|
||
`recipes/arjectl.toml` lo construye **del mismo commit que `arje-zero`** (`98db584f`), y eso no es
|
||
comodidad: el bus es un protocolo entre dos binarios, y un cliente de otro árbol puede conectar y no
|
||
entenderse con el init. Publica **sólo `arjectl`**; el crate también produce un `systemctl` de
|
||
camuflaje que acá no se instala — un `systemctl` en el PATH de una distro sin systemd invita a
|
||
escribir runbooks con el verbo ajeno.
|
||
|
||
#### El hueco que sólo se ve usándolo: el genesis no alcanza
|
||
|
||
Con `arjectl` en la imagen, el primer intento falló con un mensaje que nombra la causa:
|
||
|
||
$ arjectl start gitea
|
||
Error: arje-zero rechazó: card gitea: No such file or directory
|
||
(buscada en /etc/arje/cards.d/gitea.json)
|
||
|
||
`start`/`restart` usan `SpawnCardFromDisk`, que lee **`/etc/arje/cards.d/<label>.json`** — y el
|
||
armado sólo escribía el `genesis` de la seed. Son dos preguntas distintas: *qué arranca solo* y *qué
|
||
se puede encarnar a pedido*. Con sólo la primera, un servicio que agota su backoff **no se puede
|
||
relanzar sin reiniciar la máquina**. `inyectar-cards.py` escribe ahora los dos árboles (y en
|
||
`cards.d` escribe TODAS, no sólo las nuevas: `sshd` viene del product-rootfs y tampoco era
|
||
relanzable).
|
||
|
||
#### Medido, con la VM arrancada UNA sola vez
|
||
|
||
| | |
|
||
|---|---|
|
||
| la imagen trae | `/etc/arje/cards.d/{gitea,sshd}.json` |
|
||
| `arjectl list-units` | tabla con PID, CPU%, MEM, HILOS y reinicios de cada Ente |
|
||
| poner `/etc/gitea/app.ini` + `arjectl start gitea` | **`GET /` → 200**, sin reiniciar (`uptime` 6 min) |
|
||
| `arjectl restart gitea` | el PID cambia (118 → 192) y sigue sirviendo 200 |
|
||
|
||
#### ⚠ `arjectl start` sobre un Ente YA VIVO lo DUPLICA
|
||
|
||
`SpawnCardFromDisk` no deduplica por label: encarna otra instancia y arje le da un ULID nuevo. En
|
||
`gitea` el síntoma fue `unable to lock level db … resource temporarily unavailable` seguido de `[F]`
|
||
— dos servidores peleando por el mismo estado, con el HTTP intermitente mientras duraba. **Para
|
||
relanzar se usa `restart`**, o se mira `list-units` antes. Un `start` idempotente es trabajo de arje,
|
||
no de esta imagen; queda anotado río arriba.
|
||
|
||
### 6.13 🧪 Ensayo con los DATOS REALES de gioser — el gitea de la caja vieja corriendo en takana
|
||
|
||
Sin tocar gioser (todo lectura) y en la VM desechable. Es el ensayo que faltaba antes de cualquier
|
||
cutover, y trajo cuatro cosas que no se ven en el papel.
|
||
|
||
**El snapshot de la DB se saca en caliente y sale consistente.** `sqlite3 gitea.db ".backup …"` con
|
||
el servidor VIVO: **1,5 s para 314 M**, `pragma integrity_check` → `ok`, 44 filas en `repository`.
|
||
Copiar el fichero a pelo con el servidor escribiendo es lo que NO hay que hacer; la API de backup de
|
||
sqlite existe justo para esto.
|
||
|
||
**Resultado, medido dentro de la VM:** `<title>GioSer Gitea: Git with a cup of tea</title>`, 200, y
|
||
`/explore/repos` listando los repos de verdad (`sergio/takana`, `tawasuyu/agora`, `card`, `chasqui`,
|
||
`cosmos`, `khipu`, `llimphi`…). Y el clon de uno de los repos copiados: **478 commits**, HEAD
|
||
correcto, ficheros reales.
|
||
|
||
#### Los tres detalles que sólo aparecen con los datos puestos
|
||
|
||
1. **El uid del origen no es el del destino.** Los ficheros llegan con su uid NUMÉRICO (`1001` en el
|
||
rsync de prueba; el gitea de gioser es **969**) y el `[[user]]` declaraba 916. O se chownean 2 G
|
||
—lento, y hay que acordarse— o gitea no puede leer sus datos, **y eso no falla al copiar: falla
|
||
al arrancar**. La receta pasa a declarar **969**, alineado con el origen.
|
||
2. **El `app.ini` de gioser escucha en `127.0.0.1:3002`**, porque allá caddy hace de proxy. Copiado
|
||
tal cual, la caja sirve — pero sólo desde dentro. El `HTTP_ADDR`/`HTTP_PORT` y el `ROOT_URL` son
|
||
parte de la adaptación, no del copiado.
|
||
3. 🧨 **El `git` de la distro NO puede clonar por HTTP.** `git clone http://…` dentro de la imagen:
|
||
|
||
git: 'remote-http' is not a git command. See 'git --help'.
|
||
fatal: remote helper 'http' aborted session
|
||
|
||
Medido sobre el ARTEFACTO sellado: `/usr/libexec/git-core/` trae `git-remote-ext`, `git-remote-fd`
|
||
y `git-http-backend` (el lado SERVIDOR), y **no** `git-remote-http`/`-https`; `strings` del binario
|
||
da **0** referencias a libcurl, pese a que `curl` está en `[deps] build`. Nadie lo había notado
|
||
porque en el hub el `git` que se usa es el de Artix, no el sellado. Es
|
||
[[subcomando-sin-driver]] otra vez: instalado, con contenido, reproducible… y sin el camino que
|
||
hace falta. **Un hub nuevo no podría clonar el repo por HTTPS.** Arreglarlo re-sella `git`, que es
|
||
raíz de `perfil.base` ⇒ unidad propia, no de paso.
|
||
|
||
### 6.14 ✅ CUTOVER DEL GITEA — sirve desde la caja, con TLS y por SSH *(2026-09-14)*
|
||
|
||
**Hecho y verificado desde fuera:** `git.gioser.net` y `git.tawasuyu.net` responden **200 con TLS
|
||
válido desde `2.29.29.217`**, `git clone` funciona **por HTTPS y por SSH:2345**, y `gitea` y `caddy`
|
||
corren supervisados por `arje-zero` en la caja. El gitea de gioser está **parado**.
|
||
|
||
**Mudanza incremental, no big-bang.** Los DNS de gioser son casi todos `CNAME → www`, así que mover
|
||
`www` habría mudado quince dominios cuyos backends siguen allá: 502 en todos. Se convirtió sólo
|
||
`git` (en las dos zonas) de CNAME a **A propio con TTL 60**, que es reversible en un minuto.
|
||
|
||
#### La secuencia que funcionó, en orden
|
||
|
||
1. Caja actualizada: `arjectl` + `gitea` + la cuenta `gitea:969` + las cards en `genesis` y `cards.d`.
|
||
2. Datos en dos fases: **1,66 GB / 18 047 ficheros** con el origen VIVO (17 s), y en el corte el
|
||
`rsync --delete` incremental + `sqlite3 ".backup"` (`integrity_check ok`).
|
||
3. **Parar el origen de verdad.** `rc-service gitea stop` dice *«already stopped»* con el proceso
|
||
vivo (el `rc-status` que miente, §Los tres hechos), y matarlo **no alcanza**: lo revive
|
||
`arje-zero`, que lo tiene como card `openrc-gitea` con `Restart` — **9001 reinicios** acumulados
|
||
en el contador. Se paró con **`arjectl stop openrc-gitea`**, y para eso hubo que extraer su card
|
||
del `genesis` y escribirla en `/etc/arje/cards.d/` (§6.12: `SpawnCardFromDisk`/`StopCardFromDisk`
|
||
leen de ahí, no del genesis).
|
||
4. DNS por la **API de Hetzner Cloud** (`/v1/zones`): el token de `hcloud` **también gestiona el
|
||
DNS** desde la unificación — la API vieja `dns.hetzner.com/api/v1` redirige a la consola. Y un
|
||
`PUT` sobre el rrset **no puede cambiar el tipo**: hay que `DELETE` del CNAME y `POST` del A.
|
||
5. TLS: el primer intento de ACME **falló contra gioser** (`502`) porque el challenge salió antes de
|
||
que propagara; con el DNS ya al día, `arjectl restart caddy` y *certificate obtained successfully*.
|
||
|
||
#### La identidad SSH se muda con el servicio
|
||
|
||
`git clone ssh://…:2345` daba **`REMOTE HOST IDENTIFICATION HAS CHANGED`**: es otra máquina. Se
|
||
copiaron las **claves de host** de gioser (`/etc/ssh/ssh_host_*`) a la caja ⇒ los clones existentes
|
||
no notan nada. El precio es que cambia también la identidad del `:22` de administración, y hay que
|
||
limpiar el `known_hosts` propio — a los clientes del servicio no les afecta, que es lo que importa.
|
||
|
||
Y el `sshd` del producto escucha sólo en `:22`: el git por SSH necesitó `Port 2345` y que el usuario
|
||
`gitea` tenga **shell real** (su `authorized_keys` fuerza `command="gitea serv …"`, y con
|
||
`/bin/false` no se ejecuta nada).
|
||
|
||
#### Lo que esto cambia para la granja
|
||
|
||
Las 23 recetas que ahora clonan por `https://git.tawasuyu.net/…` **apuntan a la caja**. Comprobado
|
||
tras el cambio: el worker resuelve `2.29.29.217` y `git ls-remote` responde. La granja depende ya
|
||
del servidor nuevo, que es el sentido de la mudanza.
|
||
|
||
#### Cómo se revierte, si hiciera falta
|
||
|
||
`arjectl start openrc-gitea` en gioser y devolver los dos `git` a `CNAME → www`. Con TTL 60, minutos.
|
||
|
||
### 6.15 Cerrar el intermedio: el viejo apagado de verdad, el `:22` cerrado y los datos respaldados
|
||
|
||
#### ⚠ El gitea viejo había VUELTO a arrancar — y lo arrancó OTRO supervisor
|
||
|
||
Media hora después del corte, gioser volvía a servir en `:3002`. `arjectl list-units` **ya no lo
|
||
mostraba** (el `stop` de arje seguía en pie) y el proceso tenía **`ppid ≠ 1`**: lo había levantado
|
||
**OpenRC**, que lo tenía en el runlevel `default`. O sea **dos supervisores para el mismo servicio**
|
||
—la card `openrc-gitea` de arje y el runlevel de OpenRC— y parar uno no para el otro.
|
||
|
||
rc-update del gitea default # que no vuelva
|
||
rc-service gitea stop # ahora sí: OpenRC lo arrancó, OpenRC sabe pararlo
|
||
|
||
Antes del corte pasaba lo contrario (`rc-service … stop` decía *«already stopped»* con el proceso
|
||
vivo, porque quien lo tenía era arje). **La pregunta no es «¿está parado?» sino «¿quién lo tiene?»**,
|
||
y hay que responderla dos veces.
|
||
|
||
#### El origen deja de servir git, y se comprueba que no se rompió lo demás
|
||
|
||
Los tres bloques (`git.gioser.net`, `git.tawasuyu.net`, `gitea.gioser.net`) salen del Caddyfile de
|
||
gioser —con respaldo previo, `caddy validate` y `reload`—, y quedan 16. Control inmediato:
|
||
`sergio.gioser.net` y `hifas.gioser.net` siguen en **200**. `gitea.gioser.net` pasa a `A` → la caja,
|
||
donde ya está su `redir`.
|
||
|
||
#### El `:22` cerrado, sin perder el acceso
|
||
|
||
Administración en **22022**, git por SSH en **2345**, y el **22 cerrado** (`Connection refused`) para
|
||
sacarse de encima el barrido de bots. La secuencia importa y es la única segura: **añadir** el puerto
|
||
nuevo → reiniciar → **entrar por él** → sólo entonces quitar el 22. Comprobado en ese orden, y el
|
||
`git clone ssh://…:2345` sigue funcionando después.
|
||
|
||
#### 🧨 Los datos del gitea NUNCA estuvieron respaldados
|
||
|
||
`respaldo-storagebox.sh` respalda el **store de artefactos**, no `/var/lib/gitea`. O sea que los 44
|
||
repos vivían en una sola copia — en gioser antes, en la caja ahora. La mudanza no lo empeoró, pero sí
|
||
lo hace urgente: **gioser se borra**. Hecho hoy desde la caja: snapshot de la DB con el servicio vivo
|
||
+ `rsync` a `u647150:gitea/` — **1,7 G** (`datos/` + `gitea.db` de 314 M).
|
||
|
||
⚠ **Al Storage Box se entra por el puerto 23**, no el 22: el 22 da SFTP/SCP restringido y contesta
|
||
`Permission denied (publickey,password)`, que se lee como *«no tengo la clave»* cuando la clave está
|
||
perfecta. Pasó en las dos máquinas antes de mirar el script (`SB_PORT=23`).
|
||
|
||
**Pendiente**: que ese respaldo sea periódico, no de una vez. Es un renglón en el cron de la caja.
|
||
|
||
### 6.16 ⚠ SÍ se perdieron dos commits en la ventana — y cómo se detectó
|
||
|
||
La pregunta correcta después de un cutover no es «¿arrancó?» sino **«¿entró algo en el viejo
|
||
mientras tanto?»**. Se contesta comparando **todos los refs de los dos lados**:
|
||
|
||
```sh
|
||
for r in <repos>/*/*.git; do git -C "$r" for-each-ref --format="$r %(refname) %(objectname)"; done | sort
|
||
```
|
||
|
||
845 refs a cada lado, y **dos diferencias**. Una era esperada (`sergio/takana` main, el nuevo por
|
||
delante con los commits posteriores al corte — `merge-base --is-ancestor` confirma fast-forward). La
|
||
otra **no**: `tawasuyu/tawasuyu` tenía en el VIEJO dos commits que el nuevo no tenía —
|
||
`b69008eb` (freebsd) y `02a9535f` (shuma, **19:48**)—, o sea **quince minutos después del snapshot
|
||
final**.
|
||
|
||
**Por qué entraron**: el gitea viejo había resucitado (§6.15, OpenRC) y un cliente con el DNS
|
||
**cacheado** —el CNAME anterior tenía TTL 600— lo empujó a gioser aunque el autoritativo ya dijera
|
||
la caja. Las dos condiciones a la vez: un origen que revive y una caché que todavía apunta.
|
||
|
||
**Recuperados** con un `git push` del ref exacto desde el clon local al gitea nuevo; la UI del nuevo
|
||
ya muestra `02a9535f`. Segunda pasada de comparación: **845/845 y una sola diferencia, la esperada**.
|
||
Y del lado de los metadatos, `action` tenía tres filas posteriores al snapshot (los mismos dos
|
||
repos) y **cero** issues, comentarios o releases: no se perdió nada que no fueran esos commits.
|
||
|
||
**La lección para el resto de la mudanza**, en orden: (1) bajar el TTL **antes** del corte, no
|
||
durante; (2) apagar el origen de verdad —los DOS supervisores— **antes** de tocar el DNS; (3)
|
||
comparar refs después, siempre, porque es la única prueba de que no se perdió nada; y (4) dejar el
|
||
origen apagado un rato **antes** de borrarlo, precisamente para poder hacer esta comparación.
|
||
|
||
### 6.17 ⚠ En gioser conviven arje Y OpenRC — y el prefijo `openrc-` de las cards ENGAÑA
|
||
|
||
El usuario avisó: *«esa máquina la tengo con arje desde hace meses»*. Tiene razón, **y las dos cosas
|
||
son ciertas a la vez**:
|
||
|
||
| medido | |
|
||
|---|---|
|
||
| PID 1 | **`arje-zero`** ✓ |
|
||
| la card `openrc-gitea` | ejecuta `sh -c "cd /var/lib/gitea && exec /usr/bin/gitea web …"` — **no llama a OpenRC** |
|
||
| `/run/openrc/started/` | **existe y tiene servicios**: NetworkManager, dbus, dhcpcd, fsck, hostname, hwclock, local, localmount… |
|
||
| `rc-update show default` | listaba `gitea` |
|
||
|
||
⇒ **El prefijo `openrc-` es herencia del NOMBRE**, de cuando `arje-absorb` tradujo los servicios de
|
||
OpenRC a cards: la card no pasa por `rc-service`, encarna el binario directo. Pero **OpenRC quedó
|
||
como residuo ACTIVO** y puede arrancar servicios por su cuenta — que es lo que revivió al gitea con
|
||
`ppid ≠ 1` después de que arje lo hubiera detenido.
|
||
|
||
**La regla para el resto de la mudanza**: antes de dar un servicio de gioser por apagado, preguntar a
|
||
los **dos**:
|
||
|
||
```sh
|
||
arjectl list-units | grep <svc> # la capa de arje (PID 1)
|
||
rc-update show default | grep <svc> # el residuo de OpenRC, que también arranca
|
||
```
|
||
|
||
Es [[la-etiqueta-no-es-el-hecho]] en su forma más cara: un nombre que dice `openrc-` y no es OpenRC,
|
||
junto a un OpenRC real que nadie esperaba que siguiera operando.
|
||
|
||
### 6.18 Mudados `takana.gioser.net` y `hifas.gioser.net` *(estáticos puros)*
|
||
|
||
Los dos son ficheros y nada más —sin backend—, así que mudarlos fue copiar 52 K, añadir su vhost al
|
||
Caddyfile de la caja, mover el DNS de CNAME a **A** y quitarlos del origen. **200 con TLS válido
|
||
desde `2.29.29.217`**, y control de que no se rompió lo demás: `sergio.gioser.net` sigue en 200 y
|
||
gioser baja de 16 a **14 vhosts**.
|
||
|
||
Se repitió el patrón de ACME: el primer intento de certificado sale ANTES de que propague el DNS,
|
||
falla contra el origen y hay que forzar el reintento con `arjectl restart caddy`. **Conviene mover
|
||
el DNS y recién entonces añadir el vhost** — o asumir un `restart` de más.
|
||
|
||
**Quedan en gioser 14 vhosts**, todos con backend propio (uvicorn, php-fpm, `tejido`, `shuma`…) o
|
||
fósiles sin DNS. Ésos no se mudan copiando un directorio: necesitan su servicio del otro lado.
|
||
|
||
### 6.19 Los fósiles, barridos: de 19 vhosts a **2**
|
||
|
||
Se midió cada dominio que quedaba —DNS por DoH **y** HTTP contra la IP del origen— antes de tocar
|
||
nada. El resultado deja la mudanza mucho más chica de lo que parecía:
|
||
|
||
| clase | cuántos | qué son |
|
||
|---|---|---|
|
||
| **fósiles sin DNS** | 5 | `aura`, `api.aura`, `sigma`, `kosmofono`, `api.kosmofono` |
|
||
| **ya viven en OTRA máquina** | 3 | `summa`, `dev.summa`, `api.dev.summa` → `154.197.1.2` |
|
||
| **502 permanente** | 2 | `mail.sigma` (:9000 caído), `api.gioser.net` (:8000 caído) |
|
||
| **mudados hoy** | 5 | los tres de git + `takana` + `hifas` |
|
||
| **VIVOS y por mudar** | **2** | `sergio.gioser.net` y `api.sergio.gioser.net` |
|
||
|
||
**Y ninguna de las raíces estáticas de los fósiles existe en disco**: `/var/www/{aura_frontend,
|
||
sigma,kosmofono,summa,summa-dev}` no están. El Caddyfile servía directorios ausentes — la
|
||
configuración sobrevivió a sus datos. De los backends, sólo `:8770` sigue escuchando, y su dominio
|
||
ya apunta a otra máquina; `:8766 :8765 :9000 :8000 :8768` están caídos.
|
||
|
||
Los 10 bloques muertos salen del Caddyfile (con respaldo previo, `validate` y `reload`), y el control
|
||
confirma que los vivos siguen: `sergio` y `api.sergio` en **200**, y los mudados respondiendo desde
|
||
la caja. **gioser pasa de 19 vhosts a 2.**
|
||
|
||
⚠ **El único fósil CON datos es `terapeuta.ec`: 279 M en `/var/www/terapeuta`**, sin DNS y sin vhost.
|
||
No se borra acá — se anota para la decisión de borrado de la máquina, que es del §6.
|
||
|
||
**Lo que esto cambia**: mudar los dos que quedan es un frente acotado —un backend Python en `:7378`
|
||
y una API en `:8771`— y no las quince cosas que la lista sugería.
|
||
|
||
### 6.20 🛟 Cinco binarios rescatados de la nada — el paso que CADUCABA
|
||
|
||
Antes de tocar `sergio.gioser.net` apareció lo urgente: **cinco procesos vivos cuyo binario YA NO
|
||
EXISTE en disco** (`/proc/<pid>/exe` → `… (deleted)`). Sólo existían como inode huérfano: si el
|
||
proceso muere o la máquina se reinicia, se pierden. Es el paso que el SDD 29 §4.0 undecies llamó *«el
|
||
que caduca»*, y caducaba de verdad.
|
||
|
||
tejido · sandokan-mcp · pacha-secretos · puerta-f6e393ffbef3999e · shuma-gateway (240 M)
|
||
|
||
Se recuperaron leyendo `/proc/<pid>/exe` con el proceso vivo, y ya están **fuera de gioser**: en
|
||
`/work/rescate-binarios/` de la caja **y** en `rescate-binarios/` del Storage Box. Cinco de los trece
|
||
binarios que nadie provee dejaron de depender de que nadie reinicie nada.
|
||
|
||
### 6.21 🧱 `sergio.gioser.net`: el muro no es el sitio, es **glibc**
|
||
|
||
El sitio son tres piezas, y sólo una se puede mudar hoy:
|
||
|
||
| pieza | qué es | mudable a takana |
|
||
|---|---|---|
|
||
| el frontend | **117 M** de estático (`dist/`) | ✅ copiado ya a `/work/www/sergioh` |
|
||
| `/shuma/*` | `shuma-gateway` en `:7378` — **ELF dinámico, y su binario estaba BORRADO** | ⚠ hay que construirlo desde tawasuyu (es Rust del monorepo) |
|
||
| `api.sergio` | **uvicorn** con un venv de **405 M** | 🧱 **no, hoy no** |
|
||
|
||
El backend es `fastapi + uvicorn + pydantic + google-generativeai + **langchain**`, con extensiones
|
||
compiladas `…cpython-314-x86_64-linux-**gnu**.so` — o sea **glibc**. Reempaquetar ese árbol de PyPI
|
||
como recetas no es realista, y correrlo en musl no es posible. **Es el caso de uso canónico del
|
||
[ADR 0015](adr/0015-imagenes-ajenas.md) (`qorpa`: imágenes ajenas glibc enjauladas)**, que está
|
||
PROPUESTO y sin implementar del todo.
|
||
|
||
⇒ **El DNS de `sergio` NO se movió**: mover el nombre sin el backend deja el sitio servido y la
|
||
consola rota. El frontend queda copiado y esperando; el corte se hace cuando exista el camino para
|
||
las otras dos piezas.
|
||
|
||
**Esto es lo que decide la fecha de borrado de gioser**: no es «mudar dominios», es que **un
|
||
servicio vivo no tiene hoy camino a takana**. Las salidas son tres y hay que elegir: (1) `qorpa`, que
|
||
es para exactamente esto; (2) que ese servicio viva en otra máquina —`summa` ya lo hace, y su DNS lo
|
||
demuestra—; o (3) que muera. Ninguna es técnica: es una decisión.
|
||
|
||
### 6.22 🧨 El `curl` de la distro NO PUEDE VALIDAR TLS — y por eso nada se baja por HTTPS
|
||
|
||
Al ir a traer la primera imagen ajena (`qorpa pull`), la caja falló con `curl failed to verify the
|
||
legitimacy of the server`. El certificado del servidor estaba bien; lo que falta es de este lado:
|
||
|
||
```
|
||
* CApath: /etc/ssl/certs
|
||
* SSL certificate ... unable to get local issuer certificate (20)
|
||
$ curl --cacert /etc/ssl/certs/ca-certificates.crt ... → 200
|
||
```
|
||
|
||
El `curl` del corpus está compilado **con `--with-ca-path=/etc/ssl/certs` y sin CAfile**, y ese
|
||
directorio contiene **un solo fichero**: el bundle. Un *capath* necesita **hash-links**
|
||
(`c_rehash`) — el índice — y no los hay. Los certificados están, el índice no.
|
||
|
||
**Por qué faltan**: `recipes/ca-certificates.toml` deja escrito el hook
|
||
`/etc/ca-certificates/update.d/certhash`, que en Alpine ejecuta `apk` al instalar. **En takana no lo
|
||
ejecuta nadie**: la distro proyecta artefactos, no corre post-install. Es la misma familia que
|
||
[[subcomando-sin-driver]] — todo presente, y el paso que lo conecta no existe.
|
||
|
||
**Alcance**: no es sólo `qorpa pull`. Es `takana install --repo https://…`, el mirror, y cualquier
|
||
`curl`/`wget` de un script en una caja instalada. **No falla al construir ni al instalar: falla la
|
||
primera vez que la máquina intenta bajar algo.** Nadie lo había visto porque el hub usa el `curl` de
|
||
Artix.
|
||
|
||
**Arreglado en la receta**: los hash-links se generan **en el build** —donde `c_rehash` y perl ya
|
||
existen— y viajan dentro del artefacto, con una guarda que **falla ruidosamente si el rehash no
|
||
produce ninguno**. Re-sella `ca-certificates`, que es raíz de `perfil.base`.
|
||
|
||
**Apaño mientras tanto**, para invocaciones puntuales: `CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`
|
||
(comprobado: 200).
|
||
|
||
### 6.23 `qorpa` en la caja: preflight en verde, y el segundo muro es `tar`
|
||
|
||
Con el backend de `sergio` sin camino a musl (§6.21), la salida elegida es **qorpa**. Estado medido
|
||
en la caja:
|
||
|
||
| paso | resultado |
|
||
|---|---|
|
||
| userns anidado · overlayfs sin privilegios | ✅ ya estaban |
|
||
| **disco** | ❌→✅ `/var/lib/hammer` vive en la raíz (3 G) ⇒ `qorpa` **se movió a `/work`** (62 G) por symlink |
|
||
| `subuid`/`subgid` | ❌→✅ rango escrito para root |
|
||
| `newuidmap`/`newgidmap` sin capability | ❌→✅ `setcap` aplicado — **y el artefacto del store NO se tocó** (inodes distintos: en una caja instalada esos binarios son copias, no hardlinks) |
|
||
| veredicto del preflight | **de «BLOQUEA 1 · LIMITA 4» a «LIMITA 1»** |
|
||
| `qorpa pull` de Arch bootstrap | baja y **verifica el sha256**, y falla al desempacar |
|
||
|
||
El segundo muro: el rootfs de Arch es `.tar.zst` y **el `tar` de la imagen es el de busybox**, que no
|
||
lo entiende — el mismo tropiezo que ya costó el lab (`tar: unrecognized option: zstd`). `zstd` SÍ
|
||
está instalado. ⇒ `qorpa pull` tiene que descomprimir con `zstd -dc | tar -x` cuando el archivo es
|
||
`.zst`, en vez de dárselo entero a `tar`. Es un arreglo pequeño en el CLI, y hasta entonces la
|
||
alternativa es una imagen `.tar.gz` (Ubuntu base) — que además obliga a reinstalar las deps del venv,
|
||
porque el de gioser trae extensiones de **CPython 3.14** y Ubuntu 24.04 lleva 3.12.
|
||
|
||
### 6.24 El TLS arreglado, la distro con nombre, y **por qué `upgrade` se quedaba sin espacio**
|
||
|
||
**El `curl` ya valida.** Con `ca-certificates` re-sellado —**119 hash-links en el capath**, generados
|
||
en el build— la caja baja por HTTPS **sin ningún apaño**: `curl -I https://…` → **200**. El arreglo
|
||
tardó tres intentos y los dos primeros los cazó la guarda que puse en la propia receta: primero
|
||
`c_rehash` no estaba en el `PATH` (lo instala esa misma receta en `/out/usr/bin`), y después los
|
||
certificados **no estaban sueltos** en `usr/share/ca-certificates/` sino en su subdirectorio
|
||
`mozilla/`, así que `c_rehash` sólo veía el bundle y lo saltaba —correctamente— con «does not
|
||
contain exactly one certificate». Sin la guarda, las tres veces habría sellado un capath vacío.
|
||
|
||
**`fastfetch` (2.68.1) corre en la caja** — y al correrlo apareció otra ausencia:
|
||
|
||
```
|
||
OS: takana x86_64 ← ahora
|
||
Host: vServer (20171111)
|
||
Kernel: Linux 7.1.2 · CPU: AMD EPYC-Rome (4) · Memory: 393 MiB / 7.58 GiB
|
||
```
|
||
|
||
…porque **`/etc/os-release` NO EXISTÍA**. La distro era anónima para cualquier programa que no fuera
|
||
suyo. Se resolvió con una receta propia (`os-release`, `source.dir`) en vez de una constante en
|
||
`takana-bootstrap`: meterlo ahí re-sellaría el `product-rootfs` —el baseline del selfhost— por cinco
|
||
líneas. Sin `VERSION_ID` ni fecha a propósito: un sello con la fecha del build cambiaría el hash del
|
||
artefacto, y con él la clausura de toda imagen que lo lleve, cada día y sin motivo.
|
||
|
||
#### 🧨 `/var/lib/hammer` es una partición de 487 MB — y ahí viven las generaciones
|
||
|
||
`upgrade apply` falló dos veces con `No space left on device` teniendo **3 G libres en la raíz**. Ni
|
||
el espacio de `/` ni los inodos (9% usados): el estado de generaciones vive en
|
||
**`/var/lib/hammer`, que es `sda3` y mide 487 MB** (la partición «estado» del layout). Cada
|
||
generación guarda una copia del árbol aplicado — el nuestro son **272 MB** — así que **dos no caben**.
|
||
|
||
⇒ El estado se movió a `/work` por symlink, igual que `qorpa`. Tras eso: `✓ generación 6 aplicada`.
|
||
**El layout de la imagen necesita revisión**: 512 MB de «estado» no alcanzan para un mecanismo que
|
||
guarda un árbol entero por generación, y el síntoma (`ENOSPC` con la raíz medio vacía) apunta al
|
||
sitio equivocado.
|
||
|
||
### 6.25 ✅ CUTOVER DE `api.sergio.gioser.net` — el primer servicio AJENO que sirve takana *(2026-09-15)*
|
||
|
||
**`https://api.sergio.gioser.net` responde 200 con certificado público válido desde `2.29.29.217`**,
|
||
y su `/api/chat/` contesta con Gemini de verdad. El backend corre **dentro de una jaula `qorpa`**
|
||
(rootfs de Arch pineado por sha256), supervisado por `arje-zero` como el ente `sergioh-api`.
|
||
|
||
Es el servicio que §6.21 declaró sin camino a musl —su venv trae extensiones
|
||
`cpython-314-x86_64-linux-**gnu**.so`— y por eso «lo que decide la fecha de borrado de gioser». El
|
||
camino elegido era `qorpa` (ADR 0015) y ahora está recorrido de punta a punta.
|
||
|
||
**La coincidencia que lo hizo barato:** el Arch bootstrap pineado trae **python 3.14.7** y el venv de
|
||
gioser es **3.14.6**. Misma serie ⇒ las extensiones compiladas no son un problema: el venv se rehace
|
||
adentro con `pip`, no se copia.
|
||
|
||
#### 🧨 EL MURO: la jaula no viajaba en NINGUNA imagen
|
||
|
||
`takana qorpa provision` abortó con un mensaje correcto y una instrucción imposible:
|
||
|
||
Error: no encuentro harkaq-exec en /root/.cache/harkaq/harkaq-exec
|
||
construilo: gcc -O1 -Wall -static -o … scripts/harkaq/harkaq-exec.c
|
||
|
||
**En una caja instalada no hay `gcc`, ni `scripts/`, ni árbol de desarrollo.** El binario sólo existía
|
||
como un `gcc` a mano en la caché de `$HOME` del hub — o sea que la jaula del ADR 0015 funcionaba
|
||
**únicamente en la máquina donde alguien la había compilado**. Y su compañero estaba igual, medido en
|
||
`build-state.json`: **`bwrap` sellado desde hace meses con `"perfiles": []`**, CERO perfiles. Los dos
|
||
se invocan por PATH, y no sólo desde `qorpa`: `takana-build/src/sandbox.rs` llama a `bwrap` en cada
|
||
build. ⇒ **ninguna imagen de takana podía enjaular nada, ni construir**. Es
|
||
[[subcomando-sin-driver]] un piso más abajo: el CLI que los llama viaja en todas las imágenes y sus
|
||
herramientas en ninguna.
|
||
|
||
Arreglado en la misma unidad de trabajo: `recipes/harkaq-exec.toml` (nueva, estática musl,
|
||
`b3:cd34954f…`, 269 K), `bwrap` + `harkaq-exec` declarados en **`perfil.base`**, y `qorpa.rs`
|
||
buscando el binario también en `/usr/bin` —el orden es `HARKAQ_BIN` → árbol de desarrollo → paquete,
|
||
para que un cambio en la jaula se pruebe sin instalar nada—. ⚠ El pin de la receta va **al commit que
|
||
tocó la fuente** (`1d9ddcee`, del 2026-09-03) y no a HEAD: el repo commitea cada media hora por el
|
||
cron de la cosecha, y pinear HEAD re-hashearía la receta cada media hora sin que su fuente se moviera.
|
||
|
||
#### Los tres tropiezos del camino, todos de la misma familia
|
||
|
||
1. **`chown`**: el `rsync` preservó el uid 1001 de gioser, y dentro del userns ese uid queda FUERA del
|
||
rango mapeado ⇒ ni siendo root adentro se puede escribir. `python -m venv` fallaba con
|
||
`Permission denied` en el directorio concedido mientras andaba perfecto en `/tmp`. El árbol del
|
||
sitio va a `root:root` del anfitrión, que es lo que el mapa de la jaula sí alcanza.
|
||
2. **Una instancia, un `run`**: el segundo `qorpa run` sobre la misma instancia es rechazado por el
|
||
kernel («dos overlays con el mismo `upper` corromperían la capa mutable»). Correcto y hay que
|
||
saberlo: con el servicio arriba **no se entra a la instancia a hacer mantenimiento**.
|
||
3. **`ps` de la caja no ve los procesos y `dig` no existe en el hub**. Dos herramientas ausentes que
|
||
se leen como diagnóstico: el primero dice «no hay nada corriendo» y el segundo dejó un `until` de
|
||
espera de DNS girando cinco minutos contra una condición que **nunca podía cumplirse**. Un
|
||
guardián que no puede pasar nunca se ve igual que uno que todavía no pasó.
|
||
|
||
#### Lo que quedó declarado, y lo que no
|
||
|
||
- `instance.toml` es la verdad: `packages = ["python","python-pip"]`, `network = true` (pacman, pip y
|
||
la propia API de Gemini la necesitan) y **un solo dir concedido** (`/work/sergioh` → `/srv/sergioh`,
|
||
rw). Ni sockets, ni dispositivos, ni nada más.
|
||
- El código y el venv viven **fuera** de la instancia, en el anfitrión: el `upper` es caché (D3) y se
|
||
tira. Y las 84 dependencias quedaron pineadas en `requirements.lock` — el `requirements.txt` del
|
||
sitio no pinea nada, así que sin el lock un `recreate` traería otras versiones.
|
||
- La tarjeta `sergioh-api` va en `/etc/arje/cards.d/` **y** en el `genesis` de `/ente/seed.card.json`
|
||
(son dos preguntas distintas: qué arranca solo y qué se puede encarnar a pedido, §6.12), con dos
|
||
guardas que salen 78 nombrando el arreglo si falta la instancia o el venv.
|
||
- ⚠ **Todavía invoca con `HARKAQ_BIN=/usr/bin`** en la tarjeta: el `takana` de la caja es el del
|
||
`commit` pineado en `recipes/takana.toml` y no trae aún la búsqueda en `/usr/bin`. Sale solo cuando
|
||
se suba ese pin.
|
||
|
||
#### El estado de la mudanza tras esto
|
||
|
||
| | |
|
||
|---|---|
|
||
| **mudado hoy** | `api.sergio.gioser.net` (DNS: CNAME→`www` **borrado**, A→`2.29.29.217` TTL 60) |
|
||
| **pendiente inmediato** | apagar el `sergioh_backend` de gioser **cuando propague** (OpenRC *y* arje: `rc-update del` + `rc-service stop` + `arjectl stop openrc-…`, §6.17). Hasta entonces sirve a los resolutores que aún tengan el CNAME cacheado |
|
||
| **lo que falta de `sergio.gioser.net`** | el frontend ya está copiado en `/work/www/sergioh`; falta `/shuma/*` → `shuma-gateway` (:7378), que es glibc dinámico y **cabe en esta misma jaula** (el binario está rescatado en `/work/rescate-binarios/`) pero cuelga de `shuma-daemon`, que es un sistema entero, no un binario |
|
||
| **y los otros dos vivos** | `gioser.net`/`www` (estáticos + `/hooks/*` en :8770 + `/reencuentro` con php-fpm) y `tawasuyu.net` (estático, raíz en `/mnt/vvv/tawasuyu`) |
|
||
|
||
### 6.26 ✅ `tawasuyu.net` y `gioser.net` MUDADOS — y el PHP que no existe en el corpus *(2026-09-15)*
|
||
|
||
**Los dos sitios que quedaban con tráfico real sirven desde la caja `takana`**, con TLS público y
|
||
verificados desde fuera. Los servicios viejos **siguen corriendo en gioser a propósito** (decisión del
|
||
usuario: mudar el DNS y dejar el origen vivo, para poder volver en un minuto).
|
||
|
||
| dominio | qué es | estado |
|
||
|---|---|---|
|
||
| `tawasuyu.net`, `www.` | landing + `/pkg` wasm + `/descargas` (3,8 G) | **200 desde 2.29.29.217** |
|
||
| `gioser.net`, `www.` | 5 raíces estáticas (428 M) + `/reencuentro` con PHP | **200 desde 2.29.29.217** |
|
||
| `sergio.gioser.net` | frontend + `/shuma/*` | **se queda en gioser**: su consola no tiene camino todavía |
|
||
|
||
**`sergio` dejó de ser un `CNAME → www` y tiene su A propio** (apuntando a gioser, sin cambio
|
||
visible). Sin eso, mover `www` se lo habría llevado puesto — es la trampa del §6.14 vista a tiempo:
|
||
en esta zona **casi todo cuelga de `www`**. Lo que sí quedó colgando son tres fósiles (`api`,
|
||
`mail.sigma`, `monitor`): antes daban 502 desde gioser y ahora fallan el TLS contra la caja. Están
|
||
muertos hace meses; se anotan, no se mudan.
|
||
|
||
**Dos cosas que el log de accesos decidió mejor que cualquier intuición:**
|
||
- La raíz de `tawasuyu.net` en gioser era **el monorepo entero (145 G) servido por HTTP**. El log
|
||
dice que las únicas rutas con tráfico son `/`, `/descargas` y `/web` ⇒ se replicaron **sólo** los
|
||
dos subárboles que el sitio usa (1,2 M + 3,8 G), con la MISMA estructura para que cada `rewrite`
|
||
siga siendo el mismo.
|
||
- **`/hooks/*` NO se muda**: lo atiende `webhook-deploy.py`, que redespliega `aura_*`, `sigma_*`,
|
||
`summa_*` y `brahman` — **los cuatro son fósiles del §6.19**, y tiene cero peticiones. Muere con la
|
||
caja vieja. Un servicio vivo que sólo sirve a muertos es basura, no deuda.
|
||
|
||
#### PHP: la segunda jaula, y por qué no entra al corpus
|
||
|
||
`/reencuentro/` es una página con **un** `guardar.php`. PHP no está en el corpus y no tiene por qué
|
||
estar: lo sirve **`php-fpm 8.5.10` dentro de la instancia `gioser-php`** (ADR 0015), por
|
||
`127.0.0.1:9000`, supervisado por arje. Control contra el original, en los dos sentidos: `POST` con la
|
||
trampa anti-robots → `{"ok":true}` 200 igual que en gioser; `GET` → **405** igual que en gioser.
|
||
|
||
⚠ **El webroot entra a la jaula EN LA MISMA RUTA** (`/work/www/gioser-web`): el `SCRIPT_FILENAME` de
|
||
FastCGI es una ruta absoluta del anfitrión, y si adentro viviera en otro sitio php-fpm contesta
|
||
«File not found» sin decir cuál. Y la config de php-fpm vive en el ANFITRIÓN
|
||
(`/work/etc/gioser-php/php-fpm.d/`) y entra por concesión: el `upper` de la instancia es caché y
|
||
`recreate` se la llevaría.
|
||
|
||
#### 🧨 Los padres que inventa bwrap son **0700** — y eso deja la concesión fuera de alcance
|
||
|
||
El muro real del día, y es general, no de PHP:
|
||
|
||
$ curl -X POST https://gioser.net/reencuentro/guardar.php
|
||
File not found. ← y el fichero ESTÁ, montado, con sus modos correctos
|
||
|
||
Para montar en `/work/www/gioser-web`, bwrap tiene que crear `/work` y `/work/www` —que la imagen de
|
||
Arch no trae— y **los crea `drwx------ root`**. Adentro somos root, así que todo a mano funciona; pero
|
||
**un servicio que baja de privilegio** (php-fpm a `http`, o cualquier instancia con `run_as`) no puede
|
||
ni atravesarlos. Medido con el control que lo separa de una confusión de permisos del anfitrión: como
|
||
`http`, leer un fichero **de la imagen** funciona y leer el **directorio concedido** da
|
||
`Permission denied`.
|
||
|
||
⇒ `grants_to_args` crea ahora los ancestros que faltan con `--perms 0755 --dir`, y **sólo los que la
|
||
imagen no trae**: hacerlo sobre `/etc` o `/home` le cambiaría los modos a la imagen. Con test que
|
||
falla a propósito si la condición se rompe (`left: ["/etc","/etc/php"]` contra `right: ["/etc/php"]`).
|
||
Misma familia que el `--perms 1777` que ya hacía falta antes del `--tmpfs /tmp`.
|
||
|
||
⚠ La caja corre el `takana` del `commit` pineado en `recipes/takana.toml`, así que **allá el arreglo
|
||
todavía no está**: el camino se abrió a mano en el `upper` de la instancia (`chmod 755 /work
|
||
/work/www`). Es exactamente lo que el arreglo vuelve innecesario en el próximo `takana upgrade`.
|
||
|
||
#### Y el estado de `rust` y de `claude`, que no es el que parece
|
||
|
||
- **`rust` está en la caja pero no instalado**: `rust-toolchain-bin` está **sellado en el store** y
|
||
declarado en `perfil.servidor`, y `rustc 1.97.0` corre invocándolo por su ruta del store — pero
|
||
**no está en el `PATH`** porque la caja se instaló antes de esa declaración. Proyectarlo son 798 M
|
||
sobre una raíz de **5,8 G con 2,9 G libres**: entra, pero el layout de la imagen (§6.24) merece la
|
||
revisión primero.
|
||
- **`claude` no está mudado**, y es otro caso de jaula: el binario de gioser es **glibc dinámico**
|
||
(`/lib64/ld-linux-x86-64.so.2`), o sea el mismo montón que el backend de `sergio`. Lo que pesa no
|
||
es el binario sino su estado: **6,8 G de `~/.claude`**, de los cuales 5,3 G son `jobs` y 976 M
|
||
`projects` — y ahí vive la memoria, **indexada POR RUTA del repo**. Mudarlo es una decisión sobre
|
||
qué se lleva, no una copia.
|
||
|
||
### 6.27 🧦 `squid` mudado — y la jaula que arrancaba DEGRADADA cuando la arranca un init *(2026-09-15)*
|
||
|
||
**El proxy de salida sirve desde la caja**: `squid 7.7` en `2.29.29.217:1137`, con la config, el
|
||
`passwd` de los tres usuarios y las dos ACL de gioser tal cual, dentro de la instancia `squid` y
|
||
supervisado por arje. **El de gioser sigue vivo e intacto** (7.6), así que los clientes que apuntan
|
||
a la IP vieja no se enteraron.
|
||
|
||
Es un servicio con gente encima: el `access.log` del origen mostraba `CONNECT api.anthropic.com` y
|
||
`CONNECT claude.ai` **en el minuto anterior a mudarlo**, desde IPs de Venezuela y con tres usuarios
|
||
autenticados. Por eso se mudó entero y sin reescribir nada.
|
||
|
||
Control en los dos sentidos: desde una IP no autorizada, **407 Proxy Authentication Required**
|
||
—igual que el original—; desde `localhost`, que su propia config permite, **TCP_TUNNEL/200 a
|
||
claude.ai y a api.anthropic.com**.
|
||
|
||
#### Los cuatro muros, y el cuarto es el que vale
|
||
|
||
1. **`provision` con la config ya montada aborta**: pacman dice «squid.conf.default exists in
|
||
filesystem» porque el paquete quiere escribir sus defaults donde está la concesión. ⇒ provisionar
|
||
**primero**, montar **después**. Queda escrito en el manifiesto.
|
||
2. **`/dev/shm` lo crea bwrap 0755 root**, y squid —que baja a `proxy`— muere con
|
||
`FATAL: shm_open(...): (13) Permission denied`. Un `chmod 1777` en el arranque.
|
||
3. **El fichero de PID sobrevive entre corridas y SIEMPRE parece fresco**: cada `run` tiene su propio
|
||
namespace de PIDs, así que el `/run/squid.pid` de la corrida anterior dice `PID 2`… y en la nueva
|
||
hay un PID 2 vivo. Squid concluye «Squid is already running» y se niega. ⇒ `rm -f` en el arranque.
|
||
Es una trampa general de cualquier servicio con pidfile dentro de una jaula con `--unshare-pid`.
|
||
4. 🧨 **El rango de subuid se buscaba por `$USER` — y un init no lo pone.** Bajo arje la tarjeta
|
||
arranca con `PATH` y `HOME` y poco más; el nombre salía **vacío**, no había rango, y la jaula caía
|
||
al **userns de un solo id**. Squid moría en `setgid(15): (22) Invalid argument` y el supervisor lo
|
||
reintentaba 80 veces. El aviso de qorpa era correcto y estaba impreso —«no hay rango para `""` en
|
||
/etc/subuid»— pero **lo que se ve en el bucle es el error de squid, no el nuestro: la degradación
|
||
silenciosa la paga el de más abajo**. Y el mismo comando a mano, desde una shell con `$USER`,
|
||
funcionaba perfecto: *el síntoma dependía de quién lo arrancaba*.
|
||
⇒ `nombre_de_usuario()` deriva el nombre del **uid real** leyendo `/etc/passwd`, y el entorno pasa
|
||
a ser el último recurso. El uid es el hecho; `$USER` es una etiqueta ([[la-etiqueta-no-es-el-hecho]]).
|
||
Las tarjetas llevan además `USER`/`LOGNAME` explícitos, que era el arreglo del día.
|
||
|
||
⚠ **`arjectl start` sobre un Ente ya vivo lo DUPLICA** (se vio con `gioser-php`: dos entes, uno
|
||
peleando por el mismo `upper`). Y matar el proceso de adentro **no libera la instancia**: el
|
||
`takana qorpa run` de afuera sigue vivo y el siguiente `run` choca. Para relanzar, `stop` y después
|
||
`start`, mirando `status` en el medio.
|
||
|
||
#### Y la pregunta que corresponde: ¿no va en una receta?
|
||
|
||
**Sí, y sigue pendiente.** El propio §6.2 pone a `squid` en la lista de recetas que esta mudanza
|
||
tiene que producir. Lo de hoy fue la vía URGENTE —el servicio estaba en uso y la jaula lo levanta en
|
||
minutos, sin compilar nada—, no la respuesta final. La diferencia con `php-fpm` importa:
|
||
**PHP no queremos que entre al corpus** (es un intérprete ajeno al proyecto, y la jaula es su lugar
|
||
definitivo), mientras que **`squid` es C, se compila con el lab y su sitio natural es una receta**
|
||
como la de `gitea`. Cuando esté, la instancia se tira: el manifiesto es descartable a propósito.
|
||
|
||
### 6.28 📦 `squid` YA NO ESTÁ ENJAULADO: corre desde el corpus *(2026-09-15)*
|
||
|
||
La jaula del §6.27 duró unas horas, que es lo que tenía que durar. **Hoy el proxy de producción es
|
||
el artefacto sellado `b3:932ba096…`**, proyectado en la caja y arrancado por arje sin bwrap de por
|
||
medio: `pid 11548 · uid=968 · exe=/usr/sbin/squid`. La instancia `qorpa/squid` se borró — el
|
||
manifiesto es descartable por diseño (D3) y esto es exactamente para lo que existía esa salida.
|
||
|
||
`recipes/squid.toml`: 7.7, estático de verdad (binario **y** los cuatro helpers), `[[user]] proxy`
|
||
con uid 968 y `[[service]]` del SDD 30. Declarado en `perfil.servidor` como paquete y en `servicios`.
|
||
|
||
**La config NO viaja en el paquete y es a propósito**: `squid.conf`, el `passwd` de los tres usuarios
|
||
y las dos ACL (412 K, 40 IPs autorizadas) son datos del SITIO. El servicio los comprueba al arrancar
|
||
y sale **78 nombrando cuál falta**, en vez de dejar un bucle de reinicios.
|
||
|
||
#### Los tres muros de la receta, en orden
|
||
|
||
1. **La URL de upstream devolvía una página HTML de 8985 bytes con un 200.**
|
||
`squid-cache.org/Versions/v7/squid-7.7.tar.xz` no es el tarball. Pinear su sha256 habría anclado
|
||
la receta a una página de error — y habría «funcionado» hasta el primer build. Va el release de
|
||
GitHub, que además trae `configure` generado.
|
||
2. 🧨 **`--export-dynamic` desactivaba el enlace estático, y el binario sellaba igual.** El primer
|
||
artefacto tenía `usr/sbin/squid` **dinámico con `NEEDED libc.so`** mientras sus helpers salían
|
||
estáticos: inerte en cualquier imagen sin cargador ([[needed-colgante-libstdcxx]]) pese a
|
||
`link = "static"`. La cadena tiene tres eslabones y ninguno es obvio: `squid_LDFLAGS` trae
|
||
`-export-dynamic` (para módulos eCAP, apagados) → **el wrapper del lab quita `-static` de
|
||
cualquier enlace que mencione `--export-dynamic`**, a propósito, para poder enlazar las `.so`
|
||
dlopen-ables de Python → y **libtool vuelve a añadirlo solo** en cuanto hay un `-dlopen`, así que
|
||
sacarlo de la variable no alcanza. Se vacía `export_dynamic_flag_spec` en el `libtool` GENERADO,
|
||
con guarda que aborta si el campo no aparece: *un `sed` que no acierta deja pasar el problema y
|
||
el binario sale dinámico sin que nada falle*.
|
||
⚠ Y `-dlopen force` **no sobra**: vaciar `squid_LDFLAGS` entero rompe el enlace con
|
||
`undefined symbol: lt__PROGRAM__LTX_preloaded_symbols`, que emite el propio libtool porque hay un
|
||
`-dlopen`. De los dos flags estorba uno; distinguirlos costó un build y adivinar cuesta lo mismo.
|
||
3. **`--disable-log-daemon-helpers` compilaba perfecto y no arrancaba.** El default de `access_log`
|
||
es `daemon:`, así que sin `log_file_daemon` squid muere con
|
||
`FATAL: logfile_daemon /usr/lib/squid/log_file_daemon: (2) No such file or directory`. **Sólo se
|
||
ve levantando el servicio con la config real; el sellado, el hash y el `-k parse` pasan los tres.**
|
||
Es la familia de [[subcomando-sin-driver]] otra vez.
|
||
|
||
#### El control que hay que correr antes de tocar producción
|
||
|
||
Con la config REAL del origen, en el hub y en un puerto aparte: `-k parse` sin FATAL, arranque
|
||
completo, **`CONNECT claude.ai` y `CONNECT api.anthropic.com` con TCP_TUNNEL/200**, el `access.log`
|
||
escrito por el log daemon, y el `basic_ncsa_auth` leyendo el `passwd` de verdad (control negativo:
|
||
con una clave mala contesta `ERR Wrong password`). Los dos primeros intentos de esta receta habrían
|
||
llegado a producción sin ese control: el binario estaba sellado y el hash era estable las dos veces.
|
||
|
||
### 6.29 ⏱️ La caja tiene HORA, LATIDO y ROTACIÓN — y el respaldo del gitea dejó de ser a mano *(2026-09-15)*
|
||
|
||
`planear.py --revisar` sobre el censo del día ordena lo que queda, y lo primero de la lista no eran
|
||
los servicios del usuario sino **tres capacidades que ninguna métrica reclama**: el disparador
|
||
periódico, la hora y la rotación de logs. Los tres paquetes ya estaban sellados y declarados en
|
||
`perfil.servidor` desde que ese perfil nació — **lo que faltaba era el eslabón que los ARRANCA**.
|
||
|
||
| | antes | ahora |
|
||
|---|---|---|
|
||
| `chronyd` | la caja iba **15 s atrasada** y nadie la corregía | stratum 3 contra `ntp{1,2,3}.hetzner.de`, **0,000005 s** de NTP |
|
||
| `crond` | no existía el latido | ejecuta el crontab real, verificado con una entrada `* * * * *` |
|
||
| `logrotate` | `/var/log/squid` sin rotar | diario con `squid -k rotate`, 14 copias |
|
||
| respaldo del gitea | **a mano, una vez** (§6.15) | `17 3 * * *`, con el snapshot consistente de la sqlite |
|
||
|
||
**El respaldo, medido:** `scripts/respaldo-gitea.sh` sube **44 repos (23 `sergio` + 21 `tawasuyu`,
|
||
1,5 G)** y un `gitea.db` de **332 M** tomado con `.backup` —consistente con el servidor vivo— al
|
||
Storage Box. El control no es que el script salga 0: es **contar los repos de los dos lados**, que
|
||
es lo que se hizo.
|
||
|
||
⚠ **`chrony` y `cronie` NO declaraban `[[service]]`**, como `squid` antes: el paquete llegaba a la
|
||
imagen y no lo arrancaba nadie. Ahora lo declaran (fuera de `hash_inputs`: los hashes no se movieron)
|
||
y `perfil.servidor` los habilita en `servicios`.
|
||
|
||
⚠ **La config va aparte y el servicio sale 78 si falta**, igual que gitea: a qué NTP se pregunta y
|
||
qué logs se guardan son decisiones del SITIO. Una caja que se sincroniza contra un pool que nadie
|
||
eligió es peor que una sin hora, porque nadie lo mira.
|
||
|
||
#### Tres cosas medidas que valen más que el resultado
|
||
|
||
1. **La deriva era de gioser, no de la caja.** Antes de chrony la caja iba 15 s atrás; después, la
|
||
caja quedó en hora de NTP y **el hub pasó a estar 3 s adelantado**. La máquina de referencia era
|
||
la equivocada — medir «contra el otro» sin un tercero no dice quién está mal.
|
||
2. **El spool de cronie es `/var/spool/cron/<usuario>`, no `…/crontabs/<usuario>`.** `crontab -l`
|
||
mostraba las entradas y `crontabs/` estaba vacío. El día que alguien restaure un crontab a mano
|
||
en el sitio equivocado va a tener un fichero perfecto que nadie lee. Lo decide un control y no la
|
||
intuición: meter un `* * * * *` en el crontab REAL y ver si dispara.
|
||
3. 🧨 **`sqlite3` seguía inerte en la caja** (`Error relocating: compressBound: symbol not found`):
|
||
es el último de los 23 binarios del §6.4, y le faltaba `zlib-shared` — **sellado y declarado en
|
||
`perfil.base` desde el 2026-09-11**, pero la caja se instaló antes. Sin él no había snapshot
|
||
consistente, o sea que el respaldo habría copiado la sqlite a lo bruto. *Un paquete declarado no
|
||
es un paquete instalado: en una caja viva, lo que vale es lo que el `upgrade` proyectó.*
|
||
|
||
### 6.30 ⚰️ Tres servicios que MUEREN — y el cortafuegos que no puede correr todavía *(2026-09-15)*
|
||
|
||
Decisión del usuario sobre lo que quedaba del censo, y las tres son «no se muda»:
|
||
|
||
| servicio | por qué muere |
|
||
|---|---|
|
||
| **`qdrant`** (`:6333`, `:6334`) | es la base vectorial de `gioser_api`, y **ese backend es un fósil**: `api.gioser.net` da 502 permanente desde el §6.9. Una base sin consumidor no se muda. La receta `recipes/qdrant.toml` queda en el catálogo —**catálogo ≠ imagen**, y eso no es deuda—, pero no entra en ningún perfil. |
|
||
| **`act_runner`** (`:41027`) | el runner de CI del gitea, un binario suelto en `~/.local/bin` que nadie provee. Muere con la caja; si mañana hace falta CI, entra por receta y no por un binario copiado. |
|
||
| **`fail2ban-server`** | **tawasuyu ya tiene el sustituto**, y es mejor: `shared/cortafuegos` (§1 de su SDD-ENTRADA) no mira logs con un daemon, usa **dos sets dinámicos del kernel** (`ban4`/`ban6`, `flags dynamic`, `timeout`) con `limit rate over N/minute` **por IP de origen**. El ban lo aplica nftables sin proceso y sin latencia; fail2ban es un daemon de Python leyendo ficheros para hacer lo mismo peor. |
|
||
|
||
#### 🧨 Pero el sustituto NO PUEDE CORRER: el kernel de la caja no trae `nf_tables`
|
||
|
||
$ nft list ruleset
|
||
netlink: Error: cache initialization failed: Invalid argument
|
||
|
||
`nft 1.1.6` **está instalado** (`nftables` es raíz de `perfil.servidor` y está sellado). Lo que falta
|
||
está un piso más abajo, en el `.config` que el artefacto publica:
|
||
|
||
CONFIG_NETFILTER=y ← sí
|
||
CONFIG_NF_CONNTRACK=y ← sí
|
||
# CONFIG_NF_TABLES is not set ← AQUÍ
|
||
# CONFIG_NETFILTER_ADVANCED is not set ← y ésta es la causa: NF_TABLES cuelga de ella
|
||
|
||
Es otra vez la lección del §6.4 muro 1: **la receta dice lo que se CAMBIÓ; sólo el `.config` sellado
|
||
dice lo que QUEDÓ**. `linux-generic` sale de un `make defconfig`, y defconfig deja `NETFILTER_ADVANCED`
|
||
apagado, que arrastra a `NF_TABLES` con él.
|
||
|
||
⇒ **Hoy la caja no tiene cortafuegos de ninguna clase**: ni fail2ban (que no se muda), ni el
|
||
sustituto (que no arranca), ni una regla. Con `:22022`, `:2345`, `:1137`, `:80` y `:443` públicos,
|
||
eso hay que decirlo entero y no dejarlo implícito. La cura es un kernel con
|
||
`CONFIG_NETFILTER_ADVANCED=y` + `NF_TABLES` (y `NFT_CT`, `NFT_LIMIT`, `NFT_COUNTER`, `NFT_LOG`),
|
||
que es trabajo del SDD 22 y termina en un **reinicio de la caja de producción**.
|
||
|
||
#### La mitigación que NO depende del kernel, hecha hoy
|
||
|
||
El `sshd` de la caja ofrecía `password` y `keyboard-interactive` — su config eran **cuatro líneas**
|
||
y ninguna decía nada de autenticación, así que regía el default de OpenSSH. Sin cortafuegos y sin
|
||
fail2ban, eso es exactamente la puerta que aquéllos cerraban. Ahora es **sólo claves**, con los
|
||
cuatro controles corridos: la clave entra ✅, `:22022` contesta `Permission denied (publickey)` sin
|
||
ofrecer contraseña ✅, `:2345` igual ✅, y el `git ls-remote` por SSH sigue funcionando ✅.
|
||
|
||
**Lo que enseña:** *una defensa que se muda tiene que mudarse con su capacidad, no con su nombre*.
|
||
Decidir «fail2ban muere porque tenemos algo mejor» es correcto **y** deja un hueco abierto hasta que
|
||
ese algo mejor pueda ejecutarse. Entre la decisión y la capacidad hay un kernel de por medio.
|
||
|
||
### 6.31 ✅ La caja arrancó con `nf_tables` — y el init la levantó ENTERA sola *(2026-09-15)*
|
||
|
||
$ nft list ruleset
|
||
$ ← sin error: vacío, que es lo correcto en una caja sin reglas
|
||
|
||
El kernel `b3:63fdd57c…` bootea, y con él **`nft` funciona por primera vez en una máquina takana**.
|
||
Comprobado con el mecanismo exacto que reemplaza a fail2ban, no con una regla de adorno: un
|
||
`set ... flags dynamic; timeout 10s` con `add @ban4 { ip saddr timeout 10s limit rate over 5/minute }
|
||
drop` — aceptado por el kernel (`rc=0`) y releído tal cual. Eso es el ban por IP **dentro del
|
||
kernel**, sin daemon.
|
||
|
||
#### Lo que el reinicio probó de paso, y vale más que el kernel
|
||
|
||
**La Semilla levantó los OCHO entes sola, todos con `↻ 0`:** `sshd`, `gitea`, `caddy`, `squid`,
|
||
`crond`, `chronyd`, `sergioh-api` y `gioser-php` — o sea los dos servicios enjaulados en `qorpa`
|
||
(con su `newuidmap`, su overlay y su `harkaq`) **arrancan desde el genesis igual que un binario
|
||
nativo**. Todo lo que se fue editando en `/ente/seed.card.json` a lo largo del día era, hasta este
|
||
momento, una declaración sin probar: un reinicio es el único examen que toma.
|
||
|
||
Desde fuera, al minuto: los siete dominios en **200**, el proxy en **407** y `git ls-remote` por
|
||
SSH:2345 devolviendo el commit. Cero intervención.
|
||
|
||
#### ⚠ `reboot` NO reinicia una caja con arje-zero
|
||
|
||
`reboot` de busybox le pide a PID 1 que lo haga, y **arje-zero no implementa ese protocolo**: el
|
||
comando vuelve, no dice nada, y la máquina sigue arriba (`up 4 days` después de «reiniciar»). Lo que
|
||
sí funciona es **`reboot -f`**, que llama a `reboot(2)` directo saltándose al init. Con `sync` antes,
|
||
claro: el corte es duro por definición.
|
||
|
||
⇒ **Y falta la pieza**: un init que gobierna un servidor de producción tiene que atender un apagado
|
||
ordenado. Hoy la única vía es el corte duro, que ya costó el `write_atomic` no durable del §6.13.
|
||
|
||
#### 🧨 Casi reinicio el HUB por un backtick en un `echo`
|
||
|
||
Escribiendo el aviso del reinicio, el mensaje decía ``… no atiende el `reboot` normal …`` dentro de
|
||
comillas DOBLES. La shell ejecutó lo que había entre backticks: **`reboot`, en gioser**. No pasó nada
|
||
por una razón que no es mérito de nadie —la sesión no es root y OpenRC contestó «you must be root»—
|
||
y el hub lleva 11 días arriba, con la granja y el cron encima.
|
||
|
||
Es la **tercera vez en el día** que un backtick dentro de un texto se ejecuta
|
||
([[heredoc-sin-comillas-ejecuta-comentarios]]), y las dos anteriores sólo se comieron un comentario.
|
||
La regla deja de ser de estilo: **nunca un backtick dentro de comillas dobles en una shell** —
|
||
comillas simples, o `$(printf '%s' …)`, o directamente no poner el acento grave en el texto.
|
||
|
||
#### Lo que queda para cerrar el frente del cortafuegos
|
||
|
||
El kernel ya no estorba; falta **la política**: `PoliticaEntrada` (.ron) → `generate-input` →
|
||
`apply-input` del cortafuegos de tawasuyu, con los puertos reales de la caja (`22022`, `2345`, `80`,
|
||
`443`, `1137`). ⚠ Y hay que aplicarla con **interruptor de hombre muerto** (aplicar, esperar, y
|
||
`flush` automático si nadie confirma): una regla mal puesta en una caja **sin consola serie** deja
|
||
la única salida en `hcloud server enable-rescue`.
|
||
|
||
### 6.32 🧱 El cortafuegos, puesto — y el primer baneado fui yo *(2026-09-15)*
|
||
|
||
La caja filtra: `table inet tawasuyu_input`, **`policy drop`**, 15 reglas de puerto, y el set `ban4`
|
||
llenándose solo con lo que golpea desde internet. A los pocos minutos: **27 IPs baneadas y paquetes
|
||
tirados por el contador**. Eso es el reemplazo de fail2ban funcionando sobre tráfico real, dentro del
|
||
kernel y sin daemon.
|
||
|
||
La política vive en `scripts/servidor/cortafuegos-entrada.ron` (formato `PoliticaEntrada` del
|
||
cortafuegos de tawasuyu) y declara los cinco puertos públicos: `22022` admin, `2345` git, `80`, `443`
|
||
y `1137` el proxy. El reglaset se genera con `cortafuegos generate-input` **en el hub** —el binario es
|
||
glibc y la caja es musl— y se aplica allá con `nft -f`.
|
||
|
||
#### 🧨 El primer baneado fui yo, en segundos
|
||
|
||
Aplicada la primera versión, desde fuera: `ssh` ✅, `https` ✅… y **`http`, el proxy y el git por SSH
|
||
muertos**. Al minuto, la caja entera dejó de contestarme.
|
||
|
||
La causa no es una regla mal escrita: **el set `@ban4` es UNO SOLO para todos los servicios**, y la
|
||
regla `ip saddr @ban4 drop` está antes que todo. O sea que pasarse de tasa en *un* puerto te tira en
|
||
*todos*. Y el `nuevas_por_min: 5` del puerto de administración —correcto contra internet— es
|
||
exactamente lo que **me** pasa a mí: cada comando remoto de esta sesión es una conexión nueva.
|
||
|
||
Lo que devolvió la máquina fue el **interruptor de hombre muerto**: antes de aplicar se deja
|
||
programado un `nft flush ruleset` a 180 s, y sólo se desarma si la verificación desde fuera sale
|
||
bien. Sin consola serie, esa es la diferencia entre un susto y un rescue.
|
||
|
||
⚠ **Y el tipo ya lo avisaba.** `PoliticaEntrada` tiene un campo `confiables` cuyo comentario dice,
|
||
palabra por palabra, lo que me acababa de pasar: *«un `nuevas_por_min` chico en el puerto de SSH es
|
||
exactamente lo que se quiere contra internet y exactamente lo que te deja afuera cuando el que abre
|
||
seis sesiones en un minuto sos vos»*. Copié el `.ron` de ejemplo en vez de leer el tipo. Es
|
||
[[lei-hasta-donde-me-daba-la-razon]] otra vez, y la cura fue una línea:
|
||
`confiables: ["204.168.193.248", "154.197.1.13"]` — el hub y el worker, que se aceptan **antes** de
|
||
la lógica de tasa y por lo tanto no pueden entrar al set.
|
||
|
||
#### Lo que este kernel todavía no puede, dicho en voz alta
|
||
|
||
`nft -c` rechazó **cinco reglas** antes de aplicar nada:
|
||
|
||
tcp dport 22022 ct count over 10 drop
|
||
^^^^^^^^ Error: Could not process rule: No such file or directory
|
||
|
||
`ct count` es `CONFIG_NFT_CONNLIMIT`, que el kernel de hoy no trae (el `.config` lo dice:
|
||
`# CONFIG_NFT_CONNLIMIT is not set`). Es el tope de conexiones **concurrentes** por servicio — el ban
|
||
por tasa, que es lo que reemplaza a fail2ban, no depende de él. Las cinco reglas quedan
|
||
**comentadas, no borradas**, en el fichero que se aplica: *lo que la política declara y el kernel no
|
||
puede tiene que verse*. El símbolo ya entró a `recipes/linux-generic.toml` y vuelve con el próximo
|
||
kernel; hasta entonces el mensaje —que no nombra la opción que falta— queda explicado ahí mismo.
|
||
|
||
#### Que sobreviva al reinicio: un servicio `oneshot`
|
||
|
||
`recipes/nftables.toml` declara ahora su `[[service]]` con `lifecycle = "oneshot"`: **cargar un
|
||
reglaset no es un daemon, es un acto**. Va **primero en el genesis**, antes que sshd y los demás:
|
||
entre que la red está y que las reglas cargan, la caja está abierta, y ese hueco se hace lo más
|
||
corto posible. Probado de verdad — `nft flush ruleset` y después `arjectl start cortafuegos`: las 15
|
||
reglas vuelven.
|
||
|
||
Su guarda sale **78 nombrando el fichero** si el reglaset no está, en vez de dejar la caja sin
|
||
filtrar creyendo que está protegida: *un cortafuegos ausente y uno vacío se ven igual desde afuera
|
||
hasta que alguien prueba*.
|
||
|
||
### 6.33 🧨 EL CORTAFUEGOS BANEÓ A LOS USUARIOS DEL PROXY — y squid nunca se cayó *(2026-09-16)*
|
||
|
||
El usuario preguntó si el proxy estaba caído. **No lo estaba**: el ente `squid` llevaba horas
|
||
`corriendo` con `↻ 0`, escuchando en `:1137` y contestando `407` a quien preguntara. Lo que estaba
|
||
caído era **el acceso**: el cortafuegos que puse ayer estaba baneando a los usuarios autorizados.
|
||
|
||
La prueba, en una línea: **`154.197.1.2` —una IP que está en la ACL de squid, o sea gente con
|
||
contraseña— apareció en `@ban4`**. Y el contador del ban iba en **38.246 paquetes tirados**.
|
||
|
||
#### La causa no es la tasa: es la RÁFAGA
|
||
|
||
tcp dport 1137 ct state new add @ban4 { ip saddr timeout 300s limit rate over 300/minute burst 5 packets } drop
|
||
|
||
`burst 5` significa «tolero cinco conexiones por encima del promedio». **Un navegador o un agente
|
||
detrás de un proxy abre decenas de CONNECT en el mismo instante**, aunque su promedio sea bajísimo:
|
||
seis de golpe y ya estás en el set. Y como **`@ban4` es UN SOLO set consultado antes que todo**,
|
||
caer por el puerto del proxy te tira también el web y el git.
|
||
|
||
⚠ El tipo del cortafuegos ya traía el campo (`rafaga`, default 5) con un comentario que decía
|
||
«para un servicio web va alto (100-200)». Otra vez: **leí el `.ron` de ejemplo y no el tipo**.
|
||
|
||
#### Lo que se hizo, en orden de urgencia
|
||
|
||
1. **`nft flush set … ban4/ban6`** — servicio restaurado en el acto.
|
||
2. **Los usuarios del proxy a `confiables`**: la misma lista que la ACL de squid (21 IPs + 5 redes).
|
||
Un proxy que autentica no necesita que el kernel le cuente las conexiones a un usuario conocido;
|
||
lo que el ban tiene que parar son las fuentes DESCONOCIDAS.
|
||
3. **`rafaga` por servicio, según su clientela**: ssh 5 · git 20 · web 100 · **proxy 200**. Y el
|
||
proxy pasa a `1200/min` con ban de 60 s: red de contención contra un flood, no control de uso.
|
||
4. Control después: **cero autorizados en el set**, 24 baneados (escáneres, que es lo que se quiere),
|
||
y tráfico real pasando — `154.194.14.37 … CONNECT api.deepseek.com:443 sigma`, 298 K tunelados.
|
||
|
||
⚠⚠ **Y un fallo de método que casi lo tapa: `nft -f` SUMA a la tabla existente.** Cargué el reglaset
|
||
corregido encima del viejo y quedaron **30 reglas**: las viejas ADELANTE, así que los `confiables`
|
||
nuevos no llegaban a evaluarse nunca. Se vio contando (`grep -c dport` daba 30 y no 15) y mirando
|
||
qué `saddr` salía primero. Por eso `cortafuegos apply-input` **borra la tabla antes de cargar** —
|
||
usar `nft -f` a pelo se saltea esa mitad.
|
||
|
||
#### La hora exacta, reconstruida del `access.log`
|
||
|
||
El usuario preguntó a qué hora se bloqueó, y el log lo dice sin ambigüedad. Su IP —`45.234.61.160`,
|
||
autenticada como `sergio`, **2709 peticiones**— tuvo estos silencios:
|
||
|
||
| silencio | desde | hasta |
|
||
|---:|---|---|
|
||
| **93,2 min** | 16-09 **11:59:37** UTC | **13:32:49** UTC ← cuando vacié los sets |
|
||
| 50,1 min | 10:44:58 | 11:35:06 |
|
||
| 10,0 min | 11:45:06 | 11:55:05 |
|
||
|
||
Los tres son el **`timeout` de 10 minutos del ban disparándose una y otra vez**: cada vez que volvía,
|
||
su primera ráfaga lo baneaba de nuevo. `154.197.1.2`, que usa el proxy de forma sostenida pero sin
|
||
ráfagas, sólo perdió huecos de 5 minutos.
|
||
|
||
🧨 **Y la exención que tenía no servía: su IP no estaba en la red eximida.** La ACL histórica de squid
|
||
exime `45.234.60.0/24` y él entra desde `45.234.61.160` — **una red de al lado**. Parecía cubierto y
|
||
no lo estaba: la pertenencia se MIDE (`ip in red`), no se deduce del parecido del prefijo. Con el
|
||
`/23` que nft dedujo al unir los dos `/24`, ahora sí.
|
||
|
||
#### Y la decisión de fondo: en un servicio que YA AUTORIZA, no va límite por tasa
|
||
|
||
El uso real de esta caja es **intensivo** —varias sesiones de agente en paralelo, cada una abriendo
|
||
túneles a la vez—, y un límite calibrado para «una persona normal navegando» no protege de nada: el
|
||
atacante ajusta su ritmo, el usuario no puede trabajar más despacio. Squid ya pide usuario y
|
||
contraseña y tiene su ACL; el ban por tasa ahí no agrega seguridad, agrega cortes. Queda apagado
|
||
(60000/min, ráfaga 1000: números que ningún cliente real alcanza) y contra un flood queda el tope
|
||
GLOBAL `syn_nuevas_por_seg`, que no distingue usuarios ni banea a nadie por ser rápido.
|
||
|
||
#### Lo que esto enseña del cortafuegos como herramienta
|
||
|
||
Un cortafuegos correcto y un cortafuegos usable no son lo mismo, y la diferencia **no se ve al
|
||
aplicarlo**: las reglas cargan, el `nft -c` pasa, los sitios responden, y el daño aparece sólo cuando
|
||
un cliente REAL hace lo que los clientes reales hacen. El único control que lo habría cazado antes es
|
||
el que no corrí: **una ráfaga de conexiones desde una IP que NO esté en `confiables`**.
|
||
|
||
### 6.34 📋 Censo del 2026-09-16: queda UN vhost — y el censo sin privilegio no veía 572 M
|
||
|
||
Censo fresco de gioser (`censar.py --local --probe-dns`, sólo lee), para saber **qué falta de
|
||
verdad** en vez de arrastrar la lista del 11.
|
||
|
||
```
|
||
servicios vivo 23 · no-declarado 17 · declarado-muerto 81 · descartados 6
|
||
dominios apunta-aca 1 · ya-mudado 6 · fosil-sin-dns 7
|
||
datos /home 25 G (copiar 27 G) · /var/lib 8,1 G · /opt 1,2 G · /var/www 288 M
|
||
```
|
||
|
||
**De los 29 dominios del Caddyfile sólo UNO sigue sirviéndose desde gioser: `sergio.gioser.net`.**
|
||
Comprobado desde fuera, resolución + HTTP, no por el fichero de config:
|
||
|
||
| dominio | resuelve a | HTTP |
|
||
|---|---|---|
|
||
| `tawasuyu.net` · `gioser.net` · `git.tawasuyu.net` · `takana.gioser.net` · `hifas.gioser.net` · `api.sergio.gioser.net` | `2.29.29.217` | 200 |
|
||
| `gitea.gioser.net` | `2.29.29.217` | 301 |
|
||
| **`sergio.gioser.net`** | **`204.168.193.248` (gioser)** | 200 |
|
||
| `api.summa.gioser.net` | `154.197.1.2` (otra máquina) | — |
|
||
| `terapeuta.ec`, `andino.ec`, `api.sigma…` y 4 más | **sin DNS** | — |
|
||
|
||
⇒ la lista de «tres vhosts» del §9 quedó vieja: `gioser.net` y `tawasuyu.net` ya están mudados
|
||
(§6.26) con su origen vivo a propósito. **Lo único que ata el DNS a gioser es la consola de `sergio`.**
|
||
|
||
#### El rescate del §6.20 sigue vigente — y caduca de verdad
|
||
|
||
Seis procesos corren hoy desde un **inodo borrado**. Cuatro tienen su copia en
|
||
`work/mudanza/rescate/` y el sha256 del `/proc/<pid>/exe` vivo **coincide bit a bit** con lo
|
||
rescatado el 12. Los otros dos (`/usr/local/bin/tejido` y `/usr/local/bin/shuma-daemon`) **no están
|
||
en el rescate**, pero en disco hay una versión **más nueva** de cada uno (hoy 11:13 y 14:20): no se
|
||
perdió nada porque el usuario los está reconstruyendo. La lección es la del §6.20 y no cambió: *ese
|
||
paso caduca, y quién caducó se ve mirando `/proc`, no el directorio*.
|
||
|
||
#### 🕳️ Y el hallazgo del día: `-e` contesta «no existe» cuando lo que pasa es «no puedo mirar»
|
||
|
||
El censo tiene tres estados a propósito (`ausente` / `sin_permiso` / `ok`) porque un `ls` sin
|
||
permiso se lee igual que un directorio vacío. Pero el tercer estado sólo se detectaba **sobre la
|
||
propia ruta**: cuando el que no deja pasar es un **ANCESTRO**, `[ -e "$p" ]` contesta que no, y la
|
||
ruta caía en `ausente` — que el censo **descarta sin decir nada**.
|
||
|
||
```
|
||
/root/.local drwx------ root root ← 0700
|
||
/root/.local/bin 572 M de binarios a mano que NINGÚN paquete provee
|
||
```
|
||
|
||
Corrido como `sergio`, el censo decía que `/root/.local/bin` no existe. Corrido como root, son
|
||
**572 M** de exactamente la clase que la mudanza no puede reconstruir: binarios puestos a mano.
|
||
Arreglado —si un ancestro existe y no es atravesable, el estado es `sin_permiso`— y probado en los
|
||
dos sentidos, que es lo que distingue un guardián de un adorno:
|
||
|
||
```
|
||
ancestro 0700 de otro dueño → sin_permiso (y sale en el ⚠ del resumen)
|
||
ruta que NO existe de verdad → ausente (y NO se reporta) ← el control que tiene que pasar
|
||
```
|
||
|
||
Con el arreglo, el censo como `sergio` pasa de **2 rutas ciegas a 13** — entre ellas el home entero
|
||
de `artix`, que antes no figuraba de ninguna manera. **El censo se corre como root**; la lista de
|
||
ciegas es el recibo de lo que una corrida sin privilegio no puede ver.
|
||
|
||
#### Lo que queda, y por qué no lo decide el programa
|
||
|
||
Descontados los que ya se decidieron (§6.30: `qdrant`, `act_runner`, `fail2ban` mueren) y los que
|
||
provee el init del destino, lo que sigue vivo en gioser es **un montón de daemons de tawasuyu que
|
||
ningún paquete provee** — binarios a mano en `/usr/local/bin` (1,2 G) y `~/.local/bin` (765 M + 572 M
|
||
en el home de root):
|
||
|
||
```
|
||
shuma-daemon · shuma-gateway (:7378) la consola de sergio.gioser.net — el único vhost que queda
|
||
willay-daemon · willay-crosscheck · tejido (:4102,:33097) · pacha · pacha-secretos
|
||
matilda · tupu · thasnuna · sandokan-watch · openclaw · zeroclaw
|
||
puerta-f6e393ffbef3999e (:34221,:44961) binario de test en target/debug, BORRADO del disco
|
||
webhook-deploy.py
|
||
```
|
||
|
||
`planear.py --revisar` los saca con su motivo, pero **la decisión es del usuario y el programa no la
|
||
va a inventar**: son las herramientas propias del usuario, no servicios de un catálogo. Y los
|
||
**datos** tampoco se recomiendan solos (§3): `/home` son 25 G que incluyen 6,8 G de `.claude` y
|
||
1,3 G de binarios sueltos.
|
||
|
||
### 6.35 🧳 Los binarios sueltos, desglosados — y qué de `.claude` viaja *(2026-09-16)*
|
||
|
||
Decisión del usuario sobre el §6.34: **shuma se construye desde tawasuyu** (ya está encolado:
|
||
`recipes/incoming/shuma-{gateway,daemon}.toml`), **los daemons de tawasuyu viven todos**, y de
|
||
`.claude` decide el criterio de quien lo usa. Falta el detalle de los sueltos, que es esto.
|
||
|
||
#### Son 2,7 G en tres directorios, y casi nada de eso es irreemplazable
|
||
|
||
| clase | cuánto | qué es | se reconstruye |
|
||
|---|---|---|---|
|
||
| **tawasuyu** | 57 ficheros · **1003 M** | `pata-llimphi` 105 M, `nahual-shell-llimphi` 98 M, `shuma-shell-llimphi` 76 M, los 14 `mirada-*`, `pata-*`, `shuma-*`, `sandokan-*`, `willay-*`, `pacha`, `tejido`, `tupu`, `matilda`, `thasnuna`… | **sí**: son salida del monorepo, que ya vive en el gitea de la caja |
|
||
| **terceros** | 10 ficheros · **841 M** | `kiro-cli*` 571 M (tres binarios), `kcl` 95 M, `qdrant` 91 M (muere, §6.30), `gitea-runner` 21 M, `act_runner` 21 M (muere), `listmonk` 19 M, `hcloud` 18 M, `goaccess` 3,4 M | no, pero **se vuelven a bajar de su origen** |
|
||
| **respaldo / duplicado** | 21 ficheros · **876 M** | `/root/.local/bin` es **una copia entera de `kiro-cli*`** (571 M) más el CLI de `claude` (219 M); el resto son `.respaldo-FECHA`, `.previo` y `.bak` | no hace falta |
|
||
| **guiones** | 27 ficheros · **< 1 M** | `git-deploy`, `webhook-deploy.py`, `update-squid-allowed-ips.sh`, `mirada-session*`, `huellita-aviso`… | **NO** ← lo único de verdad frágil |
|
||
|
||
⇒ **lo caro no es lo valioso**. De los 2,7 G, 1,8 G son reconstruibles o duplicados, y lo que no
|
||
está en ningún repo ni en ningún paquete son **27 guiones que suman menos de un megabyte** — el
|
||
pegamento que nadie recuerda hasta que falta. Van al plan como una sola entrada, y con nombre.
|
||
|
||
Y los **572 M de `/root/.local/bin`** que el censo no veía (§6.34) resultaron ser exactamente eso:
|
||
la copia de `kiro-cli*` que ya está en el home de `sergio`. La lección no cambia —un censo que dice
|
||
«no hay nada» de lo que no pudo mirar es peor que uno que falla—, pero el contenido, esta vez, era
|
||
duplicado.
|
||
|
||
#### `.claude`: 6,8 G en disco, **15 M es lo que hace falta para seguir funcionando igual**
|
||
|
||
Criterio, porque es lo que me deja trabajar allá como acá:
|
||
|
||
| viaja | qué | tamaño |
|
||
|---|---|---|
|
||
| ✅ | `projects/*/memory/` de los 18 proyectos — la memoria destilada | **6,2 M** |
|
||
| ✅ | `settings.json`, `plugins/`, `history.jsonl` | ~8,6 M |
|
||
| ⛔ | `jobs/` (5,3 G), `downloads/` (228 M), `archivo-sesiones/` (286 M), `file-history/`, `shell-snapshots/`, `cache/`, `paste-cache/` | 6,1 G |
|
||
| ⚠ | `projects/*/*.jsonl` — los transcripts crudos (973 M) | ver abajo |
|
||
|
||
Los **transcripts no hacen falta para funcionar**: la memoria es su destilado y es lo que se lee al
|
||
arrancar una sesión. Lo que se pierde sin ellos es `--resume` de una sesión vieja y la posibilidad
|
||
de releer una conversación que nadie resumió. Por eso no van a la caja —que tiene 42 G libres en
|
||
`/work` y los quiere para el store— sino al **Storage Box del respaldo**, que es donde vive lo que
|
||
se guarda por si acaso. La memoria, en cambio, viaja con la máquina.
|
||
|
||
#### ⚠ Y una trampa de ruta que haría todo esto inútil
|
||
|
||
**La memoria se indexa POR RUTA**: el directorio se llama `-mnt-vvv-takana` porque el repo vive en
|
||
`/mnt/vvv/takana`. En la caja el repo está en **`/opt/takana`** ⇒ copiar `memory/` tal cual deja los
|
||
163 ficheros en disco y **ninguno se encuentra**, sin que nada falle. Las dos salidas son renombrar
|
||
el directorio a la ruta de destino (`-opt-takana`) o darle al repo la misma ruta que acá. Se elige
|
||
al copiar, no después.
|
||
|
||
#### 🕳️ La caja no tiene usuario `sergio`
|
||
|
||
`/etc/passwd` de `2.29.29.217`: `root`, `sshd`, `gitea`, `proxy`. Nada más. En gioser,
|
||
`shuma-daemon` corre como `sergio` —y `shuma-gateway` también—, así que la tarjeta de arje que los
|
||
declare tiene que **crear la cuenta primero**; y el payload `Native` de arje **no tiene campo de
|
||
usuario** (SDD 29 §4.0 quinquies), así que declararlos sin más los pone a correr como root. Es la
|
||
misma advertencia que ya lleva `recipes/incoming/shuma-daemon.toml` en su cabecera, escrita donde se
|
||
va a leer.
|
||
|
||
### 6.36 🚚 Lo que se movió hoy — y las cuatro verificaciones que se hicieron EN DESTINO *(2026-09-16)*
|
||
|
||
Con las decisiones del §6.35 puestas, se movió lo que no dependía de nada más. **Cada copia se
|
||
verificó del lado del destino**, que es la regla que el `rsync` con exit 0 y 1367 artefactos vacíos
|
||
dejó escrita (§6.13, y la regla 2 de `aplicar.py`).
|
||
|
||
| qué | a dónde | verificación en destino |
|
||
|---|---|---|
|
||
| **los 27 guiones** (75 KB) | `takana:/work/rescate-guiones/` | `sha256sum -c SHA256SUMS` ⇒ **27 OK** |
|
||
| **la memoria de Claude** (733 ficheros, 6,3 M) + `settings.json`, `plugins`, `history.jsonl` | `takana:/root/.claude/` | 733 ficheros en origen y **733 en destino** |
|
||
| **los transcripts crudos** (1 966 ficheros, 1,3 G) | Storage Box `claude-gioser/` | `rsync -an` de vuelta: **1 solo fichero pendiente**, y es el transcript de la sesión que estaba escribiéndolo |
|
||
| **la clave PRIVADA de release** | `takana:/root/.config/takana/keys/` 0600 | sha256 idéntico en los dos lados **y** su pública == `trust/release.ed25519.pub` |
|
||
| credenciales y config (gitconfig, npmrc, gh, ssh, crontabs, Caddyfile) | `takana:/work/mudanza-secretos/` 0700 | 27 ficheros en origen y **27 en destino** |
|
||
| estado de los daemons que viven (`sigma`, `tupu`, `willay`, `sandokan-watch.json`) + `/var/www/para-respaldar` | `takana:/work/mudanza-estado/` | 351 ficheros y 68 M en los dos lados |
|
||
|
||
#### ⚠ Las credenciales NO se instalaron en su sitio, a propósito
|
||
|
||
La caja **ya tiene** su `/root/.ssh` (con `github5`), su crontab (el latido de la caja, §6.29) y su
|
||
`/etc/caddy/Caddyfile` con los siete vhosts que ya sirve. Pisar cualquiera de los tres con el de
|
||
gioser rompe lo que hoy funciona. Quedan en `/work/mudanza-secretos/` con su `LEEME.txt`, y se
|
||
instalan **cuando se mude el hub**, que es otra unidad de trabajo. El `.gitconfig` es el caso más
|
||
claro: trae los `insteadOf` que **reescriben remotos**, así que instalarlo cambia a dónde empuja
|
||
`git push` sin que nadie lo pida.
|
||
|
||
La **única** excepción es la clave de release, que sí fue a su ruta canónica: sin ella nadie puede
|
||
volver a firmar el repo, y ese fallo no aparece el día que se borra gioser sino la próxima vez que
|
||
alguien publica.
|
||
|
||
#### 🔍 Un «576 M contra 1,3 G» que parecía una copia a medias
|
||
|
||
`du -sh` en el Storage Box decía **576 M** de los 1,3 G subidos. Con el antecedente del rsync que
|
||
llenó el disco, eso se lee como copia truncada. No lo era: el Storage Box **comprime**, y su shell
|
||
restringida no tiene `find` ni acepta tuberías, así que el conteo de ficheros —la verificación
|
||
obvia— ahí no se puede hacer. Lo que sí se puede es **preguntarle a rsync**: una corrida `-an`
|
||
compara tamaño y fecha contra el destino real y dijo **1 fichero pendiente**, el que estaba
|
||
creciendo mientras se copiaba.
|
||
|
||
⇒ *la verificación tiene que poder correrse en el destino que hay, no en el que uno imagina*. Y un
|
||
número que no cuadra merece una segunda medición antes de un diagnóstico: `du` medía otra cosa.
|
||
|
||
#### Lo que NO se movió, y no es olvido
|
||
|
||
**~10 G de árboles personales en `/home/sergio`** —`android-sdk` 1 G, `maps`, `imm`, `humanoid`,
|
||
`hifas`, `fdroid`, `valens-conversion`, `pyswisseph`, `accordion`…— y `/opt` (1,2 G, que es
|
||
`android-sdk` otra vez y `google`). **Qué datos valen no se deduce de la máquina** (§3), y éstos son
|
||
proyectos del usuario, no servicios de un censo: van a la lista de decisiones, no a una copia hecha
|
||
por criterio ajeno.
|
||
|
||
De `/var/lib` (8,1 G) se movió sólo el estado de los daemons que viven: **6,1 G son swap** y 1,9 G
|
||
el gitea, que ya está mudado desde el §6.14.
|
||
|
||
### 6.37 ✅ CUTOVER DE `sergio.gioser.net` — el último vhost, y gioser deja de servir *(2026-09-16)*
|
||
|
||
**Ya no queda ningún dominio sirviéndose desde gioser.** El último era éste, y lo que lo ataba no
|
||
era el sitio sino su consola: `/shuma/*`. Verificado desde fuera, contra la IP nueva:
|
||
|
||
```
|
||
https://sergio.gioser.net/ → 200 408 bytes, la SPA
|
||
https://sergio.gioser.net/shuma/ → 200 «shuma-gateway ok» ← el gateway, no la SPA
|
||
https://sergio.gioser.net/ruta-inventada → 200 la SPA (el fallback, que es lo correcto)
|
||
```
|
||
|
||
⚠ **El `200` de `/shuma/` había que mirarlo dos veces.** El bloque termina en
|
||
`try_files {path} {path}/ /index.html`, así que **cualquier ruta inexistente devuelve 200 con la
|
||
SPA**: un `curl -o /dev/null` que sólo mira el código habría dado el mismo verde con el proxy mal
|
||
puesto. Lo que decide es el CUERPO — 17 bytes que dicen `shuma-gateway ok` — y el control es
|
||
compararlo con lo que el origen viejo sigue sirviendo, byte por byte. Igual con `/shuma/rpc`: las
|
||
dos máquinas contestan el MISMO `400 {"error":"bad json: …"}`.
|
||
|
||
#### Lo que hubo que construir, en orden
|
||
|
||
1. **Las dos recetas** (§6.34) sellaron en el worker: `b3:107dfb54…-shuma-gateway` (6,3 M) y
|
||
`b3:f9b92acc…-shuma-daemon` (5,6 M) — los hashes que `takana hash` había anticipado antes de
|
||
encolarlas. `file` dice **`statically linked`**: nada de cargador, que era exactamente lo que le
|
||
faltaba al binario glibc de gioser (y cuyo inodo, además, ya estaba borrado del disco).
|
||
2. **La cuenta, declarada y sin privilegio.** `[[user]] shuma` (uid 967) en la receta del daemon, y
|
||
el descenso con `setuidgid` dentro del `argv` de la Card, igual que gitea — porque el payload
|
||
`Native` de arje **no tiene campo de usuario**. Declararlo «como estaba» lo habría puesto de
|
||
root, y esto es un demonio que abre PTYs y lanza shells.
|
||
3. **⚠ Y la shell de la cuenta NO es un detalle.** El default de `[[user]]` es `/bin/false`, que es
|
||
lo correcto para gitea o squid y **exactamente lo contrario** para éste: con la shell inerte el
|
||
servicio arranca, se supervisa, contesta 200… y cada pestaña que alguien abra muere al instante.
|
||
Un fallo que no se ve al desplegar: se ve al usarlo. Va `shell = "/bin/sh"`.
|
||
4. **La config que no se regenera.** El `gateway-token` (32 B) y `keys/identity.x25519` viajaron a
|
||
`/var/lib/shuma/.config/shuma/`: generar unos nuevos deja fuera a los clientes ya emparejados.
|
||
Por eso la guarda de la Card **exige** el token y no lo crea, y dice de dónde sale.
|
||
5. **Las dos Cards** entran en `/etc/arje/cards.d/` **y** en el `genesis` de `/ente/seed.card.json`
|
||
(son dos preguntas distintas: qué se puede encarnar a pedido y qué arranca solo). La semilla
|
||
quedó con 13 entradas y vuelve a parsear.
|
||
6. **Declarado en `perfil.servidor`**: los dos paquetes y los dos labels en `servicios`. Entran como
|
||
PAR — el gateway sin el daemon es un puente a ninguna parte.
|
||
|
||
`[[user]]` y `[[service]]` están **fuera de `hash_inputs`**, así que declarar todo eso no movió
|
||
ningún ArtifactHash: comprobado en los dos, antes y después.
|
||
|
||
#### 🔁 El DNS: la API tiene una acción para esto y el §6.25 no la había encontrado
|
||
|
||
Aquel cutover dejó escrito «un `PUT` sobre el rrset no puede cambiar el tipo: hay que `DELETE` del
|
||
CNAME y `POST` del A». Es cierto para un cambio de TIPO. Para cambiar el VALOR, el `PUT` tampoco
|
||
sirve —`can't update records with this endpoint`— pero existe una acción que lo hace **atómica**:
|
||
|
||
```
|
||
POST /v1/zones/<id>/rrsets/<nombre>/<tipo>/actions/set_records {"records":[{"value":"…"}]}
|
||
```
|
||
|
||
Sin ventana sin registro, que es lo que sí tiene el `DELETE`+`POST`. Con TTL 60 la resolución
|
||
pública cambió **en menos de 8 segundos**, y volver atrás es la misma llamada con la IP vieja.
|
||
|
||
#### El origen sigue vivo, a propósito
|
||
|
||
Como en §6.26: en gioser `shuma-daemon`, `shuma-gateway` y su bloque de caddy **siguen corriendo**.
|
||
Mudar el nombre y dejar el origen encendido permite volver en un minuto, y no cuesta nada mientras
|
||
la máquina exista. Lo que ya no existe es la dependencia: **ningún dominio resuelve a gioser**.
|
||
|
||
⚠ Pendiente menor, anotado donde se va a leer: el daemon avisa al arrancar que no encuentra
|
||
`shuma-askpass` («sudo/ssh pedirán la clave por el TTY»). Es otro crate del mismo workspace y es
|
||
otra receta — no bloquea la consola.
|
||
|
||
### 6.38 🧩 Los primeros cuatro daemons propios, corriendo en la caja *(2026-09-16)*
|
||
|
||
De las diez recetas encoladas, cuatro sellaron y ya corren supervisadas por arje:
|
||
|
||
| | cuenta | qué hace, comprobado |
|
||
|---|---|---|
|
||
| `matilda` | **root** | lee el access log de caddy y archivó **78 ficheros** en `/var/lib/tupu/takana/` |
|
||
| `pacha` | `pacha` (965) | vivo, 0 reinicios, con SUS reglas (`reglas.ron`), no las de fábrica |
|
||
| `pacha-secretos` | `pacha` | vivo, 0 reinicios — y su binario en gioser era un **inodo borrado** |
|
||
| `sandokan-watch` | root | **frenado a propósito**: no puede trabajar acá (ver abajo) |
|
||
|
||
#### ⚠ Tres corren como ROOT, y está declarado en vez de heredado
|
||
|
||
Lo natural sería una cuenta sin privilegio por servicio, como `shuma` o `gitea`. Con el trío de la
|
||
telemetría **no se puede**, y las dos razones son medidas:
|
||
|
||
1. `matilda` lee `/var/log/caddy/requests.json`, que caddy crea **`-rw------- root root`** y recrea
|
||
igual en cada rotación: un grupo con permiso de lectura se pierde en la próxima.
|
||
2. `tupu`, `matilda` y `sandokan-watch` escriben en el MISMO `/var/lib/tupu`, y en gioser sus 322
|
||
ficheros son `root:root`. Bajar uno solo deja **mezcla de dueños en un árbol compartido**: escribe
|
||
el primero y los otros fallan al modificar.
|
||
|
||
⇒ o los tres como root, o ninguno. La salida limpia está anotada en la receta para el día que se
|
||
quiera: que caddy escriba con `mode 640` y que el árbol sea de una cuenta propia. `pacha` y
|
||
`pacha-secretos` sí bajan a una cuenta propia, porque en gioser tampoco corrían como root.
|
||
|
||
#### 🕳️ La caja se llamaba «(none)» — y eso se archivaba
|
||
|
||
`matilda` archiva POR MÁQUINA, y sus primeros 72 ficheros fueron a `/var/lib/tupu/**none**/`:
|
||
|
||
```
|
||
$ hostname → (none)
|
||
$ cat /etc/hostname → no existe
|
||
```
|
||
|
||
**Ninguna caja takana fija su hostname**: no lo hace `netup`, no está en el product-rootfs, no hay
|
||
`/etc/hostname`. Nunca importó porque nada archivaba por nombre — hasta que llegó algo que sí. Y no
|
||
falla: archiva bajo `none`, y dos máquinas distintas archivarían en la misma carpeta.
|
||
|
||
Puesto `/etc/hostname` + una Card **OneShot** `hostname` en el genesis, para que sobreviva al
|
||
reinicio sin depender de que alguien se acuerde. Con el nombre puesto, matilda re-archiva en
|
||
`/var/lib/tupu/takana/`. Quedan 72 ficheros huérfanos bajo `none/`: son los 20 minutos anteriores.
|
||
|
||
⇒ **lo correcto es que lo haga `netup`**, que es quien configura la identidad de red de la máquina;
|
||
la Card es la mitigación de hoy, y está dicho en su propia guarda.
|
||
|
||
#### 🛡️ Un vigía que no vigila es PEOR que uno caído, porque el caído se ve
|
||
|
||
`sandokan-watch` arrancó, quedó «corriendo · 0 reinicios»… y no guardaba una línea. Lo dice él mismo
|
||
si uno lo corre a mano:
|
||
|
||
```
|
||
sandokan-watch · sin fuente de accesos (ni /var/log/auth.log ni journalctl): no se guarda nada
|
||
```
|
||
|
||
En gioser lee el `auth.log` de un syslog que **en takana no existe** — no hay systemd ni syslog. Su
|
||
Card ahora lleva una guarda que sale **78** nombrando el problema, así que el servicio no queda
|
||
verde mintiendo; y **no entra al `genesis`**, sólo a `cards.d`, porque no tiene sentido que arranque
|
||
al boot para fallar. Destrabarlo es darle el diario de arje como fuente, y eso es trabajo de su repo.
|
||
|
||
#### Dos herramientas que no existen donde se prueba
|
||
|
||
Dos veredictos falsos en una tarde, los dos por probar con algo que la caja no tiene:
|
||
|
||
- **`/dev/tcp/host/puerto` no existe en busybox `ash`** ⇒ un barrido de puertos contra gioser dio
|
||
«cerrado» para los cinco, incluidos `:80` y `:443`, que sirven. Con `curl telnet://` el resultado
|
||
es el contrario: `:2345` y `:443` **abiertos**, `:22` cerrado.
|
||
- **La expansión de llaves (`{a,b}`) tampoco** ⇒ un `ls /store/*-{matilda,pacha}` dijo que los
|
||
artefactos no habían llegado. Habían llegado.
|
||
|
||
Es la misma lección del §6.23 (`ps` y `dig` ausentes) y merece repetirse: *una prueba que no puede
|
||
pasar se ve igual que una que todavía no pasó*.
|
||
|
||
### 6.39 🕸️ Los nueve daemons propios, y la línea que separa «se muda» de «se INTERCAMBIA» *(2026-09-16)*
|
||
|
||
Nueve de las diez recetas sellaron. Cinco corren; cuatro están instaladas y **frenadas a propósito**,
|
||
cada una por un motivo distinto y con su guarda diciéndolo.
|
||
|
||
| corriendo | | frenado | por qué |
|
||
|---|---|---|---|
|
||
| `matilda` · `tupu` | root, escriben `/var/lib/tupu/takana/` | `sandokan-watch` | en takana no hay `auth.log` ni journalctl |
|
||
| `pacha` · `pacha-secretos` | cuenta `pacha` (965) | `tejido` | **misma identidad libp2p que el de gioser** |
|
||
| `willay-daemon` | root, con su índice mudado | `willay-crosscheck` | necesita el roster de tejido |
|
||
| `shuma-daemon` · `shuma-gateway` | cuenta `shuma` (§6.37) | `thasnuna` | pide `claude` autenticado y `sandokan-mcp` |
|
||
|
||
#### ⚠ La regla que sirvió para los vhosts NO sirve para una identidad
|
||
|
||
Con los sitios web la pauta fue: **mudá el DNS y dejá el origen encendido**, así volver cuesta un
|
||
minuto (§6.26). Con `tejido` eso es exactamente lo que NO hay que hacer, y el README del propio
|
||
tejido lo dice: *«la clave que el roster atesta ES la identidad de transporte libp2p»*
|
||
(`~/.tejido/device.seed`). Dos máquinas con la misma semilla no son dos réplicas del mismo servicio:
|
||
son **el mismo PeerId en dos sitios**, o sea dos impostores mutuos para el resto de la flota.
|
||
|
||
⇒ `tejido` se **intercambia**: se apaga allá, se enciende acá, en ese orden. Está todo listo para el
|
||
intercambio —binario, cuenta `tejido` (964), identidad instalada 700 en `/var/lib/tejido/.tejido/`—
|
||
y su Card vive en `cards.d` pero **no en el `genesis`**, para que un reinicio no lo encienda solo.
|
||
|
||
Y arrastra a otro: `willay-crosscheck` **no es independiente**. Corriendo su línea a mano:
|
||
|
||
```
|
||
Error: no hay roster en /root/.tejido/roster.postcard — emparejá primero (`tejido serve` / `tejido join`)
|
||
```
|
||
|
||
Vive dentro de la red de tejido, así que hereda su condición. Se enciende en el mismo movimiento.
|
||
|
||
#### Lo que cada guarda evita
|
||
|
||
- `thasnuna`: su `INSTALAR.md` —escrito hoy por el frente tawasuyu para esta misma mudanza— pide
|
||
tres cosas, y dos no están: `sandokan-mcp` en el PATH («el agente contesta pero no tiene
|
||
herramientas») y **`claude` instalado y AUTENTICADO** («no hay anfitrión»). El CLI de claude es un
|
||
binario **glibc de ~300 M**: en una caja musl pide jaula qorpa, como `sergioh-api`.
|
||
- Y de ahí sale un hallazgo que vale para el respaldo entero: ese documento explica por qué el token
|
||
no va en la Card, y el motivo está medido en la máquina vieja — **la Card `openrc-openclaw` de
|
||
gioser lleva la API key de su proveedor EN CLARO dentro del JSON de `/etc/arje/cards.d/`**, que es
|
||
un directorio que se respalda y se copia. Un secreto dentro de una Card viaja a todas partes.
|
||
- `willay-crosscheck`: `--label` **nombra a la máquina**. En gioser decía `momento`; copiarlo tal
|
||
cual habría hecho que la caja nueva se anunciara con el nombre de la que se borra. Ahora sale de
|
||
`hostname`, y si no hay, la guarda lo dice.
|
||
|
||
#### 🔁 Tres veredictos falsos en una tarde, los tres por probar con lo que la caja no tiene
|
||
|
||
Van con el §6.38 y ya son patrón, no anécdota:
|
||
|
||
| la prueba | lo que dijo | lo que pasaba |
|
||
|---|---|---|
|
||
| `/dev/tcp/host/puerto` | los 5 puertos de gioser «cerrados» | busybox `ash` no tiene `/dev/tcp` |
|
||
| `ls /store/*-{a,b}` | «los artefactos no llegaron» | busybox no expande llaves |
|
||
| `find … -newermt "-2 minutes"` | «tupu no escribe hace 2 min» | tupu **sí** escribe: su `mtime` avanza cada 10 s |
|
||
|
||
El de `tupu` es el más instructivo porque casi produce un diagnóstico al revés: el fichero tiene
|
||
**tamaño constante** (es una serie de tamaño fijo), así que «no cambió de bytes» tampoco probaba
|
||
nada. Lo que decide es el `mtime`, medido dos veces con 40 s de por medio.
|
||
|
||
### 6.40 🚪 Las ocho puertas, medidas de nuevo *(2026-09-16)* — y la que falta es UNA
|
||
|
||
La tabla del §6.3 es del 10 de septiembre. Re-medida hoy, entera:
|
||
|
||
| # | puerta | estado | medido |
|
||
|---|---|---|---|
|
||
| 1 | arranca takana puro, se entra por SSH | ✅ | sigue en pie |
|
||
| 2 | el store está entero | ✅ | **1632 artefactos, 0 vacíos** (eran 1362) · ⚠ `/store` al 88 %, 11,3 G libres |
|
||
| 3 | los cuatro grafos idénticos | ✅ | §6.7 |
|
||
| 4 | **la granja late en la caja nueva** | ❌ | **el latido sigue en gioser**: la caja no tiene el cron |
|
||
| 5 | el repo se sirve y el host se actualiza solo | ⬖ | sirve; consumir exige el lab entero (SDD 27 §4) |
|
||
| 6 | cada dominio vivo responde 200 desde fuera | ✅ | **los 8 contra `2.29.29.217`** — y `sergio` ya entre ellos |
|
||
| 7 | el respaldo corre desde la caja | ⬖ | el script **corre y lista** el Storage Box desde la caja; falta una corrida completa |
|
||
| 8 | lo fósil, anotado y decidido | ✅ | §6.9 + `docs/state/mudanza-decisiones.txt` |
|
||
|
||
#### La puerta 4 es ahora una línea de crontab — y por qué no la puse
|
||
|
||
La caja ya tiene **todo** lo que el latido necesita, comprobado uno por uno: `git`, `python3`,
|
||
`rsync`, `ssh`, `takana`, `flock`, la llave del worker en `/root/.ssh/github5` y el `.fleet` con
|
||
`dev.gioser.net`. Le faltaban dos costuras y están hechas:
|
||
|
||
```
|
||
/opt/takana/store -> /store (el script espera `./store`; acá es partición)
|
||
/opt/takana/target/release/takana -> /usr/bin/takana (no hay árbol de build en una caja instalada)
|
||
```
|
||
|
||
Y el repo de `/opt/takana`, que estaba **5 días atrasado**, quedó en `origin/main` exacto (0
|
||
diferencias contra el remoto), con un tar de respaldo de los 92 ficheros que el árbol tenía antes.
|
||
⚠ Esos 92 no eran trabajo local: eran **contenido que ya estaba río arriba** —el cambio de SSH a
|
||
HTTPS de las recetas de tawasuyu, entre otros— metido en el árbol sin commitear. Un árbol «sucio»
|
||
que en realidad está *atrasado*, que es el mismo espejismo que el §5 del informe de tawasuyu
|
||
describe para el monorepo.
|
||
|
||
Prueba de sólo lectura desde la caja, que es lo más lejos que se puede llegar sin cambiar nada:
|
||
|
||
```
|
||
▶ dev.gioser.net (154.197.1.13) load: 2.72 · store: 895 artefactos
|
||
══ AVANCE KDE ══ 197/198 sellado
|
||
══ CRON DE COSECHA ══ ⚠ NO instalado en el crontab
|
||
```
|
||
|
||
**No lo instalé, y el motivo es el mismo que el de `tejido`**: con el cron puesto en las dos
|
||
máquinas, las dos harían `rsync --delete` del repo al worker y las dos cosecharían y commitearían.
|
||
El latido **se intercambia, no se duplica** — se apaga en gioser y se enciende acá, en ese orden, y
|
||
es el último movimiento antes del borrado.
|
||
|
||
⇒ **de las ocho, una sola está en rojo, y es una decisión de corte, no trabajo pendiente.**
|
||
|
||
### 6.41 🔀 INTERCAMBIO de `tejido` — la identidad cambió de máquina, no de dueño *(2026-09-17)*
|
||
|
||
Hecho, y en este orden, que es el único posible: **apagar allá → encender acá**.
|
||
|
||
```
|
||
gioser tejido relay (964) · tejido serve (29240) · willay-crosscheck (15334) → detenidos
|
||
takana tejido 4102 · willay-crosscheck 4103 → corriendo
|
||
```
|
||
|
||
**La prueba de que la identidad VIAJÓ y no se generó otra** no es que el servicio arranque —eso
|
||
pasaría igual con semillas nuevas, y sería el fallo silencioso que esto existe para evitar— sino que
|
||
la semilla sea la misma:
|
||
|
||
```
|
||
device.seed gioser cad6f35b633e8637… caja cad6f35b633e8637… ✓ idéntica
|
||
roster.postcard gioser 590f2713075ac934… caja 590f2713075ac934… ✓ idéntica
|
||
```
|
||
|
||
El `PeerId` sale de esa semilla, así que sigue siendo `12D3KooWBw2u8mt2ciUjX69qpVAZiJZ8sZMGxz8eHJEnz4rzg37L`.
|
||
|
||
#### ⚠ LO QUE SÍ CAMBIA, Y HAY QUE TOCARLO A MANO: el multiaddr
|
||
|
||
Los clientes tienen configurada la dirección COMPLETA del relay, IP incluida — se leyó del proceso
|
||
que corría en gioser:
|
||
|
||
```
|
||
antes: /ip4/204.168.193.248/tcp/4102/p2p/12D3KooWBw2u…
|
||
ahora: /ip4/2.29.29.217/tcp/4102/p2p/12D3KooWBw2u… ← mismo PeerId, otra IP
|
||
```
|
||
|
||
Un cliente con el viejo no falla «mal»: reintenta contra una IP que pronto no existirá. **El PeerId
|
||
no hay que tocarlo; la IP sí, en cada equipo del roster.**
|
||
|
||
#### 🧱 Sin el cortafuegos, el intercambio habría sido un verde falso
|
||
|
||
El reglaset de la caja abría `80, 443, 1137, 2345, 22022` **y nada más**: `4102` estaba cerrado. Con
|
||
el relay encendido y el puerto filtrado, `arjectl status` diría «corriendo · 0 reinicios» y la flota
|
||
no llegaría — el peor de los verdes. Se abrieron `4102` (relay) y `4103` (crosscheck), con su ban
|
||
por tasa como el resto. **No se abrió `33097`**: ése era el puerto efímero de un `tejido serve`
|
||
(cliente), no un servicio que alguien busque.
|
||
|
||
Y el reglaset quedó **idempotente**, que era deuda del §6.33: sus dos primeras líneas crean y borran
|
||
la tabla antes de definirla, así que `nft -f` **reemplaza** en vez de acumular. Comprobado contando:
|
||
15 reglas `dport` antes, 21 después (no 36).
|
||
|
||
⚠ **La política que genera ese fichero NO EXISTE EN NINGÚN DISCO.** Su cabecera dice «GENERADO, no
|
||
editar a mano — regenerar con `cortafuegos generate-input --policy <ruta>`», pero esa ruta no está
|
||
ni en la caja, ni en gioser, ni en el repo (sólo dos `politica_*_ejemplo.ron` en tawasuyu), y el
|
||
binario `cortafuegos` tampoco está instalado en ninguna de las dos. **El generado sobrevivió a su
|
||
fuente**: hasta que la política se escriba y se versione, esto se edita a mano — justo lo que su
|
||
primera línea pide no hacer.
|
||
|
||
#### 🆔 `takana` acepta ULIDs que `arje-zero` RECHAZA
|
||
|
||
La Card de tejido no encarnaba: `arje-zero rechazó: card tejido JSON: invalid character at line 7`.
|
||
La línea 7 es el `id`, y el id era `01M2EKDA00TEJ1D0RELAY00001` — que tiene una **`L`**, carácter
|
||
**excluido del alfabeto Crockford** de ULID (junto con `I`, `O`, `U`). `takana service-cards` lo
|
||
validó como bueno («26 caracteres alfanuméricos») y el consumidor lo tiró.
|
||
|
||
Dos cosas de esto: el validador del PRODUCTOR es más laxo que el del consumidor —y eso siempre
|
||
termina en un fallo lejos de donde se escribió—, y el barrido propio que ya corrí sobre todos los
|
||
`id` del repo con el alfabeto correcto había marcado justo ése. Era el único con `L` (venía de
|
||
«RELAY»); ahora es `01M2EKDA00TEJ1D0RE1AY00001`.
|
||
|
||
#### Estabilidad, medida y no supuesta
|
||
|
||
`willay-crosscheck` marcaba **4296 reinicios**, que asusta hasta mirar de dónde vienen: son las
|
||
horas en que su guarda lo frenó a propósito por falta de roster. Con el mismo pid a los 70 s y
|
||
1 m 53 s de vida, está estable. `tejido`: 0 reinicios. Los dos puertos aceptan conexión desde fuera.
|
||
|
||
⚠ De paso, un detalle feo: `arjectl status | head -2` hace que **arjectl paniquee** con
|
||
`failed printing to stdout: Broken pipe`. No rompe nada, pero un `EPIPE` sin manejar en una
|
||
herramienta de operación aparece justo cuando alguien la encadena con `head`/`grep -q`.
|
||
|
||
#### Cómo volver, si hiciera falta
|
||
|
||
Está escrito en `work/mudanza/volver-atras-tejido.txt`: apagar los dos de la caja y relanzar los tres
|
||
de gioser con las líneas exactas que se leyeron de `/proc/<pid>/environ` y `cmdline`. **Nunca las dos
|
||
a la vez**: es la misma identidad.
|
||
|
||
## 7. Reusar los scripts que ya existen, y no escribir de nuevo
|
||
|
||
Pedido explícito del usuario. El inventario de lo que ya hace el trabajo:
|
||
|
||
| para | script existente | qué le falta |
|
||
|---|---|---|
|
||
| armar el rootfs de un perfil | `scripts/hydrate-profile.py` | nada — es el camino bueno (los `hydrate-*.sh` por escritorio **divergen** de `targets.toml`) |
|
||
| producir el `.img` | `scripts/install-image.sh` / `scripts/product-image.sh` | apuntar al `perfil.servidor` |
|
||
| escribir a disco | `scripts/takana-install.sh` | el envoltorio de rescue del §3.2 |
|
||
| instalador con TUI | `scripts/takana-live-install.sh` | ser el **mismo motor** que el remoto, con las respuestas por fichero |
|
||
| provisionar un worker | `scripts/farm/vps-setup.sh` | ya soporta apt **y** dnf; le falta la rama «takana puro» (sin gestor de paquetes ajeno) |
|
||
| enchufar a la granja | `scripts/farm/.fleet` | **está en `.gitignore`** ⇒ un hub recién clonado nace con la flota vacía **en silencio** |
|
||
| latido | `scripts/farm/cosecha-cron.sh` | decidir cron vs tarjeta de arje (§3.4) |
|
||
| respaldo | `scripts/respaldo-storagebox.sh` | nada — es interrumpible y continuable |
|
||
| absorber el init ajeno | `arje-absorb` | el lector de procesos vivos (§4) |
|
||
|
||
⚠ **Un motor, tres frentes.** La tentación es escribir el instalador remoto aparte del TUI. Ahí es
|
||
donde divergen: ya pasó con los `hydrate-*.sh` contra `targets.toml`, y con la unidad systemd del
|
||
worker que quedó vieja mientras el repo estaba al día. La instalación remota tiene que ser **la misma
|
||
que hace la persona**, grabada en un fichero de respuestas.
|
||
|
||
---
|
||
|
||
## 8. `churay` como instalador centralizado — la dirección, no la decisión
|
||
|
||
El usuario lo planteó el 2026-09-09: **un instalador único, con CLI y TUI**, que sirva para
|
||
|
||
- las **apps de tawasuyu**,
|
||
- **paquetes sueltos en cualquier distro**, al estilo nix (instalar sin ser root, sin tocar el
|
||
sistema anfitrión),
|
||
- **takana** mismo,
|
||
- y **`llimphi`**.
|
||
|
||
Encaja con lo que ya hay y por eso vale escribirlo: `takana install` ya resuelve por nombre contra un
|
||
índice firmado y **ya acepta múltiples orígenes**; `qorpa` ([ADR 0015](adr/0015-imagenes-ajenas.md))
|
||
ya corre imágenes ajenas enjauladas sobre el mismo kernel; y el vigía de espejos de churay ya corre
|
||
en cron desde gioser.
|
||
|
||
**No se decide acá.** Es un ADR propio, y tiene al menos tres preguntas abiertas antes de escribir
|
||
una línea: (1) si el instalador «en cualquier distro» hidrata binarios o reproduce desde fuente —la
|
||
misma pregunta del [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md), y la respuesta puede no
|
||
ser la misma fuera de takana; (2) quién es el dueño del verbo, si `takana install` crece o si
|
||
`churay` lo envuelve; (3) qué pasa con un host glibc, que es exactamente lo que el ADR 0015 aparta
|
||
del store a propósito. Queda anotado para que se **decida**, no para que se descubra a mitad.
|
||
|
||
---
|
||
|
||
## 9. Estado y orden
|
||
|
||
**Decisión del usuario (2026-09-14): la mudanza NO pasa por terceros.** Nada de espejar a GitHub
|
||
para «tener copia fuera»: la copia fuera **la da la propia mudanza** — en cuanto el gitea vive en la
|
||
caja nueva, los 26 repos dejan de existir sólo en la máquina que se borra. El objetivo es dejar de
|
||
pagar dos máquinas, no montar una dependencia más. `scripts/mudanza/espejar-repos.sh` queda como
|
||
herramienta disponible, no como paso del plan.
|
||
|
||
**Y el worker no necesita credenciales: las fuentes de tawasuyu se sirven por HTTPS público.**
|
||
Las 23 recetas que apuntaban a `gitea@git.tawasuyu.net` (SSH) pasaron a `https://git.tawasuyu.net/…`
|
||
— la URL es locator y no entra en `hash_inputs` (ADR 0013), así que se cambió **con control y sin
|
||
mover un solo ArtifactHash**. Medido en el worker: clona y `git archive` extrae el árbol (155 M) sin
|
||
ninguna clave. Un usuario propio en gitea sólo haría falta para un repo PRIVADO, y hoy ninguna
|
||
receta usa uno.
|
||
|
||
### Lo que YA está, y no hay que volver a tocar
|
||
|
||
| | |
|
||
|---|---|
|
||
| la caja `takana` (`2.29.29.217`) | arranca takana puro, PID 1 `arje-zero` — §3.8 |
|
||
| store, grafos, granja, respaldo | puertas 2, 3, 4 y 7 — §6.3, §6.7, §6.8, §6.10 |
|
||
| repo firmado servido por la caja | §5.1 |
|
||
| **la imagen del perfil `servidor` con gitea** | **arranca y sirve 200, supervisado por arje** — §6.11 |
|
||
| **`arjectl` para operar sin reiniciar** | §6.12 |
|
||
| **el gitea de gioser corriendo sobre takana, con sus datos** | ensayo completo — §6.13 |
|
||
|
||
### Lo que falta para el cutover, en este orden
|
||
|
||
⚠ **La lista de abajo es del 2026-09-11 y los pasos 1-5 YA ESTÁN** (gitea mudado y sirviendo, §6.14;
|
||
estáticos mudados, §6.18; fósiles barridos, §6.19; `api.sergio` mudado, §6.25; `tawasuyu.net` y
|
||
`gioser.net` mudados, §6.26; squid, §6.27-28; hora, latido y rotación, §6.29; cortafuegos, §6.31-33).
|
||
|
||
**El estado vigente está medido en el §6.34 (2026-09-16), y es más corto de lo que decía esta lista:
|
||
queda UN solo vhost sirviéndose desde gioser — `sergio.gioser.net` — y el montón de daemons propios
|
||
del usuario que ningún paquete provee.** Los pasos 6 y 7 son lo único que sigue abierto.
|
||
|
||
|
||
1. **Actualizar la caja a la imagen nueva** (gitea + `arjectl` + las cuentas de `[[user]]`).
|
||
`takana upgrade` está probado (§6.13 del SDD 27 y §5.4 acá); el `dd` es para provisionar, no para
|
||
actualizar — sobrescribe el disco local y re-etiqueta la partición.
|
||
2. **Mudar los datos del gitea**: `sqlite3 … ".backup"` (1,5 s, consistente con el servidor vivo) +
|
||
`rsync -aH` de `/var/lib/gitea` y `/etc/gitea`. El corte final se hace con el gitea del origen
|
||
**parado**, para que el último snapshot no pierda escrituras.
|
||
3. **Caddy en la caja**: los **6 dominios vivos** del §6.9, con su TLS. Los otros 13 no se mudan.
|
||
4. **DNS**: apuntar esos 6 a la IP nueva. El resto de los clones y remotos no se toca — siguen
|
||
resolviendo al mismo nombre.
|
||
5. **Verificar desde fuera**: cada dominio vivo responde 200 contra la IP nueva **y** un `git clone`
|
||
real contra el gitea mudado.
|
||
6. **El resto de servicios vivos** del censo (qdrant, squid, act-runner, php-fpm…), por el plan que
|
||
`planear.py` ya emite.
|
||
7. **Borrar gioser** — sólo con las 8 puertas en verde, a mano, nunca por automatización (§6).
|
||
|
||
### Bloqueantes conocidos, con su tamaño
|
||
|
||
- **`git` sellado sin `remote-http`** (§6.13): afecta a clonar POR HTTPS *desde* una caja takana, no
|
||
al gitea que sirve. Re-sella `git`, raíz de `perfil.base` ⇒ unidad propia.
|
||
- **Puerta 5** sigue a medias: la caja sirve su repo pero no se instala de él (`install` reproduce y
|
||
eso exige el lab entero). No bloquea la mudanza; bloquea «el servidor se sirve a sí mismo».
|
||
|
||
**El volumen del store NO se engancha a la caja hasta que pase la puerta 1** — ya pasó, y el store
|
||
local de 68,7 G alcanza, así que la mudanza no depende de mover el volumen de gioser.
|