feat(infra): auto-commit con 86 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:38:15 +02:00
parent 34c27876e0
commit 621e8895c9
85 changed files with 11840 additions and 92 deletions
+1
View File
@@ -38,3 +38,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 31 | [autonomous_loop.md](autonomous_loop.md) | Reglas para `fn-orquestador` + `/autonomous-task`: sandbox obligatorio, paths protegidos, filtro proposals auto-aplicables, watchdog, idempotencia. Issue 0069 |
| 32 | [../../dev/TAXONOMY.md](../../dev/TAXONOMY.md) | Allowlist canonica para dominios/tipos/scopes/estados/prioridades + flow patterns. Aplica a `dev/issues/` y `dev/flows/`. Issues 0100 + 0103 |
| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
| 34 | [dod_quality.md](dod_quality.md) | DoD Quality Triada: Mecanica + Cobertura (golden + edge + error path con evidencia ejecutable) + Vida util validada (>=7 dias uso real). Cierra anti-criterios contra checkbox vago. Aplica a `dev/flows/` y issues user-facing. |
+131
View File
@@ -0,0 +1,131 @@
# DoD Quality Triada
**Definition of Done no es un checkbox que se marca a mano. Es un contrato de calidad con 3 capas obligatorias + evidencia ejecutable + uso real >=7 dias.**
Aplica a todos los `dev/flows/` y, por extension, a issues que cierran capabilities user-facing (`dev/issues/`). El registry mismo (funciones puras, tipos) queda exento: su DoD vive en sus tests unitarios.
---
## Por que existe esta regla
El antipatron a eliminar: "tarea hecha porque pase los tests una vez". Despues:
- El flow funciona en `home-wsl` pero falla en `pc-aurgi`.
- El error path declarado nunca se ejercito y cuando ocurre en produccion no esta manejado.
- El dashboard de observabilidad lleva 30 dias sin abrirse.
- El proceso muere cada noche y nadie lo ve hasta que el operador intenta usarlo.
- El approval flow se salta porque "para test es mas comodo".
Resultado: deuda invisible. Cada flow "done" se rompe al primer uso real, el operador pierde confianza en el sistema, y el bucle reactivo no detecta nada porque la telemetria esta verde (los tests sintenticos pasan).
DoD Quality Triada cambia las reglas: cerrar = probar comportamiento + sobrevivir uso real, no = compilar verde.
---
## Las 3 capas
### Capa 1: Mecanica (pre-requisito, NO es DoD por si misma)
Compilar verde, tests verdes, indexado limpio, `fn doctor` verde, `uses_functions` sin drift.
**Regla**: la mecanica NO basta. Es la base para empezar a probar comportamiento. Si te quedas aqui, el flow no esta hecho.
### Capa 2: Cobertura de comportamiento
Cada escenario relevante con prueba ejecutable y assert material. NO smoke "el comando no peto". Minimo:
- **1 golden path** — el caso feliz documentado con assert sobre output concreto.
- **>=2 edge cases** — inputs limite, estados raros, condiciones de borde.
- **>=1 error path** — fallo provocado intencionalmente, manejado y observable (sin crash, sin silent-fail).
Formato canonico (tabla en `## Definition of Done` del flow/issue):
```markdown
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|---|---|---|---|
| Golden: <desc> | unit / e2e | `<cmd>` | <output concreto> |
| Edge 1: <desc> | unit / e2e | `<cmd>` | <comportamiento concreto> |
| Error 1: <desc> | e2e | `<cmd que rompe>` | <fallo manejado, no crash> |
```
Cuando aplique, cada fila genera un `e2e_check` en el `app.md` correspondiente (issue 0068). `fn-analizador` los corre periodicamente y deja entry en `e2e_runs`.
### Capa 3: Vida util validada
El flow no esta hecho hasta que sobrevive **uso real durante >=7 dias** sin romperse silenciosamente. Cada metrica con umbral medible y dashboard observable.
Formato canonico:
```markdown
| Metrica | Umbral | Donde se observa | Ventana |
|---|---|---|---|
| <metrica 1> | `>=N` | `<dashboard URL / app panel>` | 7 dias |
| crashes | `0` | `journalctl -u <unit>` | 7 dias |
| huecos audit chain | `0` | `cmd: <verify>` | continuo |
```
Reglas:
- Metricas NO se auto-reportan; las lee el operador del dashboard real.
- Si el dashboard no existe o no se ha abierto en 30 dias, el item se invalida.
- Crashes del proceso = 0, huecos en audit = 0, error_rate < umbral declarado.
### Capa transversal: User-facing reforzado
- Surface concreta NO BD ni log (UI app, room Matrix, dashboard, archivo en vault).
- Usage real: humano usa en su PC, su contexto, >=N veces variadas en >=7 dias.
- Variado: >=3 capabilities/casos distintos (no solo "abre dashboard y mira").
- Onboarding: parrafo en `## Notas` que explica como usar la cosa sin leer el flow.
- Latencia medida (no declarada).
---
## Reglas duras para marcar `status: done`
`/flow done` (y por extension cierres de issues user-facing) DEBE rechazar el cierre si:
1. Falta cualquiera de las 3 capas (mecanica + cobertura + vida).
2. Cobertura tiene <1 golden, <2 edge, o <1 error path con evidencia.
3. Vida util tiene tabla vacia o sin dashboard observable real.
4. User-facing usage real <7 dias o <N usos declarados.
5. Cualquier anti-criterio marcado como cierto.
6. `## Notas` sin parrafo onboarding.
7. Algun item de DoD sin comando/URL/log query asociado — solo texto.
Hoy parte de esta validacion es manual (revision humana del operador). La validacion programatica vive en `audit_dod_schema_go_infra` (issue 0114) + `fn doctor dod` y se ampliara hasta cubrir las 3 capas (TBD).
---
## Antipatrones (invalidan la DoD aunque los checkboxes esten verdes)
| Antipatron | Por que es malo | Sustituir por |
|---|---|---|
| Marcar `done` porque pasa una vez | Tarea "hecha" se rompe al primer uso real | Capa 3: >=7 dias de uso real |
| Checkbox sin evidencia ejecutable | DoD se convierte en placebo | Cada item con `cmd:` / URL / log query |
| Test que solo verifica camino feliz | El error path es donde se pierden datos | Capa 2: >=1 error path ejercitado |
| Observabilidad declarada pero dashboard no abierto en 30 dias | Telemetria muerta = ceguera | Capa 3: dashboard real, operador lo abre |
| "Repetible 3 veces consecutivas" con BD efimera | No prueba sobre datos reales acumulados | Capa 3: PC real del operador, datos vivos |
| Approval saltado en algun camino | Security gate roto pero invisible | Anti-criterio explicito: `audit_log` lo prueba |
| Error path manejado solo "en teoria" | Cuando ocurra en produccion el manejo no existe | Capa 2: entry real en `e2e_runs` o audit |
| Solo-en-mi-PC | Falla en otra maquina del operador | Anti-criterio explicito, probar >=2 PCs |
| Self-test que retorna `pass` sin asserts materiales | False positive sistemico | Asserts sobre output concreto, no exit-0 |
| Silent-fail (proceso muere sin alerta) | Operador no se entera hasta intentar usar | Capa 3: crashes=0 + alerta visible |
---
## Relacion con otras reglas
- [[e2e_validation]] — los escenarios de Capa 2 cuando aplican a apps se materializan como `e2e_checks` en `app.md`. `fn-analizador` (fase 4 del bucle reactivo) los corre.
- [[registry_calls]] — la evidencia de uso (`call_monitor.calls`) alimenta los umbrales de Capa 3.
- [[function_growth_and_self_docs]] — cada funcion del registry tiene su propio contrato self-doc (Ejemplo + Cuando usarla + Gotchas). DoD del flow NO sustituye al self-doc de la funcion; lo complementa para el nivel sistema.
- [[autonomous_loop]] — `fn-orquestador` autonomo NO puede marcar `done` sin que se cumplan las 3 capas. Su criterio de convergencia incluye DoD Quality.
- [[apps_tbd]] — TBD garantiza master desplegable; DoD garantiza que lo desplegado funciona en uso real.
---
## TL;DR
1. **Mecanica** = compilar verde (pre-requisito, NO suficiente).
2. **Cobertura** = golden + >=2 edge + >=1 error path con evidencia ejecutable.
3. **Vida util** = >=7 dias de uso real sin romper silenciosamente, dashboard observable abierto.
4. **User-facing reforzado** = humano usa en PC real, >=N veces variadas.
5. **Anti-criterios** invalidan la DoD aunque todo este verde.
6. Sin evidencia ejecutable (cmd/URL/log), NO es DoD: es deseo.
+22
View File
@@ -0,0 +1,22 @@
[2026-05-22 23:18:14.872] [INFO] app start: Agents Dashboard
[2026-05-22 23:24:12.811] [INFO] app start: Agents Dashboard
[2026-05-22 23:24:14.628] [INFO] [connect] testing https://agents.organic-machine.com...
[2026-05-22 23:24:14.758] [INFO] [connect] OK
[2026-05-22 23:24:14.765] [INFO] [db] base_url saved
[2026-05-22 23:24:14.765] [INFO] [fetch_agents] starting
[2026-05-22 23:24:14.766] [INFO] [fetch_agents] requesting https://agents.organic-machine.com/agents
[2026-05-22 23:24:14.903] [INFO] [fetch_agents] response status=200 err= body_len=3146
[2026-05-22 23:24:14.904] [INFO] [fetch_agents] parsed 11 rows
[2026-05-22 23:24:14.904] [INFO] [fetch_agents] done
[2026-05-22 23:24:14.910] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
[2026-05-22 23:27:07.469] [INFO] app start: Agents Dashboard
[2026-05-22 23:27:08.242] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
[2026-05-22 23:27:36.670] [INFO] app start: Agents Dashboard
[2026-05-22 23:27:37.446] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
[2026-05-22 23:28:07.068] [INFO] app start: Agents Dashboard
[2026-05-22 23:30:03.025] [INFO] app start: Agents Dashboard
[2026-05-22 23:30:38.605] [INFO] app start: Agents Dashboard
[2026-05-22 23:30:48.267] [INFO] app start: Agents Dashboard
[2026-05-22 23:40:58.931] [INFO] app start: Agents Dashboard
[2026-05-22 23:41:16.455] [INFO] app start: Agents Dashboard
[2026-05-22 23:42:35.646] [INFO] app start: Agents Dashboard
+68
View File
@@ -0,0 +1,68 @@
---
name: wg_client_install
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "wg_client_install(config_path_or_stdin, [interface_name]) -> json"
description: "Device-side: instala wg0.conf en /etc/wireguard/, habilita systemd wg-quick@wg0, verifica handshake con hub. Idempotente. Acepta config por path o stdin (para pipes desde wg_client_config)."
tags: [wireguard, client, install, mesh, systemd]
uses_functions: [wg_install_bash_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: config_path_or_stdin
desc: "path al archivo .conf existente, o '-' para leer de stdin (compatible con pipe desde wg_client_config)"
- name: interface_name
desc: "nombre de la interfaz WireGuard (default: wg0). Determina /etc/wireguard/<iface>.conf y la unit systemd wg-quick@<iface>"
output: "JSON {status, interface, hub_endpoint, handshake_seen}. status: installed | already-configured | installed-no-handshake | installed-no-systemd"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/wg_client_install.sh"
---
## Ejemplo
```bash
source bash/functions/infra/wg_client_install.sh
# Desde pipe (caso más común en flow 0009):
wg_client_config_go_infra | jq -r '.INI' | wg_client_install -
# {"status":"installed","interface":"wg0","hub_endpoint":"203.0.113.1:51820","handshake_seen":true}
# Desde archivo .conf generado previamente:
wg_client_install /tmp/peer_laptop.conf
# {"status":"installed","interface":"wg0","hub_endpoint":"203.0.113.1:51820","handshake_seen":true}
# Con interfaz personalizada:
wg_client_install /tmp/peer_laptop.conf wg1
# {"status":"installed","interface":"wg1","hub_endpoint":"203.0.113.1:51820","handshake_seen":true}
# Segunda ejecución con misma config (idempotente):
wg_client_install /tmp/peer_laptop.conf
# {"status":"already-configured","interface":"wg0","hub_endpoint":"203.0.113.1:51820","handshake_seen":false}
```
## Cuando usarla
Cuando necesites conectar un nuevo peer al mesh WireGuard en el flow 0009. Úsala justo después de `wg_client_config` (que genera el .conf) para instalarlo en el device peer. Es el paso final del onboarding de un nodo: config generada → instalada → verificada con handshake.
## Gotchas
- **Requiere root/sudo** para escribir en `/etc/wireguard/`, hacer `chmod 600`, y ejecutar `systemctl`. El operador debe tener `sudo` sin password para estos comandos, o ejecutar la función como root.
- **Idempotente por contenido**: si `/etc/wireguard/<iface>.conf` ya existe con el mismo contenido, retorna `status=already-configured` sin tocar nada. Si el contenido difiere, hace backup automático con timestamp antes de sobreescribir.
- **NetworkManager**: si NM gestiona la interfaz wg0, `wg-quick` puede fallar con conflicto. Solución: crear `/etc/NetworkManager/conf.d/99-wg.conf` con `[keyfile]\nunmanaged-devices=interface-name:wg0` y reiniciar NM antes de ejecutar esta función.
- **WSL2 sin systemd** (variantes antiguas o sin `/etc/wsl.conf` con `[boot] systemd=true`): `systemctl` no está disponible. La función detecta esto, emite `status=installed-no-systemd` con instrucciones en stderr para levantar la interfaz manualmente con `sudo wg-quick up wg0`. Para autostart en WSL2 sin systemd: añadir `sudo wg-quick up wg0` al final de `~/.bashrc`.
- **WSL2 con systemd**: kernel WSL2 >= 5.6 (default en distros recientes) incluye WireGuard built-in. Habilitar systemd en WSL2 con `[boot]\nsystemd=true` en `/etc/wsl.conf` y reiniciar WSL. Luego esta función funciona igual que en Linux nativo.
- **Android / Termux**: NO usar esta función. Termux no tiene systemd ni `/etc/wireguard/`. En Android usar la app WireGuard oficial (F-Droid / Play Store) e importar el .conf generado por `wg_client_config` directamente desde la app.
- **handshake_seen=false con status=installed-no-handshake**: la interfaz está activa pero el hub no ha respondido en 10s. No es un error fatal — puede tardar más si el hub está ocupado o hay NAT traversal pendiente. Verificar: endpoint accesible por UDP, hub corriendo con `wg show`, claves public/preshared coincidentes.
- Los logs van siempre a stderr con prefijo `[wg_client_install]`; stdout es exclusivamente el JSON de resultado.
## Capability growth log
<!-- Rellenar solo cuando haya version bump real -->
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# wg_client_install — Device-side: instala wg0.conf en /etc/wireguard/, habilita
# systemd wg-quick@<iface>, verifica handshake con hub. Idempotente.
# Acepta config por path o stdin ("-").
# Exit 0 = éxito (installed o already-configured), 1 = error fatal.
wg_client_install() {
local config_src="${1:--}"
local iface="${2:-wg0}"
local conf_dest="/etc/wireguard/${iface}.conf"
local config_content="" hub_endpoint="" handshake_seen="false"
_wg_ci_log() { echo "[wg_client_install] $*" >&2; }
# ── Prereq: wg debe estar instalado ──────────────────────────────────────
if ! command -v wg &>/dev/null; then
_wg_ci_log "ERROR: 'wg' no encontrado. Ejecuta wg_install primero."
return 1
fi
# ── Leer contenido del .conf ──────────────────────────────────────────────
if [[ "${config_src}" == "-" ]]; then
_wg_ci_log "Leyendo config desde stdin"
config_content=$(cat) || { _wg_ci_log "ERROR: fallo al leer stdin"; return 1; }
elif [[ -f "${config_src}" ]]; then
_wg_ci_log "Leyendo config desde ${config_src}"
config_content=$(cat "${config_src}") || { _wg_ci_log "ERROR: fallo al leer ${config_src}"; return 1; }
else
_wg_ci_log "ERROR: '${config_src}' no es un path existente ni '-' (stdin)"
return 1
fi
if [[ -z "${config_content}" ]]; then
_wg_ci_log "ERROR: contenido de config vacío"
return 1
fi
# ── Extraer endpoint del hub para incluirlo en el JSON de salida ──────────
hub_endpoint=$(printf '%s\n' "${config_content}" | grep -m1 '^Endpoint\s*=' | sed 's/.*=\s*//' | tr -d '[:space:]' || true)
# ── Idempotencia: comparar con conf existente ─────────────────────────────
if [[ -f "${conf_dest}" ]]; then
local existing_content
existing_content=$(sudo cat "${conf_dest}" 2>/dev/null || cat "${conf_dest}" 2>/dev/null || true)
if [[ "${existing_content}" == "${config_content}" ]]; then
_wg_ci_log "Configuración idéntica ya presente en ${conf_dest}; nada que hacer"
printf '{"status":"already-configured","interface":"%s","hub_endpoint":"%s","handshake_seen":false}\n' \
"${iface}" "${hub_endpoint}"
return 0
fi
# Contenido difiere → backup + rewrite
local backup="${conf_dest}.bak.$(date +%Y%m%d%H%M%S)"
_wg_ci_log "Configuración existente difiere; backup → ${backup}"
sudo cp "${conf_dest}" "${backup}" \
|| { _wg_ci_log "ERROR: no se pudo hacer backup de ${conf_dest}"; return 1; }
fi
# ── Crear directorio y escribir conf ─────────────────────────────────────
sudo mkdir -p "/etc/wireguard" \
|| { _wg_ci_log "ERROR: no se pudo crear /etc/wireguard"; return 1; }
printf '%s\n' "${config_content}" | sudo tee "${conf_dest}" >/dev/null \
|| { _wg_ci_log "ERROR: no se pudo escribir ${conf_dest}"; return 1; }
sudo chmod 600 "${conf_dest}" \
|| { _wg_ci_log "WARN: no se pudo chmod 600 ${conf_dest}"; }
_wg_ci_log "Config escrita en ${conf_dest} (chmod 600)"
# ── Habilitar + arrancar systemd unit ─────────────────────────────────────
if ! command -v systemctl &>/dev/null; then
_wg_ci_log "WARN: systemctl no disponible."
_wg_ci_log " En WSL2 sin systemd: ejecuta 'sudo wg-quick up ${iface}' manualmente."
_wg_ci_log " Para autostart en WSL2: añade 'sudo wg-quick up ${iface}' a ~/.bashrc o usa WSL2 con systemd habilitado."
printf '{"status":"installed-no-systemd","interface":"%s","hub_endpoint":"%s","handshake_seen":false}\n' \
"${iface}" "${hub_endpoint}"
return 0
fi
_wg_ci_log "Habilitando y arrancando wg-quick@${iface}"
if ! sudo systemctl enable --now "wg-quick@${iface}" 2>&1 | tee /dev/stderr >&2; then
_wg_ci_log "ERROR: systemctl enable --now wg-quick@${iface} falló."
_wg_ci_log " En WSL2: asegúrate de tener kernel >= 5.6 y systemd habilitado (/etc/wsl.conf: [boot] systemd=true)."
_wg_ci_log " Si NetworkManager gestiona ${iface}: añade 'unmanaged-devices=interface-name:${iface}' a /etc/NetworkManager/conf.d/99-wg.conf"
return 1
fi
_wg_ci_log "wg-quick@${iface} habilitado y activo"
# ── Esperar handshake (hasta 10 s) ────────────────────────────────────────
local deadline=$(( $(date +%s) + 10 ))
_wg_ci_log "Esperando handshake en ${iface} (timeout 10s)..."
while [[ $(date +%s) -lt ${deadline} ]]; do
local hs_output
hs_output=$(sudo wg show "${iface}" latest-handshakes 2>/dev/null || true)
# latest-handshakes devuelve "<pubkey> <unix_ts>"; ts > 0 = handshake visto
if printf '%s\n' "${hs_output}" | awk '{print $2}' | grep -qE '^[1-9][0-9]+$'; then
handshake_seen="true"
_wg_ci_log "Handshake confirmado en ${iface}"
break
fi
sleep 1
done
if [[ "${handshake_seen}" == "false" ]]; then
_wg_ci_log "WARN: timeout esperando handshake en ${iface}. La interfaz está activa pero el hub no ha respondido aún."
_wg_ci_log " Verifica: endpoint accesible, hub corriendo, claves correctas."
printf '{"status":"installed-no-handshake","interface":"%s","hub_endpoint":"%s","handshake_seen":false}\n' \
"${iface}" "${hub_endpoint}"
return 0
fi
printf '{"status":"installed","interface":"%s","hub_endpoint":"%s","handshake_seen":true}\n' \
"${iface}" "${hub_endpoint}"
return 0
}
+66
View File
@@ -0,0 +1,66 @@
---
name: wg_hub_setup
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "wg_hub_setup(private_key, subnet_cidr, listen_port) -> json"
description: "Configura el host como hub WireGuard (servidor). Crea /etc/wireguard/wg0.conf con clave privada + IP pool + ListenPort. Abre UDP en firewall (ufw o iptables), habilita ip_forward persistente en /etc/sysctl.d/99-wireguard.conf, persiste y arranca systemd unit wg-quick@wg0. Idempotente: misma PrivateKey = no-op; PrivateKey distinta = backup + rewrite."
tags: [wireguard, hub, infra, mesh, systemd]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: private_key
desc: "base64 WG private key del hub (44 chars, generada por wg_keygen o `wg genkey`)"
- name: subnet_cidr
desc: "subnet hub con bits del host, ej. 10.42.0.1/24. El hub recibe la .1"
- name: listen_port
desc: "UDP port donde escucha WireGuard (default 51820, rango 1024-65535)"
output: "JSON {status, config_path, interface, hub_ip}. status: configured | reconfigured | already-configured"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/wg_hub_setup.sh"
---
## Ejemplo
```bash
# Generar clave (o usar wg_keygen del registry)
PRIVKEY=$(wg genkey)
source bash/functions/infra/wg_hub_setup.sh
wg_hub_setup "$PRIVKEY" "10.42.0.1/24" 51820
# {"status":"configured","config_path":"/etc/wireguard/wg0.conf","interface":"wg0","hub_ip":"10.42.0.1"}
# Segunda ejecución con la misma clave → no-op
wg_hub_setup "$PRIVKEY" "10.42.0.1/24" 51820
# {"status":"already-configured","config_path":"/etc/wireguard/wg0.conf","interface":"wg0","hub_ip":"10.42.0.1"}
# Cambiar clave → backup de conf anterior + rewrite
wg_hub_setup "$NUEVA_PRIVKEY" "10.42.0.1/24" 51820
# {"status":"reconfigured","config_path":"/etc/wireguard/wg0.conf","interface":"wg0","hub_ip":"10.42.0.1"}
```
## Cuando usarla
Cuando necesites convertir un VPS/host en el nodo central (hub) de una red mesh WireGuard. Úsala inmediatamente después de `wg_install` para dejar el hub listo para recibir peers. El hub escucha en un puerto UDP público; los peers se conectan a él con su propia clave y la AllowedIPs del hub.
## Gotchas
- Requiere `sudo` con NOPASSWD para: `tee /etc/wireguard/`, `chmod`, `sysctl`, `iptables`/`ufw`, `systemctl`. Configurar antes en sudoers.
- NUNCA reusar la misma `private_key` entre hubs distintos. Cada hub tiene su propio par de claves independiente.
- El bloque `PostUp`/`PostDown` usa `eth0` como interfaz de salida para NAT. En VPS con interfaz distinta (ens3, enp3s0) editar `/etc/wireguard/wg0.conf` manualmente antes de reiniciar.
- Conflicto de subnet con docker0 si usas 172.17.0.0/16. Evitar solapamiento — usar 10.42.x.x o 192.168.200.x para WireGuard.
- `systemd-resolved` en VPS Ubuntu puede interferir con resolución DNS cuando WireGuard está activo si el conf añade `DNS =`. Esta función NO setea DNS para evitar el problema — configurarlo a nivel peer si se necesita.
- Si `systemctl start wg-quick@wg0` falla, revisar logs con `journalctl -u wg-quick@wg0 -n 50`.
- En entornos cloud (AWS/GCP/Azure) el security group / firewall de red del proveedor también debe abrir el puerto UDP, independientemente de ufw/iptables local.
## Capability growth log
<!-- Rellenar solo cuando haya version bump real -->
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# wg_hub_setup — Configura el host como hub WireGuard (servidor central).
# Crea /etc/wireguard/wg0.conf con [Interface] block, abre UDP en firewall,
# habilita ip_forward persistente, arranca y verifica wg-quick@wg0.
# Idempotente: si el conf existe con la misma PrivateKey -> no-op.
# Emite JSON a stdout. Logs a stderr con prefijo [wg_hub_setup].
# Exit 0 = éxito, 1 = fallo.
wg_hub_setup() {
local private_key="${1:-}"
local subnet_cidr="${2:-10.42.0.1/24}"
local listen_port="${3:-51820}"
_wg_hub_log() { echo "[wg_hub_setup] $*" >&2; }
# ── Validación de entradas ──────────────────────────────────────────────
# private_key: base64 estándar de 44 caracteres (32 bytes)
if [[ -z "${private_key}" ]]; then
_wg_hub_log "ERROR: private_key requerida (base64 44 chars, generada por wg genkey)"
return 1
fi
if ! [[ "${private_key}" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then
_wg_hub_log "ERROR: private_key no parece base64 válida (se esperan 44 chars terminando en '=')"
return 1
fi
# subnet_cidr: 10.x.x.x/nn
if ! [[ "${subnet_cidr}" =~ ^10\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then
_wg_hub_log "ERROR: subnet_cidr debe ser 10.x.x.x/nn, recibido: '${subnet_cidr}'"
return 1
fi
# listen_port: 1024-65535
if ! [[ "${listen_port}" =~ ^[0-9]+$ ]] || (( listen_port < 1024 || listen_port > 65535 )); then
_wg_hub_log "ERROR: listen_port debe ser un entero entre 1024 y 65535, recibido: '${listen_port}'"
return 1
fi
# ── Verificar que wireguard-tools esté instalado ────────────────────────
if ! command -v wg &>/dev/null; then
_wg_hub_log "ERROR: 'wg' no encontrado. Ejecuta wg_install primero."
return 1
fi
if ! command -v wg-quick &>/dev/null; then
_wg_hub_log "ERROR: 'wg-quick' no encontrado. Instala wireguard-tools."
return 1
fi
# ── Extraer hub_ip (parte sin CIDR prefix) y determinar config_path ────
local hub_ip="${subnet_cidr%%/*}"
local config_path="/etc/wireguard/wg0.conf"
local interface="wg0"
local action_status=""
# ── Idempotencia: comparar PrivateKey existente ─────────────────────────
if [[ -f "${config_path}" ]]; then
local existing_key
existing_key=$(sudo grep -E '^\s*PrivateKey\s*=' "${config_path}" 2>/dev/null \
| head -n1 | sed 's/.*=\s*//')
if [[ "${existing_key}" == "${private_key}" ]]; then
_wg_hub_log "Config existente con misma PrivateKey — no-op (status=already-configured)"
printf '{"status":"already-configured","config_path":"%s","interface":"%s","hub_ip":"%s"}\n' \
"${config_path}" "${interface}" "${hub_ip}"
return 0
else
_wg_hub_log "Config existente con PrivateKey DIFERENTE — haciendo backup y reescribiendo"
local backup_path="${config_path}.bak.$(date +%Y%m%d%H%M%S)"
sudo cp "${config_path}" "${backup_path}" \
|| { _wg_hub_log "ERROR: no se pudo hacer backup en ${backup_path}"; return 1; }
_wg_hub_log "Backup guardado en ${backup_path}"
action_status="reconfigured"
fi
else
action_status="configured"
fi
# ── Asegurar que /etc/wireguard existe con permisos correctos ───────────
if [[ ! -d /etc/wireguard ]]; then
sudo mkdir -p /etc/wireguard \
|| { _wg_hub_log "ERROR: no se pudo crear /etc/wireguard"; return 1; }
sudo chmod 700 /etc/wireguard
_wg_hub_log "Directorio /etc/wireguard creado"
fi
# ── Escribir /etc/wireguard/wg0.conf ────────────────────────────────────
_wg_hub_log "Escribiendo ${config_path} (Address=${subnet_cidr}, ListenPort=${listen_port})"
sudo tee "${config_path}" > /dev/null <<EOF
[Interface]
Address = ${subnet_cidr}
ListenPort = ${listen_port}
PrivateKey = ${private_key}
SaveConfig = false
# NAT: permite que los peers accedan a internet via este hub (opcional, comentar si no se desea)
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
EOF
if [[ $? -ne 0 ]]; then
_wg_hub_log "ERROR: no se pudo escribir ${config_path}"
return 1
fi
sudo chmod 600 "${config_path}" \
|| { _wg_hub_log "ERROR: chmod 600 ${config_path} falló"; return 1; }
_wg_hub_log "Permisos 600 aplicados a ${config_path}"
# ── Habilitar ip_forward persistente ────────────────────────────────────
local sysctl_file="/etc/sysctl.d/99-wireguard.conf"
if [[ ! -f "${sysctl_file}" ]] || ! grep -q "net.ipv4.ip_forward" "${sysctl_file}" 2>/dev/null; then
_wg_hub_log "Habilitando ip_forward en ${sysctl_file}"
echo "net.ipv4.ip_forward = 1" | sudo tee "${sysctl_file}" > /dev/null \
|| { _wg_hub_log "ERROR: no se pudo escribir ${sysctl_file}"; return 1; }
fi
sudo sysctl -p "${sysctl_file}" >&2 \
|| _wg_hub_log "WARN: sysctl -p falló (puede ignorarse si el kernel ya tiene ip_forward=1)"
# ── Abrir puerto en firewall ─────────────────────────────────────────────
if command -v ufw &>/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
_wg_hub_log "ufw activo — abriendo UDP/${listen_port}"
sudo ufw allow "${listen_port}/udp" >&2 \
|| _wg_hub_log "WARN: ufw allow ${listen_port}/udp falló (verificar manualmente)"
elif command -v iptables &>/dev/null; then
_wg_hub_log "ufw inactivo — usando iptables para abrir UDP/${listen_port}"
sudo iptables -C INPUT -p udp --dport "${listen_port}" -j ACCEPT 2>/dev/null \
|| sudo iptables -A INPUT -p udp --dport "${listen_port}" -j ACCEPT >&2 \
|| _wg_hub_log "WARN: iptables INPUT rule falló (verificar manualmente)"
else
_wg_hub_log "WARN: ni ufw ni iptables disponibles — abre el puerto ${listen_port}/udp manualmente"
fi
# ── Detener interfaz si estaba corriendo (para aplicar nueva config) ────
if sudo wg show "${interface}" &>/dev/null 2>&1; then
_wg_hub_log "Interfaz ${interface} activa — deteniendo antes de reconfigurar"
sudo systemctl stop "wg-quick@${interface}" 2>/dev/null \
|| sudo wg-quick down "${interface}" 2>/dev/null \
|| _wg_hub_log "WARN: no se pudo detener ${interface} (puede que no estuviera activa)"
fi
# ── Habilitar y arrancar wg-quick@wg0 ────────────────────────────────────
_wg_hub_log "Habilitando systemd unit wg-quick@${interface}"
sudo systemctl enable "wg-quick@${interface}" >&2 \
|| { _wg_hub_log "ERROR: systemctl enable wg-quick@${interface} falló"; return 1; }
_wg_hub_log "Arrancando wg-quick@${interface}"
sudo systemctl start "wg-quick@${interface}" >&2 \
|| { _wg_hub_log "ERROR: systemctl start wg-quick@${interface} falló"; return 1; }
# ── Verificar que la interfaz está UP ────────────────────────────────────
local retries=5
local up=0
for (( i=0; i<retries; i++ )); do
if sudo wg show "${interface}" &>/dev/null 2>&1; then
up=1
break
fi
sleep 1
done
if [[ "${up}" -eq 0 ]]; then
_wg_hub_log "ERROR: 'wg show ${interface}' falló tras ${retries}s — la interfaz no arrancó"
return 1
fi
_wg_hub_log "Interfaz ${interface} UP (status=${action_status})"
printf '{"status":"%s","config_path":"%s","interface":"%s","hub_ip":"%s"}\n' \
"${action_status}" "${config_path}" "${interface}" "${hub_ip}"
return 0
}
+51
View File
@@ -0,0 +1,51 @@
---
name: wg_install
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "wg_install() -> json"
description: "Instala wireguard + wireguard-tools en Linux (debian/ubuntu/fedora/arch). Idempotente. Carga modulo kernel. Emite JSON con distro detectada y version instalada."
tags: [wireguard, install, infra, mesh, deploy]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params: []
output: "JSON {status, distro, version}. status=installed o already-present."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/wg_install.sh"
---
## Ejemplo
```bash
source bash/functions/infra/wg_install.sh
wg_install
# {"status":"installed","distro":"ubuntu","version":"wireguard-tools 1.0.20210914"}
# Si ya está instalado:
wg_install
# {"status":"already-present","distro":"ubuntu","version":"wireguard-tools 1.0.20210914"}
```
## Cuando usarla
Cuando necesites asegurarte de que wireguard-tools está disponible en un host antes de configurar un peer o hub WireGuard. Úsala como paso previo en pipelines de bootstrapping de nodos mesh (flow wireguard).
## Gotchas
- Requiere `sudo` con NOPASSWD para apt-get/dnf/pacman y para modprobe. El operador debe haberlo configurado antes.
- `modprobe wireguard` puede fallar en kernels < 5.6 sin DKMS instalado (wireguard-dkms). La función lo trata como advertencia, no como error fatal — la instalación de las herramientas igual se completa.
- En RHEL/CentOS instala `epel-release` automáticamente antes de wireguard-tools.
- Distros no reconocidas en `/etc/os-release ID` producen exit 1 con mensaje de error explícito en stderr.
- Los logs van siempre a stderr con prefijo `[wg_install]`; stdout es exclusivamente el JSON de resultado.
## Capability growth log
<!-- Rellenar solo cuando haya version bump real -->
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# wg_install — Instala wireguard + wireguard-tools en Linux (debian/ubuntu/fedora/arch).
# Idempotente: si wg ya está instalado emite JSON con status=already-present y sale.
# Carga módulo kernel wireguard. Emite JSON a stdout. Logs a stderr con prefijo [wg_install].
# Exit 0 = éxito, 1 = fallo.
wg_install() {
local distro="" version="" status=""
_wg_log() { echo "[wg_install] $*" >&2; }
# Detectar distro via /etc/os-release
if [[ -f /etc/os-release ]]; then
distro=$(. /etc/os-release && echo "${ID:-unknown}")
else
_wg_log "ERROR: /etc/os-release no encontrado; no se puede detectar distro"
return 1
fi
_wg_log "Distro detectada: ${distro}"
# Comprobar si wg ya está instalado (idempotencia)
if command -v wg &>/dev/null; then
version=$(wg --version 2>/dev/null | head -n1 || echo "unknown")
_wg_log "wireguard-tools ya presente (${version}); cargando módulo kernel"
# Intentar cargar módulo igualmente (no fatal)
sudo modprobe wireguard 2>/dev/null || true
printf '{"status":"already-present","distro":"%s","version":"%s"}\n' "${distro}" "${version}"
return 0
fi
# Instalar según distro
case "${distro}" in
debian|ubuntu|linuxmint|pop|kali|raspbian)
_wg_log "Usando apt-get (${distro})"
sudo apt-get update -y >&2 || { _wg_log "ERROR: apt-get update falló"; return 1; }
sudo apt-get install -y wireguard wireguard-tools >&2 \
|| { _wg_log "ERROR: apt-get install wireguard falló"; return 1; }
;;
fedora)
_wg_log "Usando dnf (fedora)"
sudo dnf install -y wireguard-tools >&2 \
|| { _wg_log "ERROR: dnf install wireguard-tools falló"; return 1; }
;;
rhel|centos|rocky|almalinux)
_wg_log "Usando dnf (rhel/centos/rocky/alma)"
sudo dnf install -y epel-release >&2 || true
sudo dnf install -y wireguard-tools >&2 \
|| { _wg_log "ERROR: dnf install wireguard-tools falló"; return 1; }
;;
arch|manjaro|endeavouros)
_wg_log "Usando pacman (arch)"
sudo pacman -S --noconfirm wireguard-tools >&2 \
|| { _wg_log "ERROR: pacman install wireguard-tools falló"; return 1; }
;;
*)
_wg_log "ERROR: distro '${distro}' no soportada (soportadas: debian/ubuntu/fedora/rhel/arch)"
return 1
;;
esac
# Verificar instalación
if ! command -v wg &>/dev/null; then
_wg_log "ERROR: 'wg' no encontrado tras la instalación"
return 1
fi
version=$(wg --version 2>/dev/null | head -n1 || echo "unknown")
_wg_log "wireguard-tools instalado: ${version}"
# Cargar módulo kernel (no fatal: kernels >=5.6 lo incluyen built-in)
if sudo modprobe wireguard 2>/dev/null; then
_wg_log "Módulo kernel wireguard cargado"
else
_wg_log "WARN: modprobe wireguard falló (puede estar built-in en el kernel o requerir DKMS)"
fi
status="installed"
printf '{"status":"%s","distro":"%s","version":"%s"}\n' "${status}" "${distro}" "${version}"
return 0
}
+79
View File
@@ -0,0 +1,79 @@
---
name: wg_status
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "wg_status([interface_name]) -> json"
description: "Parsea `wg show <iface> dump` a JSON estructurado con peers, handshake age, status (online/stale/never), bytes rx/tx. Resuelve device_id desde comentarios en wg0.conf. Para dashboards (agents_dashboard Mesh panel)."
tags: [wireguard, status, observability, json, infra]
params:
- name: interface_name
desc: "Nombre de la interface WireGuard (default wg0)"
output: "JSON con interface info + array de peers. Cada peer incluye public_key, device_id (de comentario # DeviceID:<id> en wg0.conf), endpoint, allowed_ips, latest_handshake_unix, latest_handshake_ago_s, rx_bytes, tx_bytes, persistent_keepalive, status (online/stale/never)."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests:
- "interface con 2 peers online y stale"
- "interface sin peers devuelve array vacio"
- "interface inexistente devuelve error JSON"
- "WG_FAKE_DUMP carga dump de archivo"
test_file_path: "bash/functions/infra/wg_status_test.sh"
file_path: "bash/functions/infra/wg_status.sh"
---
## Ejemplo
```bash
# Estado real de wg0
source bash/functions/infra/wg_status.sh
wg_status | jq .
# Interface distinta
wg_status wg1 | jq .peers[].status
# Sin sudo real (testing / CI)
WG_FAKE_DUMP=bash/functions/infra/wg_status_test_dump.tsv wg_status wg0 | jq .
```
Salida representativa:
```json
{
"interface": "wg0",
"public_key": "abcXYZ123...",
"listen_port": "51820",
"peers": [
{
"public_key": "peerKey1...",
"device_id": "pc-aurgi",
"endpoint": "1.2.3.4:54321",
"allowed_ips": ["10.42.0.10/32"],
"latest_handshake_unix": 1716000000,
"latest_handshake_ago_s": 42,
"rx_bytes": 12345,
"tx_bytes": 67890,
"persistent_keepalive": 25,
"status": "online"
}
]
}
```
## Cuando usarla
Cuando necesites saber el estado del mesh WireGuard desde un script, dashboard o agente. Usa antes de mostrar el panel Mesh en `agents_dashboard`. Llama cada N segundos para polling ligero desde shell sin depender de la API de WireGuard.
## Gotchas
- Requiere `CAP_NET_ADMIN` / root: `wg show` falla sin permisos. En produccion ejecutar via `sudo -n wg show wg0 dump` o dar permiso al binario. Para tests sin sudo: `WG_FAKE_DUMP=<path>` carga el dump desde archivo.
- `listen_port` se devuelve como string (tal como lo emite `wg show dump`). El campo es `"0"` si wg no esta activo pero la interface existe.
- `device_id` queda `""` si no hay comentario `# DeviceID:<id>` antes del `[Peer]` correspondiente en `/etc/wireguard/<iface>.conf`.
- Status `stale` cubre desde 180s hasta cualquier valor mayor. No hay distincion entre "hace 5 min" y "hace 3 dias" — ambos son `stale`. Para un threshold mas fino, usar `latest_handshake_ago_s` directamente.
- Si `/etc/wireguard/<iface>.conf` no existe o no es legible, `device_id` sera `""` para todos los peers (la funcion no falla, solo omite el lookup).
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# wg_status — Parsea `wg show <iface> dump` a JSON estructurado con peers,
# handshake age, status (online/stale/never), bytes rx/tx.
# Resuelve device_id desde comentarios # DeviceID:<id> en wg0.conf.
#
# Usage:
# wg_status [interface_name] # default: wg0
#
# Env:
# WG_FAKE_DUMP=<path> # lee dump de archivo en vez de llamar wg show (para tests)
wg_status() {
local iface="${1:-wg0}"
local conf="${WG_FAKE_CONF:-/etc/wireguard/${iface}.conf}"
local now
now=$(date +%s)
# --- obtener dump (real o fake) ---
local dump
if [[ -n "${WG_FAKE_DUMP:-}" ]]; then
if [[ ! -f "$WG_FAKE_DUMP" ]]; then
printf '{"error":"WG_FAKE_DUMP file not found: %s"}\n' "$WG_FAKE_DUMP"
return 1
fi
dump=$(cat "$WG_FAKE_DUMP")
else
if ! command -v wg &>/dev/null; then
printf '{"error":"wg command not found"}\n'
return 1
fi
if ! dump=$(wg show "$iface" dump 2>&1); then
if echo "$dump" | grep -qi "no such device\|does not exist\|unable to access interface"; then
printf '{"error":"interface not found"}\n'
return 1
fi
printf '{"error":"%s"}\n' "$(echo "$dump" | head -n1 | sed 's/"/\\"/g')"
return 1
fi
fi
# --- primera linea: info de la propia interface ---
# formato: <private_key>\t<public_key>\t<listen_port>\t<fwmark>
local iface_line
iface_line=$(echo "$dump" | head -n1)
local iface_pubkey iface_port
iface_pubkey=$(echo "$iface_line" | awk -F'\t' '{print $2}')
iface_port=$(echo "$iface_line" | awk -F'\t' '{print $3}')
# --- leer DeviceID map desde wg0.conf ---
# Busca patron:
# # DeviceID:<id>
# [Peer]
# PublicKey = <pk>
# Producimos pares "pk\tdevice_id" en un archivo temporal para lookup via awk
local device_map
device_map=$(awk '
/^#[[:space:]]*DeviceID:/ {
split($0, a, "DeviceID:")
did = a[2]
gsub(/^[[:space:]]+|[[:space:]]+$/, "", did)
pending_did = did
}
/^\[Peer\]/ {
in_peer = 1
}
in_peer && /^PublicKey[[:space:]]*=/ {
pk = $0
sub(/^PublicKey[[:space:]]*=[[:space:]]*/, "", pk)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", pk)
if (pending_did != "") {
print pk "\t" pending_did
pending_did = ""
}
in_peer = 0
}
' "$conf" 2>/dev/null)
# --- parsear peers (lineas 2..N del dump) ---
# formato peer: <public_key>\t<preshared_key>\t<endpoint>\t<allowed_ips>\t<latest_handshake>\t<rx_bytes>\t<tx_bytes>\t<persistent_keepalive>
local peers_json
peers_json=$(echo "$dump" | tail -n +2 | awk -v now="$now" -v dmap="$device_map" '
BEGIN {
# construir lookup device_id
n = split(dmap, lines, "\n")
for (i = 1; i <= n; i++) {
if (lines[i] != "") {
split(lines[i], parts, "\t")
pk_to_did[parts[1]] = parts[2]
}
}
first = 1
printf "["
}
NF >= 7 {
pk = $1
endpoint = $3
allowed = $4
hs = $5 + 0
rx = $6 + 0
tx = $7 + 0
ka = $8
# device_id lookup
did = (pk in pk_to_did) ? pk_to_did[pk] : ""
# handshake age y status
if (hs == 0) {
ago = 0
status = "never"
} else {
ago = now - hs
if (ago < 180) status = "online"
else if (ago < 86400) status = "stale"
else status = "stale"
}
# persistent_keepalive
ka_val = (ka == "off" || ka == "") ? 0 : ka + 0
# endpoint null si "(none)"
ep_val = (endpoint == "(none)") ? "null" : "\"" endpoint "\""
# allowed_ips array
n_ips = split(allowed, ips_arr, ",")
ips_json = "["
for (j = 1; j <= n_ips; j++) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", ips_arr[j])
ips_json = ips_json "\"" ips_arr[j] "\""
if (j < n_ips) ips_json = ips_json ","
}
ips_json = ips_json "]"
if (!first) printf ","
first = 0
printf "{"
printf "\"public_key\":\"%s\"", pk
printf ",\"device_id\":\"%s\"", did
printf ",\"endpoint\":%s", ep_val
printf ",\"allowed_ips\":%s", ips_json
printf ",\"latest_handshake_unix\":%d", hs
printf ",\"latest_handshake_ago_s\":%d",ago
printf ",\"rx_bytes\":%d", rx
printf ",\"tx_bytes\":%d", tx
printf ",\"persistent_keepalive\":%d", ka_val
printf ",\"status\":\"%s\"", status
printf "}"
}
END { printf "]" }
' FS='\t')
# --- output final ---
printf '{"interface":"%s","public_key":"%s","listen_port":%s,"peers":%s}\n' \
"$iface" "$iface_pubkey" "$iface_port" "$peers_json"
}
# Permitir invocacion directa: bash wg_status.sh [iface]
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
wg_status "$@"
fi
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Tests para wg_status
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/wg_status.sh"
PASS=0
FAIL=0
assert_contains() {
local test_name="$1" needle="$2" haystack="$3"
if echo "$haystack" | grep -qF "$needle"; then
echo "PASS: $test_name"
PASS=$((PASS+1))
else
echo "FAIL: $test_name — expected to contain '$needle'"
echo " got: $haystack"
FAIL=$((FAIL+1))
fi
}
assert_not_contains() {
local test_name="$1" needle="$2" haystack="$3"
if ! echo "$haystack" | grep -qF "$needle"; then
echo "PASS: $test_name"
PASS=$((PASS+1))
else
echo "FAIL: $test_name — expected NOT to contain '$needle'"
echo " got: $haystack"
FAIL=$((FAIL+1))
fi
}
# --- fixtures ---
FAKE_DUMP=$(mktemp)
FAKE_DUMP_EMPTY=$(mktemp)
FAKE_CONF=$(mktemp)
trap 'rm -f "$FAKE_DUMP" "$FAKE_DUMP_EMPTY" "$FAKE_CONF"' EXIT
NOW=$(date +%s)
HS_ONLINE=$(( NOW - 60 )) # 60s ago → online
HS_STALE=$(( NOW - 500 )) # 500s ago → stale
# dump con 2 peers (tabs como separador)
printf '%s\n' \
"privKeyBase64== ifacePubKey== 51820 off" \
"peerKey1== (none) 1.2.3.4:54321 10.42.0.10/32 ${HS_ONLINE} 12345 67890 25" \
"peerKey2== (none) 5.6.7.8:12345 10.42.0.20/32 ${HS_STALE} 111 222 0" \
> "$FAKE_DUMP"
# dump vacío (solo línea de interface, sin peers)
printf '%s\n' "privKeyBase64== ifacePubKey== 51820 off" > "$FAKE_DUMP_EMPTY"
# conf con DeviceID comments
cat > "$FAKE_CONF" <<'CONF'
[Interface]
PrivateKey = privKeyBase64==
Address = 10.42.0.1/24
ListenPort = 51820
# DeviceID:pc-aurgi
[Peer]
PublicKey = peerKey1==
AllowedIPs = 10.42.0.10/32
# DeviceID:home-wsl
[Peer]
PublicKey = peerKey2==
AllowedIPs = 10.42.0.20/32
CONF
# --- Test: interface con 2 peers online y stale ---
result=$(WG_FAKE_DUMP="$FAKE_DUMP" WG_FAKE_CONF="$FAKE_CONF" wg_status wg0)
assert_contains "interface con 2 peers online y stale" '"interface":"wg0"' "$result"
assert_contains "interface con 2 peers online y stale" '"listen_port":51820' "$result"
assert_contains "interface con 2 peers online y stale" '"public_key":"ifacePubKey=="' "$result"
assert_contains "interface con 2 peers online y stale" '"status":"online"' "$result"
assert_contains "interface con 2 peers online y stale" '"status":"stale"' "$result"
assert_contains "interface con 2 peers online y stale" '"device_id":"pc-aurgi"' "$result"
assert_contains "interface con 2 peers online y stale" '"device_id":"home-wsl"' "$result"
assert_contains "interface con 2 peers online y stale" '"rx_bytes":12345' "$result"
assert_contains "interface con 2 peers online y stale" '"persistent_keepalive":25' "$result"
# --- Test: interface sin peers devuelve array vacio ---
result_empty=$(WG_FAKE_DUMP="$FAKE_DUMP_EMPTY" WG_FAKE_CONF="$FAKE_CONF" wg_status wg0)
assert_contains "interface sin peers devuelve array vacio" '"peers":[]' "$result_empty"
assert_not_contains "interface sin peers devuelve array vacio" '"error"' "$result_empty"
# --- Test: interface inexistente devuelve error JSON ---
result_err=$(wg_status nonexistent_iface_xyz 2>/dev/null || true)
assert_contains "interface inexistente devuelve error JSON" '"error"' "$result_err"
# --- Test: WG_FAKE_DUMP carga dump de archivo ---
result_fake=$(WG_FAKE_DUMP="$FAKE_DUMP" WG_FAKE_CONF="$FAKE_CONF" wg_status wg0)
assert_contains "WG_FAKE_DUMP carga dump de archivo" '"public_key":"ifacePubKey=="' "$result_fake"
assert_contains "WG_FAKE_DUMP carga dump de archivo" '"peers":[{' "$result_fake"
echo "---"
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
+274
View File
@@ -0,0 +1,274 @@
---
name: agentes-dispositivos-mesh
id: 0009
status: pending
created: 2026-05-23
updated: 2026-05-23
priority: high
risk: high
related_issues: [0134, 0135, 0136, 0137, 0138, 0139, 0140, 0141, 0142, 0143]
apps: [agents_dashboard, agents_and_robots, wg_hub, device_agent]
projects: [element_agents]
vaults: []
capability_groups: [wireguard, device-agent, docker-agent]
trigger: manual
schedule: ""
expected_runtime_s: 300
tags: [mesh, wireguard, matrix, e2ee, agents, devices, docker, sandboxing]
---
## Goal
Hablar desde Element con dispositivos completos (PCs, moviles, raspberry, IoT) y con
contenedores Docker como si fueran agentes Matrix. Cada device/container ejecuta sus
capabilities declaradas (shell/fs/camera/docker/sensores) bajo:
1. **Mesh WireGuard** anclado en `organic-machine.com` — sin abrir puertos en los devices.
2. **Matrix E2EE** como bus de control y chat — un room por device/container.
3. **Capability manifest firmado** ed25519 — el device rechaza lo que no este firmado.
## Pre-requisitos
- VPS `organic-machine.com` con root SSH (alias `vps` en `~/.ssh/config`).
- `agents_and_robots` y `agents_dashboard` desplegados (ya OK).
- `pass` con clave operador ed25519 (`pass insert operator/ed25519` — crear si falta).
- `apt-get install wireguard wireguard-tools` permitido en el VPS.
- Devices Linux/WSL: sudo sin password para `wg`, `wg-quick`, `systemctl`.
- Devices Android: Termux + WireGuard app + `pkg install golang openssh-client`.
## Funciones del registry recomendadas
| Rol | Funcion candidata | Estado |
|---|---|---|
| WG install (host) | `wg_install_bash_infra` | FALTA: crear |
| WG keygen | `wg_keygen_go_infra` | FALTA: crear |
| WG hub setup | `wg_hub_setup_bash_infra` | FALTA: crear |
| WG peer add (hub) | `wg_peer_add_go_infra` | FALTA: crear |
| WG peer remove (hub) | `wg_peer_remove_go_infra` | FALTA: crear |
| WG peer revoke (kill switch) | `wg_peer_revoke_go_infra` | FALTA: crear |
| WG client config gen | `wg_client_config_go_infra` | FALTA: crear |
| WG client install (device) | `wg_client_install_bash_infra` | FALTA: crear |
| WG status (parse `wg show`) | `wg_status_bash_infra` | FALTA: crear |
| Docker list (host) | `docker_container_list_go_infra` | FALTA: crear |
| Docker exec capability | `docker_container_exec_go_infra` | FALTA: crear |
| Docker logs tail | `docker_container_logs_go_infra` | FALTA: crear |
| Docker container enroll | `docker_container_enroll_go_infra` | FALTA: crear |
| Capability sign | `capability_manifest_sign_go_infra` | FALTA: crear |
| Capability verify | `capability_manifest_verify_go_infra` | FALTA: crear |
| Enrollment token gen | `enrollment_token_create_go_infra` | FALTA: crear |
| Enrollment token verify | `enrollment_token_verify_go_infra` | FALTA: crear |
| Matrix room per device | `matrix_room_for_device_py_browser` (extender) | OK base, EXTENDER |
| Provision hub pipeline | `provision_wg_hub_bash_pipelines` | FALTA: crear |
| Enroll device pipeline | `enroll_device_bash_pipelines` | FALTA: crear |
| Sink audit log | `device_audit_append_go_infra` | FALTA: crear |
| Notify approval | `matrix_send_message_py_browser` (existente) | OK |
## Apps tocadas
- `agents_dashboard` (cockpit ImGui) — panel "Mesh" + "Devices" + "Containers" + approval queue.
- `agents_and_robots` (hub Matrix VPS) — listener Matrix por device/container.
- `wg_hub` (nuevo service Go en VPS) — enrollment endpoint, peer CRUD, SSE stream.
- `device_agent` (nuevo binario per-host) — capability dispatcher con sandbox.
- `container_agent_sidecar` (opcional, nuevo) — sidecar para containers que necesitan WG-peer propio.
## Projects relacionados
- `element_agents` (parent project — agents Matrix).
## Vaults / storage
- `apps/wg_hub/operations.db` — tabla `wg_peers`, `wg_enrollment_tokens`, `device_audit`.
- `apps/agents_dashboard/local_files/agents_dashboard.db` — cache devices + capabilities.
- `pass operator/ed25519` — clave maestra del operador (firma manifests).
- `pass wg/preshared/<device_id>` — PSK por peer.
## Capability groups consultados
- `wireguard` (nuevo, ver `docs/capabilities/wireguard.md`).
- `device-agent` (nuevo, capability dispatcher + sandbox + audit).
- `docker-agent` (nuevo, capabilities sobre containers locales).
## Flow
### Fase A — registry primero (delegar a fn-constructor en paralelo)
1. `function: wg_install_bash_infra` (delegada).
2. `function: wg_keygen_go_infra` (delegada).
3. `function: wg_hub_setup_bash_infra` (delegada).
4. `function: wg_peer_add_go_infra` (delegada).
5. `function: wg_peer_remove_go_infra` (delegada).
6. `function: wg_peer_revoke_go_infra` (delegada).
7. `function: wg_client_config_go_infra` (delegada).
8. `function: wg_client_install_bash_infra` (delegada).
9. `function: wg_status_bash_infra` (delegada).
10. `cmd: ./fn index` — registra las 9 nuevas.
11. `cmd: fn doctor unused | grep wg_` — confirma que estan listas y no huerfanas (se usan en pasos C).
### Fase C — POC manual end-to-end
12. `function: wg_install_bash_infra` (sobre `organic-machine.com` via SSH).
13. `function: wg_keygen_go_infra` → key par hub.
14. `function: wg_hub_setup_bash_infra` — wg0, 10.42.0.1/24, ufw 51820/udp, persistencia.
15. `function: wg_keygen_go_infra` → key par device `home-wsl`.
16. `function: wg_peer_add_go_infra` (en hub) → asigna 10.42.0.10.
17. `function: wg_client_config_go_infra` → genera client.conf.
18. `function: wg_client_install_bash_infra` (en `home-wsl`).
19. `cmd: ping -c3 10.42.0.1` desde `home-wsl` — verifica handshake.
20. `cmd: curl http://10.42.0.1:8080/healthz` — agents_and_robots accesible por IP privada.
21. Repetir 15-19 para `pc-aurgi`.
### Fase B — spec + capability manifest + bot Matrix
22. Issue 0134 spec protocol: envelope JSON `{request_id, capability, args, signature, nonce}`,
error model, approval flow, audit chain hash.
23. `function: capability_manifest_sign_go_infra` (operator firma).
24. `function: capability_manifest_verify_go_infra` (device verifica antes de aceptar request).
25. `function: enrollment_token_create_go_infra` (token QR firmado, TTL 10min).
26. `function: enrollment_token_verify_go_infra` (hub valida en `/enroll`).
27. Implementar `apps/device_agent/` (Go cross-compile) — Matrix client + capability dispatcher + sandbox firejail.
28. Panel "Devices" en `agents_dashboard` — lista + capability matrix + approval queue + boton revoke.
29. Bot Matrix por device: cuando hablas en el room `#dev-aurgi:organic-machine.com`,
`agents_and_robots` parsea, valida capability, despacha a device_agent, devuelve resultado al room.
### Fase D — agentes-contenedores docker
30. `function: docker_container_list_go_infra` — corre en host con docker socket access.
31. `function: docker_container_exec_go_infra` — exec en container con whitelist binarios.
32. `function: docker_container_logs_go_infra` — tail logs SSE.
33. Modo "light": container expuesto via host's `device_agent` capability `docker.*`.
Element room: `#host-aurgi:organic-machine.com` con comando `!docker exec mycontainer ps`.
34. Modo "deep": container = peer WG propio. `container_agent_sidecar` corre WG dentro del container
(privileged) o sidecar gluetun-wg. Manifest firmado mapea `agent_X` → container_id.
35. Sub-bot Matrix por container: `#cont-mycontainer:organic-machine.com` (opcional, modo deep).
## Acceptance
- [ ] 9 funciones `wg_*` creadas + indexadas + sin huerfanas.
- [ ] Hub WG corriendo en `organic-machine.com`, `wg show` muestra interface wg0.
- [ ] `home-wsl` y `pc-aurgi` con IP estable 10.42.0.10/11, `ping` OK.
- [ ] `agents_and_robots` accesible solo desde subnet 10.42.0.0/24 (publico = DROP en :8080).
- [ ] `agents_dashboard` panel "Mesh" muestra peers vivos via SSE.
- [ ] Chat en `#dev-aurgi` ejecuta capability (ej. `!ls /home/lucas`) y devuelve resultado.
- [ ] Capability fuera del manifest rechazada con error en room.
- [ ] Capability `requires_approval=true` espera confirmacion en `#operator-approvals` antes de ejecutar.
- [ ] `docker.container.list` invocado desde Element devuelve containers del host.
- [ ] `docker.container.exec` con binario fuera de whitelist rechazado.
- [ ] Revoke device desde `agents_dashboard` → device pierde acceso en <5s.
- [ ] Audit log append-only inviolable (hash chain) sobrevive reinicio.
## Definition of Done
Triada obligatoria (ver `.claude/rules/dod_quality.md`). Sin las 3 capas + 0 anti-criterios el flow NO se mueve a `completed/`.
### Mecanica (pre-requisito)
- [ ] **Build device_agent**: `cd apps/device_agent && CGO_ENABLED=0 GOOS=linux go build -o device_agent .` exit 0; cross-compile `GOOS=windows` + `GOOS=android GOARCH=arm64` tambien verdes.
- [ ] **Build agents_and_robots + agents_dashboard**: `./fn run redeploy_cpp_app_windows agents_dashboard apps/agents_dashboard --build` + Go build de `agents_and_robots` exit 0.
- [ ] **Tests unitarios funciones nuevas verdes**: `CGO_ENABLED=1 go test -tags fts5 -count=1 ./functions/infra/...` cubriendo wg_*, capability_*, enrollment_*, device_audit_*. Lista de IDs en `## Notas`.
- [ ] **`./fn index`** sin warnings nuevos tras anadir las ~20 funciones.
- [ ] **`./fn doctor unused --json | jq '.[]|select(.id|startswith("wg_"))'`** vacio (las wg_* tienen consumidores reales).
- [ ] **`./fn doctor uses-functions`** verde para `apps/device_agent/app.md`, `apps/wg_hub/app.md`, `apps/agents_dashboard/app.md`.
- [ ] **`./fn doctor services-spec`** verde para `wg_hub.service` y `device_agent.service` con bloque service: completo.
### Cobertura de comportamiento
Minimo: golden + 8 edge/error documentados aqui con assert ejecutable. Cada uno deja entry en `e2e_runs` de la app afectada (`apps/device_agent/operations.db`, `apps/wg_hub/operations.db`).
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|---|---|---|---|
| Golden: comando whitelist OK | e2e | Element `!exec ls /home/lucas` en `#dev-home-wsl` | output `ls` en <3s, entry en `device_audit` con hash valido |
| Edge: comando NO whitelist rechazado | e2e | Element `!exec rm -rf /` | reply `capability rejected: shell.exec.rm not in manifest`; entry `device_audit` status=`rejected_capability` |
| Edge: capability fuera de manifest | e2e | Element `!camera.snapshot` en device sin esa capability | reply `capability not in manifest`; alerta a `#operator-approvals` |
| Edge: replay nonce viejo | e2e | reenviar mismo envelope con nonce ya visto (cmd test: `device_agent --replay-test <envelope.json>`) | rechazo + log `nonce_replay`; entry `device_audit` status=`rejected_nonce` |
| Edge: ed25519 manifest invalido | e2e | servir manifest firmado por clave que no es operator; `device_agent` lo recibe en enrollment | `device_agent` rechaza + no instala wg_peer; hub log muestra `manifest_invalid_signature` |
| Edge: token enrollment expirado | e2e | `enrollment_token_create` con TTL=1s, esperar 5s, `POST /enroll` | hub responde 401 `token_expired`; cmd `curl ...` exit != 0 |
| Approval flow honrado | e2e | Element `!fs.write /tmp/x hello` (requires_approval=true); operador hace 👍 en `#operator-approvals` | exec ocurre SOLO tras approval; sin approval no escribe; entry `device_audit` con `approval_msg_id` |
| Approval flow no se salta | e2e | Forzar via API directa salto del approval queue (test negativo: cmd `curl --data ...` directo al device) | device rechaza + log; sin approval_msg_id en envelope = rechazo |
| Mesh-down handled | e2e | `wg-quick down wg0` en hub mientras device manda comando | device entra en `degraded`, comando encolado o respuesta `mesh_unreachable`; al volver hub: handshake reanuda, cola se vacia |
| Dos devices simultaneos sin interferencia | e2e | `home-wsl` y `pc-aurgi` ejecutan capabilities en paralelo (script python con 2 threads) | cada audit chain es independiente, sin cross-contamination; `device_audit` muestra 2 chains separadas, hash chain valido en cada una |
| Audit chain valida tras restart | e2e | matar `device_agent` mid-flight (`kill -9`) + relanzar; `cmd: device_audit_verify_chain --device home-wsl` | chain integra, hash anterior coincide, sin huecos |
| Revoke device <5s | e2e | desde `agents_dashboard` panel "Mesh" boton "Revoke home-wsl"; medir tiempo hasta `wg show` no liste peer | peer ausente en <5s; siguientes comandos a `#dev-home-wsl` -> `peer_revoked` |
**Regla**: cada fila genera `e2e_check` en `app.md` correspondiente (issue 0068). `fn-analizador` los corre periodicamente.
### Vida util validada
| Metrica | Umbral | Donde se observa | Ventana |
|---|---|---|---|
| Peers vivos en mesh | `>=2` constantes (home-wsl + pc-aurgi) | `agents_dashboard` panel "Mesh" (last_handshake < 3min) | 7 dias |
| Crashes `device_agent` | `0` | `journalctl --user -u device_agent.service` en cada device | 7 dias |
| Crashes `wg_hub` | `0` | `ssh vps journalctl -u wg_hub.service` | 7 dias |
| Huecos en audit chain | `0` | `cmd: device_audit_verify_chain --all` | continuo |
| Rollback de wg config | `0 ocurrencias` | hub: `git -C /etc/wireguard status` debe ser clean; sin restore manual | 7 dias |
| Handshake fail rate | `<5%` | `wg show all dump` parseado por `agents_dashboard` | 7 dias |
| Approval queue stuck | `0 pendientes >24h` | `agents_dashboard` panel "Approvals" | continuo |
| Comandos exec latencia p95 | `<3s` | `call_monitor.function_stats` para `capability.shell.exec` | 7 dias |
| Replay attacks bloqueados | `>=1 detectado y bloqueado` (pen-test real) | `device_audit` status=`rejected_nonce` count | 30 dias |
### User-facing (reforzado)
- [ ] **User-facing surface**: humano abre Element en movil/web (`element.organic-machine.com`), entra a `#dev-<nombre>` y escribe comandos. Output en el mismo room. NO en una BD, NO en un log.
- [ ] **User-facing usage real**: el operador (humano) usa Element con `home-wsl` Y `pc-aurgi` (>=2 maquinas reales), **>=1 sesion/dia durante >=7 dias consecutivos**, **>=20 comandos totales** repartidos entre devices.
- [ ] **User-facing variado**: cubre capabilities de **>=4 tipos**: read (`!fs.read`, `!ls`), write (`!fs.write`), exec (`!exec`), approval-required (`!fs.write` en path sensible), docker (`!docker exec`).
- [ ] **User-facing onboarding**: parrafo en `## Notas` con pasos numerados: abrir Element -> entrar a room -> `!help` -> ejemplo de comando. Sin leer el flow entero.
- [ ] **User-facing latencia**: tras enviar mensaje en Element, output visible en <3s (read/exec) o <5s (con approval) — medido y registrado en `## Notas`.
### Anti-criterios (invalidan DoD aunque checkboxes verdes)
- [ ] **Solo-en-home-wsl**: el flow funciona en mi WSL pero falla en `pc-aurgi` u otro device fisico.
- [ ] **device_agent muere cada noche**: cualquier crash recurrente del proceso device_agent en los 7 dias de validacion.
- [ ] **Approval flow se salta**: alguna entrada en `device_audit` con capability `requires_approval=true` ejecutada sin `approval_msg_id` valido.
- [ ] **Audit chain rota**: `device_audit_verify_chain` reporta huecos o hash mismatch en algun device.
- [ ] **wg config drift**: cambios manuales en `/etc/wireguard/wg0.conf` del hub sin pasar por `wg_peer_add/remove/revoke`. Git status muestra cambios sin trackear.
- [ ] **Dashboard fantasma**: `agents_dashboard` declarado pero el operador no lo abre durante la ventana de 7 dias. Telemetria muerta.
- [ ] **Pen-test no ejercitado**: replay attack / capability fuera de manifest / token expirado declarados pero sin entry real en `device_audit` con status `rejected_*` en los 7 dias.
- [ ] **Silent-fail**: peer cae >24h y nadie se entera (sin alerta a `#operator-approvals` ni badge rojo en dashboard).
- [ ] **Secrets en repo**: cualquier hit de `git grep -E 'PrivateKey|PSK|operator/ed25519' -- ':!*.md'` en cualquier rama.
### Custom (security-specific, deben tener evidencia en `device_audit`)
- [ ] _(custom)_ Pen-test capability fuera de manifest: entry `device_audit` status=`rejected_capability` ejercitado intencionalmente >=1 vez.
- [ ] _(custom)_ Pen-test replay: entry `device_audit` status=`rejected_nonce` ejercitado >=1 vez con cmd reproducible.
- [ ] _(custom)_ Stale device: forzar `home-wsl` offline >24h, verificar badge `stale` en `agents_dashboard` + mensaje en `#operator-approvals`.
- [ ] _(custom)_ Operator key rotation: ejecutar rollover de la clave ed25519 maestra + revoke-all + re-enroll, sin perder audit chain historica. Documentado en `## Notas`.
## Telemetria esperada
- `call_monitor.calls`: cada `wg_*`, `capability.*`, `docker.*` con duration_ms, success.
- `apps/wg_hub/operations.db`: tabla `wg_peers` + `device_audit` (hash-chained append-only).
- `apps/agents_and_robots/operations.db`: tabla `matrix_capability_dispatches`.
- `apps/agents_dashboard/local_files/agents_dashboard.db`: cache devices + approval queue.
- Dashboards visibles: `agents_dashboard` panel "Mesh" (peers vivos + last handshake + bytes rx/tx).
- Matrix room `#operator-approvals` recibe cada approval_request.
- Element en movil aprueba/rechaza con reacciones (👍/👎) o comando `!approve <id>`.
## Riesgos / gotchas
- **VPS UDP/51820**: firewall del proveedor del VPS puede bloquearlo. Verificar con `nc -u -v vps 51820`.
- **NAT carrier-grade (4G/5G)**: device tras NAT estricto → `PersistentKeepalive = 25` obligatorio.
- **Sleep laptop / android doze**: handshake muere. Auto-reconnect via `systemd-networkd-wait-online` + script.
- **Privilegio sudo**: `wg-quick` requiere root. Devices necesitan sudo-NOPASSWD para `wg-quick@wg0`.
- **Clock skew**: tokens enrollment + nonces dependen de NTP. Forzar `chrony` en VPS y devices.
- **Container privileged**: modo "deep" docker requiere `--cap-add NET_ADMIN`. Riesgo si container compromised.
Mitigacion: solo modo "deep" para containers de tu propio control (ej. `agents_and_robots` self-hosted), no third-party.
- **Operator key compromise**: si tu ed25519 leaks → cualquiera firma manifests. Plan B: rotacion + revoke-all + re-enroll.
- **Matrix homeserver compromise**: chat E2EE protege contenido, pero metadata (quien habla con quien) leak.
Aceptable porque homeserver es tuyo en `organic-machine.com`.
## Notas
(rellenar tras ejecutar fases A/C/B/D)
### Para hablar con un device desde Element (onboarding)
1. Abre Element en movil o web (`element.organic-machine.com`).
2. Entra al room `#dev-<nombre>` (un room por device).
3. Escribe `!help` → bot del room (`agents_and_robots`) responde con capability matrix del device.
4. Escribe comando, ej. `!exec ls /home/lucas` o `!fs.read /var/log/syslog`.
5. Si capability requiere approval, te llega notification a `#operator-approvals` → reaccionas 👍 → ejecuta.
6. Output aparece en el mismo room del device.
### Para hablar con un container docker
1. Si el host del container ya esta en la mesh: room `#dev-<host>` con `!docker exec <container> <cmd>`.
2. Modo deep: room dedicado `#cont-<container>` (solo containers enrolled).
+1
View File
@@ -12,6 +12,7 @@ Tabla de casos de uso multi-app. Mantenida por `/flow create` y `/flow done`.
| [0006](0006-metabase-versioning.md) | metabase-versioning | gitops | auto_metabase, dag_engine | pending | medium | 0% | 2026-05-16 |
| [0007](0007-matrix-telemetry-bot.md) | matrix-telemetry-bot | event-driven | data_factory, dag_engine, call_monitor, agents_and_robots | pending | low | 0% | 2026-05-16 |
| [0008](0008-kanban-cpp-and-agent-workflows.md) | kanban-cpp-and-agent-workflows | realtime-loop | kanban_cpp, kanban, skill_tree, agent_runner_api | pending | medium | 0% | 2026-05-18 |
| [0009](0009-agentes-dispositivos-mesh.md) | agentes-dispositivos-mesh | event-driven | agents_dashboard, agents_and_robots, wg_hub, device_agent | pending | high | 0% | 2026-05-23 |
## Leyenda
+92 -25
View File
@@ -12,49 +12,116 @@ Un flow describe una secuencia de pasos que atraviesa varias apps (`navegator_da
- **Definition of Done OBLIGATORIA** — ver seccion abajo. Sin DoD el flow NO puede crearse.
- Cerrados se mueven a `completed/`.
## Definition of Done (OBLIGATORIA)
## Definition of Done (OBLIGATORIA — triada)
Cada flow al crearse DEBE declarar un bloque `## Definition of Done` distinto de `## Acceptance`. Sin el, `/flow create` rechaza el scaffold y `/flow done` rechaza el cierre.
**Diferencia:**
**Regla absoluta**: DoD no es checkbox que se marca a mano. Cada item lleva **evidencia ejecutable** (comando, e2e_check, dashboard URL con datos frescos, log query, screenshot link). Si no puedes probarlo, no es DoD: es deseo. Ver `.claude/rules/dod_quality.md` para la regla completa.
**Diferencia con `## Acceptance`:**
| `## Acceptance` | `## Definition of Done` |
|---|---|
| Checks task-level del flow (ejecucion concreta) | Contrato global de calidad para considerar el flow CERRADO |
| Pueden quedar `[ ]` mientras iteras | TODOS deben estar `[x]` antes de mover a `completed/` |
| Verifica que el flow CORRE | Verifica que el flow es REPETIBLE, OBSERVABLE y MANTENIBLE |
| Checks task-level del flow (el flow corre una vez) | Contrato global de calidad: el flow sobrevive uso real |
| Pueden quedar `[ ]` mientras iteras | TODAS las capas verdes con evidencia antes de mover a `completed/` |
| Verifica que el flow CORRE | Verifica que el flow es REPETIBLE, OBSERVABLE, MANTENIBLE y USADO |
**Plantilla minima de DoD** (anadir/ajustar segun flow):
### Las 3 capas obligatorias
**1. Mecanica** (pre-requisito, NO es DoD por si misma):
Build verde, tests verdes, `fn index` limpio, `fn doctor` verde, `uses_functions` sin drift. Hacer compilar la cosa NO es haberla terminado.
**2. Cobertura de comportamiento**:
Tabla `escenario | tipo | comando | resultado esperado`. Minimo 1 golden + 2 edge + 1 error path con assert real, no smoke "no peto". Cuando aplique, las pruebas dejan entry en `e2e_runs` de la app afectada.
**3. Vida util validada**:
Tabla `metrica | umbral | dashboard | ventana`. El flow sobrevive **>=7 dias de uso real** sin romperse silenciosamente. Crashes = 0, huecos en audit chains = 0, error_rate < umbral declarado, dashboard observable abierto periodicamente. **El humano usa la cosa en su PC, en su contexto real, >=N veces variadas, no en sandbox aislado**.
### Plantilla obligatoria
Ver `template.md` para el esqueleto completo. Bloques:
```markdown
## Definition of Done
- [ ] **Repetibilidad**: el flow corre N veces consecutivas (N declarado en el flow, default 3) sin intervencion manual.
- [ ] **Observabilidad**: queda trazado en `call_monitor.calls` + `data_factory.runs` + dashboard correspondiente.
- [ ] **Error-path**: al menos 1 modo de fallo probado y manejado (no crash silencioso).
- [ ] **Idempotencia**: re-ejecutar no duplica datos ni rompe estado (clave en sinks).
- [ ] **Secrets**: cero credenciales en disco fuera de `pass`/vaults; cero datos sensibles fuera de `risk` declarado.
- [ ] **Docs**: `## Notas` rellenado con hallazgos reales + comandos para reproducir.
- [ ] **Registry-first**: todas las piezas reutilizables existen como funciones del registry (no inline en apps).
- [ ] **INDEX + status**: `status: done` en frontmatter + fila actualizada en `INDEX.md` + archivo movido a `completed/`.
### Mecanica
- [ ] Build verde (`cmd: ...`)
- [ ] Tests verdes (`cmd: ...`)
- [ ] fn index limpio
- [ ] fn doctor verde
- [ ] uses_functions auditado
### Cobertura de comportamiento
| Escenario | Tipo | Comando | Resultado esperado |
|---|---|---|---|
| Golden: ... | unit/e2e | `cmd` | output concreto |
| Edge 1: ... | unit/e2e | `cmd` | comportamiento concreto |
| Error 1: ... | e2e | `cmd que rompe` | fallo manejado, no crash |
| Error 2: ... | e2e | `cmd` | degradacion graceful + log |
### Vida util validada
| Metrica | Umbral | Donde se observa | Ventana |
|---|---|---|---|
| <metrica> | `>=N` | `<dashboard URL>` | 7 dias |
| crashes | `0` | `journalctl ...` | 7 dias |
### User-facing (reforzado)
- [ ] User-facing surface (lugar concreto, NO BD ni log).
- [ ] User-facing usage real: >=N veces en >=7 dias, en PC real, con inputs reales.
- [ ] User-facing variado: >=3 capabilities/casos distintos.
- [ ] User-facing onboarding (parrafo en `## Notas`).
- [ ] User-facing latencia <X medida.
### Anti-criterios (invalidan DoD aunque checkboxes verdes)
- [ ] solo-en-mi-PC
- [ ] solo-en-sandbox-vacio
- [ ] camino feliz unico (error paths declarados pero nunca ejercitados)
- [ ] dashboard fantasma (no abierto en >30 dias)
- [ ] self-test sin asserts
- [ ] silent-fail
- [ ] approval saltado
```
Cada flow puede anadir DoD especificos al dominio (ej. `bbva-movimientos`: "datos NUNCA cruzan a registry.organic-machine"). El bloque DoD se **versiona con el flow** — un cambio de DoD requiere bump de `updated:` en frontmatter.
### Reglas duras para marcar `status: done`
### User-facing surface (sub-bloque OBLIGATORIO dentro de DoD)
`/flow done` rechaza el cierre si:
"DoD verde" sin valor visible al humano = plumbing limpio sin razon de existir. Cada DoD DEBE incluir, al menos, estos cuatro checks tipo `User-facing`:
1. Falta alguna de las 3 capas (mecanica + cobertura + vida).
2. En Cobertura: <1 golden, <2 edge, <1 error path con evidencia.
3. En Vida util: tabla vacia o sin dashboard observable real.
4. User-facing usage real <7 dias o <N usos declarados.
5. Cualquier anti-criterio marcado como cierto.
6. `## Notas` sin parrafo onboarding.
7. Algun item sin comando/URL/log query — solo texto.
```markdown
- [ ] **User-facing**: <accion concreta del humano + lugar exacto donde ve/usa el output>.
- [ ] **User-facing repeat**: el humano vuelve manana al mismo lugar y ve datos frescos sin conocer el flow.
- [ ] **User-facing onboarding**: parrafo en `## Notas` que explica "para ver/usar esto: hacer X" — sin leer el flow.
- [ ] **User-facing latencia**: el humano percibe el cambio en <X segundos|minutos> tras el evento (X declarado por flow).
```
Cada flow puede anadir DoD especificos al dominio. El bloque DoD se **versiona con el flow** — un cambio de DoD requiere bump de `updated:` en frontmatter.
Regla: si la respuesta a "donde lo ves" es "en una BD" o "en un log" -> NO vale. Tiene que ser una superficie usada por el humano (UI de una app, sala Matrix, dashboard, Metabase card, repo Gitea, archivo en vault abierto a mano). Si el output solo lo consume otra app/flow, esa app/flow es quien debe declarar su propia user-facing surface.
### User-facing surface (regla complementaria)
`/flow done` rechaza el cierre si falta alguno de los 4 user-facing checks o si `## Notas` no contiene parrafo onboarding.
Si la respuesta a "donde lo ves" es "en una BD" o "en un log" -> NO vale. Tiene que ser una superficie usada por el humano (UI de app, sala Matrix, dashboard, Metabase card, repo Gitea, archivo en vault abierto a mano). Si el output solo lo consume otra app/flow, esa app/flow declara su propia user-facing surface.
### Antipatrones documentados
| Antipatron | Por que es malo |
|---|---|
| Marcar `done` porque pasa una vez | Tarea "hecha" se rompe al primer uso real |
| Checkbox sin evidencia ejecutable | DoD se convierte en placebo, no en gate |
| Test que solo verifica camino feliz | El error path es donde se pierden datos en produccion |
| Observabilidad declarada pero dashboard no abierto en 30 dias | Telemetria muerta = ceguera |
| "Repetible 3 veces consecutivas" con BD efimera | No prueba comportamiento sobre datos reales acumulados |
| Aprobacion saltada en algun camino | Security gate roto pero invisible |
| Error path manejado solo "en teoria" | Cuando ocurra en produccion el manejo no existe |
### Validacion programatica de DoD (TBD)
`/flow done` ejecuta checks programaticos:
- Parsea bloques `### Mecanica`, `### Cobertura`, `### Vida util`, `### User-facing`, `### Anti-criterios`.
- Verifica que cada item tiene `cmd:` / URL / log query / e2e_check_id asociado.
- Cuenta filas en Cobertura: >=1 golden + >=2 edge + >=1 error.
- Cruza con `e2e_runs` y `call_monitor.calls` para confirmar evidencias en BDs reales.
- Aborta cierre si falta cobertura o algun anti-criterio esta marcado.
Hoy parte de esto es manual (revision humana). Ver `audit_dod_schema_go_infra` (issue 0114) + `fn doctor dod`.
### DoD evidence schema (issue 0114, opcional)
+60 -14
View File
@@ -68,28 +68,74 @@ Pasos numerados. Cada paso puede ser:
## Acceptance
Checks task-level del flow — verifican que el flow CORRE una vez. Pueden quedar `[ ]` mientras iteras. NO sustituyen a la DoD.
- [ ] Criterio 1.
- [ ] Criterio 2.
## Definition of Done
Contrato global de cierre. TODOS marcados antes de mover a `completed/`. Ver README.md seccion "Definition of Done".
**Filosofia triada (ver `.claude/rules/dod_quality.md`):** DoD no es checkbox que se marca a mano. Cada item debe llevar **evidencia ejecutable** (comando, e2e_check, screenshot link, dashboard URL con datos frescos, log query). Si no puedes probarlo, no es DoD: es deseo. Las 3 capas son obligatorias.
- [ ] **Repetibilidad**: corre 3 veces consecutivas sin intervencion manual.
- [ ] **Observabilidad**: trazado en `call_monitor.calls` + `data_factory.runs` + dashboard relevante.
- [ ] **Error-path**: >=1 modo de fallo probado y manejado.
- [ ] **Idempotencia**: re-ejecucion no duplica ni corrompe sinks.
- [ ] **Secrets**: cero credenciales fuera de `pass`/vaults; risk declarado coincide con datos reales.
- [ ] **Docs**: `## Notas` con hallazgos + comandos reproducibles.
- [ ] **Registry-first**: piezas reutilizables viven como funciones del registry.
- [ ] **INDEX + status**: `status: done` + `INDEX.md` actualizado + movido a `completed/`.
### Mecanica (pre-requisito, NO sustituye al resto)
### User-facing (obligatorio)
Construir verde no es estar hecho. Es la base para empezar a probar.
- [ ] **User-facing**: <accion concreta del humano + lugar exacto donde ve/usa el output>.
- [ ] **User-facing repeat**: humano vuelve manana al mismo lugar, ve datos frescos sin conocer el flow.
- [ ] **User-facing onboarding**: parrafo en `## Notas` explica "para ver/usar esto: hacer X" sin leer el flow.
- [ ] **User-facing latencia**: humano percibe el cambio en <Xs|Xmin> tras el evento (X declarado).
- [ ] **Build verde** (`cmd: <comando build>`).
- [ ] **Tests unitarios verdes** (`cmd: <comando test>`, listar IDs/paths).
- [ ] **`fn index` limpio** (sin warnings nuevos).
- [ ] **`fn doctor` verde** en artefactos tocados (`cmd: ./fn doctor --json | jq ...`).
- [ ] **`uses_functions` auditado** (sin drift en `app.md` vs imports reales).
### Cobertura de comportamiento
Cada escenario debe tener una prueba ejecutable con assert real (no smoke "no peto"). Anadir tantas filas como casos relevantes — golden path + edge cases + error paths.
| Escenario | Tipo de prueba | Comando / evidencia | Resultado esperado |
|---|---|---|---|
| Golden path: <descripcion> | unit / e2e / manual | `<cmd>` | <output concreto, no "ok"> |
| Edge case 1: <input limite> | unit / e2e | `<cmd>` | <comportamiento concreto> |
| Edge case 2: <estado raro> | unit / e2e | `<cmd>` | <comportamiento concreto> |
| Error path 1: <fallo esperado> | e2e | `<cmd que provoca fallo>` | <fallo manejado, no crash> |
| Error path 2: <recursos caidos> | e2e | `<cmd>` | <degradacion graceful + log> |
**Regla**: al menos 1 golden + 2 edge + 1 error path. Tests inscritos en `e2e_runs` de la app correspondiente cuando aplique.
### Vida util validada
El flow no esta hecho hasta que sobrevive **uso real** durante >=7 dias sin romperse silenciosamente. Cada metrica tiene umbral medible y dashboard observable.
| Metrica | Umbral | Donde se observa | Ventana |
|---|---|---|---|
| <metrica 1, ej. handshakes vivos> | `>=N` | `<dashboard URL / app panel>` | 7 dias |
| <metrica 2, ej. error_rate> | `<X%` | `<dashboard URL>` | 7 dias |
| <metrica 3, ej. duracion p95> | `<Xms` | `call_monitor.function_stats` | 30 dias |
| crashes del proceso | `0` | `journalctl -u <unit>` | 7 dias |
| huecos en audit chain | `0` | `cmd: <verify chain>` | continuo |
**Regla**: las metricas NO se autoreportan en el flow; las lee el operador del dashboard real. Si el dashboard no existe, el item se invalida.
### User-facing (reforzado)
"DoD verde" sin uso humano real = plumbing limpio sin razon de existir.
- [ ] **User-facing surface**: <accion concreta del humano + lugar exacto donde ve/usa el output (NO una BD, NO un log)>.
- [ ] **User-facing usage real**: el humano (operador) usa la cosa en **su PC, en su contexto real**, **>=N veces en >=7 dias**, con inputs reales (no demo, no sandbox).
- [ ] **User-facing variado**: cubre >=3 capabilities/casos distintos (no solo "abre dashboard y mira").
- [ ] **User-facing onboarding**: parrafo en `## Notas` que explica "para ver/usar esto: hacer X" sin leer el flow.
- [ ] **User-facing latencia**: percepcion del cambio <Xs|Xmin> tras el evento (X declarado, medido).
### Anti-criterios (invalidan la DoD aunque los checkboxes esten verdes)
Marca el flow como **NO done** si cualquiera de estas condiciones es cierta:
- [ ] **Solo-en-mi-PC**: el flow funciona en `home-wsl` pero falla en `pc-aurgi` u otro PC del operador.
- [ ] **Repetible-en-sandbox-vacio**: solo pasa con BD limpia / cuenta limpia / sin datos historicos.
- [ ] **Camino feliz unico**: los error paths fueron declarados pero NUNCA se ejercitaron (sin entry en `e2e_runs` o logs reales).
- [ ] **Dashboard fantasma**: el dashboard declarado en "Vida util" no se ha abierto en >30 dias.
- [ ] **Self-test que no apesta**: el `e2e_check` retorna `pass` sin verificar nada material (no asserts).
- [ ] **Silent-fail**: el proceso muere/degrada sin alerta visible al operador.
- [ ] **Approval saltado**: alguna capability con `requires_approval=true` fue ejecutada sin el approval flow.
### Custom (opcional, dominio-especifico)
+90
View File
@@ -0,0 +1,90 @@
---
id: "0131"
title: "Modulo C++ chat_panel — panel ImGui para chat con agentes"
status: pendiente
type: app
domain:
- cpp-stack
- agents
- dev-ux
scope: cross-stack
priority: alta
depends:
- "0113"
blocks: []
related:
- "0130"
created: 2026-05-22
updated: 2026-05-22
tags: [cpp, imgui, agents, chat, module, sse]
flow: ""
---
# 0131 — Modulo C++ chat_panel
**Status:** pendiente
## Por que
Tras lanzar un agente desde kanban_cpp (issue 0130), no hay forma de interactuar con el desde la propia app. Hoy el flujo es: lanzar agente, abrir terminal aparte, `tail -f /tmp/wt-.../agent.log`. Queremos un panel C++ reutilizable que cualquier app embebra para chatear con un agente (Claude headless o futuros) y ver su output en streaming.
## Que entrega
Modulo `cpp/functions/viz/chat_panel/` (paquete del registry, kind: function, lang: cpp, domain: viz). API:
```cpp
namespace fn_chat {
struct ChatPanel {
// run_id del agent_runner_api; null = panel vacio "no agent attached"
std::string run_id;
std::string backend_url = "http://127.0.0.1:8486"; // agent_runner_api
bool auto_scroll = true;
};
void render(ChatPanel& panel);
}
```
Comportamiento:
- Conecta SSE `/api/runs/<run_id>/sse` en background thread (reusa `sse_client_cpp_core`).
- Parsea eventos `state`, `log`, `evidence`, `finish` y los renderiza:
- `log` → linea cruda en buffer scrollable.
- `state` → badge superior con status (`pending/running/done/aborted/failed`).
- `evidence` → chip lateral con kind + payload_url.
- `finish` → marca run terminada, deja conexion para ver historico.
- Input box inferior (multiline) + boton "Send". POST a `/api/runs/<run_id>/message` (endpoint A IMPLEMENTAR en agent_runner_api — extension paralela; si no existe, boton se deshabilita).
- Toolbar: `Abort run`, `Clear buffer`, `Show evidence panel`.
## Estructura
```
cpp/functions/viz/chat_panel/
chat_panel.h
chat_panel.cpp
chat_panel.md
chat_panel_test.cpp
```
## Reusa del registry
- `sse_client_cpp_core` — SSE async.
- `http_request_cpp_core` — POST mensajes / abort.
- `selectable_text_cpp_viz` — copy log lines.
- `data_table_cpp_viz` — opcional para tabla de evidencias.
## DoD
- Modulo compila en Linux + Windows.
- Demo en `primitives_gallery` o app dedicada `agent_chat_demo` con run_id fijo + mock SSE feeder.
- Integracion en kanban_cpp v2: nuevo panel "Chat" que se abre al hacer click en card con agent_active, run_id se pasa automatico.
- `e2e_checks`: smoke con mock SSE; assertion: tras emitir 3 eventos de log, panel los muestra en orden.
## Anti-scope (v1)
- No persiste history local (refresh = perdemos buffer; agent.log es la fuente).
- No syntax highlight markdown / codigo.
- Sin multi-run (un panel = un run).
- Sin file diff inline (kind:evidence con kind:diff queda como link a `git show`).
## Notas
Si el endpoint POST `/api/runs/:id/message` no existe en agent_runner_api, abrir issue paralelo `0131b` para anadirlo (claude headless aceptara mensajes via stdin del subprocess — el runner debe forwardearlos). Para v1 se acepta panel read-only.
@@ -0,0 +1,92 @@
---
id: "0132"
title: "Modulo C++ terminal_panel — emulador TTY ImGui embebible"
status: pendiente
type: app
domain:
- cpp-stack
- dev-ux
- apps-infra
scope: cross-stack
priority: alta
depends: []
blocks: []
related:
- "0130"
- "0131"
created: 2026-05-22
updated: 2026-05-22
tags: [cpp, imgui, terminal, pty, module]
flow: ""
---
# 0132 — Modulo C++ terminal_panel
**Status:** pendiente
## Por que
Apps del ecosistema (kanban_cpp, services_monitor, agents_dashboard) necesitan ver output crudo de comandos shell sin abrir un terminal externo. Tipico: tail de un log, watch de un curl, ejecutar `git status` rapido. Solucion estandar: modulo `terminal_panel` reusable que arranca un shell hijo via PTY y lo renderiza en ImGui.
## Que entrega
Modulo `cpp/functions/viz/terminal_panel/`:
```cpp
namespace fn_term {
struct TerminalPanel {
std::string shell; // "/bin/bash" linux, "powershell.exe" windows; default auto
std::string cwd; // working dir; default = current
std::vector<std::string> env; // KEY=VAL extras
int scrollback_lines = 5000;
bool readonly = false; // true = no input forwarding (tail-only)
};
void open(TerminalPanel& panel); // crea proceso hijo + PTY
void render(TerminalPanel& panel);
void send(TerminalPanel& panel, const std::string& text); // stdin
void close(TerminalPanel& panel);
}
```
Implementacion:
- Linux: `forkpty` + `read/write` non-blocking en background thread.
- Windows: ConPTY (CreatePseudoConsole) + ReadFile en thread.
- Buffer circular `scrollback_lines` filas; render con `ImGui::TextUnformatted` por chunk para minimizar costo.
- Soporte minimo de ANSI: cursor pos, color FG/BG basico (16 colores), clear screen. NO soporte completo (no Vim, no top, no curses pesado).
- Toolbar: clear, copy selection, reset shell, scroll-to-bottom.
## Estructura
```
cpp/functions/viz/terminal_panel/
terminal_panel.h
terminal_panel.cpp
terminal_panel.md
terminal_panel_linux.cpp // forkpty path
terminal_panel_windows.cpp // ConPTY path
terminal_panel_test.cpp
```
## Reusa del registry
- `logger_cpp_core` (fn_log) — log errores spawn/io.
- `ansi_parser_cpp_core` — si existe, parsear secuencias ANSI. Si no, delegar a `fn-constructor` para crearlo dentro de este issue (sub-deliverable).
## DoD
- Compila Linux + Windows.
- Demo: `primitives_gallery` muestra terminal corriendo `bash -i` (linux) / `cmd.exe` (windows).
- Smoke test: spawn `echo hello && exit 0` → buffer contiene "hello".
- Integracion en kanban_cpp v2: panel "Logs" que toma `run_id` de issue activa y arranca `tail -f /tmp/wt-<slug>-<runid>/agent.log` (readonly=true).
- FPS sin caida bajo carga de `yes "x"` (saturado): 60fps target con scrollback truncado.
## Anti-scope (v1)
- Sin soporte completo ANSI (no italics, no 256 colores, no Unicode wide).
- Sin Vim / programas curses-pesados (cursor visible solo).
- Sin SSH remoto (solo shell local).
- Sin tabs multiples en un panel (un panel = un proceso).
## Notas
ConPTY requiere Windows 10 v1809+. Si target inferior, fallback a CreatePipe sin PTY (sin redimensionado).
+979
View File
@@ -0,0 +1,979 @@
---
id: "0134"
title: "Mesh protocol spec: capability manifests, ed25519 envelopes, enrollment, audit chain"
status: pending
type: spec
domain:
- infra
- cybersecurity
- protocols
scope: cross-app
priority: high
depends: []
blocks:
- "0135"
- "0136"
- "0137"
- "0138"
- "0139"
- "0140"
- "0141"
- "0142"
- "0143"
related:
- "0069"
related_flows:
- "0009"
created: 2026-05-24
updated: 2026-05-24
tags: [mesh, wireguard, matrix, e2ee, ed25519, manifest, audit-chain, security, spec, agents, devices]
flow: "0009"
dependencies: []
---
# 0134 — Mesh protocol spec
**Status:** pending
## Por que
Flow 0009 (`agentes-dispositivos-mesh`) introduce un bus de control multi-device sobre WireGuard + Matrix donde cada dispositivo (PC, movil, raspberry, container Docker) ejecuta capabilities firmadas por el operador. Sin un protocolo formal compartido, cada implementacion (device_agent en Go, bot dispatcher en agents_and_robots, panel Mesh en agents_dashboard, hub en wg_hub) va a derivar.
Este issue cierra Fase B del flow: define el contrato exacto que **toda** implementacion debe respetar — wire format, firmas, replay protection, approval flow, audit chain, error model, threat model. Las issues 0135-0143 implementan lo que aqui se define.
Una vez aceptado este spec, ningun cambio en el wire format se acepta sin un nuevo issue + bump de `protocol_version`.
## Anti-scope
- NO define como provision el WG hub (ver 0136).
- NO define UI del panel Mesh (ver 0138).
- NO define implementacion concreta del bot Matrix (ver 0142).
- NO entra en como se persiste `pass operator/ed25519` mas alla de su uso.
- NO define schema de la `operations.db` de cada app — solo el subset estrictamente compartido (`audit_log`, `room_devices`, `seen_nonces`).
## Conventions
- `protocol_version` (string): **"mesh/1"** — incluido en todo envelope.
- Todo timestamp es Unix epoch **segundos** (`int64`).
- Todo `*_id` es `[a-z0-9_-]+` lowercase, 4-64 chars.
- Todo nonce es 16 bytes random (`crypto/rand`), serializado como **base64url sin padding**.
- Todo hash es SHA-256, serializado como **hex lowercase** (64 chars).
- Toda firma ed25519 es 64 bytes, serializada como **base64url sin padding** (86 chars).
- Toda clave publica ed25519 es 32 bytes, serializada como **base64url sin padding** (43 chars).
- Fingerprint de clave publica = primeros 16 bytes hex de `SHA-256(pubkey_raw_32_bytes)`.
- JSON canonical: claves ordenadas alfabeticamente, sin espacios, UTF-8, sin BOM. Para firmas siempre usar la forma canonica.
---
## 1. JSON envelope
Toda invocacion de capability viaja como par request/response, ya sea sobre Matrix (eventos `m.room.message` con `msgtype = m.capability.*`) o sobre HTTP dentro del mesh WG (`POST /capability`).
### 1.1 Request
```json
{
"protocol_version": "mesh/1",
"request_id": "req_01J9XYZABCDEF",
"manifest_id": "manifest_home-wsl_v3",
"capability": "fs.read",
"args": {
"path": "/var/log/syslog",
"max_bytes": 4096
},
"ts": 1748131200,
"nonce": "Yk9p6Xs_3hZQk4mB7lWcvA",
"signature": "u2vh...QkA"
}
```
- `request_id`: ULID generado por el caller (agents_and_robots o el operador). Idempotency key — si la misma request llega 2x, device_agent debe devolver el mismo response sin re-ejecutar.
- `manifest_id`: id del capability manifest contra el cual se evalua. El device debe tener este manifest activo o rechazar `manifest_invalid`.
- `capability`: dotted name, ej. `shell.exec`, `fs.read`, `docker.container.list`. Debe estar en `manifest.capabilities[].name`.
- `args`: objeto JSON especifico de la capability. Schema validado por device_agent contra el manifest.
- `ts`: Unix seconds. Edad maxima 60s (ver §5).
- `nonce`: 16 bytes random, base64url. Unico por request (ver §5).
- `signature`: ed25519 sobre canonical bytes (ver 1.3).
### 1.2 Response
```json
{
"protocol_version": "mesh/1",
"request_id": "req_01J9XYZABCDEF",
"ok": true,
"result": {
"stdout": "May 24 12:00:00 localhost systemd[1]: Started session-1.scope.\n",
"stderr": "",
"exit_code": 0,
"truncated": false
},
"error": null,
"duration_ms": 42,
"audit_hash": "a3f5...09bc"
}
```
- `ok`: boolean. Si false, `result` ausente y `error` poblado.
- `error`: objeto `{code, message, details?}` cuando `ok=false`. `code` debe estar en §10.
- `duration_ms`: tiempo de ejecucion en device_agent (no incluye latencia Matrix).
- `audit_hash`: `this_hash` del registro en `audit_log` (ver §7). Permite al caller verificar la cadena.
Response NO va firmado por defecto — viaja sobre canal autenticado (Matrix E2EE o WG mesh). Si en el futuro se requiere firma de response (audit externo), se anade campo opcional `response_signature` con el mismo esquema canonical.
### 1.3 Canonical bytes para firma del request
```
canonical = "mesh/1\n" +
request_id + "\n" +
manifest_id + "\n" +
capability + "\n" +
sha256_hex(json_canonical(args)) + "\n" +
int_to_string(ts) + "\n" +
nonce
```
Bytes UTF-8, separador `\n` (0x0A). No trailing newline. Hash del args para no exponer args grandes a la firma (la firma se valida contra el hash, y `args` se reentrega tal cual; cualquier modificacion rompe la firma).
`json_canonical(args)`:
1. Si `args` es null o ausente, `json_canonical = "null"`.
2. Si `args` es objeto, recursivo: ordenar claves alfabeticamente, valores serializados sin espacios, strings con escape JSON estandar.
3. Si `args` es array, recursivo sobre cada elemento, sin reordenar.
### 1.4 Transport binding
| Transport | Request encoding | Response encoding |
|---|---|---|
| Matrix room event | `content.body` = JSON string, `msgtype` = `m.capability.request` | `m.capability.response` event en el mismo room |
| HTTP intra-mesh (`https://10.42.0.10:7777/capability`) | `POST` body JSON | response body JSON |
| SSE (streaming logs, `docker.logs --follow`) | request via POST | response inicial JSON `{ok, result: {stream_id}}` + SSE `event: chunk` |
Matrix es default. HTTP solo lo activa el operador con `mesh_http=true` en el manifest para casos de baja latencia (`docker.logs` tail interactivo, transferencias >1MB que Matrix limita).
---
## 2. Capability manifest
Documento firmado por el operador que autoriza a un device a ejecutar un set acotado de capabilities. Sin manifest valido, device_agent rechaza todo.
### 2.1 Schema YAML (legible) — fuente de verdad
```yaml
# manifest_home-wsl_v3.yaml
protocol_version: mesh/1
manifest_id: manifest_home-wsl_v3
device_id: home-wsl
operator: egutierrez@aurgi.com
operator_pubkey_fingerprint: "a1b2c3d4e5f60718"
issued_at: 1748131200
expires_at: 1779667200 # 1 year later
capabilities:
- name: shell.exec
requires_approval: false
constraints:
binaries_whitelist: [ls, cat, head, tail, grep, ps, df, du, uname, uptime]
max_duration_s: 10
max_output_bytes: 65536
cwd_allowed: ["/home/lucas", "/tmp"]
- name: fs.read
requires_approval: false
constraints:
paths_allowed: ["/home/lucas/**", "/var/log/syslog", "/etc/os-release"]
paths_denied: ["/home/lucas/.ssh/**", "/home/lucas/.password-store/**"]
max_bytes: 1048576
- name: fs.write
requires_approval: true
constraints:
paths_allowed: ["/home/lucas/inbox/**"]
max_bytes: 1048576
- name: docker.container.list
requires_approval: false
- name: docker.container.exec
requires_approval: true
constraints:
containers_allowed: ["agents_and_robots", "registry_api"]
binaries_whitelist: [ls, cat, ps]
max_duration_s: 30
```
### 2.2 JSON canonical (lo que se firma)
```json
{
"capabilities": [
{"constraints": {"binaries_whitelist": ["ls","cat","head","tail","grep","ps","df","du","uname","uptime"], "cwd_allowed":["/home/lucas","/tmp"], "max_duration_s": 10, "max_output_bytes": 65536}, "name": "shell.exec", "requires_approval": false},
{"constraints": {"max_bytes": 1048576, "paths_allowed": ["/home/lucas/**","/var/log/syslog","/etc/os-release"], "paths_denied": ["/home/lucas/.ssh/**","/home/lucas/.password-store/**"]}, "name": "fs.read", "requires_approval": false},
{"constraints": {"max_bytes": 1048576, "paths_allowed":["/home/lucas/inbox/**"]}, "name": "fs.write", "requires_approval": true},
{"name": "docker.container.list", "requires_approval": false},
{"constraints": {"binaries_whitelist": ["ls","cat","ps"], "containers_allowed": ["agents_and_robots","registry_api"], "max_duration_s": 30}, "name": "docker.container.exec", "requires_approval": true}
],
"device_id": "home-wsl",
"expires_at": 1779667200,
"issued_at": 1748131200,
"manifest_id": "manifest_home-wsl_v3",
"operator": "egutierrez@aurgi.com",
"operator_pubkey_fingerprint": "a1b2c3d4e5f60718",
"protocol_version": "mesh/1"
}
```
Producido por `capability_manifest_canonicalize_go_infra` (function 0135 entrega).
### 2.3 Canonical bytes para firma del manifest
```
manifest_canonical = "mesh/1/manifest\n" + json_canonical(manifest_without_signature)
```
Donde `manifest_without_signature` es el JSON canonical de §2.2. El prefijo `mesh/1/manifest\n` es domain separator — evita que una firma de manifest pueda interpretarse como firma de envelope.
### 2.4 Manifest signed envelope (lo que se entrega al device)
```json
{
"manifest": { /* §2.2 */ },
"signature": "k7Yp...QwE"
}
```
Persistido en device como `~/.config/device_agent/manifests/manifest_home-wsl_v3.json`.
### 2.5 Reglas de verificacion (device_agent al arrancar y al recibir request)
1. Parsear `manifest` y `signature`.
2. Computar `manifest_canonical`.
3. Verificar `ed25519.Verify(operator_pubkey, manifest_canonical, signature)`.
4. Rechazar si `expires_at < now()``manifest_invalid` con `details.reason = "expired"`.
5. Rechazar si `issued_at > now() + 300` (clock skew) → `manifest_invalid` con `details.reason = "future_issued"`.
6. Rechazar si `device_id` no coincide con `~/.config/device_agent/device_id``manifest_invalid`.
7. Rechazar si `operator_pubkey_fingerprint` no coincide con la pubkey conocida → `manifest_invalid`.
### 2.6 Rotacion
Un manifest nuevo coexiste con el anterior hasta su `expires_at`. Para forzar revocacion inmediata: el hub publica un evento `manifest_revoked` (en room `#operator-broadcast`) firmado por el operador con la lista de `manifest_id` revocados. device_agent mantiene `~/.config/device_agent/revoked_manifests.json` y lo consulta antes de aceptar.
---
## 3. ed25519 signing flow
### 3.1 Keypair
- **Privada** del operador: `pass operator/ed25519`. Linea 1 = base64url de los 32 bytes del seed ed25519. Lineas siguientes = metadata (operador email, created_at, fingerprint).
- **Publica** del operador: `~/.fn_operator.pub`. Contenido = base64url de los 32 bytes raw + `\n`. Distribuida a cada device en su `~/.config/device_agent/operator.pub` durante enrollment.
### 3.2 Generacion (una sola vez en la vida del operador, idempotente)
```bash
# Funcion del registry: operator_keygen_bash_infra (0135 entrega)
operator_keygen() {
if pass show operator/ed25519 >/dev/null 2>&1; then
echo "operator key already exists; skipping"
return 0
fi
local seed pub fp
seed=$(openssl rand 32 | base64 -w0 | tr '+/' '-_' | tr -d '=')
pub=$(echo "$seed" | go_ed25519_derive_pub) # helper Go: seed → pub
fp=$(echo -n "$(echo "$pub" | base64url -d)" | sha256sum | cut -c1-32)
pass insert -m operator/ed25519 <<EOF
$seed
operator: $(git config user.email)
created_at: $(date +%s)
fingerprint: $fp
pubkey: $pub
EOF
echo "$pub" > ~/.fn_operator.pub
chmod 600 ~/.fn_operator.pub
}
```
### 3.3 Sign
```go
// capability_manifest_sign_go_infra (issue 0135)
func SignManifest(m Manifest, seed []byte) ([]byte, error) {
if len(seed) != ed25519.SeedSize {
return nil, ErrInvalidSeed
}
canonical, err := canonicalizeManifest(m)
if err != nil { return nil, err }
msg := append([]byte("mesh/1/manifest\n"), canonical...)
priv := ed25519.NewKeyFromSeed(seed)
return ed25519.Sign(priv, msg), nil
}
```
Para envelopes:
```go
func SignRequest(r Request, seed []byte) ([]byte, error) {
canonical, err := canonicalRequestBytes(r)
if err != nil { return nil, err }
priv := ed25519.NewKeyFromSeed(seed)
return ed25519.Sign(priv, canonical), nil
}
```
`canonicalRequestBytes` implementa §1.3.
### 3.4 Verify (device_agent)
```go
// capability_manifest_verify_go_infra (issue 0135)
func VerifyManifest(signed SignedManifest, pubkey []byte) error {
if len(pubkey) != ed25519.PublicKeySize {
return ErrInvalidPubkey
}
canonical, err := canonicalizeManifest(signed.Manifest)
if err != nil { return err }
msg := append([]byte("mesh/1/manifest\n"), canonical...)
if !ed25519.Verify(pubkey, msg, signed.Signature) {
return ErrSignatureInvalid
}
return nil
}
```
### 3.5 Domain separators (criticos)
Cada tipo de firma usa prefix unico para evitar cross-protocol attacks:
| Tipo | Prefix |
|---|---|
| Manifest | `"mesh/1/manifest\n"` |
| Request envelope | `"mesh/1/request\n"` (implicito en §1.3 — usa el `\n` join) |
| Enrollment token | `"mesh/1/enroll\n"` |
| Approval token | `"mesh/1/approval\n"` |
| Manifest revocation | `"mesh/1/revoke\n"` |
---
## 4. Enrollment token
Token corto firmado por el operador que un device usa **una sola vez** para registrarse contra `wg_hub` y obtener su config WireGuard + manifest inicial.
### 4.1 Payload JSON (canonical)
```json
{
"allowed_capabilities": ["shell.exec", "fs.read", "docker.container.list"],
"device_id": "home-wsl",
"expires_at": 1748131800,
"issued_at": 1748131200,
"nonce": "Tk9NbjVxV3JLcF9j",
"operator_pubkey_fingerprint": "a1b2c3d4e5f60718",
"protocol_version": "mesh/1",
"purpose": "enroll"
}
```
- `expires_at - issued_at` <= 600s (10 min). Hub rechaza si excede.
- `allowed_capabilities`: subset que el operador autoriza a este enrollment. El manifest final puede ser mas restrictivo pero no mas amplio.
- `nonce` previene replay incluso si el operador reusa el token por error.
- `purpose: "enroll"` es discriminator interno; los otros valores reservados son `"approval"` (§6) y `"revoke"`.
### 4.2 Wire format
```
base64url(json_canonical(payload)) + "." + base64url(ed25519_signature)
```
Ejemplo (truncado):
```
eyJhbGxvd2VkX2NhcGFiaWxpdGllcyI6WyJzaGVsbC5leGVjIiwiZnMucmVhZCJdLCJkZXZpY2VfaWQiOiJob21lLXdzbCIsLi4u.k7YpQwE9Vh...
```
Aproximadamente 280-320 bytes. Cabe en un QR Code v6 (error correction M).
### 4.3 Generacion (operador)
```bash
# enroll_device pipeline (issue 0139) llama:
./fn run enrollment_token_create home-wsl \
--capabilities "shell.exec,fs.read,docker.container.list" \
--ttl 600
# stdout: token base64url
```
### 4.4 Verificacion (wg_hub `POST /enroll`)
```go
// enrollment_token_verify_go_infra (issue 0135)
func VerifyEnrollToken(raw string, pubkey []byte) (*EnrollPayload, error) {
parts := strings.Split(raw, ".")
if len(parts) != 2 { return nil, ErrTokenMalformed }
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil { return nil, ErrTokenMalformed }
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil { return nil, ErrTokenMalformed }
msg := append([]byte("mesh/1/enroll\n"), payloadBytes...)
if !ed25519.Verify(pubkey, msg, sig) {
return nil, ErrSignatureInvalid
}
var p EnrollPayload
if err := json.Unmarshal(payloadBytes, &p); err != nil {
return nil, ErrTokenMalformed
}
now := time.Now().Unix()
if p.ExpiresAt < now { return nil, ErrTokenExpired }
if p.IssuedAt > now + 300 { return nil, ErrTokenFutureIssued }
if p.Purpose != "enroll" { return nil, ErrTokenWrongPurpose }
return &p, nil
}
```
### 4.5 POST /enroll (wg_hub)
```
POST https://organic-machine.com/enroll
Content-Type: application/json
{
"enrollment_token": "eyJhbGxv...QwE",
"device_pubkey_wg": "K3v8...j0c=", // WG public key del device, generada por wg_keygen
"device_hostname": "home-wsl",
"device_os": "linux-wsl2",
"device_arch": "x86_64"
}
```
Response:
```json
{
"ok": true,
"wg_config": "[Interface]\nPrivateKey = <keep on device>\nAddress = 10.42.0.10/24\n[Peer]\nPublicKey = ...\nEndpoint = organic-machine.com:51820\nAllowedIPs = 10.42.0.0/24\nPersistentKeepalive = 25\n",
"manifest": { /* signed manifest */ },
"matrix_room": "!abc123:organic-machine.com",
"matrix_invite_url": "https://matrix.to/#/!abc123:organic-machine.com?via=organic-machine.com"
}
```
Hub marca el token como `consumed` en `wg_enrollment_tokens` (token_nonce as PK) — segundo uso rechazado con `nonce_replay`.
---
## 5. Replay protection
### 5.1 Nonces
- 16 bytes `crypto/rand` por request.
- Server (device_agent O hub) mantiene tabla `seen_nonces`:
```sql
CREATE TABLE IF NOT EXISTS seen_nonces (
nonce TEXT PRIMARY KEY, -- base64url
seen_at INTEGER NOT NULL, -- unix seconds
request_id TEXT NOT NULL,
expires_at INTEGER NOT NULL -- seen_at + 300
);
CREATE INDEX IF NOT EXISTS idx_seen_nonces_expires ON seen_nonces(expires_at);
```
- TTL = 300s. Job periodico (cada 60s) borra entradas con `expires_at < now()`.
### 5.2 Timestamp
- `ts` debe estar en `[now-60, now+30]`. Mas viejo → `nonce_replay`. Mas futuro → `signature_invalid` con `details.reason="clock_skew"`.
- Asume devices con NTP sync (`chrony`/`systemd-timesyncd`). Si un device tiene clock drift >30s, el operador recibe alerta en `#operator-approvals`.
### 5.3 Algoritmo
```go
// Pseudo
func AcceptNonce(db *sql.DB, nonce string, ts int64, requestID string) error {
now := time.Now().Unix()
if ts < now - 60 { return ErrNonceReplay }
if ts > now + 30 { return ErrSignatureInvalid /* clock_skew */ }
_, err := db.Exec(
`INSERT INTO seen_nonces(nonce, seen_at, request_id, expires_at) VALUES(?,?,?,?)`,
nonce, now, requestID, now+300,
)
if isUniqueViolation(err) { return ErrNonceReplay }
return err
}
```
`AcceptNonce` se llama **antes** de ejecutar la capability, despues de verificar la firma. Si la firma es invalida pero el nonce es nuevo, NO se inserta (evita amplificar log spam).
---
## 6. Approval flow
Para capabilities con `requires_approval: true`, device_agent NO ejecuta sin recibir un approval token firmado por el operador.
### 6.1 Secuencia
```
[operator] [agents_and_robots] [device_agent] [#operator-approvals]
| | | |
| !exec rm -rf /tmp/x in #dev-home-wsl | |
|------------------->| | |
| |--- request envelope ->| |
| | |--- decide: approval needed
| | | |
| |<--- approval_request -| |
| |--- post to #op-approvals ----------------->|
| | | |
|<--- notification --| | |
| |
|--- reacts 👍 OR posts !approve req_01J9... ------------------->|
| |
| |<--- captures reaction/cmd -----------------|
| |--- signs approval_token (via operator key)
| |--- posts approval_token to #dev-home-wsl
| | | |
| |--- approval_token --->| |
| | |--- verifies token |
| | |--- executes |
| |<--- response ---------| |
|<-- output in #dev-home-wsl |
```
### 6.2 Approval token payload
```json
{
"protocol_version": "mesh/1",
"purpose": "approval",
"request_id": "req_01J9XYZABCDEF",
"manifest_id": "manifest_home-wsl_v3",
"capability": "shell.exec",
"args_hash": "a3f5...09bc",
"approver": "egutierrez@aurgi.com",
"approved_at": 1748131245,
"expires_at": 1748131305,
"nonce": "Yk9p6Xs_3hZQk4mB7lWcvA"
}
```
`args_hash` debe coincidir con `sha256_hex(json_canonical(args))` del request original — evita que el operador apruebe `ls /tmp` y el bot reemplace por `rm -rf /tmp`.
### 6.3 Wire format
Igual que enrollment token: `base64url(payload) + "." + base64url(signature)`, domain separator `"mesh/1/approval\n"`.
### 6.4 Captura por agents_and_robots
`agents_and_robots` corre como bot Matrix con la operator key cargada (a traves de `pass operator/ed25519` montado en `/etc/agents_and_robots/operator.key` con permisos 400, owned by service user). Cuando detecta:
- Reaccion `m.reaction` con key `👍` (U+1F44D) sobre el evento `approval_request` en `#operator-approvals`, **emitida por el matrix_id del operador** (configurado en `apps/agents_and_robots/config.yaml::operator_matrix_id`).
- O mensaje `!approve <request_id>` en `#operator-approvals` desde el mismo matrix_id.
Entonces firma el approval token y lo envia a device_agent (via Matrix event `m.capability.approval` en el room del device).
### 6.5 Timeout
Si device_agent no recibe approval token en 60s tras enviar approval_request, responde al room con error `approval_timeout`. El operador puede re-emitir el comando original (genera nuevo `request_id`, nuevo nonce, nueva approval).
### 6.6 Approval denegada
Reaccion `👎` (U+1F44E) o comando `!deny <request_id>` → bot firma un `approval_denied_token` (mismo formato + `denied=true`). device_agent responde `approval_denied`.
### 6.7 Verificacion en device_agent
```go
// Pseudo
func VerifyApproval(token string, req Request, pubkey []byte) error {
payload, err := decodeApprovalToken(token, pubkey)
if err != nil { return err }
if payload.RequestID != req.RequestID { return ErrApprovalMismatch }
if payload.Capability != req.Capability { return ErrApprovalMismatch }
if payload.ArgsHash != sha256Hex(canonicalJSON(req.Args)) { return ErrApprovalMismatch }
if payload.ExpiresAt < time.Now().Unix() { return ErrApprovalExpired }
if payload.Denied { return ErrApprovalDenied }
return nil
}
```
---
## 7. Audit log hash chain
Append-only log local a cada device_agent con hash chain que detecta tampering. Replicado periodicamente al hub para archivo tamper-evident off-device.
### 7.1 Schema (`apps/device_agent/audit.db`)
```sql
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
request_id TEXT NOT NULL,
manifest_id TEXT NOT NULL,
capability TEXT NOT NULL,
args_hash TEXT NOT NULL,
approval_id TEXT, -- nullable, request_id del approval token usado
exit_code INTEGER, -- nullable mientras no haya respuesta
ok INTEGER NOT NULL, -- 0/1
error_code TEXT, -- nullable
duration_ms INTEGER NOT NULL,
prev_hash TEXT NOT NULL, -- hex 64 chars
this_hash TEXT NOT NULL, -- hex 64 chars
UNIQUE(request_id)
);
CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts);
```
Migracion vive en `apps/device_agent/migrations/001_init.sql`. Regla `db_migrations.md`.
### 7.2 Hash chain
```
record_canonical = ts + "|" + request_id + "|" + manifest_id + "|" +
capability + "|" + args_hash + "|" + approval_id_or_empty + "|" +
exit_code_str + "|" + ok_str + "|" + error_code_or_empty + "|" +
duration_ms_str
this_hash = sha256_hex(prev_hash + "\n" + record_canonical)
```
Para el primer registro `prev_hash = "0000...0000"` (64 zeros).
`wg_peer_revoke_go_infra` (ya existente) hace algo similar para revocations; este spec usa el mismo patron para todas las invocaciones.
### 7.3 Append helper
```go
// device_audit_append_go_infra (issue 0135)
func AppendAudit(db *sql.DB, rec Record) (string, error) {
var prev string
err := db.QueryRow(`SELECT this_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prev)
if err == sql.ErrNoRows {
prev = strings.Repeat("0", 64)
} else if err != nil {
return "", err
}
canonical := canonicalRecord(rec)
h := sha256.Sum256([]byte(prev + "\n" + canonical))
this := hex.EncodeToString(h[:])
_, err = db.Exec(`INSERT INTO audit_log(...) VALUES(...)`, /* fields, prev, this */)
if err != nil { return "", err }
return this, nil
}
```
Transaccion con `BEGIN IMMEDIATE` para evitar carrera entre prev_hash select y insert.
### 7.4 Verificacion (cualquiera con copia del db)
```go
// device_audit_verify_go_infra (issue 0135)
func VerifyChain(db *sql.DB) error {
rows, _ := db.Query(`SELECT id, prev_hash, this_hash, /* fields */ FROM audit_log ORDER BY id`)
expected := strings.Repeat("0", 64)
for rows.Next() {
var rec Record
rows.Scan(&rec.ID, &rec.PrevHash, &rec.ThisHash /* ... */)
if rec.PrevHash != expected { return fmt.Errorf("chain broken at id %d", rec.ID) }
canonical := canonicalRecord(rec)
h := sha256.Sum256([]byte(rec.PrevHash + "\n" + canonical))
if hex.EncodeToString(h[:]) != rec.ThisHash {
return fmt.Errorf("hash mismatch at id %d", rec.ID)
}
expected = rec.ThisHash
}
return nil
}
```
### 7.5 Replicacion al hub
Cada 60s device_agent hace `POST /audit/replicate` al hub con el bloque de registros nuevos (delta sobre el ultimo replicado). El hub valida la cadena, anade su propio `replicated_at`, y almacena en `apps/wg_hub/operations.db::device_audit` (tabla espejo + meta `last_replicated_id` por device).
Si el hub detecta `chain_broken` o `hash_mismatch`, emite evento a `#operator-approvals` con severity=critical y marca device como `status='compromised'` en `wg_peers`.
---
## 8. Room ↔ device mapping
### 8.1 Schema (`apps/agents_and_robots/operations.db`)
```sql
CREATE TABLE IF NOT EXISTS room_devices (
room_id TEXT PRIMARY KEY, -- !abc123:organic-machine.com
device_id TEXT NOT NULL,
manifest_id TEXT NOT NULL,
role TEXT NOT NULL, -- 'device' | 'container' | 'approval' | 'broadcast'
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
active INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_room_devices_device_id ON room_devices(device_id);
CREATE INDEX IF NOT EXISTS idx_room_devices_role ON room_devices(role);
```
Migracion: `apps/agents_and_robots/migrations/NNN_room_devices.sql`.
### 8.2 Roles especiales
- `role='approval'`: hay exactamente UN room con este rol, default alias `#operator-approvals:organic-machine.com`. Bot publica `approval_request` aqui y escucha reacciones del operador.
- `role='broadcast'`: alias `#operator-broadcast:organic-machine.com`. Bot publica eventos de control firmados (revocations, manifest rotations).
- `role='device'`: room 1:1 por device. Alias por convencion `#dev-<device_id>:organic-machine.com`.
- `role='container'`: para modo "deep" docker (containers como peers WG). Alias `#cont-<container_id>:organic-machine.com`.
### 8.3 Resolucion al dispatchar
Cuando el bot recibe un `!cmd` en cualquier room:
1. Busca `SELECT device_id, manifest_id, role FROM room_devices WHERE room_id=? AND active=1`.
2. Si no existe → ignora (o responde "this room is not bound to a device").
3. Si `role='device'`, dispatcha al `device_agent` correspondiente.
4. Si `role='approval'` o `role='broadcast'`, NO acepta `!exec`/`!fs.*`/`!docker.*` — solo `!approve`, `!deny`, `!revoke`, `!help`.
---
## 9. Element commands
Comandos que el bot de `agents_and_robots` parsea y traduce a envelopes. Estos viven en rooms `role='device'` o `role='container'` (excepto `!approve`/`!deny` que viven en `role='approval'`).
| Command | Capability | Args | Notas |
|---|---|---|---|
| `!help` | (meta) | none | Bot responde con capability matrix del manifest del device del room |
| `!exec <argv...>` | `shell.exec` | `{argv: [...], cwd?: string}` | argv splits por shlex, sin shell wrapping |
| `!fs.read <path> [bytes]` | `fs.read` | `{path, max_bytes?}` | default max_bytes = manifest.max_bytes |
| `!fs.write <path> <<<content` | `fs.write` | `{path, content_base64}` | content viene en heredoc o quoted; bot codifica base64 |
| `!fs.ls <path>` | `fs.list` | `{path}` | output: array de {name,type,size} |
| `!docker exec <container> <argv...>` | `docker.container.exec` | `{container, argv}` | container debe estar en `containers_allowed` |
| `!docker logs <container> [tail]` | `docker.container.logs` | `{container, tail?, follow?}` | `--follow` activa SSE |
| `!docker ps` | `docker.container.list` | `{}` | output: tabla containers vivos |
| `!approve <req_id>` | (meta) | `{request_id}` | solo en `role='approval'`, solo del operator_matrix_id |
| `!deny <req_id>` | (meta) | `{request_id, reason?}` | idem |
| `!revoke <device_id>` | (meta) | `{device_id, reason?}` | solo del operator_matrix_id; emite `manifest_revoked` + `wg_peer_revoke` |
| `!status` | (meta) | none | bot responde: device IP WG, last_handshake, manifest_id activo, capabilities count |
### 9.1 Parsing rules
- Lexer shlex-style (`shlex.split` Python o equivalente). Quoted strings respetan espacios.
- Si parsing falla → `!help` corto en la misma linea + abort.
- Args desconocidos para una capability → `manifest_invalid` con `details.unknown_args: [...]`.
### 9.2 Output rendering
El bot formatea responses para Matrix:
- `ok=true`, output corto (<2KB): formatted text con `<pre><code>...</code></pre>` (Matrix `formatted_body`).
- `ok=true`, output largo: trim a 2KB + link al artifact subido (Matrix media repo si el homeserver lo permite, sino paste a `paste.organic-machine.com`).
- `ok=false`: render como `[ERROR error_code] message\n<details>` con codigo de color rojo en clientes que soportan colored text.
---
## 10. Error model
Todos los `error.code` son strings snake_case. El cliente NO debe parsear `message` — solo `code`. `details` es objeto libre por code.
| code | meaning | details fields | retry? |
|---|---|---|---|
| `manifest_invalid` | Manifest no firma, expirado, device_id mismatch, o no tiene la capability | `reason`, `manifest_id`, `expires_at?` | no — pedir manifest nuevo |
| `capability_denied` | Capability esta en manifest pero los args violan constraints | `constraint_violated`, `value` | no — ajustar args |
| `binary_not_whitelisted` | shell.exec con binario fuera de `binaries_whitelist` | `binary`, `whitelist` | no |
| `path_not_allowed` | fs.* con path fuera de `paths_allowed` o en `paths_denied` | `path`, `allowed_globs`, `denied_globs` | no |
| `container_not_allowed` | docker.* sobre container fuera de `containers_allowed` | `container`, `allowed_list` | no |
| `approval_timeout` | requires_approval=true y no llego token en 60s | `waited_s` | si — re-enviar |
| `approval_denied` | operador denego | `approver`, `reason?` | no |
| `approval_mismatch` | approval token args_hash != request args_hash | `expected_hash`, `got_hash` | no — posible MITM |
| `nonce_replay` | nonce ya visto en ventana TTL=300s | `nonce`, `first_seen_at` | no — generar nonce nuevo |
| `signature_invalid` | firma ed25519 no verifica | `reason` (e.g. `clock_skew`, `bad_pubkey`, `corrupted`) | no |
| `token_expired` | enrollment o approval token expirado | `expires_at`, `now` | no |
| `token_consumed` | enrollment token ya usado (nonce en `wg_enrollment_tokens`) | `first_use_at` | no |
| `device_revoked` | device esta en revocation list | `revoked_at`, `reason` | no |
| `capability_not_found` | capability name no existe en device_agent | `name`, `available` | no |
| `execution_failed` | la capability ejecuto y devolvio exit != 0 | `exit_code`, `stderr` (trimmed) | depende — semantica de la capability |
| `output_too_large` | output > `max_output_bytes` | `bytes`, `limit` | no — pedir con tail/head |
| `duration_exceeded` | timeout `max_duration_s` excedido | `limit_s`, `killed_signal` | no |
| `transport_error` | error de Matrix/HTTP transport debajo del envelope | `transport`, `inner_error` | si con backoff |
| `internal` | bug en device_agent/hub/bot; NO leakear stack al room | `incident_id` | no — operator debe ver logs |
### 10.1 Mapping a HTTP status (transport HTTP intra-mesh)
| code | HTTP |
|---|---|
| `manifest_invalid`, `signature_invalid`, `token_*` | 401 |
| `capability_denied`, `binary_not_whitelisted`, `path_not_allowed`, `container_not_allowed`, `device_revoked` | 403 |
| `capability_not_found` | 404 |
| `nonce_replay`, `approval_mismatch` | 409 |
| `approval_timeout`, `duration_exceeded` | 408 |
| `output_too_large` | 413 |
| `approval_denied` | 403 |
| `execution_failed` | 200 (con ok=false, exit_code en body) |
| `transport_error`, `internal` | 500 |
Matrix transport ignora HTTP status — el `ok=false` y `error.code` son suficientes.
---
## 11. Security threat model
Top 10 ataques + mitigaciones. Listadas por probabilidad x impacto. Cada item refiere al control que lo mitiga.
### T1. Operator ed25519 key leak
- **Attack**: laptop comprometida, `pass operator/ed25519` exfiltrado.
- **Impact**: atacante firma manifests + approvals — control total de devices.
- **Mitigation**:
- GPG-encrypted at rest via `pass` (depende de GPG subkey).
- Rotacion forzada: `!revoke-all` en `#operator-broadcast` → todos los devices reciben `manifest_revoked` con `device_id="*"` → entran en modo enroll.
- Hardware-backed key (YubiKey, ssh-agent con touch policy) — out of scope v1, candidato a issue futuro.
- Detection: hub registra todas las firmas (mensaje `signed_by_operator_at`); operador revisa diariamente.
### T2. Compromised device executes unauthorized capabilities
- **Attack**: device_agent comprometido, atacante quiere ejecutar capabilities fuera del manifest.
- **Impact**: limitado a las capabilities del manifest (asumiendo verify es correcto).
- **Mitigation**:
- Manifest verify obligatorio antes de cada request (§2.5).
- Sandbox: `firejail` (Linux) o equivalente — ver issue 0140.
- Whitelist binarios + paths + containers (§2).
- Audit chain replicado a hub (§7.5) — atacante no puede borrar audit.
### T3. Replay attack
- **Attack**: atacante captura request firmado valido (de un log, de Matrix federation leak), lo reenvia.
- **Impact**: capability ejecutada 2x.
- **Mitigation**: §5. Nonce TTL=300s + ts window 60s. SQLite UNIQUE constraint.
### T4. MITM despite WireGuard
- **Attack**: alguien dentro del mesh WG (otro device comprometido) intercepta requests entre bot y device.
- **Impact**: leer args/output; modificar args si firma se ignora.
- **Mitigation**:
- HTTP intra-mesh sobre TLS (cert auto-firmado mesh-only, pinned).
- Matrix transport: E2EE via Olm/Megolm — bot debe verificar device keys del room antes de aceptar.
- Firma del envelope (§1.3) — args modificados → `signature_invalid`.
### T5. Container escape
- **Attack**: container con `docker.container.exec` activado escapa a host.
- **Impact**: host comprometido, no mas que T2.
- **Mitigation**:
- `binaries_whitelist` estricta en `docker.container.exec` (sin `bash`, `sh`, `nsenter`, `unshare`).
- Modo "deep" (container con WG-peer propio) solo para containers de propia infra (`agents_and_robots`, `registry_api`).
- Docker socket NUNCA expuesto via capability (capability solo via `docker_container_exec_go_infra` que NO usa `--privileged` en exec).
- Detection: container con syscalls anomalas → logged por seccomp profile (out of scope v1).
### T6. Malicious manifest with crafted globs
- **Attack**: operador firma manifest con `paths_allowed: ["/home/lucas/**"]` pero device_agent tiene bug en glob matcher que permite `..` traversal.
- **Impact**: fs.read fuera del directorio.
- **Mitigation**:
- Implementacion glob: `filepath.Match` Go + canonicalizar path con `filepath.Clean` + verificar `strings.HasPrefix(cleaned, allowed_prefix)`.
- Reject paths con `..` antes de glob match.
- Test suite con corpus de path traversal (`../../etc/passwd`, `/home/lucas/../etc/passwd`, symlinks).
### T7. Enrollment token theft
- **Attack**: token QR fotografiado por tercero.
- **Impact**: tercero hace POST /enroll con su propia WG pubkey → device fantasma en la mesh.
- **Mitigation**:
- TTL=600s.
- Single-use (nonce consumed en hub).
- Operador recibe alerta en `#operator-approvals` cada vez que un device hace POST /enroll exitoso — si no esperabas un enroll, `!revoke` inmediato.
### T8. Matrix homeserver compromise
- **Attack**: atacante root en `organic-machine.com` modifica eventos Matrix.
- **Impact**: si E2EE roto, todo el contenido leak. Si E2EE OK, solo metadata.
- **Mitigation**:
- Megolm E2EE entre operador y bot — keys nunca en disco del homeserver.
- Envelope firmado (§1.3) — atacante no puede inyectar requests sin operator key.
- Hub WG segregado: `wg_hub` corre en mismo VPS pero NO confia en Matrix para autorizacion (solo para transport).
### T9. Clock skew abuse
- **Attack**: device con clock muy adelantado firma requests con `ts` futuro grande, los almacena, los reenvia cuando `ts` cae en ventana.
- **Impact**: replay extendido mas alla de TTL.
- **Mitigation**:
- Ventana ts: `[now-60, now+30]` — clock skew tolerado pequeño.
- Devices forzados a sync NTP (chrony) — el provision check verifica `chronyc tracking` reporta `Leap status: Normal`.
- Hub alerta a `#operator-approvals` si recibe replicacion de audit con `ts` que difiere >30s del `received_at`.
### T10. Denial of service via approval flooding
- **Attack**: atacante con manifest valido pero capabilities limitadas spam `requires_approval=true` requests para inundar `#operator-approvals`.
- **Impact**: operador pierde signal en noise; legitimo approval enterrado.
- **Mitigation**:
- Rate limit en agents_and_robots: por device_id, max 10 approval_requests / 5min.
- Excedente → silently dropped + audit entry + `#operator-approvals` recibe resumen agregado (`device home-wsl: 47 approval requests in 5min, throttled`).
- Si pattern repetido, operador `!revoke home-wsl`.
---
## 12. Implementation order
Las issues 0135-0143 implementan lo definido en este spec. Dependencias y orden:
| # | Issue | Que entrega | Depende de | Bloquea |
|---|---|---|---|---|
| 0135 | capability manifest sign/verify funcs | `capability_manifest_sign_go_infra`, `capability_manifest_verify_go_infra`, `enrollment_token_create_go_infra`, `enrollment_token_verify_go_infra`, `device_audit_append_go_infra`, `device_audit_verify_go_infra`, `operator_keygen_bash_infra` | 0134 (spec) | 0136, 0137, 0140, 0142 |
| 0136 | provision_wg_hub pipeline | `provision_wg_hub_bash_pipelines` que compone las 9 funciones `wg_*` (ver flow 0009 Fase C) | 0135 (audit funcs solamente; resto independiente) | 0137 |
| 0137 | wg_hub Go service | `apps/wg_hub/` con endpoints `POST /enroll`, `GET /peers`, `POST /peers/:id/revoke`, `POST /audit/replicate`, SSE `/events` | 0135, 0136 | 0138, 0139 |
| 0138 | agents_dashboard Mesh panel | Panel ImGui en `apps/agents_dashboard/` con lista de peers, last_handshake, bytes rx/tx, approval queue, boton revoke | 0137 | — |
| 0139 | enroll_device pipeline | `enroll_device_bash_pipelines` que el operador corre en su laptop para enroll un device nuevo (genera token, lo muestra como QR, hace POST /enroll en nombre del device si tiene SSH) | 0135, 0137 | 0140, 0141 |
| 0140 | device_agent Go binary | `apps/device_agent/` — Matrix client + capability dispatcher + sandbox firejail + audit chain. Cross-compile linux/amd64, linux/arm64, windows/amd64 | 0135, 0139 | 0142 |
| 0141 | Android Termux variant | `apps/device_agent_android/` — variante para Termux con WG via wg-go (userspace) y capabilities limitadas (no firejail) | 0140 | — |
| 0142 | Matrix bot dispatcher routes | Extender `apps/agents_and_robots/` con dispatcher `m.capability.*` → device, parse de comandos §9, room_devices table | 0135, 0137, 0140 | 0143 |
| 0143 | Operator approval flow | Capturar reactions en `#operator-approvals`, firmar approval tokens, enviar a device, registrar timeout. En `apps/agents_and_robots/` | 0142 | — |
### 12.1 Paralelismo
- 0135 secuencial (todo lo demas depende).
- 0136 + 0140 paralelos tras 0135.
- 0137 espera 0136 (necesita las funciones `wg_*`).
- 0138 + 0139 + 0140 paralelos tras 0137.
- 0141 tras 0140.
- 0142 + 0143 ultimo bloque.
Skill `parallel-fix-issues` puede orquestar 0136/0140 y 0138/0139 en worktrees aislados (ojo: 0140 crea sub-repo `apps/device_agent/`, requiere `git init` dentro como dice `apps_subrepo.md`).
### 12.2 Acceptance gate para cerrar 0134
- [ ] Este documento mergeado en `dev/issues/`.
- [ ] Issues 0135-0143 creados con frontmatter coherente (`dependencies` apuntando aqui, `related_flows: [0009]`).
- [ ] Capabilities groups `wireguard`, `device-agent`, `docker-agent` con stubs en `docs/capabilities/` referenciando este spec.
- [ ] No changes en wire format hasta que todos los issues 0135-0143 cierren — cambios posteriores requieren nuevo issue + bump `protocol_version`.
---
## Notas
### Wire format evolution policy
`protocol_version: mesh/1` es immutable durante el ciclo de vida de las issues 0135-0143. Cualquier cambio breaking (renombrar campo, cambiar canonical bytes, anadir campo obligatorio) requiere bump a `mesh/2` con un issue nuevo que documente migracion y compat layer.
Cambios non-breaking aceptados sin bump:
- Anadir nuevos `error.code` (clientes los manejan via fallback a `internal`).
- Anadir nuevas capabilities (devices viejos las rechazan con `capability_not_found`).
- Anadir campos opcionales con default backwards-compatible.
### Test corpus
Cada funcion de §3 y §4 entrega test fixtures en `cpp/functions/*/testdata/` o equivalente:
- `manifest_valid.json` + `manifest_valid.sig` — par validable.
- `manifest_expired.json` — para test de §2.5 paso 4.
- `manifest_tampered.json` + sig original — para test de signature_invalid.
- `enroll_token_valid.txt`, `enroll_token_expired.txt`, `enroll_token_wrong_purpose.txt`.
- `path_traversal_corpus.txt` con 50+ paths maliciosos para test T6.
### Capability groups stubs
Como parte de este issue se crean stubs minimos en:
- `docs/capabilities/wireguard.md` — lista de las 9 funciones `wg_*` (referenciadas desde flow 0009).
- `docs/capabilities/device-agent.md` — capability dispatcher + sandbox + audit chain.
- `docs/capabilities/docker-agent.md` — capabilities sobre containers.
Cada uno con seccion `## Ejemplo canonico` + `## Fronteras` segun regla `capability_groups.md`. Los stubs se llenan a medida que las funciones se crean en 0135-0142.
### Observabilidad
- Cada envelope request/response loggea en `call_monitor.calls` con `function_id = capability_<name>_<lang>_<domain>` cuando la implementacion exista. Si la capability es solo metadata (`!help`), no se loggea.
- `audit_log` (§7) es separado de `call_monitor` — el primero es tamper-evident del operator, el segundo es telemetria del agente Claude.
- Panel "Mesh" de `agents_dashboard` (issue 0138) consume:
- `wg_hub::wg_peers` (peers vivos + last_handshake + tx/rx).
- `wg_hub::device_audit` (replica de audit chains — para verificacion offline).
- `agents_and_robots::room_devices` (mapping rooms ↔ devices).
- `agents_and_robots::approvals_pending` (queue de approvals pendientes).
### Capability growth log
`v0.1.0 (2026-05-24)` — initial spec mesh/1.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,232 @@
---
id: "0146"
title: "add-pc one-shot: añade PC al mesh + agente LLM en <2min desde movil"
status: pending
priority: high
created: 2026-05-24
related_flows: ["0009"]
related_issues: ["0134", "0144", "0145"]
dependencies: []
tags: [mesh, wireguard, ssh, scaffolder, agents, llm, scaling, dx]
---
## Objetivo
Reducir de 8 pasos manuales (~15min) a **1 comando (<2min)** el flujo de añadir un PC nuevo al mesh con su propio agente LLM conversacional. Goal final: chatear desde Element movil con cualquier PC del usuario tras un `./fn run add_pc <name>`.
## Estado actual (post-0145)
Pipeline manual funcional pero verboso:
1. Instalar wireguard en PC nuevo.
2. wg_keygen.
3. wg_peer_add en hub (organic-machine.com).
4. wg_client_config + wg_client_install.
5. Build/scp device_agent binario.
6. Manifest YAML local.
7. systemd unit.
8. provision-agent-user.sh + edit launcher main.go + rebuild + restart agents_and_robots.
Solo agent-wsl-lucas existe. Bloqueado por friccion de pasos para escalar a aurgi-pc, windows-lucas, raspberry, etc.
## Vision
```
operador$ ./fn run add_pc aurgi-pc --via wg
[1/9] generating WG keypair...
[2/9] enrolling peer at hub (10.42.0.21)...
[3/9] cross-compiling device_agent for linux/amd64...
[4/9] uploading binary + manifest + systemd unit via SSH...
[5/9] starting WG + device_agent on remote...
[6/9] provisioning Matrix user @agent-aurgi-pc...
[7/9] generating agent config + system prompt...
[8/9] wiring launcher + rebuild...
[9/9] restarting agents_and_robots.service...
✓ agent-aurgi-pc live. Send a DM from your Matrix client.
```
Y para hosts sin posibilidad de instalar binary:
```
operador$ ./fn run add_pc customer-vps-01 --via ssh --ssh-alias customer-prod
✓ agent-customer-vps-01 live (ssh-backed). Send a DM.
```
## Arquitectura
Dos backends para un mismo UX:
### Backend A — WG + device_agent (mesh nativo)
- PC tiene WG client + binary device_agent corriendo.
- Comandos viajan VPS → WG → device_agent → exec local → audit chain LOCAL.
- 14 capabilities completas (fs.*, git.*, docker.*, pkg.*, proc.*, shell.exec, shell.eval).
- Para tus PCs (laptop, desktop, raspberry, mac, movil rooted).
### Backend B — SSH-only (sin binary remoto)
- PC tiene solo SSH server. VPS tiene SSH key autorizada.
- Comandos viajan VPS → ssh.Executor → exec remoto → audit en VPS.
- Tools reducidos: `ssh_exec(argv)`, `ssh_fs_read`, `ssh_fs_list`. Sin docker/git/pkg salvo wrapper.
- Para customer servers, VPS terceros, throwaway boxes.
LLM agent ve diferentes tool sets segun backend. Mismo system prompt template.
## Tareas
### Fase 1 — Pipeline `add_pc_wg_bash_pipelines`
1.1. Cross-compile device_agent matrices:
- `GOOS=linux GOARCH=amd64` (default)
- `GOOS=linux GOARCH=arm64` (raspberry pi4+, mac M-series via Linux)
- `GOOS=windows GOARCH=amd64`
- `GOOS=darwin GOARCH=arm64`
- Reusa `nohup` cgo-free build (swap mattn/go-sqlite3 → modernc.org/sqlite si no esta hecho ya).
- Output: `cpp/build/cross/device_agent.<os>-<arch>`.
1.2. Funcion `cross_compile_device_agent_bash_infra(target_os, target_arch)` que devuelve path al binario.
1.3. Funcion `add_pc_wg_bash_pipelines(name, ssh_alias, target_os?, target_arch?)`. Compone:
- wg_keygen_go_infra (hub side: priv hub + psk; client side: priv cliente)
- wg_peer_add_go_infra (en hub via SSH al VPS)
- wg_client_config_go_infra (genera client.conf)
- ensure_remote_wireguard_installed (SSH al target, apt/dnf install wireguard si falta)
- wg_client_install_bash_infra (en target via SSH push)
- cross_compile_device_agent_bash_infra (local)
- rsync_device_agent_bundle (binary + manifest template + systemd unit → target ~/.local/bin/ + ~/.config/device_agent/)
- start_device_agent_service (systemctl --user enable --now device_agent)
- provision_agent_user (ssh al VPS, ejecuta dev-scripts/agent/provision-agent-user.sh con --mode user)
- wire_launcher_import (edita cmd/launcher/main.go en VPS, anade blank import, git commit + rebuild + restart service)
- assert_dm_received (espera 30s a que el bot mande "hola" via notify-developer.sh)
1.4. Manifest template Y matrix per-OS: paths_allowed difieren (`/home/<user>/**` en Linux, `C:\Users\<user>\**` en Windows). Templates en `dev-scripts/agent/templates/manifest.<os>.yaml.tmpl`.
1.5. Idempotente: re-run con mismo name → no-op + verificar state. Si peer existe pero device_agent caido, restart.
1.6. Rollback: si paso N falla, deshacer 1..N-1. Estado parcial NO debe quedar (peer huerfano, Matrix user sin agent, etc).
### Fase 2 — Pipeline `add_pc_ssh_bash_pipelines` (backend B)
2.1. Funcion `ssh_exec_capability_go_infra` — wrapper que recibe `{argv, host}` y hace `ssh <host> -- <argv...>`. Whitelist binaries opcional. Audit en VPS (`apps/agents_and_robots/ssh_audit.db` o similar).
2.2. Funcion `ssh_fs_read_capability_go_infra`, `ssh_fs_list_capability_go_infra` (read-only, no write para evitar accidentes en customer boxes).
2.3. Tool registry adapter: cuando agent config tiene `device_mesh.backend: ssh`, el adapter no apunta a HTTP device_agent — apunta a las funciones `ssh_*` directamente. Mantener interface ToolRegistry pero swap implementation.
2.4. `add_pc_ssh_bash_pipelines(name, ssh_alias)` compone:
- assert_ssh_reachable (BatchMode yes connect test)
- provision_agent_user --mode user --backend ssh
- generate agent config con `device_mesh.backend: ssh, ssh_alias: <alias>`
- wire launcher + restart
NO toca el remote — solo VPS.
### Fase 3 — Cross-compile device_agent CGO-free
3.1. Swap mattn/go-sqlite3 (CGO) → modernc.org/sqlite (pure Go) en device_agent. Tests verde tras swap.
3.2. `cross_compile_device_agent_bash_infra` produce 4 binarios en <30s.
3.3. Bundle script `make-bundle.sh <os> <arch>` empaqueta zip con binario + manifest.template + systemd-unit/launchd-plist/Task-Scheduler.xml segun OS.
### Fase 4 — agents_dashboard "Add device" panel C++
4.1. Modal nuevo en panel "Devices" con:
- Input: nombre del PC.
- Dropdown backend: WG mesh / SSH-only.
- Si WG: SSH alias para upload + OS/arch detect via uname remote.
- Si SSH: solo alias.
- Boton "Add". Spawn pipeline en background. Stream logs en TextLog.
4.2. Grid de status: device_id, IP mesh, last handshake, capabilities count, last command ts, audit chain integrity.
4.3. Boton "Revoke" por device → llama wg_peer_revoke + deactivate Matrix user + remove launcher import + restart. Confirmacion doble.
### Fase 5 — Health monitor cron + alertas
5.1. Cron 5min `monitor_mesh_health_bash_pipelines`:
- wg_status → cada peer con last_handshake > 600s → mark stale.
- HTTP GET /health a cada device_agent IP del mesh → si falla → mark unreachable.
- verify_hash_chain por device → si rota → mark corrupted.
5.2. Alertas Matrix a `#operator-alerts` (room a crear) con mensaje formato:
```
[ALERT] device_id=aurgi-pc status=stale (handshake 8min ago)
[ALERT] device_id=home-wsl status=hash_chain_corrupted (id=47 broken)
```
5.3. Dashboard tab "Health" muestra el feed SSE.
### Fase 6 — Movil UX validation
6.1. Test en Element movil iOS/Android:
- Lista de rooms con 1 per device.
- Notifications activas → push cuando agent responde.
- Smoke tests de capabilities mas comunes via voice-to-text.
6.2. Documentar `docs/mobile-control.md` con flujo recomendado:
- Como agrupar rooms por device en Element favorites.
- Comandos comunes ("status", "deploy X", "que esta caido").
- Tiempos esperados (claude-code latency 3-5s + tool exec 0.1-2s).
## Aceptacion (DoD triada)
### Mecanica
- `./fn run add_pc <name> --via wg` exit 0 + agent live en <2min en wallclock.
- `./fn run add_pc <name> --via ssh` exit 0 + agent live en <30s.
- Tests unit + integration verde en `bash/functions/pipelines/add_pc_*`.
### Cobertura
- Smoke matrix: 4 target OS (linux/amd64, linux/arm64, windows/amd64, darwin/arm64) cada uno con add_pc_wg flujo end-to-end.
- Rollback: simular falla en paso 5 (binary upload corrupted) → assert estado limpio (no peer huerfano, no Matrix user, no entry en launcher).
- SSH backend: target solo con SSH + sin sudo → agent funciona con tools ssh_exec read-only.
- Anti-criterio A3 (heredado de 0009): tras add_pc, smoke real via Matrix → audit DB en device tiene entries reales (no bot hallucination).
### Vida util
- 5 PCs reales añadidos durante 7 dias.
- 0 revokes manuales por error de provision.
- Operador usa Element movil >=1 sesion/dia interactuando con >=2 devices distintos.
- Health monitor detecta peer caido en <10min (test con `wg-quick down` aleatorio).
### Anti-criterios
- Si add_pc deja estado parcial (peer en wg0 + no agent en launcher) → invalida.
- Si SSH backend ejecuta comandos sin audit en VPS → invalida (no fake "ssh OK" sin log).
- Si dashboard muestra device "online" pero ultimo handshake >24h → invalida (false positive grave).
## Sub-issues planificados
| ID | Titulo | Esfuerzo |
|---|---|---|
| 0146a | cross_compile_device_agent + CGO-free swap a modernc.org/sqlite | 2h ✅ |
| 0146b | add_pc_wg_bash_pipelines (Fase 1) | 4h |
| 0146c | add_pc_ssh_bash_pipelines + ssh_exec_capability (Fase 2) | 3h |
| 0146d | Bundle script multi-OS + manifest templates (Fase 3) | 2h |
| 0146e | agents_dashboard panel "Add device" + status grid (Fase 4) | 4h |
| 0146f | monitor_mesh_health pipeline + alertas Matrix (Fase 5) | 1.5h |
| 0146g | Movil UX doc + smoke real con 4 devices fisicos (Fase 6) | 1h+observacion 7d |
Total: ~17h dev + 7d observacion.
## Decisiones de diseño
1. **Pipeline en bash compose funciones del registry**, no codigo Go monolitico. Permite que cada paso sea trazable + reusable individualmente.
2. **modernc.org/sqlite** vs mattn/go-sqlite3: pure Go elimina CGO + cross-compile trivial. Performance es comparable (modernc benchmarks dentro del 10% para nuestro workload de audit append).
3. **Backend SSH NO replica el manifest enforcement remoto** — el manifest vive en VPS y filtra antes de SSH. Trade-off aceptable: SSH backend = "trust the VPS sudo enforcement". Para PCs propios usa WG backend.
4. **Cada device = un agent Matrix separado** (NO un agent multi-device). Razon: aislamiento blast radius + room por device = UX claro en Element + capability manifest distinto por device. Coste: mas Matrix users + mas claude-code subprocesses.
5. **NO usar Ansible/Terraform** para este flujo. Pipeline bash + funciones del registry es suficiente y evita la dep externa. Si crece a >50 PCs, reconsiderar.
## Riesgos
- **Cross-compile + CGO-free**: el swap a modernc puede romper audit en runtime si schemas no migran. Mitigar con test golden DB + WAL mode check.
- **Windows systemd equivalente**: Task Scheduler es feo. Considera nssm.exe para autostart fiable. Documentar bien en bundle.
- **SSH key trust amplification**: backend B requiere SSH agent del VPS confiable a TODOS los target hosts. Si VPS comprometido → todos los SSH targets caen. Reforzar con SSH key per-host + revocacion centralizada.
- **Mac iCloud signing**: device_agent.app necesitaria notarization para auto-launch en macOS reciente. Skip para POC, abordar si añadimos Mac al mesh real.
- **Movil notifications**: Element push depends on FCM (Android) / APNs (iOS). Sin push, el operador puede perderse approvals time-sensitive. Doc sobre alternativas (NTFY, Gotify).
## Notas
- **2026-05-24 — 0146a done**: swap mattn/go-sqlite3 → modernc.org/sqlite v1.50.1 (pure Go). 4 binarios cross-compile OK (linux-amd64 11MB, linux-arm64 10MB, windows-amd64 11MB, darwin-arm64 10MB), todos stripped + statically linked. Build script idempotente en `apps/device_agent/build_all.sh`. Self-test pass en linux-amd64 nativo. Quedan smoke tests reales en windows/darwin/arm cuando 0146b despliegue a peers fisicos.
- `./fn run add_pc` deberia llamar via mcp__registry__fn_run para que telemetria de issue 0085 quede registrada.
- Aprovechar 0144b provision-agent-user.sh que ya esta hecho — solo compone, no reescribe.
- Sub-issue 0146g (UX movil) cierra el flow 0009 completo al fin: "humano controla N maquinas desde movil".
- Si esto funciona, abrir issue 0147 para "voice control" — Element soporta voice messages; usar transcripcion (Whisper local en VPS) → inyectar como texto al agent.
@@ -0,0 +1,129 @@
---
id: "0164"
title: "Bots agents_and_robots: cryptohelper.Init() cuelga al habilitar encryption=true"
status: pending
priority: high
created: 2026-05-24
related_flows: ["0009"]
related_issues: ["0144", "0162"]
dependencies: ["0162"]
tags: [matrix, e2ee, mautrix, cryptohelper, agents, hang, debug]
---
## Objetivo
Que los agents de `agents_and_robots` (`agent-wsl-lucas`, `agent-windows-lucas`, y futuros) puedan operar con `encryption.enabled=true` en su `config.yaml` y **leer + responder en DMs encrypted** (megolm) con el operator. Hoy todos corren con `enabled=false` para no colgarse; consecuencia: bot puede ENVIAR a room encrypted (cleartext que Element marca como warning) pero NO LEE replies del operator (megolm cifra, bot no descifra) → chat bidireccional roto.
Bloquea Flow 0009 DoD ("Element → PC interaction working") en el camino encrypted.
## Contexto
- mautrix-go v0.21.1 con cryptohelper (tag `goolm` pure-Go).
- Synapse en VPS organic-machine.com con MSC3861/MAS activo (issue 0162 done 2026-05-24).
- `encryption_enabled_by_default_for_room_type` activo en Synapse → TODA DM nueva nace con `m.megolm.v1.aes-sha2` (no override client-side).
- Bots usan password tokens (no application_service). Tokens emitidos pre-migracion siguen validos (verificado: `/account/whoami` OK con bot token post-MAS).
- `verify.sh agent-windows-lucas` corrio OK: genero crypto.db, upload cross-signing keys, escribio `SSSS_RECOVERY_KEY_AGENT_WINDOWS_LUCAS` en `.env`.
## Reproduccion
```bash
# En VPS, agent-windows-lucas:
sudo sed -i 's/enabled: false/enabled: true/' agents/agent-windows-lucas/config.yaml
sudo systemctl restart agents_and_robots
sleep 30
# Bot stuck:
sudo tail logs/agent-windows-lucas/2026-05-24.jsonl
# Last line forever: "initializing e2ee" — runner nunca llega a "starting matrix sync"
# /agents API endpoint reports running=false
```
## Diagnostico actual (incompleto)
SIGQUIT al proceso launcher revelo bots NO-encrypted en `Listener.Run → SyncWithContext` (normal). NO se pudo aislar la stack de **windows-lucas** durante hang — necesita pprof targeted o log adicional dentro de `InitCrypto`.
Hipotesis (ordenadas):
| ID | Hipotesis | Evidencia que la apoya | Como confirmar |
|---|---|---|---|
| H1 | `cryptohelper.Init()` bloquea en primer `/keys/device_signing/upload` por UIA — MAS no acepta el formato auth heredado | MAS recien activo, password_config disabled, mautrix-go usa UIA password flow | inyectar log antes/despues de cada llamada en `cryptohelper.Init` |
| H2 | `cryptohelper.Init()` bloquea en `OlmMachine.Load` por `crypto.db` schema mismatch | crypto.db generado por `cmd/verify` puede tener schema distinto al que cryptohelper espera | reset crypto.db + dejar que cryptohelper bootstrap solo (sin verify.sh) |
| H3 | El listener trata de hacer initial sync ANTES de e2ee init terminar, deadlock en mutex | "starting matrix sync" NUNCA aparece post-`initializing e2ee` | revisar order en `devagents/runtime.go` |
| H4 | Pickle key mismatch entre verify.sh (lo recibe en hex) y runtime (lo decodifica diferente) | Provision-script genero base64; nosotros pusimos hex; runtime acepta hex? | log de pickle key length en runtime |
## Tareas
### Fase 1 — Diagnostico
1.1. Inyectar logging EN `shell/matrix/client.go::InitCrypto` antes/despues de cada paso (cryptohelper construct, Init, OlmMachine.Load, etc) para identificar la linea que bloquea.
1.2. Reproducir hang en agent test aislado (`agent-e2ee-test`):
- Crear bot fresh con provision-agent-user.sh
- Activar encryption=true
- Restart launcher
- Capturar stack
1.3. Con stack identificado, decidir cual hipotesis (H1-H4) aplica.
### Fase 2 — Fix segun hipotesis
- **Si H1 (MAS UIA)**: investigar si mautrix-go v0.21.1 soporta MSC3861 UIA. Si no: bump a v0.22+ que soporta o usar `device_signing/upload` con SSSS-protected path.
- **Si H2 (schema mismatch)**: dejar cryptohelper bootstrap solo, NO usar verify.sh primero. Verify.sh queda como "post-bootstrap repair".
- **Si H3 (sync deadlock)**: refactor `devagents/runtime.go` para que e2ee init complete antes de spawn listener.
- **Si H4 (pickle key)**: arreglar provision-agent-user.sh para generar pickle key como hex.
### Fase 3 — Validacion (DoD triada)
#### Mecanica
- Bot con `encryption.enabled=true` start OK (running=true en /agents API).
- No hang en logs (paso de "initializing e2ee" → "starting matrix sync" en < 30s).
- Build limpio `go build -tags goolm`.
#### Cobertura
| Escenario | Cmd / evidencia | Resultado |
|---|---|---|
| Golden: operator envia mensaje encrypted en DM, bot lee + responde encrypted | Element web → `#agent-windows-lucas` DM → "hola" | bot responde en < 15s, log muestra decrypted msg + claude_code_response + encrypted send |
| Edge: bot reinicia, crypto.db persiste, re-key OK | `sudo systemctl restart agents_and_robots` mid-conversation | bot continua descifrando mensajes anteriores + nuevos sin re-bootstrap |
| Edge: operator reverify device | Element → device list → forget device → re-verify | bot detecta cambio, sigue cifrando OK |
| Error: crypto.db corrupto | `rm crypto.db` mid-run | bot detecta + auto-recovery (per `docs/e2ee.md`) + re-bootstrap < 60s |
| Error: token revoked | revocar via admin API | bot logout limpio + restart picks up nuevo token |
#### Vida util validada (7 dias)
| Metrica | Umbral | Donde | Ventana |
|---|---|---|---|
| Bot uptime con encryption=true | `> 99%` | `/agents/<id>` API | 7 dias |
| Mensajes encrypted leidos | `>= 10` real conversation | `logs/agent-*/...jsonl` decrypted lines | 7 dias |
| Crashes cryptohelper | `0` | journalctl `agents_and_robots` | 7 dias |
| Latency decrypt msg | `p95 < 2s` | log timestamps | 7 dias |
### Anti-criterios
- NO marcar done si bot solo escribe pero no lee.
- NO marcar done si hang reaparece tras reinicio del servicio.
- NO marcar done si solo funciona en 1 bot (debe replicarse: wsl-lucas + windows-lucas + 1 mas).
## Estado actual workaround
- `agent-wsl-lucas`: `encryption.enabled=false`. DM con operator es UNencrypted (probablemente porque fue creada antes de Synapse activar default-encrypt). Funciona bidireccional.
- `agent-windows-lucas`: `encryption.enabled=false`. DM con operator (room `!ymFSupZVqYpOWunuHI` o `!qeuqopdkeYHWdAfMaN`) es ENCRYPTED (Synapse forced). Bot envia clear-text → operator ve mensaje + warning. Operator reply encrypted → bot NO lee.
## Funciones del registry candidatas (post-fix)
- `mautrix_cryptohelper_init_with_timeout_go_infra` — wrapper con context.WithTimeout para evitar hang infinito.
- `agent_e2ee_bootstrap_bash_pipelines` — pipeline: provision agent → set encryption=true → verify.sh → restart + wait healthy.
## Notas
**Pickle key format bug**: `provision-agent-user.sh` genera base64 (`openssl rand -base64 32`). `cmd/verify` espera hex. Fix in scope de este issue o nuevo issue (`0165-provision-pickle-key-hex.md`).
**Subagent investigation report** (2026-05-24) confirmo:
- E2EE machinery YA existe end-to-end (InitCrypto, FetchCrossSigningKeys, SignOwnDevice, verify.sh).
- docs/e2ee.md cubre failure modes conocidos.
- mautrix-go v0.21.1 puede tener bug pre-MSC3861-aware con MAS.
**Pendiente upstream check**: mautrix-go release notes v0.22+ para MSC3861 support. Si esta soportado, bump version es probablemente el fix.
## Capability growth log
- v0.1.0 (2026-05-24) — issue creado tras reproducir hang post-MAS migration con verify.sh OK pero cryptohelper.Init aun cuelga.
+2
View File
@@ -41,6 +41,8 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
| [backends](backends.md) | — | Stacks backend (Go net/http+SQLite default, MCP, mautrix, bubbletea, httpx, docker-compose): decision tree + esqueleto canonico + funciones del registry a componer |
| [kanban](kanban.md) | 5 | Parser/writer/scanner/watcher de dev/issues/ y dev/flows/: base del backend kanban_cpp v2 |
| [wireguard](wireguard.md) | 7 | Instalar, configurar, operar y monitorizar mesh WireGuard hub-and-spoke: keygen, hub setup, peer add/revoke, status JSON |
| [matrix-mas](matrix-mas.md) | 5 | Migración Synapse→MAS: habilitar MSC3861, verificar login flows, parche .well-known, registro clientes OAuth2, syn2mas |
## Como anadir grupo
+82
View File
@@ -0,0 +1,82 @@
# wireguard — Capability Group
Instalar, configurar, operar y monitorizar un mesh WireGuard hub-and-spoke desde Go y Bash.
## Funciones
| ID | Firma corta | Que hace |
|---|---|---|
| `wg_install_bash_infra` | `wg_install() -> json` | Instala wireguard-tools en Linux (debian/ubuntu/fedora/arch). Idempotente. |
| `wg_keygen_go_infra` | `WGKeygen(withPSK bool) (WGKeys, error)` | Genera par de claves Curve25519 (privada+publica+opcional PSK). |
| `wg_hub_setup_bash_infra` | `wg_hub_setup(private_key, subnet_cidr, listen_port) -> json` | Configura el host como hub: crea wg0.conf, abre firewall, habilita ip_forward, arranca wg-quick@wg0. Idempotente. |
| `wg_client_install_bash_infra` | `wg_client_install(config_path_or_stdin, [iface]) -> json` | Device-side: instala wg0.conf, habilita wg-quick, verifica handshake con hub. |
| `wg_peer_remove_go_infra` | `WGPeerRemove(deviceID, configPath string) (WGPeerRemoveResult, error)` | Quita peer del hub por device_id, syncconf en caliente. Idempotente. |
| `wg_peer_revoke_go_infra` | `WGPeerRevoke(deviceID, operator, reason, configPath, auditDBPath string) (WGPeerRevokeAudit, error)` | Kill switch: revoca peer permanentemente con audit log hash-chained. |
| `wg_status_bash_infra` | `wg_status([interface_name]) -> json` | Parsea `wg show <iface> dump` a JSON con peers, handshake age, status (online/stale/never), bytes rx/tx, device_id. |
## Ejemplo canonico — setup completo de un nodo nuevo
```bash
# 1. Instalar wg en el hub (si no esta instalado)
source bash/functions/infra/wg_install.sh
wg_install | jq .version
# 2. Generar claves para el hub
# (via Go — requiere wg binary)
./fn run wg_keygen_go_infra
# 3. Configurar hub
source bash/functions/infra/wg_hub_setup.sh
wg_hub_setup "$HUB_PRIVATE_KEY" "10.42.0.0/24" 51820 | jq .
# 4. Instalar config en cliente (acepta stdin para pipes)
source bash/functions/infra/wg_client_install.sh
wg_client_install /path/to/wg0.conf | jq .handshake_ok
# 5. Ver estado del mesh
source bash/functions/infra/wg_status.sh
wg_status wg0 | jq .peers[].status
```
## Ejemplo canonico — monitorizar mesh (dashboard / agents_dashboard Mesh panel)
```bash
source bash/functions/infra/wg_status.sh
# Peers online
wg_status | jq '[.peers[] | select(.status=="online")] | length'
# Tabla compacta: device_id, status, ago
wg_status | jq -r '.peers[] | [.device_id, .status, (.latest_handshake_ago_s|tostring)+"s"] | @tsv'
# Testing sin sudo (WG_FAKE_DUMP + WG_FAKE_CONF)
WG_FAKE_DUMP=fixtures/dump.tsv WG_FAKE_CONF=fixtures/wg0.conf wg_status wg0 | jq .
```
## Ejemplo canonico — revocar peer comprometido
```bash
# Quitar peer de wg0.conf (sin audit log)
./fn run wg_peer_remove_go_infra -- --device-id pc-comprometido --config /etc/wireguard/wg0.conf
# Kill switch con audit log inviolable
./fn run wg_peer_revoke_go_infra -- \
--device-id pc-comprometido \
--operator lucas \
--reason "dispositivo perdido" \
--config /etc/wireguard/wg0.conf \
--audit-db /var/lib/wg/audit.db
```
## Fronteras
- **No cubre**: gestión de DNS WireGuard, split-tunnel avanzado, integración con cloud providers (AWS VPC, Tailscale).
- **No cubre**: rotación automática de claves (cron + wg_keygen + wg_hub_setup es la composición manual).
- **No cubre**: UI web para el mesh — `wg_status` provee el JSON que consume `agents_dashboard`.
- `nordvpn_set_protocol_bash_infra` usa WireGuard internamente (NordLynx) pero no pertenece a este grupo — opera NordVPN, no un mesh propio.
## Prerequisitos
- `wireguard-tools` instalado en el host (usar `wg_install_bash_infra`).
- `wg show` requiere `CAP_NET_ADMIN` / root. Para tests: `WG_FAKE_DUMP` + `WG_FAKE_CONF`.
- Comentarios `# DeviceID:<id>` antes de cada `[Peer]` en `/etc/wireguard/wg0.conf` para resolver `device_id` en `wg_status`.
+22
View File
@@ -0,0 +1,22 @@
# Issues — fn_registry / agent-wsl-lucas
## [ISSUE-001] Agentes no pueden enviar fotos/capturas de pantalla
**Fecha:** 2026-05-25
**Reportado por:** @egutierrez
**Severidad:** Media
### Descripción
Los agentes (agent-wsl-lucas y similares) no tienen acceso a las tools `screenshot` y `browser.screenshot` en su entorno de ejecución. Tampoco existe mecanismo de file transfer directo desde el agente al chat Matrix.
### Impacto
No es posible capturar y enviar imágenes de pantalla desde el agente al operador directamente en el room.
### Workaround actual
Ejecutar `scrot`, `import` (ImageMagick) u otra herramienta via `exec` para guardar la captura en un path local (`/tmp/` o `~/`), y que el operador acceda al archivo manualmente.
### Requisitos para solución
- Habilitar tool `screenshot` en el manifest del device_agent (`wsl-lucas.yaml`), o
- Implementar mecanismo de upload/transfer de archivos binarios desde el agente al room Matrix.
### Estado
Abierto
+247
View File
@@ -0,0 +1,247 @@
package infra
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// DockerExecOpts configura la ejecucion de un comando dentro de un container Docker.
type DockerExecOpts struct {
ContainerID string // ID o nombre del container destino.
Cmd []string // argv. Cmd[0] = binario, debe estar en BinariesAllowed.
BinariesAllowed []string // Whitelist de binarios permitidos. EMPTY = rechaza todo.
User string // Usuario/grupo "UID:GID"; vacio = default del container.
WorkingDir string // Directorio de trabajo dentro del container.
Env []string // Variables de entorno en formato "KEY=VAL".
TimeoutSeconds int // Timeout de ejecucion; default 30 si es 0.
DockerHost string // Socket Docker; default "unix:///var/run/docker.sock".
}
// DockerExecResult contiene el resultado de ejecutar un comando en un container.
type DockerExecResult struct {
ExitCode int // Codigo de salida del proceso.
Stdout string // Salida estandar capturada.
Stderr string // Salida de error capturada.
Duration int64 // Duracion real de ejecucion en milisegundos.
}
// DockerContainerExec ejecuta un comando dentro de un container Docker via Engine API.
//
// Flujo: POST /containers/<id>/exec → POST /exec/<id>/start → demux stream → GET /exec/<id>/json.
// El comando se pasa como argv directo (sin shell). Aplica whitelist obligatoria de binarios.
// Timeout gestionado via context.WithTimeout.
func DockerContainerExec(opts DockerExecOpts) (DockerExecResult, error) {
// --- Validacion de seguridad (prioritaria, antes de contactar el engine) ---
if len(opts.Cmd) == 0 {
return DockerExecResult{}, fmt.Errorf("docker exec: Cmd must not be empty")
}
if len(opts.BinariesAllowed) == 0 {
return DockerExecResult{}, fmt.Errorf("docker exec: no binaries whitelisted: refusing")
}
bin := opts.Cmd[0]
inWhitelist := false
for _, b := range opts.BinariesAllowed {
if b == bin {
inWhitelist = true
break
}
}
if !inWhitelist {
return DockerExecResult{}, fmt.Errorf("docker exec: binary %q not in whitelist %v", bin, opts.BinariesAllowed)
}
if opts.ContainerID == "" {
return DockerExecResult{}, fmt.Errorf("docker exec: ContainerID must not be empty")
}
// --- Defaults ---
timeout := opts.TimeoutSeconds
if timeout <= 0 {
timeout = 30
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
// Reuse dockerHTTPClient from docker_container_logs.go (same package).
client, baseURL, err := dockerHTTPClient(opts.DockerHost)
if err != nil {
return DockerExecResult{}, fmt.Errorf("docker exec: building client: %w", err)
}
start := time.Now()
// --- Step 1: Create exec instance ---
execID, err := dockerExecCreate(ctx, client, baseURL, opts)
if err != nil {
return DockerExecResult{}, fmt.Errorf("docker exec create: %w", err)
}
// --- Step 2: Start exec and capture stream ---
stdout, stderr, err := dockerExecStart(ctx, client, baseURL, execID)
if err != nil {
return DockerExecResult{}, fmt.Errorf("docker exec start: %w", err)
}
// --- Step 3: Inspect to get exit code ---
exitCode, err := dockerExecInspect(ctx, client, baseURL, execID)
if err != nil {
return DockerExecResult{}, fmt.Errorf("docker exec inspect: %w", err)
}
duration := time.Since(start).Milliseconds()
return DockerExecResult{
ExitCode: exitCode,
Stdout: stdout,
Stderr: stderr,
Duration: duration,
}, nil
}
// dockerExecCreate llama POST /containers/<id>/exec y devuelve el ExecID.
func dockerExecCreate(ctx context.Context, client *http.Client, baseURL string, opts DockerExecOpts) (string, error) {
type createBody struct {
AttachStdout bool `json:"AttachStdout"`
AttachStderr bool `json:"AttachStderr"`
Tty bool `json:"Tty"`
Cmd []string `json:"Cmd"`
User string `json:"User,omitempty"`
WorkingDir string `json:"WorkingDir,omitempty"`
Env []string `json:"Env,omitempty"`
}
body := createBody{
AttachStdout: true,
AttachStderr: true,
Tty: false,
Cmd: opts.Cmd,
User: opts.User,
WorkingDir: opts.WorkingDir,
Env: opts.Env,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/containers/%s/exec", baseURL, opts.ContainerID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", fmt.Errorf("engine returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var result struct {
ID string `json:"Id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.ID == "" {
return "", fmt.Errorf("engine returned empty exec ID")
}
return result.ID, nil
}
// dockerExecStart llama POST /exec/<id>/start y demux el stream multiplexado de Docker.
// Reusa dockerDemuxFrame de docker_container_logs.go (mismo paquete).
// Retorna (stdout, stderr, error).
func dockerExecStart(ctx context.Context, client *http.Client, baseURL, execID string) (string, string, error) {
type startBody struct {
Detach bool `json:"Detach"`
Tty bool `json:"Tty"`
}
bodyBytes, _ := json.Marshal(startBody{Detach: false, Tty: false})
url := fmt.Sprintf("%s/exec/%s/start", baseURL, execID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
if err != nil {
return "", "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return "", "", fmt.Errorf("docker exec timed out")
}
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", "", fmt.Errorf("engine returned %d on start: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
// Demux usando dockerDemuxFrame de docker_container_logs.go.
var stdoutBuf, stderrBuf strings.Builder
for {
streamType, payload, err := dockerDemuxFrame(resp.Body)
if err == io.EOF {
break
}
if err != nil {
if ctx.Err() != nil {
return "", "", fmt.Errorf("docker exec timed out")
}
return "", "", fmt.Errorf("reading exec stream: %w", err)
}
switch streamType {
case 1: // stdout
stdoutBuf.Write(payload)
case 2: // stderr
stderrBuf.Write(payload)
}
}
return stdoutBuf.String(), stderrBuf.String(), nil
}
// dockerExecInspect llama GET /exec/<id>/json y devuelve el ExitCode.
func dockerExecInspect(ctx context.Context, client *http.Client, baseURL, execID string) (int, error) {
url := fmt.Sprintf("%s/exec/%s/json", baseURL, execID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return -1, err
}
resp, err := client.Do(req)
if err != nil {
return -1, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return -1, fmt.Errorf("engine returned %d on inspect: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var result struct {
ExitCode int `json:"ExitCode"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return -1, err
}
return result.ExitCode, nil
}
+73
View File
@@ -0,0 +1,73 @@
---
name: docker_container_exec
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DockerContainerExec(opts DockerExecOpts) (DockerExecResult, error)"
description: "Exec comando dentro de container Docker con whitelist obligatoria de binarios. SIN shell expansion. Stream demuxado stdout/stderr. Timeout context-cancellable. Capability docker.container.exec del device_agent."
tags: [docker, docker-agent, exec, security, infra]
uses_functions: []
uses_types: [docker_exec_result_go_infra, error_go_core]
returns: [docker_exec_result_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: [bytes, context, encoding/json, fmt, io, net/http, strings, time]
params:
- name: opts.ContainerID
desc: "ID o nombre del container destino. Obligatorio."
- name: opts.Cmd
desc: "argv del comando. Cmd[0] es el binario a ejecutar; debe estar en BinariesAllowed. Sin shell expansion."
- name: opts.BinariesAllowed
desc: "Whitelist exacta de binarios permitidos. EMPTY = rechaza todo sin contactar el engine. Security-critical."
- name: opts.User
desc: "Usuario/grupo en formato UID:GID (ej: '1000:1000'). Vacio = default del container."
- name: opts.WorkingDir
desc: "Directorio de trabajo dentro del container. Vacio = default del container."
- name: opts.Env
desc: "Variables de entorno adicionales en formato KEY=VAL. Combinadas con las del container."
- name: opts.TimeoutSeconds
desc: "Timeout de la operacion completa en segundos. Default 30 si es 0 o negativo."
- name: opts.DockerHost
desc: "Socket Docker. Default 'unix:///var/run/docker.sock'. Soporta 'unix://', 'tcp://', 'http://'."
output: "DockerExecResult{ExitCode, Stdout, Stderr, Duration} con el resultado completo del comando ejecutado."
tested: true
tests:
- "binario en whitelist exitcode 0 stdout stderr capturados"
- "binario NO en whitelist error sin contactar engine"
- "whitelist vacia rechaza todo"
- "timeout simulado"
test_file_path: "functions/infra/docker_container_exec_test.go"
file_path: "functions/infra/docker_container_exec.go"
---
## Ejemplo
```go
result, err := infra.DockerContainerExec(infra.DockerExecOpts{
ContainerID: "my-app-container",
Cmd: []string{"cat", "/etc/hostname"},
BinariesAllowed: []string{"cat", "ls", "id", "env"},
User: "1000:1000",
TimeoutSeconds: 10,
})
if err != nil {
log.Fatalf("exec failed: %v", err)
}
fmt.Printf("exit=%d stdout=%q stderr=%q duration=%dms\n",
result.ExitCode, result.Stdout, result.Stderr, result.Duration)
```
## Cuando usarla
Cuando necesitas ejecutar un comando dentro de un container en ejecucion desde Go, con control de seguridad sobre que binarios pueden invocarse. Indispensable para el capability group `docker-agent` (flow 0009 A2): health-checks, introspection, file reads, reconfigurations controladas. Usar antes de cualquier operacion que requiera acceso al filesystem o procesos del container sin montar volumenes adicionales.
## Gotchas
- **NUNCA usar `BinariesAllowed` vacio en produccion**: la funcion rechaza por diseno. Cualquier lista vacia es un error de configuracion, no un "permitir todo".
- **Sin shell expansion**: no puedes hacer pipes, redirects ni `$VAR` desde `Cmd`. Para eso el manifest del agent debe usar un binario que implemente esa logica (ej. `python3 -c "..."` si python3 esta en la whitelist).
- **Stream demux 8-byte header**: el protocolo Docker multiplexado (Tty=false) prefixa cada frame con 8 bytes. Esta funcion lo demux correctamente; si cambias a Tty=true el stream es raw y el demux falla.
- **Timeout incluye overhead de red**: el `TimeoutSeconds` aplica al flujo completo (create + start + stream + inspect). En containers locales el overhead es <10ms; en TCP remoto puede ser mas alto.
- **ExitCode -1**: solo aparece si falla la llamada a `/exec/{id}/json` (error de red/timeout), no como exit code real del proceso.
- **DockerHost en TCP**: usar `tcp://host:2375` para daemons remotos sin TLS. Para TLS, el cliente HTTP necesitaria cert/key — no soportado en esta version (ver Gotchas de produccion).
@@ -0,0 +1,190 @@
package infra
import (
"encoding/binary"
"encoding/json"
"net"
"net/http"
"strings"
"testing"
"time"
)
// newUnixDockerServer levanta un httptest-style server en un socket Unix temporal.
// Retorna el server (ya iniciado) y el path al socket.
func newUnixDockerServer(t *testing.T, handler http.Handler) (socketPath string) {
t.Helper()
socketPath = t.TempDir() + "/docker_exec_test.sock"
ln, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatalf("listen unix %s: %v", socketPath, err)
}
srv := &http.Server{Handler: handler}
go srv.Serve(ln) //nolint:errcheck
t.Cleanup(func() {
srv.Close()
ln.Close()
})
return socketPath
}
// buildDockerExecHandler construye un http.Handler que simula el flujo completo del
// Docker Engine API para exec: create → start (stream multiplexado) → inspect.
func buildDockerExecHandler(t *testing.T, exitCode int, stdoutPayload, stderrPayload string, delayStart time.Duration) http.Handler {
t.Helper()
const fakeExecID = "deadbeefcafe"
// Construir stream multiplexado de Docker (Tty=false).
// Frame: [type(1)] [0 0 0(3)] [size big-endian uint32(4)] [payload]
buildFrame := func(streamType byte, payload string) []byte {
if payload == "" {
return nil
}
b := make([]byte, 8+len(payload))
b[0] = streamType
binary.BigEndian.PutUint32(b[4:8], uint32(len(payload)))
copy(b[8:], payload)
return b
}
var streamBody []byte
streamBody = append(streamBody, buildFrame(1, stdoutPayload)...)
streamBody = append(streamBody, buildFrame(2, stderrPayload)...)
mux := http.NewServeMux()
// POST /containers/{id}/exec — devuelve ExecID.
mux.HandleFunc("/containers/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/exec") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"Id": fakeExecID}) //nolint:errcheck
})
// POST /exec/{id}/start — transmite stream multiplexado.
// GET /exec/{id}/json — devuelve ExitCode.
mux.HandleFunc("/exec/", func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/start") && r.Method == http.MethodPost:
if delayStart > 0 {
select {
case <-r.Context().Done():
http.Error(w, "cancelled", http.StatusGatewayTimeout)
return
case <-time.After(delayStart):
}
}
w.WriteHeader(http.StatusOK)
if len(streamBody) > 0 {
w.Write(streamBody) //nolint:errcheck
}
case strings.HasSuffix(r.URL.Path, "/json") && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]int{"ExitCode": exitCode}) //nolint:errcheck
default:
http.NotFound(w, r)
}
})
return mux
}
func TestDockerContainerExec(t *testing.T) {
const containerID = "abc123container"
t.Run("binario en whitelist exitcode 0 stdout stderr capturados", func(t *testing.T) {
handler := buildDockerExecHandler(t, 0, "hello stdout\n", "hello stderr\n", 0)
socketPath := newUnixDockerServer(t, handler)
result, err := DockerContainerExec(DockerExecOpts{
ContainerID: containerID,
Cmd: []string{"ls", "-la"},
BinariesAllowed: []string{"ls", "cat", "echo"},
DockerHost: "unix://" + socketPath,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ExitCode != 0 {
t.Errorf("ExitCode: got %d, want 0", result.ExitCode)
}
if result.Stdout != "hello stdout\n" {
t.Errorf("Stdout: got %q, want %q", result.Stdout, "hello stdout\n")
}
if result.Stderr != "hello stderr\n" {
t.Errorf("Stderr: got %q, want %q", result.Stderr, "hello stderr\n")
}
if result.Duration < 0 {
t.Errorf("Duration should be >= 0, got %d", result.Duration)
}
})
t.Run("binario NO en whitelist error sin contactar engine", func(t *testing.T) {
// No levantamos server: si se contacta el engine, falla con connection refused.
// La validacion debe fallar ANTES de intentar conectar.
_, err := DockerContainerExec(DockerExecOpts{
ContainerID: containerID,
Cmd: []string{"bash", "-c", "rm -rf /"},
BinariesAllowed: []string{"ls", "cat"},
DockerHost: "unix:///tmp/nonexistent-docker-for-test.sock",
})
if err == nil {
t.Fatal("expected error for binary not in whitelist, got nil")
}
if !strings.Contains(err.Error(), "not in whitelist") {
t.Errorf("error should mention whitelist, got: %v", err)
}
})
t.Run("whitelist vacia rechaza todo", func(t *testing.T) {
_, err := DockerContainerExec(DockerExecOpts{
ContainerID: containerID,
Cmd: []string{"ls"},
BinariesAllowed: []string{},
DockerHost: "unix:///tmp/nonexistent-docker-for-test.sock",
})
if err == nil {
t.Fatal("expected error for empty whitelist, got nil")
}
if !strings.Contains(err.Error(), "no binaries whitelisted") {
t.Errorf("error should mention empty whitelist, got: %v", err)
}
})
t.Run("timeout simulado", func(t *testing.T) {
// El handler demora 2s en /start, el timeout de la funcion es 1s.
handler := buildDockerExecHandler(t, 0, "should not arrive", "", 2*time.Second)
socketPath := newUnixDockerServer(t, handler)
done := make(chan error, 1)
go func() {
_, err := DockerContainerExec(DockerExecOpts{
ContainerID: containerID,
Cmd: []string{"sleep"},
BinariesAllowed: []string{"sleep"},
DockerHost: "unix://" + socketPath,
TimeoutSeconds: 1,
})
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Fatal("expected timeout error, got nil")
}
// Cualquier error de contexto/red es valido (context deadline exceeded, connection reset, etc.)
case <-time.After(5 * time.Second):
t.Fatal("test itself timed out — DockerContainerExec did not respect TimeoutSeconds")
}
})
}
+196
View File
@@ -0,0 +1,196 @@
package infra
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// DockerContainerInfo holds the essential fields for a Docker container,
// mapped from the Engine API /containers/json response.
type DockerContainerInfo struct {
ID string // short id (12 chars)
Names []string // e.g. ["/my-container"]
Image string // image name
State string // running|exited|paused|...
Status string // human label, e.g. "Up 2 hours"
Ports []string // e.g. "0.0.0.0:8080->8080/tcp"
Networks []string // network names
Labels map[string]string // container labels
}
// DockerContainerListOpts controls which containers are returned and where
// the Docker daemon is reached.
type DockerContainerListOpts struct {
All bool // if true, include stopped/exited containers (default: false = only running)
Filters []string // Docker filter expressions, e.g. "label=app=agents_and_robots", "status=running"
DockerHost string // default "unix:///var/run/docker.sock". Use "tcp://host:port" for remote.
}
// DockerContainerList lists Docker containers on the local (or remote) host
// by calling the Docker Engine HTTP API directly. No docker CLI required.
//
// The DockerHost field selects the transport:
// - empty or "unix:///var/run/docker.sock" → unix socket
// - "tcp://host:port" → plain HTTP (no TLS)
func DockerContainerList(opts DockerContainerListOpts) ([]DockerContainerInfo, error) {
client, baseURL, err := dockerListHTTPClient(opts.DockerHost)
if err != nil {
return nil, fmt.Errorf("docker_container_list: build client: %w", err)
}
// Build query string
q := url.Values{}
if opts.All {
q.Set("all", "1")
}
if len(opts.Filters) > 0 {
filters := buildDockerFilters(opts.Filters)
b, err := json.Marshal(filters)
if err != nil {
return nil, fmt.Errorf("docker_container_list: marshal filters: %w", err)
}
q.Set("filters", string(b))
}
endpoint := baseURL + "/containers/json"
if len(q) > 0 {
endpoint += "?" + q.Encode()
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("docker_container_list: new request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("docker_container_list: do request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("docker_container_list: read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("docker_container_list: daemon returned %d: %s", resp.StatusCode, string(body))
}
return parseDockerContainerList(body)
}
// dockerListHTTPClient returns an *http.Client wired for unix socket or TCP,
// and a base URL ("http://localhost" for unix, "http://host:port" for TCP).
func dockerListHTTPClient(dockerHost string) (*http.Client, string, error) {
if dockerHost == "" {
dockerHost = "unix:///var/run/docker.sock"
}
if strings.HasPrefix(dockerHost, "unix://") {
sockPath := strings.TrimPrefix(dockerHost, "unix://")
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", sockPath)
},
}
return &http.Client{Transport: transport}, "http://localhost", nil
}
if strings.HasPrefix(dockerHost, "tcp://") {
host := strings.TrimPrefix(dockerHost, "tcp://")
return &http.Client{}, "http://" + host, nil
}
return nil, "", fmt.Errorf("unsupported docker host scheme: %q (use unix:// or tcp://)", dockerHost)
}
// buildDockerFilters converts []string{"label=k=v", "status=running"} into
// the map[string][]string format that the Docker Engine API expects.
func buildDockerFilters(filters []string) map[string][]string {
m := make(map[string][]string)
for _, f := range filters {
idx := strings.IndexByte(f, '=')
if idx < 0 {
continue
}
key := f[:idx]
val := f[idx+1:]
m[key] = append(m[key], val)
}
return m
}
// parseDockerContainerList decodes the raw JSON from /containers/json.
func parseDockerContainerList(body []byte) ([]DockerContainerInfo, error) {
var raw []struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
Status string `json:"Status"`
Labels map[string]string `json:"Labels"`
NetworkSettings struct {
Networks map[string]json.RawMessage `json:"Networks"`
} `json:"NetworkSettings"`
Ports []struct {
IP string `json:"IP"`
PrivatePort uint16 `json:"PrivatePort"`
PublicPort uint16 `json:"PublicPort"`
Type string `json:"Type"`
} `json:"Ports"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("docker_container_list: parse response: %w", err)
}
result := make([]DockerContainerInfo, 0, len(raw))
for _, c := range raw {
id := c.ID
if len(id) > 12 {
id = id[:12]
}
ports := make([]string, 0, len(c.Ports))
for _, p := range c.Ports {
if p.IP != "" && p.PublicPort != 0 {
ports = append(ports, fmt.Sprintf("%s:%d->%d/%s", p.IP, p.PublicPort, p.PrivatePort, p.Type))
} else if p.PrivatePort != 0 {
ports = append(ports, fmt.Sprintf("%d/%s", p.PrivatePort, p.Type))
}
}
networks := make([]string, 0, len(c.NetworkSettings.Networks))
for name := range c.NetworkSettings.Networks {
networks = append(networks, name)
}
labels := c.Labels
if labels == nil {
labels = map[string]string{}
}
result = append(result, DockerContainerInfo{
ID: id,
Names: c.Names,
Image: c.Image,
State: c.State,
Status: c.Status,
Ports: ports,
Networks: networks,
Labels: labels,
})
}
return result, nil
}
+76
View File
@@ -0,0 +1,76 @@
---
name: docker_container_list
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DockerContainerList(opts DockerContainerListOpts) ([]DockerContainerInfo, error)"
description: "Lista containers Docker del host via engine API (unix socket o TCP). Sin SDK pesado — net/http directo. Soporta filtros, All=true para exited. Usado por device_agent como capability docker.container.list."
tags: [docker, docker-agent, container, list, infra]
uses_functions: []
uses_types: [error_go_core, docker_container_info_go_infra]
returns: [docker_container_info_go_infra]
returns_optional: false
error_type: "error_go_core"
imports:
- context
- encoding/json
- fmt
- io
- net
- net/http
- net/url
- strings
- time
tested: true
tests:
- "lista solo running"
- "All=true incluye exited"
- "filter label aplica"
test_file_path: "functions/infra/docker_container_list_test.go"
file_path: "functions/infra/docker_container_list.go"
params:
- name: opts
desc: "DockerContainerListOpts — All (incluir exited), Filters (expresiones label=k=v / status=running), DockerHost (unix socket o tcp://host:port)"
output: "Slice de DockerContainerInfo con id, names, image, state, ports, networks para cada container."
---
## Ejemplo
```go
// Listar solo containers corriendo con un label específico
containers, err := DockerContainerList(infra.DockerContainerListOpts{
Filters: []string{"label=app=agents_and_robots"},
DockerHost: "unix:///var/run/docker.sock",
})
if err != nil {
log.Fatal(err)
}
for _, c := range containers {
fmt.Printf("%s %-20s %s %s\n", c.ID, c.Names[0], c.State, c.Status)
}
// Todos los containers (incluye exited) en host remoto
all, err := DockerContainerList(infra.DockerContainerListOpts{
All: true,
DockerHost: "tcp://192.168.1.10:2375",
})
```
## Cuando usarla
Cuando necesites listar containers Docker desde un agente o servicio sin depender del CLI `docker` instalado en el host — por ejemplo, al implementar la capability `docker.container.list` en un `device_agent` que recibe comandos desde Element/Matrix. También útil en tests y en entornos donde el binario docker no está en el PATH pero el socket sí es accesible.
## Gotchas
- Requiere acceso al docker socket. El proceso debe correr como root o en el grupo `docker`. En WSL2, el socket está en `/var/run/docker.sock` si Docker Desktop está activo.
- `Ports` puede estar vacío para containers en host network mode (`--network host`) — el engine no reporta port bindings en ese caso.
- Para acceso remoto sin TLS (`tcp://`), el daemon Docker debe tener `-H tcp://0.0.0.0:2375` habilitado explícitamente (deshabilitado por defecto por seguridad). Usar SSH tunnel o TLS para producción.
- `DockerHost` acepta `unix://` y `tcp://` solamente. Esquemas `https://` o `ssh://` retornan error.
- El campo `Names` incluye el slash inicial: `["/my-container"]`. Al mostrar al usuario, usar `strings.TrimPrefix(name, "/")`.
- Filters del tipo `label=k=v` incluyen el segundo `=` en el valor (el split es en el primer `=`). Para filtrar por presencia de label sin valor: `"label=app"`.
## Notas
Implementa la misma semántica que `GET /containers/json` del Docker Engine API v1.41+. No requiere el SDK `github.com/docker/docker` (evita ~50 MB de dependencias transitivas). El helper `dockerListHTTPClient` maneja la dialección unix socket requerida por `net/http`.
@@ -0,0 +1,223 @@
package infra
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// dockerAPIResponse is the minimal shape that /containers/json returns.
type dockerAPIResponse struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
// Status intentionally omitted to test zero-value handling
Labels map[string]string `json:"Labels"`
Ports []struct {
IP string `json:"IP"`
PrivatePort uint16 `json:"PrivatePort"`
PublicPort uint16 `json:"PublicPort"`
Type string `json:"Type"`
} `json:"Ports"`
NetworkSettings struct {
Networks map[string]json.RawMessage `json:"Networks"`
} `json:"NetworkSettings"`
}
// startMockDockerUnix starts an HTTP server listening on a temporary unix
// socket and returns the DockerHost string and a cleanup func.
func startMockDockerUnix(t *testing.T, handler http.Handler) (dockerHost string, cleanup func()) {
t.Helper()
tmp := filepath.Join(t.TempDir(), "docker.sock")
ln, err := net.Listen("unix", tmp)
if err != nil {
t.Fatalf("listen unix %s: %v", tmp, err)
}
srv := httptest.NewUnstartedServer(handler)
srv.Listener = ln
srv.Start()
return "unix://" + tmp, func() {
srv.Close()
os.Remove(tmp)
}
}
func TestDockerContainerList(t *testing.T) {
running := dockerAPIResponse{
ID: "abc123def456gh",
Names: []string{"/my-app"},
Image: "nginx:latest",
State: "running",
Ports: []struct {
IP string `json:"IP"`
PrivatePort uint16 `json:"PrivatePort"`
PublicPort uint16 `json:"PublicPort"`
Type string `json:"Type"`
}{{IP: "0.0.0.0", PrivatePort: 80, PublicPort: 8080, Type: "tcp"}},
Labels: map[string]string{"app": "my-app"},
}
running.NetworkSettings.Networks = map[string]json.RawMessage{
"bridge": json.RawMessage(`{}`),
}
exited := dockerAPIResponse{
ID: "deadbeef1234ab",
Names: []string{"/old-app"},
Image: "redis:7",
State: "exited",
}
labeled := dockerAPIResponse{
ID: "cafe00112233ab",
Names: []string{"/agents-app"},
Image: "agents:latest",
State: "running",
Labels: map[string]string{
"app": "agents_and_robots",
"team": "device",
},
}
t.Run("lista solo running", func(t *testing.T) {
// Serve only running containers (All=false → daemon omits exited).
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/containers/json" {
http.NotFound(w, r)
return
}
// Verify All param NOT set
if r.URL.Query().Get("all") != "" {
t.Errorf("expected no 'all' param, got %q", r.URL.Query().Get("all"))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]dockerAPIResponse{running})
})
dockerHost, cleanup := startMockDockerUnix(t, handler)
defer cleanup()
containers, err := DockerContainerList(DockerContainerListOpts{
All: false,
DockerHost: dockerHost,
})
if err != nil {
t.Fatalf("DockerContainerList: %v", err)
}
if len(containers) != 1 {
t.Fatalf("expected 1 container, got %d", len(containers))
}
c := containers[0]
if c.ID != "abc123def456" {
t.Errorf("ID: got %q, want %q", c.ID, "abc123def456")
}
if len(c.Names) == 0 || c.Names[0] != "/my-app" {
t.Errorf("Names: got %v, want [\"/my-app\"]", c.Names)
}
if c.State != "running" {
t.Errorf("State: got %q, want \"running\"", c.State)
}
if len(c.Ports) == 0 {
t.Errorf("expected ports, got empty")
}
if len(c.Networks) == 0 || c.Networks[0] != "bridge" {
t.Errorf("Networks: got %v, want [\"bridge\"]", c.Networks)
}
})
t.Run("All=true incluye exited", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/containers/json" {
http.NotFound(w, r)
return
}
if r.URL.Query().Get("all") != "1" {
t.Errorf("expected all=1, got %q", r.URL.Query().Get("all"))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]dockerAPIResponse{running, exited})
})
dockerHost, cleanup := startMockDockerUnix(t, handler)
defer cleanup()
containers, err := DockerContainerList(DockerContainerListOpts{
All: true,
DockerHost: dockerHost,
})
if err != nil {
t.Fatalf("DockerContainerList: %v", err)
}
if len(containers) != 2 {
t.Fatalf("expected 2 containers, got %d", len(containers))
}
states := map[string]bool{}
for _, c := range containers {
states[c.State] = true
}
if !states["running"] {
t.Error("missing running container")
}
if !states["exited"] {
t.Error("missing exited container")
}
})
t.Run("filter label aplica", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/containers/json" {
http.NotFound(w, r)
return
}
filtersRaw := r.URL.Query().Get("filters")
if filtersRaw == "" {
t.Error("expected filters param, got empty")
}
// Verify the filter map includes label key
var fm map[string][]string
if err := json.Unmarshal([]byte(filtersRaw), &fm); err != nil {
t.Errorf("parse filters: %v", err)
}
labelFilters := fm["label"]
found := false
for _, lf := range labelFilters {
if lf == "app=agents_and_robots" {
found = true
}
}
if !found {
t.Errorf("filter label=app=agents_and_robots not found in %v", fm)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]dockerAPIResponse{labeled})
})
dockerHost, cleanup := startMockDockerUnix(t, handler)
defer cleanup()
containers, err := DockerContainerList(DockerContainerListOpts{
All: true,
Filters: []string{"label=app=agents_and_robots"},
DockerHost: dockerHost,
})
if err != nil {
t.Fatalf("DockerContainerList: %v", err)
}
if len(containers) != 1 {
t.Fatalf("expected 1 container, got %d", len(containers))
}
c := containers[0]
if c.Labels["app"] != "agents_and_robots" {
t.Errorf("Labels[app]: got %q, want %q", c.Labels["app"], "agents_and_robots")
}
})
}
+290 -11
View File
@@ -1,23 +1,302 @@
package infra
import (
"context"
"encoding/binary"
"fmt"
"os/exec"
"strconv"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// DockerContainerLogs obtiene los logs de un contenedor. tail limita las últimas N líneas (0 = todas).
func DockerContainerLogs(nameOrID string, tail int) (string, error) {
args := []string{"logs"}
if tail > 0 {
args = append(args, "--tail", strconv.Itoa(tail))
// dockerHTTPClient devuelve un http.Client configurado para hablar con el daemon Docker.
// host puede ser "" (unix socket por defecto), "unix:///ruta/al/socket" o "tcp://host:port".
func dockerHTTPClient(host string) (*http.Client, string, error) {
if host == "" {
host = "unix:///var/run/docker.sock"
}
args = append(args, nameOrID)
out, err := exec.Command("docker", args...).CombinedOutput()
u, err := url.Parse(host)
if err != nil {
return "", fmt.Errorf("docker logs %s: %w", nameOrID, err)
return nil, "", fmt.Errorf("docker host URL invalida %q: %w", host, err)
}
return string(out), nil
var transport http.RoundTripper
var baseURL string
switch u.Scheme {
case "unix":
socketPath := u.Path
if socketPath == "" {
socketPath = u.Host // algunos parsers meten el path en Host para unix://
}
transport = &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
},
}
baseURL = "http://localhost"
case "tcp", "http":
transport = http.DefaultTransport
baseURL = "http://" + u.Host
case "https":
transport = http.DefaultTransport
baseURL = "https://" + u.Host
default:
return nil, "", fmt.Errorf("docker host scheme no soportado: %q", u.Scheme)
}
return &http.Client{Transport: transport, Timeout: 0}, baseURL, nil
}
// dockerLogsURL construye la URL para GET /containers/<id>/logs con los parametros dados.
func dockerLogsURL(baseURL, containerID string, opts DockerLogsOpts, follow bool) string {
tail := opts.Tail
if tail == 0 {
tail = 100
}
tailStr := fmt.Sprintf("%d", tail)
if tail < 0 {
tailStr = "all"
}
stdout := opts.Stdout
stderr := opts.Stderr
if !stdout && !stderr {
stdout = true
stderr = true
}
params := url.Values{}
params.Set("stdout", boolParam(stdout))
params.Set("stderr", boolParam(stderr))
params.Set("tail", tailStr)
params.Set("timestamps", boolParam(opts.Timestamps))
if follow {
params.Set("follow", "1")
} else {
params.Set("follow", "0")
}
if opts.Since != "" {
params.Set("since", opts.Since)
}
return fmt.Sprintf("%s/containers/%s/logs?%s", baseURL, url.PathEscape(containerID), params.Encode())
}
func boolParam(b bool) string {
if b {
return "1"
}
return "0"
}
// dockerDemuxFrame lee un frame del protocolo de multiplexion de Docker.
// El frame header tiene 8 bytes: [stream_type(1), 0,0,0, size(4 big-endian)].
// Retorna (streamType, payload, error). streamType: 1=stdout, 2=stderr.
// Retorna io.EOF cuando no hay mas frames.
func dockerDemuxFrame(r io.Reader) (uint8, []byte, error) {
var header [8]byte
if _, err := io.ReadFull(r, header[:]); err != nil {
return 0, nil, err // io.EOF si el stream termino limpiamente
}
streamType := header[0]
size := binary.BigEndian.Uint32(header[4:8])
if size == 0 {
return streamType, nil, nil
}
payload := make([]byte, size)
if _, err := io.ReadFull(r, payload); err != nil {
return 0, nil, fmt.Errorf("leyendo payload del frame docker: %w", err)
}
return streamType, payload, nil
}
// dockerStreamToString convierte el streamType del frame header al string "stdout"/"stderr".
func dockerStreamToString(t uint8) string {
if t == 2 {
return "stderr"
}
return "stdout"
}
// dockerParsePayload convierte el payload de un frame en DockerLogLines.
// Cada linea del payload se convierte en una DockerLogLine separada.
// Si timestamps esta habilitado, Docker prefija cada linea con "2006-01-02T15:04:05.000000000Z ".
func dockerParsePayload(streamType uint8, payload []byte, timestamps bool) []DockerLogLine {
raw := strings.TrimRight(string(payload), "\n")
rawLines := strings.Split(raw, "\n")
stream := dockerStreamToString(streamType)
lines := make([]DockerLogLine, 0, len(rawLines))
for _, l := range rawLines {
if l == "" {
continue
}
line := DockerLogLine{Stream: stream}
if timestamps {
// Docker antepone timestamp seguido de espacio: "2026-05-23T12:00:00.000Z texto"
idx := strings.Index(l, " ")
if idx > 0 {
line.Timestamp = l[:idx]
line.Line = l[idx+1:]
} else {
line.Line = l
}
} else {
line.Line = l
}
lines = append(lines, line)
}
return lines
}
// DockerContainerLogs obtiene los logs de un contenedor en modo snapshot (sin follow).
// Retorna hasta opts.Tail lineas (default 100, -1 = todas). Demuxea stdout/stderr.
func DockerContainerLogs(opts DockerLogsOpts) ([]DockerLogLine, error) {
client, baseURL, err := dockerHTTPClient(opts.DockerHost)
if err != nil {
return nil, err
}
reqURL := dockerLogsURL(baseURL, opts.ContainerID, opts, false)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("construyendo request logs: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("docker logs %s: %w", opts.ContainerID, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("container %q no encontrado", opts.ContainerID)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("docker logs HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var result []DockerLogLine
for {
streamType, payload, err := dockerDemuxFrame(resp.Body)
if err == io.EOF {
break
}
if err != nil {
return result, fmt.Errorf("leyendo frame docker logs: %w", err)
}
if len(payload) == 0 {
continue
}
lines := dockerParsePayload(streamType, payload, opts.Timestamps)
result = append(result, lines...)
}
return result, nil
}
// DockerContainerLogsStream hace follow de los logs de un contenedor en modo streaming.
// Por cada linea recibida llama cb. Si cb retorna error, el stream se cancela.
// ctx permite cancelacion externa. No hace reconexion automatica — el caller decide si reintentar.
func DockerContainerLogsStream(ctx context.Context, opts DockerLogsOpts, cb func(DockerLogLine) error) error {
client, baseURL, err := dockerHTTPClient(opts.DockerHost)
if err != nil {
return err
}
// Para streaming necesitamos un cliente sin timeout de lectura global.
client.Timeout = 0
reqURL := dockerLogsURL(baseURL, opts.ContainerID, opts, true)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return fmt.Errorf("construyendo request logs stream: %w", err)
}
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("docker logs stream %s: %w", opts.ContainerID, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("container %q no encontrado", opts.ContainerID)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("docker logs stream HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
// Leer frames hasta ctx cancel, EOF o error de cb.
for {
// Verificar cancelacion antes de cada lectura.
select {
case <-ctx.Done():
return ctx.Err()
default:
}
streamType, payload, err := dockerDemuxFrame(resp.Body)
if err == io.EOF {
return nil // contenedor termino o daemon cerro el stream
}
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("leyendo frame docker logs stream: %w", err)
}
if len(payload) == 0 {
continue
}
lines := dockerParsePayload(streamType, payload, opts.Timestamps)
for _, line := range lines {
if err := cb(line); err != nil {
return err // caller cancela via error
}
}
}
}
// dockerSince convierte duracion tipo "10m" a unix timestamp string para la API de Docker.
// La API acepta directamente duraciones asi que esta funcion es solo documentacion del contrato.
// Docker Engine acepta: unix timestamp int, RFC3339 timestamp, o Go duration string ("10m", "1h30m").
func dockerSince(_ string) string {
// Documentacion: Docker acepta directamente la string. No necesitamos conversion.
return ""
}
// dockerClientWithTimeout crea un http.Client con timeout de conexion pero sin timeout de lectura.
// Util para detectar rapido si el daemon no responde.
func dockerClientWithTimeout(host string, connectTimeout time.Duration) (*http.Client, string, error) {
client, baseURL, err := dockerHTTPClient(host)
if err != nil {
return nil, "", err
}
if transport, ok := client.Transport.(*http.Transport); ok {
origDial := transport.DialContext
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
return origDial(dialCtx, network, addr)
}
}
return client, baseURL, nil
}
+79 -18
View File
@@ -3,36 +3,97 @@ name: docker_container_logs
kind: function
lang: go
domain: infra
version: "1.0.0"
version: "2.0.0"
purity: impure
signature: "func DockerContainerLogs(nameOrID string, tail int) (string, error)"
description: "Obtiene los logs de un contenedor Docker. El parámetro tail limita a las últimas N líneas (0 devuelve todos los logs)."
tags: [docker, container, logs, infra]
signature: "func DockerContainerLogs(opts DockerLogsOpts) ([]DockerLogLine, error)"
description: "Tail/grep logs de container Docker via engine API. Snapshot (N lineas) o streaming (callback por linea con context cancel). Demux frame stdout/stderr. Capability docker.container.logs del device_agent."
tags: [docker, docker-agent, logs, streaming, infra]
uses_functions: []
uses_types: []
returns: []
uses_types:
- docker_logs_opts_go_infra
- docker_log_line_go_infra
- error_go_core
returns:
- docker_log_line_go_infra
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strconv]
imports:
- context
- encoding/binary
- fmt
- io
- net
- net/http
- net/url
- strings
- time
params:
- name: nameOrID
desc: "nombre o ID del contenedor Docker"
- name: tail
desc: "numero de ultimas lineas a devolver (0 devuelve todos los logs)"
output: "logs del contenedor como string"
tested: false
tests: []
test_file_path: ""
- name: opts
desc: "Parametros de la peticion: container ID, tail N, since, stdout/stderr, timestamps, docker host. Ver DockerLogsOpts."
output: "Slice de DockerLogLine con stream (stdout/stderr), timestamp RFC3339 opcional y texto de la linea."
tested: true
tests:
- "snapshot stdout y stderr demuxeados"
- "container no encontrado retorna error"
- "timestamps parseados del prefijo Docker"
- "tail y since se envian como query params"
- "streaming recibe lineas via callback"
- "ctx cancel detiene el stream"
- "callback error cancela el stream"
- "frame stdout decodificado correctamente"
- "frame stderr decodificado correctamente"
test_file_path: "functions/infra/docker_container_logs_test.go"
file_path: "functions/infra/docker_container_logs.go"
---
## Ejemplo
```go
// Últimas 100 líneas
logs, err := DockerContainerLogs("my-app", 100)
// Snapshot: ultimas 50 lineas de stdout+stderr
lines, err := DockerContainerLogs(infra.DockerLogsOpts{
ContainerID: "registry_api",
Tail: 50,
Since: "10m",
Stdout: true,
Stderr: true,
Timestamps: true,
})
if err != nil {
log.Fatal(err)
}
fmt.Println(logs)
for _, l := range lines {
fmt.Printf("[%s] %s %s\n", l.Stream, l.Timestamp, l.Line)
}
// Streaming: follow hasta cancelacion
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err = infra.DockerContainerLogsStream(ctx, infra.DockerLogsOpts{
ContainerID: "registry_api",
Stdout: true,
Stderr: true,
}, func(line infra.DockerLogLine) error {
fmt.Printf("[%s] %s\n", line.Stream, line.Line)
if strings.Contains(line.Line, "FATAL") {
return fmt.Errorf("fatal error detectado")
}
return nil
})
```
## Cuando usarla
Cuando el device_agent necesite leer o monitorizar logs de un container Docker en tiempo real. Usar modo snapshot para health checks puntuales (N ultimas lineas). Usar streaming para tail -f reactivo con procesamiento por linea.
## Gotchas
- Containers sin `--tty` usan el protocolo de multiplexion de 8 bytes — esta funcion lo demuxea correctamente. Containers con `--tty` mezclan stdout/stderr en un stream plano sin headers, lo que puede dar `Stream = "stdout"` para todo o parsear mal el header (byte 0 podria ser el primer caracter de texto).
- Streaming consume una goroutine/conexion hasta que `ctx` se cancele o `cb` retorne error. El caller es responsable del ciclo de vida del contexto.
- `Since` acepta unix timestamp en string, RFC3339 o duracion Go ("10m", "1h30m"). El daemon Docker acepta los 3 formatos directamente.
- Sin reconexion automatica en streaming. Si el daemon reinicia o la conexion se corta, el caller recibe error y decide si reintentar.
- `DockerHost` vacio conecta a `/var/run/docker.sock`. En sistemas donde el socket esta en otra ruta (Docker Desktop macOS, Podman), pasar la URL explicitamente.
## Capability growth log
v2.0.0 (2026-05-23) — reemplaza implementacion CLI (exec docker logs) por engine API HTTP con demux de frames. Anade DockerLogsOpts, DockerLogLine, modo streaming con callback y ctx cancel. Consumidor nordvpn_container_start actualizado.
@@ -0,0 +1,320 @@
package infra
import (
"context"
"encoding/binary"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// buildDockerFrame construye un frame del protocolo de multiplexion de Docker.
// streamType: 1=stdout, 2=stderr. payload: contenido (puede incluir newline).
func buildDockerFrame(streamType uint8, payload string) []byte {
data := []byte(payload)
frame := make([]byte, 8+len(data))
frame[0] = streamType
binary.BigEndian.PutUint32(frame[4:8], uint32(len(data)))
copy(frame[8:], data)
return frame
}
// buildMultiFrame concatena multiples frames en un unico slice de bytes.
func buildMultiFrame(frames ...[]byte) []byte {
var buf []byte
for _, f := range frames {
buf = append(buf, f...)
}
return buf
}
func TestDockerContainerLogs_Snapshot(t *testing.T) {
t.Run("snapshot stdout y stderr demuxeados", func(t *testing.T) {
body := buildMultiFrame(
buildDockerFrame(1, "linea stdout 1\n"),
buildDockerFrame(2, "linea stderr 1\n"),
buildDockerFrame(1, "linea stdout 2\n"),
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/containers/") {
t.Errorf("path inesperado: %s", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
w.Write(body)
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "test-container",
Tail: 10,
Stdout: true,
Stderr: true,
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
lines, err := DockerContainerLogs(opts)
if err != nil {
t.Fatalf("DockerContainerLogs error: %v", err)
}
if len(lines) != 3 {
t.Fatalf("esperadas 3 lineas, got %d", len(lines))
}
if lines[0].Stream != "stdout" {
t.Errorf("linea[0].Stream = %q, want stdout", lines[0].Stream)
}
if lines[0].Line != "linea stdout 1" {
t.Errorf("linea[0].Line = %q, want 'linea stdout 1'", lines[0].Line)
}
if lines[1].Stream != "stderr" {
t.Errorf("linea[1].Stream = %q, want stderr", lines[1].Stream)
}
if lines[1].Line != "linea stderr 1" {
t.Errorf("linea[1].Line = %q, want 'linea stderr 1'", lines[1].Line)
}
if lines[2].Stream != "stdout" {
t.Errorf("linea[2].Stream = %q, want stdout", lines[2].Stream)
}
})
t.Run("container no encontrado retorna error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"message":"No such container: missing"}`))
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "missing",
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
_, err := DockerContainerLogs(opts)
if err == nil {
t.Fatal("esperaba error para container no encontrado")
}
if !strings.Contains(err.Error(), "missing") {
t.Errorf("error no menciona el container: %v", err)
}
})
t.Run("timestamps parseados del prefijo Docker", func(t *testing.T) {
// Docker prefija: "2026-05-23T12:00:00.000000000Z texto\n"
payload := "2026-05-23T12:00:00.000000000Z hello timestamps\n"
body := buildDockerFrame(1, payload)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("timestamps") != "1" {
t.Errorf("timestamps param no enviado, query: %s", r.URL.RawQuery)
}
w.WriteHeader(http.StatusOK)
w.Write(body)
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "ts-container",
Stdout: true,
Timestamps: true,
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
lines, err := DockerContainerLogs(opts)
if err != nil {
t.Fatalf("error: %v", err)
}
if len(lines) != 1 {
t.Fatalf("esperada 1 linea, got %d", len(lines))
}
if lines[0].Timestamp != "2026-05-23T12:00:00.000000000Z" {
t.Errorf("Timestamp = %q, want RFC3339", lines[0].Timestamp)
}
if lines[0].Line != "hello timestamps" {
t.Errorf("Line = %q, want 'hello timestamps'", lines[0].Line)
}
})
t.Run("tail y since se envian como query params", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("tail") != "50" {
t.Errorf("tail = %q, want '50'", q.Get("tail"))
}
if q.Get("since") != "10m" {
t.Errorf("since = %q, want '10m'", q.Get("since"))
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "c1",
Tail: 50,
Since: "10m",
Stdout: true,
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
lines, err := DockerContainerLogs(opts)
if err != nil {
t.Fatalf("error: %v", err)
}
if len(lines) != 0 {
t.Errorf("esperadas 0 lineas de body vacio, got %d", len(lines))
}
})
}
func TestDockerContainerLogsStream(t *testing.T) {
t.Run("streaming recibe lineas via callback", func(t *testing.T) {
frames := buildMultiFrame(
buildDockerFrame(1, "stream line 1\n"),
buildDockerFrame(2, "stream line 2\n"),
buildDockerFrame(1, "stream line 3\n"),
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("follow") != "1" {
t.Errorf("follow param no enviado")
}
w.WriteHeader(http.StatusOK)
w.Write(frames)
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "stream-container",
Stdout: true,
Stderr: true,
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
var received []DockerLogLine
ctx := context.Background()
err := DockerContainerLogsStream(ctx, opts, func(line DockerLogLine) error {
received = append(received, line)
return nil
})
if err != nil {
t.Fatalf("DockerContainerLogsStream error: %v", err)
}
if len(received) != 3 {
t.Fatalf("esperadas 3 lineas, got %d", len(received))
}
if received[0].Line != "stream line 1" {
t.Errorf("received[0].Line = %q", received[0].Line)
}
if received[1].Stream != "stderr" {
t.Errorf("received[1].Stream = %q, want stderr", received[1].Stream)
}
})
t.Run("ctx cancel detiene el stream", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
flusher := w.(http.Flusher)
w.Write(buildDockerFrame(1, "antes del cancel\n"))
flusher.Flush()
// Bloquear hasta que el cliente cierre la conexion.
<-r.Context().Done()
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "cancel-container",
Stdout: true,
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
var count int
err := DockerContainerLogsStream(ctx, opts, func(line DockerLogLine) error {
count++
return nil
})
if err == nil {
t.Error("esperaba error de cancelacion de contexto")
}
if count == 0 {
t.Error("esperaba recibir al menos 1 linea antes del cancel")
}
})
t.Run("callback error cancela el stream", func(t *testing.T) {
frames := buildMultiFrame(
buildDockerFrame(1, "linea 1\n"),
buildDockerFrame(1, "linea 2\n"),
buildDockerFrame(1, "linea 3\n"),
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(frames)
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "cb-error-container",
Stdout: true,
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
stopErr := errors.New("stop processing")
var count int
err := DockerContainerLogsStream(context.Background(), opts, func(line DockerLogLine) error {
count++
if count >= 2 {
return stopErr
}
return nil
})
if !errors.Is(err, stopErr) {
t.Errorf("esperaba stopErr, got: %v", err)
}
if count < 2 {
t.Errorf("esperaba al menos 2 invocaciones del callback, got %d", count)
}
if count > 3 {
t.Errorf("callback invocado demasiadas veces tras error: %d", count)
}
})
}
func TestDockerDemuxFrame(t *testing.T) {
t.Run("frame stdout decodificado correctamente", func(t *testing.T) {
payload := "hello world"
frame := buildDockerFrame(1, payload)
r := strings.NewReader(string(frame))
streamType, data, err := dockerDemuxFrame(r)
if err != nil {
t.Fatalf("error: %v", err)
}
if streamType != 1 {
t.Errorf("streamType = %d, want 1", streamType)
}
if string(data) != payload {
t.Errorf("payload = %q, want %q", string(data), payload)
}
})
t.Run("frame stderr decodificado correctamente", func(t *testing.T) {
frame := buildDockerFrame(2, "error line")
r := strings.NewReader(string(frame))
streamType, _, err := dockerDemuxFrame(r)
if err != nil {
t.Fatalf("error: %v", err)
}
if streamType != 2 {
t.Errorf("streamType = %d, want 2 (stderr)", streamType)
}
})
}
+29
View File
@@ -0,0 +1,29 @@
package infra
// DockerLogsOpts parametriza la peticion de logs al engine API de Docker.
type DockerLogsOpts struct {
// ContainerID es el ID o nombre del contenedor.
ContainerID string
// Tail es el numero de ultimas lineas a devolver. -1 = todas. Default efectivo 100 si es 0.
Tail int
// Since filtra logs desde este instante. Acepta unix timestamp ("1716400000") o duracion ("10m", "1h").
Since string
// Stdout incluye el stream stdout (default true si ambos son false).
Stdout bool
// Stderr incluye el stream stderr (default true si ambos son false).
Stderr bool
// Timestamps incluye el timestamp RFC3339 de cada linea en el campo Line prefijado por Docker.
Timestamps bool
// DockerHost es la URL del socket/TCP del daemon Docker. Vacio = unix:///var/run/docker.sock.
DockerHost string
}
// DockerLogLine es una linea de log de un contenedor Docker con su stream de origen.
type DockerLogLine struct {
// Stream indica el origen: "stdout" o "stderr".
Stream string
// Timestamp es el timestamp RFC3339 de la linea. Vacio si DockerLogsOpts.Timestamps es false.
Timestamp string
// Line es el contenido de la linea de log (sin newline final).
Line string
}
+15
View File
@@ -0,0 +1,15 @@
package infra
// ErrorGoCore is the standard error type for impure functions in the infra package.
// It wraps a message string and an optional machine-readable Code, and satisfies the error interface.
type ErrorGoCore struct {
Message string
Code string // optional machine-readable error code (e.g. "FETCH_ERROR", "CONVERGENCE_FAILED")
}
func (e *ErrorGoCore) Error() string {
if e.Code != "" {
return e.Code + ": " + e.Message
}
return e.Message
}
+107
View File
@@ -0,0 +1,107 @@
//go:build goolm || libolm
package infra
import (
"context"
"fmt"
"os"
"path/filepath"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
)
// MatrixCryptoInitConfig parametriza la inicializacion del crypto store Olm/Megolm.
type MatrixCryptoInitConfig struct {
// Client es el *mautrix.Client ya inicializado via MatrixClientInit.
// Debe tener AccessToken, UserID y DeviceID poblados.
Client *mautrix.Client
// StorePath es la ruta absoluta al archivo SQLite del crypto store.
// Debe ser separado del state store. El SDK gestiona el schema internamente.
// Si el directorio padre no existe, se crea con permisos 0700.
// Ejemplo: "/home/lucas/.config/matrix_client_pc/egutierrez/crypto.db"
StorePath string
// PickleKey son exactamente 32 bytes usados por cryptohelper para cifrar las
// sesiones Olm en disco at-rest. DEBE persistir entre arranques (guardar en keyring).
// Si se pierde, el store SQLite se vuelve inutilizable y hay que crear nuevo dispositivo.
PickleKey []byte
}
// MatrixCryptoInitResult contiene el helper listo para usar.
type MatrixCryptoInitResult struct {
// Helper es el *cryptohelper.CryptoHelper inicializado.
// Ya esta asignado a client.Crypto — el Sync loop cifra/descifra automaticamente.
Helper *cryptohelper.CryptoHelper
// StorePath es la ruta al archivo SQLite del crypto store (igual que cfg.StorePath).
StorePath string
}
// MatrixCryptoInit inicializa el crypto store Olm/Megolm para un cliente mautrix
// usando cryptohelper — el wrapper oficial que abstrae SQLite + Olm identity keys +
// one-time key upload + decrypt automatico via el Syncer.
//
// Pasos:
// 1. Valida inputs (Client no nil con AccessToken/UserID/DeviceID, StorePath
// absoluto, PickleKey exactamente 32 bytes).
// 2. Crea el directorio padre de StorePath con permisos 0700 si no existe.
// 3. Construye el helper via cryptohelper.NewCryptoHelper(client, pickleKey, storePath).
// 4. Llama helper.Init(ctx) — crea tablas SQLite, carga cuenta Olm, sube one-time keys.
// 5. Asigna client.Crypto = helper para que SendMessageEvent cifre automaticamente.
// 6. Devuelve MatrixCryptoInitResult con el helper listo.
func MatrixCryptoInit(ctx context.Context, cfg MatrixCryptoInitConfig) (*MatrixCryptoInitResult, error) {
// 1. Validar Client
if cfg.Client == nil {
return nil, fmt.Errorf("matrix_crypto_init: Client no puede ser nil")
}
if cfg.Client.AccessToken == "" {
return nil, fmt.Errorf("matrix_crypto_init: Client.AccessToken no puede estar vacio")
}
if cfg.Client.UserID == "" {
return nil, fmt.Errorf("matrix_crypto_init: Client.UserID no puede estar vacio")
}
if cfg.Client.DeviceID == "" {
return nil, fmt.Errorf("matrix_crypto_init: Client.DeviceID no puede estar vacio — descubrirlo via MatrixClientInit o Whoami antes de llamar MatrixCryptoInit")
}
// Validar StorePath
if cfg.StorePath == "" {
return nil, fmt.Errorf("matrix_crypto_init: StorePath no puede estar vacio")
}
if !filepath.IsAbs(cfg.StorePath) {
return nil, fmt.Errorf("matrix_crypto_init: StorePath debe ser una ruta absoluta (got %q)", cfg.StorePath)
}
// Validar PickleKey: exactamente 32 bytes
if len(cfg.PickleKey) != 32 {
return nil, fmt.Errorf("matrix_crypto_init: PickleKey debe tener exactamente 32 bytes (got %d)", len(cfg.PickleKey))
}
// 2. Crear directorio padre con permisos 0700 (datos sensibles)
storeDir := filepath.Dir(cfg.StorePath)
if err := os.MkdirAll(storeDir, 0700); err != nil {
return nil, fmt.Errorf("matrix_crypto_init: no se pudo crear directorio del store %q: %w", storeDir, err)
}
// 3. Construir CryptoHelper — acepta string como path SQLite directamente (v0.28 API)
helper, err := cryptohelper.NewCryptoHelper(cfg.Client, cfg.PickleKey, cfg.StorePath)
if err != nil {
return nil, fmt.Errorf("matrix_crypto_init: NewCryptoHelper failed: %w", err)
}
// 4. Init: crea tablas SQLite, carga cuenta Olm, sube one-time keys al servidor
if err := helper.Init(ctx); err != nil {
return nil, fmt.Errorf("matrix_crypto_init: helper.Init failed (comprueba conectividad con Synapse y validez del token): %w", err)
}
// 5. Asignar client.Crypto para que SendMessageEvent cifre automaticamente
cfg.Client.Crypto = helper
return &MatrixCryptoInitResult{
Helper: helper,
StorePath: cfg.StorePath,
}, nil
}
+96
View File
@@ -0,0 +1,96 @@
---
name: matrix_crypto_init
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func MatrixCryptoInit(ctx context.Context, cfg MatrixCryptoInitConfig) (*MatrixCryptoInitResult, error)"
description: "Inicializa el crypto store Olm/Megolm para un *mautrix.Client usando cryptohelper v0.28+. Crea el SQLite store, carga la cuenta Olm, sube one-time keys al servidor y asigna client.Crypto para que SendMessageEvent cifre automaticamente en rooms E2EE."
tags: [matrix, mautrix, e2ee, olm, megolm, crypto, cryptohelper, infra, matrix-mas]
params:
- name: ctx
desc: "context.Context con deadline/cancel. Se propaga a helper.Init() que hace HTTP a Synapse. Usar timeout de al menos 5s (primera vez puede tardar ~500ms por /keys/upload)."
- name: cfg.Client
desc: "*mautrix.Client ya inicializado via MatrixClientInit. Debe tener AccessToken, UserID y DeviceID poblados. DeviceID es obligatorio — descubrirlo via Whoami antes si no lo tienes."
- name: cfg.StorePath
desc: "Ruta absoluta al archivo SQLite del crypto store. Separado del state store. Si el directorio padre no existe, se crea con permisos 0700. Ejemplo: /home/lucas/.config/matrix_client_pc/egutierrez/crypto.db"
- name: cfg.PickleKey
desc: "Exactamente 32 bytes usados para cifrar las sesiones Olm at-rest en el SQLite. Generar con crypto/rand.Read(). DEBE persistir entre arranques — guardar en keyring del sistema. Si se pierde, el store se vuelve inutilizable."
output: "*MatrixCryptoInitResult con Helper (*cryptohelper.CryptoHelper ya asignado a client.Crypto y listo para Sync/SendMessageEvent) y StorePath (ruta al SQLite). Llamar helper.Close() en shutdown."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/crypto/cryptohelper"
tested: true
tests:
- "Client nil devuelve error"
- "AccessToken vacio devuelve error"
- "UserID vacio devuelve error"
- "DeviceID vacio devuelve error"
- "StorePath vacio devuelve error"
- "StorePath relativo devuelve error"
- "PickleKey != 32 bytes devuelve error"
- "directorio del store se crea con permisos 0700"
- "input valido Init exito helper no nil"
- "Synapse 401 en keys upload devuelve error"
test_file_path: "functions/infra/matrix_crypto_init_test.go"
file_path: "functions/infra/matrix_crypto_init.go"
---
## Ejemplo
```go
import (
"context"
"crypto/rand"
infra "fn-registry/functions/infra"
)
// Paso 1: cliente ya inicializado (ver matrix_client_init_go_infra)
clientRes, err := infra.MatrixClientInit(infra.MatrixClientInitConfig{
HomeserverURL: "https://matrix-af2f3d.organic-machine.com",
UserID: "@egutierrez:matrix-af2f3d.organic-machine.com",
AccessToken: "mxat_xyz...",
DeviceID: "MYDEVICEID",
StoreDir: "/home/lucas/.config/matrix_client_pc/egutierrez/",
})
if err != nil { panic(err) }
// Paso 2: generar PickleKey (guardar en keyring, NO en codigo)
pickleKey := make([]byte, 32)
if _, err := rand.Read(pickleKey); err != nil { panic(err) }
// Persistir: secret-tool store --label="matrix pickle" service matrix account @user:server
// Paso 3: activar E2EE
ctx := context.Background()
cryptoRes, err := infra.MatrixCryptoInit(ctx, infra.MatrixCryptoInitConfig{
Client: clientRes.Client,
StorePath: "/home/lucas/.config/matrix_client_pc/egutierrez/crypto.db",
PickleKey: pickleKey,
})
if err != nil { panic(err) }
defer cryptoRes.Helper.Close()
// Ahora clientRes.Client.SendMessageEvent en rooms E2EE cifra automaticamente.
// El Syncer descifra mensajes recibidos tambien automaticamente.
```
## Cuando usarla
Llamar UNA vez por sesion, tras `MatrixClientInit` y ANTES de arrancar `client.Sync()`. El orden es critico: si Sync arranca antes, los primeros eventos cifrados llegan sin handler Olm y se pierden. Una vez asignado `client.Crypto`, el Sync loop gestiona cifrado y descifrado transparente sin codigo adicional.
## Gotchas
- **PickleKey DEBE sobrevivir entre arranques**: si pierdes los 32 bytes, el store SQLite no se puede abrir y debes hacer nuevo login con nuevo DeviceID. Guardar obligatoriamente en keyring: `secret-tool store --label="matrix pickle key" service matrix_client_pc account pickle_key_@egutierrez:servidor`.
- **DeviceID es obligatorio**: a diferencia de `MatrixClientInit` (que puede descubrirlo via Whoami), esta funcion falla si `Client.DeviceID` esta vacio para evitar crear un store huerfano vinculado a ningun dispositivo real.
- **StorePath debe ser persistente**: NO usar `/tmp/`. Si el store se pierde entre arranques, se pierden las sesiones Olm — los mensajes historicos en rooms E2EE NO se podran descifrar sin Key Backup (issue 0150 full).
- **Init() hace HTTP a Synapse**: primera vez ~500ms por `/keys/upload`. Usar context con timeout >= 5s. Si devuelve error con "M_UNKNOWN_TOKEN", el access token caducó — refrescar via OIDC.
- **Sin cross-signing/SAS**: otros dispositivos ven el tuyo como "unverified" (amber warning en Element). E2EE sigue funcionando — cifra y descifra OK via TOFU. Cross-signing e implementacion de verificacion quedan para issue 0150 completo.
- **Build tag obligatorio**: el archivo requiere `-tags goolm` (puro Go, sin CGO) o `-tags libolm` (CGO + libolm-dev instalado). Sin ninguno de los dos, el archivo no compila (build constraint).
- **client.Syncer debe ser ExtensibleSyncer**: `mautrix.DefaultSyncer` lo implementa. Si usas Syncer custom, verificar que implementa `mautrix.ExtensibleSyncer` o `NewCryptoHelper` fallara.
- **Cerrar el helper en shutdown**: `helper.Close()` cierra la conexion SQLite del store. Imprescindible para evitar WAL leak en el crypto.db.
+321
View File
@@ -0,0 +1,321 @@
//go:build goolm || libolm
package infra
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// makeTestClient construye un *mautrix.Client apuntando al servidor dado con
// credenciales validas para los tests.
func makeTestClient(t *testing.T, serverURL string) *mautrix.Client {
t.Helper()
cli, err := mautrix.NewClient(serverURL, "@user:localhost", "test-token")
if err != nil {
t.Fatalf("mautrix.NewClient: %v", err)
}
cli.AccessToken = "test-token"
cli.UserID = id.UserID("@user:localhost")
cli.DeviceID = id.DeviceID("TESTDEVICE")
return cli
}
// validPickleKey genera una clave de 32 bytes para tests.
func validPickleKey() []byte {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
return key
}
// newSynapseMock crea un httptest.Server que responde a los endpoints
// necesarios para Init(): /keys/upload y /keys/query.
// Acepta un statusCode para /keys/upload (200 = exito, 401 = token invalido).
func newSynapseMock(t *testing.T, uploadStatus int) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// POST /_matrix/client/v3/keys/upload -> one-time key counts
mux.HandleFunc("/_matrix/client/v3/keys/upload", func(w http.ResponseWriter, r *http.Request) {
if uploadStatus != http.StatusOK {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(uploadStatus)
resp := map[string]any{
"errcode": "M_UNKNOWN_TOKEN",
"error": "Invalid access token",
}
_ = json.NewEncoder(w).Encode(resp)
return
}
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"one_time_key_counts": map[string]int{
"signed_curve25519": 50,
},
}
_ = json.NewEncoder(w).Encode(resp)
})
// POST /_matrix/client/v3/keys/query -> empty device keys
mux.HandleFunc("/_matrix/client/v3/keys/query", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"device_keys": map[string]any{},
"failures": map[string]any{},
"master_keys": map[string]any{},
"user_signing_keys": map[string]any{},
"self_signing_keys": map[string]any{},
}
_ = json.NewEncoder(w).Encode(resp)
})
// GET /_matrix/client/v3/sync -> minimal empty sync
mux.HandleFunc("/_matrix/client/v3/sync", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"next_batch": "s0_1",
"rooms": map[string]any{},
"to_device": map[string]any{"events": []any{}},
"device_one_time_keys_count": map[string]any{},
}
_ = json.NewEncoder(w).Encode(resp)
})
// Catchall para no dejar requests colgados
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
})
return httptest.NewServer(mux)
}
func TestMatrixCryptoInit(t *testing.T) {
t.Run("Client nil devuelve error", func(t *testing.T) {
ctx := context.Background()
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: nil,
StorePath: "/tmp/crypto_test.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con Client nil, got nil")
}
if !strings.Contains(err.Error(), "Client no puede ser nil") {
t.Errorf("mensaje de error inesperado: %q", err.Error())
}
})
t.Run("AccessToken vacio devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "")
cli.UserID = "@user:localhost"
cli.DeviceID = "DEVID"
cli.AccessToken = ""
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "/tmp/crypto_test.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con AccessToken vacio, got nil")
}
})
t.Run("UserID vacio devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "", "token_abc")
cli.DeviceID = "DEVID"
cli.AccessToken = "token_abc"
cli.UserID = ""
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "/tmp/crypto_test.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con UserID vacio, got nil")
}
})
t.Run("DeviceID vacio devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "token_abc")
cli.AccessToken = "token_abc"
cli.UserID = "@user:localhost"
cli.DeviceID = ""
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "/tmp/crypto_test.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con DeviceID vacio, got nil")
}
})
t.Run("StorePath vacio devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "token")
cli.AccessToken = "token"
cli.UserID = "@user:localhost"
cli.DeviceID = id.DeviceID("DEVID")
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con StorePath vacio, got nil")
}
})
t.Run("StorePath relativo devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "token")
cli.AccessToken = "token"
cli.UserID = "@user:localhost"
cli.DeviceID = id.DeviceID("DEVID")
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "relative/path/crypto.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con StorePath relativo, got nil")
}
})
t.Run("PickleKey != 32 bytes devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "token")
cli.AccessToken = "token"
cli.UserID = "@user:localhost"
cli.DeviceID = id.DeviceID("DEVID")
// Clave de 16 bytes (demasiado corta)
shortKey := make([]byte, 16)
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "/tmp/crypto_test.db",
PickleKey: shortKey,
})
if err == nil {
t.Fatal("esperaba error con PickleKey de 16 bytes, got nil")
}
if !strings.Contains(err.Error(), "32 bytes") {
t.Errorf("mensaje de error debe mencionar '32 bytes', got %q", err.Error())
}
})
t.Run("directorio del store se crea con permisos 0700", func(t *testing.T) {
tmpDir := t.TempDir()
storeDir := filepath.Join(tmpDir, "sub", "crypto_store")
storePath := filepath.Join(storeDir, "crypto.db")
srv := newSynapseMock(t, http.StatusOK)
defer srv.Close()
cli := makeTestClient(t, srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// El Init puede fallar (e.g. sync loop), pero el directorio debe crearse.
_, _ = MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: storePath,
PickleKey: validPickleKey(),
})
if _, statErr := os.Stat(storeDir); os.IsNotExist(statErr) {
t.Fatalf("el directorio %q no fue creado", storeDir)
}
info, statErr := os.Stat(storeDir)
if statErr != nil {
t.Fatalf("no se pudo stat el directorio: %v", statErr)
}
perm := info.Mode().Perm()
if perm != 0700 {
t.Errorf("permisos del directorio: got %04o, want 0700", perm)
}
})
t.Run("input valido Init exito helper no nil", func(t *testing.T) {
tmpDir := t.TempDir()
storePath := filepath.Join(tmpDir, "crypto.db")
srv := newSynapseMock(t, http.StatusOK)
defer srv.Close()
cli := makeTestClient(t, srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
res, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: storePath,
PickleKey: validPickleKey(),
})
if err != nil {
t.Fatalf("MatrixCryptoInit failed: %v", err)
}
if res == nil {
t.Fatal("resultado es nil")
}
if res.Helper == nil {
t.Fatal("Helper es nil")
}
if res.StorePath != storePath {
t.Errorf("StorePath: got %q, want %q", res.StorePath, storePath)
}
if cli.Crypto == nil {
t.Error("client.Crypto no fue asignado")
}
// Verificar que el archivo SQLite fue creado
if _, err := os.Stat(storePath); os.IsNotExist(err) {
t.Error("archivo crypto.db no fue creado")
}
if err := res.Helper.Close(); err != nil {
t.Errorf("Helper.Close() error: %v", err)
}
})
t.Run("Synapse 401 en keys upload devuelve error", func(t *testing.T) {
tmpDir := t.TempDir()
storePath := filepath.Join(tmpDir, "crypto.db")
srv := newSynapseMock(t, http.StatusUnauthorized)
defer srv.Close()
cli := makeTestClient(t, srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: storePath,
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con Synapse 401, got nil")
}
if !strings.Contains(err.Error(), "helper.Init failed") {
t.Errorf("mensaje de error inesperado: %q", err.Error())
}
})
}
+121
View File
@@ -0,0 +1,121 @@
package infra
import (
"bytes"
"context"
"fmt"
"github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// matrixMarkdownToHTML convierte Markdown a HTML sanitizado con goldmark + bluemonday.
// El HTML resultante es seguro para incluir en formatted_body de un evento Matrix.
// Allowlist: bluemonday UGCPolicy + <details>, <summary>, <code>, <pre>.
func matrixMarkdownToHTML(markdown string) (string, error) {
var buf bytes.Buffer
if err := goldmark.Convert([]byte(markdown), &buf); err != nil {
return "", fmt.Errorf("matrix_message_send: goldmark convert: %w", err)
}
p := bluemonday.UGCPolicy()
p.AllowElements("details", "summary", "code", "pre")
sanitized := p.SanitizeBytes(buf.Bytes())
return string(sanitized), nil
}
// matrixSendEvent es el helper interno que llama a client.SendMessageEvent
// y devuelve el id.EventID asignado por Synapse.
func matrixSendEvent(ctx context.Context, client *mautrix.Client, roomID id.RoomID, eventType event.Type, content interface{}) (id.EventID, error) {
resp, err := client.SendMessageEvent(ctx, roomID, eventType, content)
if err != nil {
return "", err
}
return resp.EventID, nil
}
// MatrixSendText envía un mensaje de texto plano (m.text) al room indicado.
// Si el room tiene E2EE activo y client.Crypto != nil, mautrix cifra automáticamente.
func MatrixSendText(ctx context.Context, client *mautrix.Client, roomID id.RoomID, body string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: body,
}
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
}
// MatrixSendMarkdown convierte markdown a HTML con goldmark, lo sanitiza con bluemonday
// (UGCPolicy + <details>, <summary>, <code>, <pre>) y envía con format=org.matrix.custom.html.
// El campo Body contiene el markdown original como fallback para clientes sin HTML.
func MatrixSendMarkdown(ctx context.Context, client *mautrix.Client, roomID id.RoomID, markdown string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
htmlBody, err := matrixMarkdownToHTML(markdown)
if err != nil {
return "", fmt.Errorf("matrix_message_send.MatrixSendMarkdown: %w", err)
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: markdown,
Format: event.FormatHTML,
FormattedBody: htmlBody,
}
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
}
// MatrixSendReply envía un mensaje con m.relates_to.m.in_reply_to apuntando a replyTo.
// El body es el texto de la respuesta. En v0.1.0 el caller construye la cita si la necesita.
// El cifrado E2EE es automático si client.Crypto está configurado.
func MatrixSendReply(ctx context.Context, client *mautrix.Client, roomID id.RoomID, replyTo id.EventID, body string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: body,
RelatesTo: (&event.RelatesTo{}).SetReplyTo(replyTo),
}
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
}
// MatrixEditMessage envía un replacement event (m.replace) compatible con Element y la spec Matrix.
// NewContent contiene el texto nuevo; Body es el fallback "* newBody" para clientes sin soporte de edición.
// eventID es el evento original a reemplazar.
func MatrixEditMessage(ctx context.Context, client *mautrix.Client, roomID id.RoomID, eventID id.EventID, newBody string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: "* " + newBody,
NewContent: &event.MessageEventContent{
MsgType: event.MsgText,
Body: newBody,
},
RelatesTo: (&event.RelatesTo{}).SetReplace(eventID),
}
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
}
// MatrixSendReaction envía un evento m.reaction con m.relates_to.rel_type=m.annotation.
// key debe ser el emoji unicode raw (ej. "👍"), no shortcode (:thumbsup:).
// Las reactions no se cifran aunque el room sea E2EE (comportamiento de mautrix-go).
func MatrixSendReaction(ctx context.Context, client *mautrix.Client, roomID id.RoomID, targetEventID id.EventID, key string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
content := &event.ReactionEventContent{
RelatesTo: event.RelatesTo{
Type: event.RelAnnotation,
EventID: targetEventID,
Key: key,
},
}
return matrixSendEvent(ctx, client, roomID, event.EventReaction, content)
}
+99
View File
@@ -0,0 +1,99 @@
---
name: matrix_message_send
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: |
func MatrixSendText(ctx context.Context, client *mautrix.Client, roomID id.RoomID, body string) (id.EventID, error)
func MatrixSendMarkdown(ctx context.Context, client *mautrix.Client, roomID id.RoomID, markdown string) (id.EventID, error)
func MatrixSendReply(ctx context.Context, client *mautrix.Client, roomID id.RoomID, replyTo id.EventID, body string) (id.EventID, error)
func MatrixEditMessage(ctx context.Context, client *mautrix.Client, roomID id.RoomID, eventID id.EventID, newBody string) (id.EventID, error)
func MatrixSendReaction(ctx context.Context, client *mautrix.Client, roomID id.RoomID, targetEventID id.EventID, key string) (id.EventID, error)
description: "Envía mensajes Matrix con todas las variantes del compositor: texto plain, markdown con HTML sanitizado, reply con m.in_reply_to, edit (m.replace) y reaction (m.annotation). Si el room es E2EE y client.Crypto está configurado via matrix_crypto_init, mautrix cifra automáticamente."
tags: [matrix, mautrix, send, message, markdown, reply, edit, reaction, infra, matrix-mas]
params:
- name: ctx
desc: "Context para cancelación y timeout de la petición HTTP a Synapse."
- name: client
desc: "*mautrix.Client autenticado. Debe tener AccessToken, UserID y DeviceID. Si es nil, error inmediato."
- name: roomID
desc: "ID del room Matrix destino. Formato: !xxx:server."
- name: body / markdown / newBody
desc: "Contenido del mensaje. Para MatrixSendMarkdown se parsea con goldmark y se sanitiza con bluemonday UGCPolicy."
- name: replyTo / eventID / targetEventID
desc: "ID del evento referenciado (para reply, edit y reaction)."
- name: key
desc: "Emoji unicode raw para reaction (ej. '👍'). No shortcodes (:thumbsup:)."
output: "id.EventID del evento enviado por Synapse + error. El EventID permite referenciar el mensaje para edits, replies o reactions posteriores."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "context"
- "bytes"
- "fmt"
- "github.com/microcosm-cc/bluemonday"
- "github.com/yuin/goldmark"
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
tested: true
tests:
- "SendText body correcto y EventID parseado"
- "SendMarkdown bold convierte a HTML strong y sanitiza script"
- "SendReply m.relates_to m.in_reply_to presente"
- "EditMessage rel_type m.replace y m.new_content"
- "SendReaction tipo m.reaction con m.annotation y key"
- "SendText client nil devuelve error"
- "SendMarkdown client nil devuelve error"
- "SendReply client nil devuelve error"
- "EditMessage client nil devuelve error"
- "SendReaction client nil devuelve error"
test_file_path: "functions/infra/matrix_message_send_test.go"
file_path: "functions/infra/matrix_message_send.go"
---
## Ejemplo
```go
import (
"context"
infra "fn-registry/functions/infra"
"maunium.net/go/mautrix/id"
)
ctx := context.Background()
roomID := id.RoomID("!abc123:organic-machine.com")
// Texto plain
evID, err := infra.MatrixSendText(ctx, client, roomID, "Hola")
// Markdown: **bold**, `code`, > quote -> HTML sanitizado
evID, err = infra.MatrixSendMarkdown(ctx, client, roomID, "**bold** + `code`")
// Reply a un evento existente
evID, err = infra.MatrixSendReply(ctx, client, roomID, id.EventID("$orig:server"), "Si, totalmente")
// Edit de un mensaje ya enviado
evID, err = infra.MatrixEditMessage(ctx, client, roomID, id.EventID("$msg:server"), "texto corregido")
// Reaction emoji
evID, err = infra.MatrixSendReaction(ctx, client, roomID, id.EventID("$msg:server"), "👍")
```
## Cuando usarla
Llamar desde el compositor del cliente Matrix (`matrix_client_pc`) tras inicializar el cliente con `matrix_client_init`. Si el room es E2EE, llamar primero a `matrix_crypto_init` para que `client.Crypto` esté configurado — el cifrado es transparente, no requiere código extra en estas funciones.
## Gotchas
- **Markdown sanitization**: goldmark puede emitir tags HTML arbitrarios si el input los contiene. Esta función aplica `bluemonday.UGCPolicy()` + allowlist extra (`details`, `summary`, `code`, `pre`). Tags fuera de la allowlist como `<script>`, `<iframe>`, `<style>` son eliminados. El texto interno puede quedar como texto plano.
- **Edits sobre mensajes cifrados**: mautrix-go cifra el `m.new_content` también. Receivers que no tengan acceso a la session megolm no verán el edit — verán el mensaje original.
- **Reactions** son evento separado `m.reaction`, NO `m.room.message`. Algunos clientes Matrix viejos las ignoran. No se cifran aunque el room sea E2EE (limitación de mautrix-go).
- **Reply quote v0.1.0**: esta función NO inserta el texto del mensaje original en el body. Es responsabilidad del caller construir la cita si la necesita. v0.2.0 podría hacer fetch del original via state cache.
- **Edit racing**: si dos edits llegan al mismo tiempo al servidor, gana el de timestamp mayor (regla Matrix server-side). No hay protección contra races en esta función.
- **client nil**: todas las funciones validan `client != nil` y retornan error inmediato. No hacen validación del formato de `roomID` — Synapse responderá con error si es inválido.
+269
View File
@@ -0,0 +1,269 @@
package infra
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// newMXTestClient construye un *mautrix.Client apuntando al servidor httptest dado.
func newMXTestClient(t *testing.T, serverURL string) *mautrix.Client {
t.Helper()
cli, err := mautrix.NewClient(serverURL, "@testuser:example.com", "mxat_test_token")
if err != nil {
t.Fatalf("newMXTestClient: %v", err)
}
cli.DeviceID = id.DeviceID("TESTDEVICE01")
return cli
}
// mxSendHandler devuelve un http.Handler que:
// - Acepta PUT /…/rooms/{roomID}/send/{eventType}/{txnID}
// - Devuelve {"event_id": "$fakeEvent123:example.com"} con 200
// - Guarda el body JSON decodificado en bodyOut y la path en pathOut para assertions
func mxSendHandler(t *testing.T, bodyOut *map[string]interface{}, pathOut *string) http.Handler {
t.Helper()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if pathOut != nil {
*pathOut = r.URL.Path
}
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if bodyOut != nil {
var parsed map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&parsed); err != nil {
t.Errorf("mxSendHandler: json decode: %v", err)
}
*bodyOut = parsed
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"event_id":"$fakeEvent123:example.com"}`))
})
}
func TestMatrixMessageSend(t *testing.T) {
ctx := context.Background()
const roomID = "!testroom:example.com"
const wantEventID = "$fakeEvent123:example.com"
t.Run("SendText body correcto y EventID parseado", func(t *testing.T) {
var body map[string]interface{}
srv := httptest.NewServer(mxSendHandler(t, &body, nil))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
evID, err := MatrixSendText(ctx, cli, id.RoomID(roomID), "Hola mundo")
if err != nil {
t.Fatalf("MatrixSendText error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
if got := body["msgtype"]; got != "m.text" {
t.Errorf("body['msgtype']: got %v, want 'm.text'", got)
}
if got := body["body"]; got != "Hola mundo" {
t.Errorf("body['body']: got %v, want 'Hola mundo'", got)
}
})
t.Run("SendMarkdown bold convierte a HTML strong y sanitiza script", func(t *testing.T) {
var body map[string]interface{}
srv := httptest.NewServer(mxSendHandler(t, &body, nil))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
evID, err := MatrixSendMarkdown(ctx, cli, id.RoomID(roomID), "**bold**")
if err != nil {
t.Fatalf("MatrixSendMarkdown error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
// Body debe ser el markdown original como fallback
if got := body["body"]; got != "**bold**" {
t.Errorf("body['body'] fallback: got %v, want '**bold**'", got)
}
// formatted_body debe contener <strong>bold</strong>
fmtBody, ok := body["formatted_body"].(string)
if !ok {
t.Fatalf("formatted_body no es string: %v", body["formatted_body"])
}
if !strings.Contains(fmtBody, "<strong>bold</strong>") {
t.Errorf("formatted_body no contiene <strong>bold</strong>, got: %q", fmtBody)
}
// format debe ser org.matrix.custom.html
if got := body["format"]; got != "org.matrix.custom.html" {
t.Errorf("format: got %v, want 'org.matrix.custom.html'", got)
}
// Sub-test: sanitizer elimina <script>
const xssPayload = `texto <script>alert(1)</script> seguro`
var body2 map[string]interface{}
srv2 := httptest.NewServer(mxSendHandler(t, &body2, nil))
defer srv2.Close()
cli2 := newMXTestClient(t, srv2.URL)
_, err = MatrixSendMarkdown(ctx, cli2, id.RoomID(roomID), xssPayload)
if err != nil {
t.Fatalf("MatrixSendMarkdown XSS error: %v", err)
}
fmtBody2, ok := body2["formatted_body"].(string)
if !ok {
t.Fatalf("formatted_body no es string (XSS test): %v", body2["formatted_body"])
}
// El sanitizer debe eliminar el tag <script>...</script> completo.
// goldmark convierte inline HTML a texto plano antes de sanitizar,
// por lo que el texto interior puede quedar como texto plano — eso es correcto.
if strings.Contains(fmtBody2, "<script>") {
t.Errorf("formatted_body contiene <script> — sanitizer no funciono: %q", fmtBody2)
}
if strings.Contains(fmtBody2, "</script>") {
t.Errorf("formatted_body contiene </script> — sanitizer no funciono: %q", fmtBody2)
}
})
t.Run("SendReply m.relates_to m.in_reply_to presente", func(t *testing.T) {
var body map[string]interface{}
srv := httptest.NewServer(mxSendHandler(t, &body, nil))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
const parentID = "$parentEvent:example.com"
evID, err := MatrixSendReply(ctx, cli, id.RoomID(roomID), id.EventID(parentID), "ack")
if err != nil {
t.Fatalf("MatrixSendReply error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
if got := body["body"]; got != "ack" {
t.Errorf("body['body']: got %v, want 'ack'", got)
}
relatesTo, ok := body["m.relates_to"].(map[string]interface{})
if !ok {
t.Fatalf("m.relates_to no es object, got: %v", body["m.relates_to"])
}
inReplyTo, ok := relatesTo["m.in_reply_to"].(map[string]interface{})
if !ok {
t.Fatalf("m.in_reply_to no es object, got: %v", relatesTo["m.in_reply_to"])
}
if got := inReplyTo["event_id"]; got != parentID {
t.Errorf("m.in_reply_to.event_id: got %v, want %q", got, parentID)
}
})
t.Run("EditMessage rel_type m.replace y m.new_content", func(t *testing.T) {
var body map[string]interface{}
srv := httptest.NewServer(mxSendHandler(t, &body, nil))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
const originalID = "$originalEvent:example.com"
evID, err := MatrixEditMessage(ctx, cli, id.RoomID(roomID), id.EventID(originalID), "texto editado")
if err != nil {
t.Fatalf("MatrixEditMessage error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
// fallback body
if got := body["body"]; got != "* texto editado" {
t.Errorf("body['body'] fallback: got %v, want '* texto editado'", got)
}
newContent, ok := body["m.new_content"].(map[string]interface{})
if !ok {
t.Fatalf("m.new_content no es object, got: %v", body["m.new_content"])
}
if got := newContent["body"]; got != "texto editado" {
t.Errorf("m.new_content.body: got %v, want 'texto editado'", got)
}
relatesTo, ok := body["m.relates_to"].(map[string]interface{})
if !ok {
t.Fatalf("m.relates_to no es object, got: %v", body["m.relates_to"])
}
if got := relatesTo["rel_type"]; got != "m.replace" {
t.Errorf("m.relates_to.rel_type: got %v, want 'm.replace'", got)
}
if got := relatesTo["event_id"]; got != originalID {
t.Errorf("m.relates_to.event_id: got %v, want %q", got, originalID)
}
})
t.Run("SendReaction tipo m.reaction con m.annotation y key", func(t *testing.T) {
var body map[string]interface{}
var capturedPath string
srv := httptest.NewServer(mxSendHandler(t, &body, &capturedPath))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
const targetID = "$targetEvent:example.com"
evID, err := MatrixSendReaction(ctx, cli, id.RoomID(roomID), id.EventID(targetID), "👍")
if err != nil {
t.Fatalf("MatrixSendReaction error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
// URL debe contener "m.reaction"
if !strings.Contains(capturedPath, "m.reaction") {
t.Errorf("URL path no contiene 'm.reaction': %q", capturedPath)
}
relatesTo, ok := body["m.relates_to"].(map[string]interface{})
if !ok {
t.Fatalf("m.relates_to no es object, got: %v", body["m.relates_to"])
}
if got := relatesTo["rel_type"]; got != "m.annotation" {
t.Errorf("m.relates_to.rel_type: got %v, want 'm.annotation'", got)
}
if got := relatesTo["key"]; got != "👍" {
t.Errorf("m.relates_to.key: got %v, want '👍'", got)
}
if got := relatesTo["event_id"]; got != targetID {
t.Errorf("m.relates_to.event_id: got %v, want %q", got, targetID)
}
})
t.Run("SendText client nil devuelve error", func(t *testing.T) {
_, err := MatrixSendText(ctx, nil, id.RoomID(roomID), "texto")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
t.Run("SendMarkdown client nil devuelve error", func(t *testing.T) {
_, err := MatrixSendMarkdown(ctx, nil, id.RoomID(roomID), "**md**")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
t.Run("SendReply client nil devuelve error", func(t *testing.T) {
_, err := MatrixSendReply(ctx, nil, id.RoomID(roomID), "$evID:x", "reply")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
t.Run("EditMessage client nil devuelve error", func(t *testing.T) {
_, err := MatrixEditMessage(ctx, nil, id.RoomID(roomID), "$evID:x", "new")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
t.Run("SendReaction client nil devuelve error", func(t *testing.T) {
_, err := MatrixSendReaction(ctx, nil, id.RoomID(roomID), "$evID:x", "👍")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
}
+300
View File
@@ -0,0 +1,300 @@
package infra
import (
"context"
"fmt"
"log"
"sort"
"strings"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// RoomSummary es el resumen de una room Matrix para renderizar en el sidebar de un cliente.
type RoomSummary struct {
RoomID string `json:"room_id"`
Name string `json:"name,omitempty"` // m.room.name o fallback
CanonicalAlias string `json:"canonical_alias,omitempty"` // #room:server
AvatarMxc string `json:"avatar_mxc,omitempty"` // mxc://...
Topic string `json:"topic,omitempty"`
IsDirect bool `json:"is_direct"` // m.direct account_data
IsSpace bool `json:"is_space"` // m.room.type == m.space
IsEncrypted bool `json:"is_encrypted"` // m.room.encryption state event presente
MemberCount int `json:"member_count"`
LastEventTs int64 `json:"last_event_ts"` // unix ms del ultimo evento conocido
UnreadCount int `json:"unread_count"` // notifications.unread + highlight
Tags []string `json:"tags,omitempty"` // m.tag account_data
}
// MatrixRoomListConfig agrupa los parametros de MatrixRoomList.
type MatrixRoomListConfig struct {
Client *mautrix.Client
}
// MatrixRoomList devuelve todos los rooms en los que el usuario esta unido,
// ordenados por LastEventTs DESC (recientes primero).
//
// Estrategia:
// 1. JoinedRooms() para la lista de room IDs.
// 2. m.direct account_data para detectar DMs.
// 3. Para cada room: State() -> nombre, alias, topic, avatar, encryption, space, members.
// 4. Messages(limit=1) -> LastEventTs (TODO: coste N*HTTP; cachear con TTL 30s).
// 5. GetRoomAccountData("m.tag") -> Tags.
//
// Sub-operaciones que fallan por room concreto no abortan el global.
// LastEventTs puede ser 0 si el store no lo cachea (ver ## Gotchas del .md).
func MatrixRoomList(ctx context.Context, cfg MatrixRoomListConfig) ([]RoomSummary, error) {
if cfg.Client == nil {
return nil, fmt.Errorf("matrix_room_list: client no puede ser nil")
}
client := cfg.Client
// 1. Rooms unidos
respJoined, err := client.JoinedRooms(ctx)
if err != nil {
return nil, fmt.Errorf("matrix_room_list: JoinedRooms: %w", err)
}
if len(respJoined.JoinedRooms) == 0 {
return []RoomSummary{}, nil
}
// 2. m.direct -> set roomID -> true
directSet := loadDirectRooms(ctx, client)
// 3. Construir summaries (secuencial para v0.1.0)
results := make([]RoomSummary, 0, len(respJoined.JoinedRooms))
for _, roomID := range respJoined.JoinedRooms {
s := buildRoomSummaryFromState(ctx, client, roomID, directSet)
results = append(results, s)
}
// 4. Ordenar DESC por LastEventTs; si empatan (ej. todo 0) -> alfabetico por Name
sort.Slice(results, func(i, j int) bool {
if results[i].LastEventTs != results[j].LastEventTs {
return results[i].LastEventTs > results[j].LastEventTs
}
return results[i].Name < results[j].Name
})
return results, nil
}
// loadDirectRooms carga m.direct account_data y devuelve un set roomID -> true.
// Falla silenciosamente: si hay error devuelve mapa vacio (IsDirect quedara false).
func loadDirectRooms(ctx context.Context, client *mautrix.Client) map[id.RoomID]bool {
result := make(map[id.RoomID]bool)
var directContent event.DirectChatsEventContent
if err := client.GetAccountData(ctx, "m.direct", &directContent); err != nil {
log.Printf("matrix_room_list: GetAccountData(m.direct) warning: %v", err)
return result
}
for _, rooms := range directContent {
for _, rid := range rooms {
result[rid] = true
}
}
return result
}
// buildRoomSummaryFromState construye el RoomSummary para un room concreto.
// Si State() falla usa el roomID como Name de emergencia.
func buildRoomSummaryFromState(ctx context.Context, client *mautrix.Client, roomID id.RoomID, directSet map[id.RoomID]bool) RoomSummary {
s := RoomSummary{
RoomID: string(roomID),
IsDirect: directSet[roomID],
}
// State del room
stateMap, err := client.State(ctx, roomID)
if err != nil {
log.Printf("matrix_room_list: State(%s) warning: %v", roomID, err)
s.Name = deriveRoomName(&s, nil)
return s
}
fillStateFields(&s, stateMap)
s.Name = deriveRoomName(&s, stateMap)
// Tags: m.tag room account_data
s.Tags = loadRoomTags(ctx, client, roomID)
// LastEventTs: Messages(limit=1, dir=backward)
// TODO(0148): caro N*HTTP -> cachear en backend con TTL 30s.
msgs, err := client.Messages(ctx, roomID, "", "", mautrix.DirectionBackward, nil, 1)
if err != nil {
log.Printf("matrix_room_list: Messages(%s) warning: %v", roomID, err)
// No fatal: LastEventTs queda 0 y el room cae al fondo del orden
} else if msgs != nil && len(msgs.Chunk) > 0 {
s.LastEventTs = msgs.Chunk[0].Timestamp
}
return s
}
// ensureParsed llama ParseRaw si el contenido no esta aun parseado.
// ParseRaw devuelve ErrContentAlreadyParsed cuando ya fue parseado (p.ej.
// por parseRoomStateArray al deserializar el state); en ese caso ignoramos
// el error y usamos el Parsed existente.
func ensureParsed(c *event.Content, evtType event.Type) {
if c.Parsed == nil {
_ = c.ParseRaw(evtType)
}
}
// fillStateFields rellena los campos del RoomSummary a partir del state map.
// parseRoomStateArray ya llama ParseRaw al deserializar, por lo que es posible
// que Content.Parsed este ya populado. ensureParsed maneja ambos casos.
func fillStateFields(s *RoomSummary, stateMap mautrix.RoomStateMap) {
// m.room.name
if nameEvts, ok := stateMap[event.StateRoomName]; ok {
if nameEvt, ok := nameEvts[""]; ok {
ensureParsed(&nameEvt.Content, event.StateRoomName)
if c := nameEvt.Content.AsRoomName(); c != nil {
s.Name = c.Name
}
}
}
// m.room.canonical_alias
if aliasEvts, ok := stateMap[event.StateCanonicalAlias]; ok {
if aliasEvt, ok := aliasEvts[""]; ok {
ensureParsed(&aliasEvt.Content, event.StateCanonicalAlias)
if c := aliasEvt.Content.AsCanonicalAlias(); c != nil {
s.CanonicalAlias = string(c.Alias)
}
}
}
// m.room.avatar
if avatarEvts, ok := stateMap[event.StateRoomAvatar]; ok {
if avatarEvt, ok := avatarEvts[""]; ok {
ensureParsed(&avatarEvt.Content, event.StateRoomAvatar)
if c := avatarEvt.Content.AsRoomAvatar(); c != nil {
s.AvatarMxc = string(c.URL)
}
}
}
// m.room.topic
if topicEvts, ok := stateMap[event.StateTopic]; ok {
if topicEvt, ok := topicEvts[""]; ok {
ensureParsed(&topicEvt.Content, event.StateTopic)
if c := topicEvt.Content.AsTopic(); c != nil {
s.Topic = c.Topic
}
}
}
// m.room.encryption (existence = encrypted)
if encEvts, ok := stateMap[event.StateEncryption]; ok {
if _, ok := encEvts[""]; ok {
s.IsEncrypted = true
}
}
// m.room.create -> IsSpace si type == "m.space"
if createEvts, ok := stateMap[event.StateCreate]; ok {
if createEvt, ok := createEvts[""]; ok {
ensureParsed(&createEvt.Content, event.StateCreate)
if c := createEvt.Content.AsCreate(); c != nil {
s.IsSpace = c.Type == event.RoomTypeSpace
}
}
}
// m.room.member: contar membership == join
if memberEvts, ok := stateMap[event.StateMember]; ok {
count := 0
for _, memberEvt := range memberEvts {
ensureParsed(&memberEvt.Content, event.StateMember)
if c := memberEvt.Content.AsMember(); c != nil && c.Membership == event.MembershipJoin {
count++
}
}
s.MemberCount = count
}
}
// deriveRoomName calcula el nombre display para el room siguiendo la jerarquia:
// 1. Name (ya seteado desde m.room.name).
// 2. CanonicalAlias.
// 3. "Direct Message" si IsDirect.
// 4. Lista de otros miembros si los hay (max 3).
// 5. "Empty room" si MemberCount <= 1.
func deriveRoomName(s *RoomSummary, stateMap mautrix.RoomStateMap) string {
if s.Name != "" {
return s.Name
}
if s.CanonicalAlias != "" {
return s.CanonicalAlias
}
if s.IsDirect {
// Intentar obtener displayname del otro miembro desde el state
if stateMap != nil {
if memberEvts, ok := stateMap[event.StateMember]; ok {
for userKey, memberEvt := range memberEvts {
ensureParsed(&memberEvt.Content, event.StateMember)
if c := memberEvt.Content.AsMember(); c != nil &&
c.Membership == event.MembershipJoin &&
userKey != "" {
if c.Displayname != "" {
return c.Displayname
}
return userKey // user ID como fallback
}
}
}
}
return "Direct Message"
}
if stateMap != nil && s.MemberCount > 1 {
// Lista de displaynames de otros miembros (max 3)
names := collectMemberNames(stateMap, 3)
if len(names) > 0 {
return strings.Join(names, ", ")
}
}
return "Empty room"
}
// collectMemberNames extrae hasta maxN displaynames de joined members del state.
func collectMemberNames(stateMap mautrix.RoomStateMap, maxN int) []string {
names := make([]string, 0, maxN)
if memberEvts, ok := stateMap[event.StateMember]; ok {
for userKey, memberEvt := range memberEvts {
if len(names) >= maxN {
break
}
ensureParsed(&memberEvt.Content, event.StateMember)
if c := memberEvt.Content.AsMember(); c != nil && c.Membership == event.MembershipJoin {
if c.Displayname != "" {
names = append(names, c.Displayname)
} else if userKey != "" {
names = append(names, userKey)
}
}
}
}
return names
}
// loadRoomTags carga m.tag room account_data y devuelve los tag names como []string.
// Falla silenciosamente devolviendo nil.
func loadRoomTags(ctx context.Context, client *mautrix.Client, roomID id.RoomID) []string {
var tagContent event.TagEventContent
if err := client.GetRoomAccountData(ctx, roomID, "m.tag", &tagContent); err != nil {
// No fatal: rooms sin tags dan 404, lo cual es normal
return nil
}
if len(tagContent.Tags) == 0 {
return nil
}
tags := make([]string, 0, len(tagContent.Tags))
for tag := range tagContent.Tags {
tags = append(tags, string(tag))
}
sort.Strings(tags) // orden determinista
return tags
}
+65
View File
@@ -0,0 +1,65 @@
---
name: matrix_room_list
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func MatrixRoomList(ctx context.Context, cfg MatrixRoomListConfig) ([]RoomSummary, error)"
description: "Devuelve la lista de rooms Matrix en los que el usuario esta unido con metadata completa (nombre, alias, avatar, topic, encryption, space, DM, tags), ordenada por LastEventTs DESC."
tags: ["matrix", "mautrix", "rooms", "summary", "state", "infra", "matrix-mas"]
params:
- name: ctx
desc: "Context de la llamada. Cancela todas las HTTP requests en curso si se cancela."
- name: cfg.Client
desc: "Cliente mautrix autenticado. Debe haber completado al menos un Sync para que JoinedRooms devuelva datos frescos. No puede ser nil."
output: "[]RoomSummary ordenado por LastEventTs DESC (rooms mas recientes primero). Si LastEventTs es 0 para todos, ordena alfabeticamente por Name."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
tested: true
tests:
- "3 rooms devueltos con metadata correcta"
- "1 room sin m.room.name usa fallback name"
- "IsDirect set correctamente segun m.direct"
- "IsEncrypted set segun presencia de m.room.encryption"
- "client nil devuelve error"
test_file_path: "functions/infra/matrix_room_list_test.go"
file_path: "functions/infra/matrix_room_list.go"
---
## Ejemplo
```go
rooms, err := MatrixRoomList(ctx, MatrixRoomListConfig{Client: client})
if err != nil {
log.Fatal(err)
}
for _, r := range rooms {
fmt.Printf("%s [%s] enc=%v dm=%v members=%d\n",
r.Name, r.RoomID, r.IsEncrypted, r.IsDirect, r.MemberCount)
}
// Output ejemplo:
// General [!abc:server] enc=true dm=false members=12
// Alice [!xyz:server] enc=true dm=true members=2
```
## Cuando usarla
Usar tras al menos un Sync completado, para poblar el sidebar de rooms en la UI. Llamar periodicamente con un TTL de 30s o tras recibir eventos `m.room.*` / `m.direct` en el sync stream. Ideal para el panel lateral de `matrix_client_pc` y `admin_panel`.
## Gotchas
- **Costoso si muchos rooms**: cada room genera 3+ HTTP calls (State, Messages, m.tag). Para N=50 rooms son ~150 HTTP calls. Cachear en el backend con TTL 30s antes de exponer al frontend.
- **Sin sync previo**: si se llama antes del primer Sync completado, `JoinedRooms` puede devolver lista vacia o stale. Siempre hacer Sync primero.
- **LastEventTs puede ser 0**: mautrix Store en memoria no persiste el timestamp del ultimo evento. Si el store es en memoria (default), `Messages(limit=1)` hace una HTTP call extra por room. Si `LastEventTs == 0`, el room cae al fondo del orden (orden alfabetico por Name como desempate).
- **UnreadCount siempre 0 en v0.1.0**: los notification counters vienen del Sync response, no de la API de state. TODO: obtenerlos del Syncer internamente.
- **Spaces planos**: esta funcion devuelve joined rooms planos. No enumera recursivamente los children de un Space. Para arbol de Space, implementar funcion separada.
- **Content.ParseRaw idempotente**: mautrix `parseRoomStateArray` llama `ParseRaw` al deserializar el state. La funcion usa `ensureParsed` que es no-op si ya esta parseado.
- **IsDirect puede ser false si m.direct no esta sincronizado**: algunas implementaciones de Synapse no sincronizan `m.direct` inmediatamente. Si IsDirect es incorrecto, hacer un Sync completo primero.
+339
View File
@@ -0,0 +1,339 @@
package infra
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// matrixTestServer simula las respuestas de Synapse para MatrixRoomList.
// Los room IDs contienen '!' que mautrix URL-codifica como %21 en el path;
// los handlers lo decodifican antes de hacer lookup.
type matrixTestServer struct {
*httptest.Server
joinedRooms []string // room IDs que devuelve /joined_rooms
roomNames map[string]string // roomID -> name (no seteado = sin m.room.name)
encryptedRooms map[string]bool // roomID -> tiene encryption event
directContent map[string][]string // userID -> []roomID
roomTags map[string][]string // roomID -> []tag names
}
func newMatrixTestServer(t *testing.T) *matrixTestServer {
t.Helper()
ts := &matrixTestServer{
joinedRooms: []string{},
roomNames: map[string]string{},
encryptedRooms: map[string]bool{},
directContent: map[string][]string{},
roomTags: map[string][]string{},
}
mux := http.NewServeMux()
// GET /_matrix/client/v3/joined_rooms
mux.HandleFunc("/_matrix/client/v3/joined_rooms", func(w http.ResponseWriter, r *http.Request) {
rooms := make([]string, len(ts.joinedRooms))
copy(rooms, ts.joinedRooms)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"joined_rooms": rooms})
})
// Prefix handler para /rooms/ y /user/
mux.HandleFunc("/_matrix/", func(w http.ResponseWriter, r *http.Request) {
// URL-decode el path completo para manejar %21 -> !
rawPath := r.URL.Path
decodedPath, err := url.PathUnescape(rawPath)
if err != nil {
decodedPath = rawPath
}
w.Header().Set("Content-Type", "application/json")
switch {
// /user/{uid}/account_data/m.direct
case strings.Contains(decodedPath, "/account_data/m.direct") && strings.Contains(decodedPath, "/user/"):
json.NewEncoder(w).Encode(ts.directContent)
// /rooms/{roomId}/state (full state array)
case strings.Contains(decodedPath, "/rooms/") && strings.HasSuffix(decodedPath, "/state"):
roomID := extractRoomIDFromPath(decodedPath, "/state")
ts.serveFullState(w, roomID)
// /rooms/{roomId}/messages
case strings.Contains(decodedPath, "/rooms/") && strings.Contains(decodedPath, "/messages"):
// Devolver chunk vacio para simplificar (LastEventTs = 0)
json.NewEncoder(w).Encode(map[string]any{
"chunk": []any{},
"start": "",
})
// /rooms/{roomId}/account_data/m.tag
case strings.Contains(decodedPath, "/rooms/") && strings.Contains(decodedPath, "/account_data/m.tag"):
roomID := extractRoomIDFromPath(decodedPath, "/account_data")
tags, ok := ts.roomTags[roomID]
if !ok || len(tags) == 0 {
http.NotFound(w, r)
return
}
tagMap := make(map[string]any)
for _, tag := range tags {
tagMap[tag] = map[string]any{}
}
json.NewEncoder(w).Encode(map[string]any{"tags": tagMap})
default:
http.NotFound(w, r)
}
})
srv := httptest.NewServer(mux)
ts.Server = srv
t.Cleanup(srv.Close)
return ts
}
// extractRoomIDFromPath extrae el roomID de /...rooms/{roomId}/{suffix}.
// suffix debe empezar con "/" (ej. "/state", "/account_data").
func extractRoomIDFromPath(path, suffix string) string {
// Encontrar el segmento entre /rooms/ y suffix
roomsIdx := strings.Index(path, "/rooms/")
if roomsIdx < 0 {
return ""
}
after := path[roomsIdx+len("/rooms/"):]
suffixIdx := strings.Index(after, suffix)
if suffixIdx < 0 {
// suffix no encontrado -> el roomID es lo que queda
return after
}
return after[:suffixIdx]
}
// serveFullState construye y escribe el array de state events para el room.
func (ts *matrixTestServer) serveFullState(w http.ResponseWriter, roomID string) {
events := []map[string]any{}
// m.room.name (si existe)
if name, ok := ts.roomNames[roomID]; ok && name != "" {
events = append(events, map[string]any{
"type": "m.room.name",
"state_key": "",
"content": map[string]any{"name": name},
"event_id": "$name",
"sender": "@bot:test",
"room_id": roomID,
})
}
// m.room.create (sin space)
events = append(events, map[string]any{
"type": "m.room.create",
"state_key": "",
"content": map[string]any{"room_version": "9"},
"event_id": "$create",
"sender": "@user:test",
"room_id": roomID,
})
// m.room.member: dos joined members
events = append(events, map[string]any{
"type": "m.room.member",
"state_key": "@alice:test",
"content": map[string]any{"membership": "join", "displayname": "Alice"},
"event_id": "$member1",
"sender": "@alice:test",
"room_id": roomID,
})
events = append(events, map[string]any{
"type": "m.room.member",
"state_key": "@bob:test",
"content": map[string]any{"membership": "join", "displayname": "Bob"},
"event_id": "$member2",
"sender": "@bob:test",
"room_id": roomID,
})
// m.room.encryption (si aplica)
if ts.encryptedRooms[roomID] {
events = append(events, map[string]any{
"type": "m.room.encryption",
"state_key": "",
"content": map[string]any{"algorithm": "m.megolm.v1.aes-sha2"},
"event_id": "$enc",
"sender": "@alice:test",
"room_id": roomID,
})
}
json.NewEncoder(w).Encode(events)
}
// newTestClient crea un cliente mautrix apuntando al servidor httptest.
func newTestClient(t *testing.T, srv *matrixTestServer) *mautrix.Client {
t.Helper()
cli, err := mautrix.NewClient(srv.URL, id.UserID("@user:test"), "test_token")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
return cli
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// Test 1: 3 rooms devueltos con metadata correcta.
func TestMatrixRoomList_ThreeRoomsMetadata(t *testing.T) {
t.Run("3 rooms devueltos con metadata correcta", func(t *testing.T) {
srv := newMatrixTestServer(t)
srv.joinedRooms = []string{"!room1:test", "!room2:test", "!room3:test"}
srv.roomNames = map[string]string{
"!room1:test": "General",
"!room2:test": "Engineering",
"!room3:test": "Random",
}
cli := newTestClient(t, srv)
rooms, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: cli})
if err != nil {
t.Fatalf("MatrixRoomList: %v", err)
}
if len(rooms) != 3 {
t.Fatalf("got %d rooms, want 3", len(rooms))
}
nameSet := map[string]bool{}
for _, r := range rooms {
nameSet[r.Name] = true
if r.RoomID == "" {
t.Error("RoomID vacio en algun room")
}
// State simulado tiene 2 joined members (alice + bob)
if r.MemberCount != 2 {
t.Errorf("room %s: got MemberCount=%d, want 2", r.RoomID, r.MemberCount)
}
}
for _, want := range []string{"General", "Engineering", "Random"} {
if !nameSet[want] {
t.Errorf("nombre %q no encontrado en rooms", want)
}
}
})
}
// Test 2: room sin m.room.name -> fallback name no vacio.
func TestMatrixRoomList_FallbackName(t *testing.T) {
t.Run("1 room sin m.room.name usa fallback name", func(t *testing.T) {
srv := newMatrixTestServer(t)
srv.joinedRooms = []string{"!noname:test"}
// No registramos nombre para !noname:test
cli := newTestClient(t, srv)
rooms, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: cli})
if err != nil {
t.Fatalf("MatrixRoomList: %v", err)
}
if len(rooms) != 1 {
t.Fatalf("got %d rooms, want 1", len(rooms))
}
r := rooms[0]
if r.Name == "" {
t.Error("Name no debe ser vacio tras fallback")
}
t.Logf("fallback name para room sin m.room.name: %q", r.Name)
})
}
// Test 3: IsDirect set correctamente segun m.direct.
func TestMatrixRoomList_IsDirect(t *testing.T) {
t.Run("IsDirect set correctamente segun m.direct", func(t *testing.T) {
srv := newMatrixTestServer(t)
srv.joinedRooms = []string{"!dm:test", "!group:test"}
srv.roomNames = map[string]string{
"!dm:test": "Alice DM",
"!group:test": "Team channel",
}
// m.direct: !dm:test es DM con @alice:test
srv.directContent = map[string][]string{
"@alice:test": {"!dm:test"},
}
cli := newTestClient(t, srv)
rooms, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: cli})
if err != nil {
t.Fatalf("MatrixRoomList: %v", err)
}
if len(rooms) != 2 {
t.Fatalf("got %d rooms, want 2", len(rooms))
}
for _, r := range rooms {
switch r.RoomID {
case "!dm:test":
if !r.IsDirect {
t.Errorf("!dm:test: IsDirect debe ser true")
}
case "!group:test":
if r.IsDirect {
t.Errorf("!group:test: IsDirect debe ser false")
}
}
}
})
}
// Test 4: IsEncrypted set segun presencia de m.room.encryption.
func TestMatrixRoomList_IsEncrypted(t *testing.T) {
t.Run("IsEncrypted set segun presencia de m.room.encryption", func(t *testing.T) {
srv := newMatrixTestServer(t)
srv.joinedRooms = []string{"!encrypted:test", "!plain:test"}
srv.roomNames = map[string]string{
"!encrypted:test": "Encrypted room",
"!plain:test": "Plain room",
}
srv.encryptedRooms = map[string]bool{
"!encrypted:test": true,
}
cli := newTestClient(t, srv)
rooms, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: cli})
if err != nil {
t.Fatalf("MatrixRoomList: %v", err)
}
if len(rooms) != 2 {
t.Fatalf("got %d rooms, want 2", len(rooms))
}
for _, r := range rooms {
switch r.RoomID {
case "!encrypted:test":
if !r.IsEncrypted {
t.Errorf("!encrypted:test: IsEncrypted debe ser true")
}
case "!plain:test":
if r.IsEncrypted {
t.Errorf("!plain:test: IsEncrypted debe ser false")
}
}
}
})
}
// Test 5: client nil -> error.
func TestMatrixRoomList_NilClient(t *testing.T) {
t.Run("client nil devuelve error", func(t *testing.T) {
_, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: nil})
if err == nil {
t.Fatal("se esperaba error para client nil, got nil")
}
if !strings.Contains(err.Error(), "nil") {
t.Errorf("el error deberia mencionar nil, got: %v", err)
}
})
}
+366
View File
@@ -0,0 +1,366 @@
package infra
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// MatrixSyncEvent es un evento normalizado emitido por MatrixSyncService.
// Cubre mensajes, pertenencia a sala, redacciones, reacciones, tipeo y estado.
type MatrixSyncEvent struct {
Type string `json:"type"` // "message" | "membership" | "redaction" | "reaction" | "edit" | "encrypted" | "presence" | "typing" | "read_receipt" | "room_state"
RoomID string `json:"room_id"` // ID de la sala (vacio para presencia global)
EventID string `json:"event_id"` // event_id unico Matrix (vacio para eventos efimeros)
Sender string `json:"sender"` // MXID del emisor (vacio para eventos efimeros)
Ts int64 `json:"ts"` // origin_server_ts en milisegundos
Body string `json:"body,omitempty"` // contenido de texto del evento (mensajes)
Raw interface{} `json:"raw,omitempty"` // *event.Event original para acceso completo
}
// MatrixSyncServiceConfig configura el servicio de sync loop de Matrix.
type MatrixSyncServiceConfig struct {
// Client es el *mautrix.Client ya inicializado con credenciales.
// Obligatorio.
Client *mautrix.Client
// InitialBackoffMS es el tiempo inicial de espera entre reintentos tras error (ms).
// Default: 1000 (1 segundo).
InitialBackoffMS int
// MaxBackoffMS es el techo del backoff exponencial (ms).
// Default: 60000 (60 segundos).
MaxBackoffMS int
// ChannelBuffer es la capacidad del canal Events.
// Si el consumer va lento y el buffer se llena, el sync se bloquea hasta
// que el consumer drene. Default: 256.
ChannelBuffer int
}
// MatrixSyncServiceHandle es el handle devuelto por MatrixSyncService.
type MatrixSyncServiceHandle struct {
// Events es el canal de eventos normalizados (cierra al Stop).
Events <-chan MatrixSyncEvent
// Errors recibe errores transitorios (red, 5xx, etc.).
// No fatal: el servicio reintenta con backoff. El caller decide si actuar.
// El canal cierra al Stop.
Errors <-chan error
// Stop cancela el sync loop de forma limpia e idempotente.
// Cierra Events y Errors. Seguro llamar varias veces.
Stop func()
}
// matrixSyncerWrapper envuelve DefaultSyncer para interceptar OnFailedSync
// e inyectar nuestro backoff exponencial y emision de errores al canal.
type matrixSyncerWrapper struct {
*mautrix.DefaultSyncer
errCh chan<- error
innerCtx context.Context
backoffMs *int
initialMS int
maxMS int
lastSyncOK *time.Time
}
// OnFailedSync implementa mautrix.Syncer. Emite el error al canal y devuelve
// el proximo backoff. Para errores fatales (401, M_FORBIDDEN) devuelve el
// backoff maximo y emite al canal — el caller decide via Stop().
func (w *matrixSyncerWrapper) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
if w.innerCtx.Err() != nil {
return 0, fmt.Errorf("matrix_sync_service: context cancelado")
}
// Emitir error al canal de forma no-bloqueante
select {
case w.errCh <- fmt.Errorf("matrix_sync_service: %w", err):
default:
}
// Reset backoff si el ultimo sync exitoso fue reciente
if time.Since(*w.lastSyncOK) < 30*time.Second {
*w.backoffMs = w.initialMS
}
// Calcular duracion de espera
wait := time.Duration(*w.backoffMs) * time.Millisecond
// Backoff exponencial con techo
*w.backoffMs *= 2
if *w.backoffMs > w.maxMS {
*w.backoffMs = w.maxMS
}
// Para errores fatales, esperar el maximo pero no retornar error
// (dejamos al caller decidir via Stop)
if isFatalMatrixError(err) {
return time.Duration(w.maxMS) * time.Millisecond, nil
}
return wait, nil
}
// GetFilterJSON delega al DefaultSyncer.
func (w *matrixSyncerWrapper) GetFilterJSON(userID id.UserID) *mautrix.Filter {
return w.DefaultSyncer.GetFilterJSON(userID)
}
// ProcessResponse delega al DefaultSyncer. Actualiza lastSyncOK en exito.
func (w *matrixSyncerWrapper) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
err := w.DefaultSyncer.ProcessResponse(ctx, resp, since)
if err == nil {
now := time.Now()
*w.lastSyncOK = now
}
return err
}
// MatrixSyncService arranca el sync loop de mautrix contra Synapse en background.
// Registra handlers para los tipos de evento mas comunes y los emite via canal.
// Implementa reconnect con backoff exponencial para errores transitorios.
//
// Requiere un *mautrix.Client ya inicializado (ver matrix_client_init).
// Opcionalmente combinar con matrix_crypto_init para descifrar m.room.encrypted.
//
// La goroutine interna vive hasta que ctx sea cancelado o se llame Stop.
// Ambas acciones cierran los canales Events y Errors.
func MatrixSyncService(ctx context.Context, cfg MatrixSyncServiceConfig) (*MatrixSyncServiceHandle, error) {
if cfg.Client == nil {
return nil, fmt.Errorf("matrix_sync_service: Client no puede ser nil")
}
// Aplicar defaults
initialBackoff := cfg.InitialBackoffMS
if initialBackoff <= 0 {
initialBackoff = 1000
}
maxBackoff := cfg.MaxBackoffMS
if maxBackoff <= 0 {
maxBackoff = 60000
}
bufSize := cfg.ChannelBuffer
if bufSize <= 0 {
bufSize = 256
}
// Context cancelable derivado del pasado
innerCtx, cancel := context.WithCancel(ctx)
// Channels
evtCh := make(chan MatrixSyncEvent, bufSize)
errCh := make(chan error, 8)
// Stop idempotente via sync.Once
var once sync.Once
stopFn := func() {
once.Do(func() {
cancel()
})
}
// Estado de backoff compartido con el wrapper
backoffMs := initialBackoff
lastSyncOK := time.Now()
// Configurar el Syncer: usar DefaultSyncer base (existente o nuevo)
var baseSyncer *mautrix.DefaultSyncer
if ds, ok := cfg.Client.Syncer.(*mautrix.DefaultSyncer); ok {
baseSyncer = ds
} else {
baseSyncer = mautrix.NewDefaultSyncer()
}
// Crear wrapper que intercepta OnFailedSync
wrapper := &matrixSyncerWrapper{
DefaultSyncer: baseSyncer,
errCh: errCh,
innerCtx: innerCtx,
backoffMs: &backoffMs,
initialMS: initialBackoff,
maxMS: maxBackoff,
lastSyncOK: &lastSyncOK,
}
cfg.Client.Syncer = wrapper
// Helper: emitir evento de forma no-bloqueante respetando ctx
emit := func(ev MatrixSyncEvent) {
select {
case evtCh <- ev:
case <-innerCtx.Done():
}
}
// Helper: extraer body de texto de Content.VeryRaw
extractBody := func(evt *event.Event) string {
raw := evt.Content.VeryRaw
if raw == nil {
return ""
}
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
return ""
}
if b, ok := m["body"].(string); ok {
return b
}
return ""
}
// Registrar event handlers sobre el DefaultSyncer base
// m.room.message — mensajes de texto, imagen, archivo
baseSyncer.OnEventType(event.EventMessage, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "message",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Body: extractBody(evt),
Raw: evt,
})
})
// m.room.encrypted — mensajes cifrados (crypto helper los descifra si esta init)
baseSyncer.OnEventType(event.EventEncrypted, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "encrypted",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.room.redaction — redacciones de mensajes
baseSyncer.OnEventType(event.EventRedaction, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "redaction",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.reaction — reacciones emoji
baseSyncer.OnEventType(event.EventReaction, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "reaction",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.room.member — cambios de pertenencia a sala
baseSyncer.OnEventType(event.StateMember, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "membership",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.typing — efimero: quien esta escribiendo en una sala
baseSyncer.OnEventType(event.EphemeralEventTyping, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "typing",
RoomID: evt.RoomID.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.receipt — read receipts
baseSyncer.OnEventType(event.EphemeralEventReceipt, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "read_receipt",
RoomID: evt.RoomID.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.presence — presencia de usuarios
baseSyncer.OnEventType(event.EphemeralEventPresence, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "presence",
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// Goroutine principal
// SyncWithContext ya es un loop bloqueante que incluye retry via OnFailedSync.
// Esta goroutine solo reinicia si SyncWithContext retorna con error inesperado.
go func() {
defer func() {
cancel()
close(evtCh)
close(errCh)
}()
for {
select {
case <-innerCtx.Done():
return
default:
}
err := cfg.Client.SyncWithContext(innerCtx)
// ctx cancelado = salida limpia
if innerCtx.Err() != nil {
return
}
// SyncWithContext retorna nil si otro Sync() lo cancelo
if err == nil {
return
}
// Cualquier otro error: pequeno delay antes de reiniciar
select {
case <-innerCtx.Done():
return
case <-time.After(time.Duration(initialBackoff) * time.Millisecond):
}
}
}()
return &MatrixSyncServiceHandle{
Events: evtCh,
Errors: errCh,
Stop: stopFn,
}, nil
}
// isFatalMatrixError devuelve true si el error indica que no tiene sentido
// reintentar (token invalido, forbidden).
func isFatalMatrixError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "M_UNKNOWN_TOKEN") ||
strings.Contains(msg, "M_FORBIDDEN") ||
strings.Contains(msg, "401")
}
+79
View File
@@ -0,0 +1,79 @@
---
name: matrix_sync_service
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func MatrixSyncService(ctx context.Context, cfg MatrixSyncServiceConfig) (*MatrixSyncServiceHandle, error)"
description: "Arranca el sync loop de mautrix contra Synapse en background con backoff exponencial, emite eventos Matrix normalizados via canal Go y expone funcion de stop idempotente."
tags: [matrix, mautrix, sync, longpoll, reconnect, goroutine, channels, infra, matrix-mas]
params:
- name: ctx
desc: "Context padre. Si se cancela, la goroutine sale limpiamente y cierra los channels."
- name: cfg.Client
desc: "*mautrix.Client ya inicializado con credenciales (HomeserverURL, AccessToken, UserID). Usar matrix_client_init para crearlo. Obligatorio."
- name: cfg.InitialBackoffMS
desc: "Milisegundos de espera inicial entre reintentos tras error de sync. Default: 1000 (1s)."
- name: cfg.MaxBackoffMS
desc: "Techo del backoff exponencial en ms. Default: 60000 (60s)."
- name: cfg.ChannelBuffer
desc: "Capacidad del buffer del canal Events. Si el consumer va lento y el buffer se llena, el sync se bloquea hasta que el consumer drene. Default: 256."
output: "*MatrixSyncServiceHandle con Events <-chan MatrixSyncEvent (canal de eventos normalizados), Errors <-chan error (errores transitorios no fatales), Stop func() (cancela y cierra todo, idempotente)."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
tested: true
tests:
- "RecibeMensajeYStop"
- "BackoffRecovery"
- "Error401NoExit"
- "StopIdempotente"
- "CtxCancelCierraChannels"
test_file_path: "functions/infra/matrix_sync_service_test.go"
file_path: "functions/infra/matrix_sync_service.go"
---
## Ejemplo
```go
ctx := context.Background()
h, err := MatrixSyncService(ctx, MatrixSyncServiceConfig{
Client: client, // *mautrix.Client de matrix_client_init
})
if err != nil {
panic(err)
}
defer h.Stop()
// Consumir errores transitorios en goroutine separada
go func() {
for e := range h.Errors {
log.Println("matrix sync error:", e)
}
}()
// Loop de eventos (bloquea hasta que h.Stop() se llame o ctx sea cancelado)
for ev := range h.Events {
fmt.Printf("[%s] %s: %s\n", ev.Type, ev.Sender, ev.Body)
}
```
## Cuando usarla
Usar despues de `MatrixClientInit` (y opcionalmente `MatrixCryptoInit`) para recibir el stream de eventos de Matrix en tiempo real. Es el servicio long-running central de cualquier cliente Matrix: matrix_client_pc, admin_panel, bots, monitores. Un solo `MatrixSyncService` por client, durante toda la vida de la aplicacion.
## Gotchas
- **Solo UN Sync por client**: dos goroutines llamando `SyncWithContext` simultaneamente sobre el mismo client rompe el `since` token y produce duplicados o perdidas. Esta funcion garantiza una sola goroutine de sync si es llamada una sola vez. NO llamar `MatrixSyncService` dos veces sobre el mismo `*mautrix.Client`.
- **Crypto antes del Sync**: mensajes `m.room.encrypted` que llegan antes de inicializar `MatrixCryptoInit` quedan sin descifrar (emitidos con `Type:"encrypted"`, `Body:""`, `Raw:*event.Event`). Inicializar crypto siempre ANTES de llamar a esta funcion.
- **Buffer de channel**: si el consumer no drena `Events` con suficiente rapidez, el sync se bloquea en el punto de emision. Synapse puede acumular deltas. Mantener el consumer rapido o aumentar `ChannelBuffer`.
- **Errores fatales (401/M_UNKNOWN_TOKEN)**: no cierran el servicio automaticamente — se emiten a `Errors` y el servicio espera con backoff maximo. El caller decide llamar `Stop()` y re-autenticar.
- **Stop idempotente**: llamar `Stop()` multiples veces es seguro; no causa panic.
- **Build tag**: el paquete `infra` requiere `-tags goolm` para compilar tests sin libolm (dependencia C de la crypto de mautrix). Los tests usan `//go:build goolm`.
+313
View File
@@ -0,0 +1,313 @@
//go:build goolm
package infra
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// fakeSynapseServer crea un httptest.Server que simula Synapse para tests de sync.
// syncHandler recibe el numero de llamada /sync (1-indexed) y devuelve la respuesta.
// nil response significa bloquear hasta ctx cancelado.
func fakeSynapseServer(t *testing.T, syncFn func(call int, w http.ResponseWriter, r *http.Request)) *httptest.Server {
t.Helper()
var callCount int32
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/_matrix/client/v3/user/@alice:localhost/filter":
// mautrix necesita este endpoint para guardar el filtro; responder con un filter_id
_ = json.NewEncoder(w).Encode(map[string]interface{}{"filter_id": "f1"})
case r.URL.Path == "/_matrix/client/v3/sync" || r.URL.Path == "/_matrix/client/r0/sync":
n := int(atomic.AddInt32(&callCount, 1))
syncFn(n, w, r)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
// syncRespMessage construye una respuesta /sync con un m.room.message.
func syncRespMessage(nextBatch string) map[string]interface{} {
return map[string]interface{}{
"next_batch": nextBatch,
"rooms": map[string]interface{}{
"join": map[string]interface{}{
"!testroom:localhost": map[string]interface{}{
"timeline": map[string]interface{}{
"events": []interface{}{
map[string]interface{}{
"event_id": "$evt001:localhost",
"type": "m.room.message",
"sender": "@alice:localhost",
"origin_server_ts": int64(1700000000000),
"room_id": "!testroom:localhost",
"content": map[string]interface{}{
"msgtype": "m.text",
"body": "hola mundo",
},
},
},
"limited": false,
},
},
},
},
}
}
// newTestSyncClient crea un *mautrix.Client apuntando al servidor dado.
func newTestSyncClient(t *testing.T, serverURL string) *mautrix.Client {
t.Helper()
cli, err := mautrix.NewClient(serverURL, "@alice:localhost", "token-test")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
cli.UserID = id.UserID("@alice:localhost")
return cli
}
// TestMatrixSyncService_RecibeMensajeYStop arranca el servicio, recibe 1 evento y hace Stop limpio.
func TestMatrixSyncService_RecibeMensajeYStop(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
if n == 1 {
_ = json.NewEncoder(w).Encode(syncRespMessage("nb_001"))
return
}
// Bloquear syncs subsiguientes hasta cancelacion
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_002"})
})
defer srv.Close()
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{
Client: cli,
ChannelBuffer: 16,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
// Esperar el primer evento
select {
case ev, ok := <-h.Events:
if !ok {
t.Fatal("canal cerrado antes de recibir evento")
}
if ev.Type != "message" {
t.Errorf("tipo esperado 'message', got %q", ev.Type)
}
if ev.Body != "hola mundo" {
t.Errorf("body esperado 'hola mundo', got %q", ev.Body)
}
if ev.Sender != "@alice:localhost" {
t.Errorf("sender esperado '@alice:localhost', got %q", ev.Sender)
}
if ev.RoomID != "!testroom:localhost" {
t.Errorf("roomID esperado '!testroom:localhost', got %q", ev.RoomID)
}
case <-time.After(5 * time.Second):
t.Fatal("timeout esperando evento")
}
// Stop limpio
h.Stop()
// Verificar que Events cierra tras Stop
timeout := time.After(3 * time.Second)
for {
select {
case _, ok := <-h.Events:
if !ok {
return // canal cerrado correctamente
}
case <-timeout:
t.Fatal("canal Events no cerro tras Stop")
}
}
}
// TestMatrixSyncService_BackoffRecovery verifica backoff con 2 errores 500 seguidos de exito.
func TestMatrixSyncService_BackoffRecovery(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
if n <= 2 {
// Primeras 2 llamadas: devolver 500
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"errcode": "M_UNKNOWN",
"error": "internal server error",
})
return
}
if n == 3 {
// Tercera llamada: respuesta correcta inmediata (no bloquear)
_ = json.NewEncoder(w).Encode(syncRespMessage("nb_recovery"))
return
}
// Cuarta en adelante: bloquear hasta cancelacion
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_x"})
})
defer srv.Close()
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{
Client: cli,
InitialBackoffMS: 50, // backoff corto para tests
MaxBackoffMS: 200,
ChannelBuffer: 16,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
defer h.Stop()
// Tras los fallos, debe llegar el evento de recovery
select {
case ev, ok := <-h.Events:
if !ok {
t.Fatal("canal cerrado antes de evento de recovery")
}
if ev.Type != "message" {
t.Errorf("tipo esperado 'message', got %q", ev.Type)
}
if ev.Body != "hola mundo" {
t.Errorf("body esperado 'hola mundo', got %q", ev.Body)
}
case <-time.After(8 * time.Second):
t.Fatal("timeout esperando evento de recovery tras backoff")
}
}
// TestMatrixSyncService_Error401NoExit verifica que 401 emite error pero no cierra el servicio.
func TestMatrixSyncService_Error401NoExit(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
if n == 1 {
// Primera llamada: 401 M_UNKNOWN_TOKEN
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"errcode": "M_UNKNOWN_TOKEN",
"error": "Invalid macaroon passed.",
})
return
}
// Bloquear: el servicio espera en backoff maximo
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_x"})
})
defer srv.Close()
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{
Client: cli,
InitialBackoffMS: 50,
MaxBackoffMS: 200,
ChannelBuffer: 8,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
// Debe recibir al menos un error (fatal 401)
select {
case syncErr := <-h.Errors:
if syncErr == nil {
t.Error("error esperado no nil")
}
case <-time.After(4 * time.Second):
t.Fatal("timeout esperando error 401 en canal Errors")
}
// El canal Events NO debe estar cerrado — el servicio sigue activo
select {
case _, ok := <-h.Events:
if !ok {
t.Fatal("canal Events no debia cerrarse con error 401 (dejar al caller decidir via Stop)")
}
case <-time.After(300 * time.Millisecond):
// Correcto: canal sigue abierto
}
h.Stop()
// Tras Stop, Events debe cerrarse
select {
case _, ok := <-h.Events:
if !ok {
return // OK
}
case <-time.After(3 * time.Second):
t.Fatal("canal Events no cerro tras Stop despues de error 401")
}
}
// TestMatrixSyncService_StopIdempotente verifica que Stop() dos veces no causa panic.
func TestMatrixSyncService_StopIdempotente(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_1"})
})
defer srv.Close()
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{
Client: cli,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
// Llamar Stop dos veces — no debe panic
defer func() {
if r := recover(); r != nil {
t.Errorf("Stop() dos veces causó panic: %v", r)
}
}()
h.Stop()
h.Stop()
}
// TestMatrixSyncService_CtxCancelCierraChannels verifica que cancelar ctx cierra Events < 1s.
func TestMatrixSyncService_CtxCancelCierraChannels(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_ctx"})
})
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(ctx, MatrixSyncServiceConfig{
Client: cli,
ChannelBuffer: 4,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
// Cancelar contexto padre
cancel()
// Events debe cerrarse en menos de 1 segundo
deadline := time.After(1 * time.Second)
for {
select {
case _, ok := <-h.Events:
if !ok {
return // canal cerrado correctamente
}
case <-deadline:
t.Fatal("canal Events no cerro en 1s tras cancelar ctx")
}
}
}
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS revoked_peers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
revoked_at INTEGER NOT NULL,
revoked_by TEXT NOT NULL,
reason TEXT NOT NULL,
prev_hash TEXT NOT NULL DEFAULT '',
this_hash TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_revoked_peers_device_id ON revoked_peers (device_id);
CREATE INDEX IF NOT EXISTS idx_revoked_peers_revoked_at ON revoked_peers (revoked_at);
+14 -3
View File
@@ -57,14 +57,25 @@ func NordVPNContainerStart(opts NordVPNContainerOpts) (string, error) {
// Esperar a que el tunel este activo
for i := 0; i < 30; i++ {
time.Sleep(1 * time.Second)
logs, logErr := DockerContainerLogs(opts.Name, 20)
lines, logErr := DockerContainerLogs(DockerLogsOpts{
ContainerID: opts.Name,
Tail: 20,
Stdout: true,
Stderr: true,
})
if logErr != nil {
continue
}
if strings.Contains(logs, "Connected") || strings.Contains(logs, "connected") {
var logText strings.Builder
for _, l := range lines {
logText.WriteString(l.Line)
logText.WriteByte('\n')
}
logsStr := logText.String()
if strings.Contains(logsStr, "Connected") || strings.Contains(logsStr, "connected") {
return id, nil
}
if strings.Contains(logs, "error") || strings.Contains(logs, "failed") {
if strings.Contains(logsStr, "error") || strings.Contains(logsStr, "failed") {
return id, fmt.Errorf("nordvpn connection failed, check logs: docker logs %s", opts.Name)
}
}
+261
View File
@@ -0,0 +1,261 @@
package infra
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
// ShellExecOpts configura la ejecucion de un comando shell con whitelist de binarios.
type ShellExecOpts struct {
// Cmd es el argv completo. Cmd[0] es el binario (absoluto o nombre en PATH).
Cmd []string
// BinariesAllowed es la whitelist de binarios permitidos.
// EMPTY = rechaza todo (defense in depth). Obligatorio.
BinariesAllowed []string
// Env son variables de entorno KEY=VAL adicionales.
// Si vacio, se usa un entorno minimo: PATH=/usr/bin:/bin, HOME, USER, LANG.
Env []string
// WorkingDir es el directorio de trabajo. Si vacio usa HOME del usuario actual.
WorkingDir string
// TimeoutSeconds es el timeout maximo. Default 30. Hard kill al cumplir.
TimeoutSeconds int
// StdinPayload es el contenido a pasar como stdin al proceso.
StdinPayload []byte
// MaxOutputBytes es el limite de stdout+stderr combinado (cada uno).
// Default 1 MB. Trunca la salida y activa Truncated=true.
MaxOutputBytes int
// User es el usuario con el que ejecutar el proceso (requiere uid=0).
// Vacio = usuario actual.
User string
}
// ShellExecResult contiene el resultado de la ejecucion shell.
type ShellExecResult struct {
ExitCode int // Codigo de salida del proceso.
Stdout string // Salida estandar capturada (puede estar truncada).
Stderr string // Salida de error capturada (puede estar truncada).
Duration int64 // Duracion real de ejecucion en milisegundos.
Truncated bool // true si stdout o stderr fue truncado por MaxOutputBytes.
TimedOut bool // true si el proceso fue matado por timeout.
}
const (
defaultTimeoutSeconds = 30
defaultMaxOutputBytes = 1 * 1024 * 1024 // 1 MB
sigkillWait = time.Second
)
// ShellExecWhitelist ejecuta un comando shell con whitelist obligatoria de binarios,
// sin shell expansion, timeout context-cancellable con SIGTERM+SIGKILL,
// stdout/stderr separados con truncate opcional.
//
// Validaciones previas al spawn (ninguna hace I/O):
// - Cmd vacio → error.
// - BinariesAllowed vacio → error (defense in depth; NUNCA pasar [] en prod).
// - Cmd[0] debe estar en la whitelist: entry absoluta (/usr/bin/ls) se compara
// con el path resolvido de Cmd[0] via exec.LookPath; entry bare name (ls)
// se compara con filepath.Base(resolvido). Basta con que una entry haga match.
// - User != "" con uid != 0 → error (se necesita root para cambiar usuario).
func ShellExecWhitelist(opts ShellExecOpts) (ShellExecResult, error) {
// --- Validacion de seguridad (sin I/O) ---
if len(opts.Cmd) == 0 {
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: Cmd must not be empty")
}
if len(opts.BinariesAllowed) == 0 {
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: no binaries whitelisted: refusing exec")
}
// Resolver el binario real (LookPath solo si no es path absoluto).
resolvedBin, err := exec.LookPath(opts.Cmd[0])
if err != nil {
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: binary %q not found in PATH: %w", opts.Cmd[0], err)
}
baseName := filepath.Base(resolvedBin)
inWhitelist := false
for _, entry := range opts.BinariesAllowed {
if strings.HasPrefix(entry, "/") {
// Entry es path absoluto: comparar con el path resolvido.
if entry == resolvedBin {
inWhitelist = true
break
}
} else {
// Entry es bare name: comparar con el basename del resolvido.
if entry == baseName {
inWhitelist = true
break
}
}
}
if !inWhitelist {
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: binary %q (resolved: %q) not in whitelist %v",
opts.Cmd[0], resolvedBin, opts.BinariesAllowed)
}
// --- Validacion de user switch ---
if opts.User != "" {
if os.Getuid() != 0 {
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: need root to switch user to %q", opts.User)
}
}
// --- Defaults ---
timeout := opts.TimeoutSeconds
if timeout <= 0 {
timeout = defaultTimeoutSeconds
}
maxOut := opts.MaxOutputBytes
if maxOut <= 0 {
maxOut = defaultMaxOutputBytes
}
// Working dir
workDir := opts.WorkingDir
if workDir == "" {
if h := os.Getenv("HOME"); h != "" {
workDir = h
} else {
workDir = "/"
}
}
// Env
env := opts.Env
if len(env) == 0 {
lang := os.Getenv("LANG")
if lang == "" {
lang = "C.UTF-8"
}
home := os.Getenv("HOME")
if home == "" {
home = "/"
}
usr := os.Getenv("USER")
if usr == "" {
usr = "root"
}
env = []string{
"PATH=/usr/bin:/bin",
"HOME=" + home,
"USER=" + usr,
"LANG=" + lang,
}
}
// --- Contexto con timeout ---
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
// --- Construir comando ---
argv := append([]string{resolvedBin}, opts.Cmd[1:]...)
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) //nolint:gosec // whitelist validated above
cmd.Env = env
cmd.Dir = workDir
// SysProcAttr para user switching (solo si root y User != "").
if opts.User != "" {
cred, err := buildCredential(opts.User)
if err != nil {
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: resolving user %q: %w", opts.User, err)
}
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: cred}
} else {
// Asegurar que el proceso puede ser matado como grupo.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// Buffers de captura.
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
// Stdin opcional.
if len(opts.StdinPayload) > 0 {
cmd.Stdin = bytes.NewReader(opts.StdinPayload)
}
start := time.Now()
// --- Ejecucion ---
runErr := cmd.Run()
duration := time.Since(start).Milliseconds()
// Determinar timedOut y exitCode.
timedOut := false
exitCode := 0
if runErr != nil {
if ctx.Err() == context.DeadlineExceeded {
timedOut = true
// SIGTERM ya fue enviado por exec.CommandContext; esperar 1s y SIGKILL.
if cmd.Process != nil {
time.Sleep(sigkillWait)
_ = cmd.Process.Kill()
}
}
if exitErr, ok := runErr.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if !timedOut {
// Error de spawn u otro — no es de exit.
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: running %q: %w", opts.Cmd[0], runErr)
}
}
// Truncar salida si supera el limite.
truncated := false
stdout := stdoutBuf.String()
stderr := stderrBuf.String()
if len(stdout) > maxOut {
stdout = stdout[:maxOut]
truncated = true
}
if len(stderr) > maxOut {
stderr = stderr[:maxOut]
truncated = true
}
return ShellExecResult{
ExitCode: exitCode,
Stdout: stdout,
Stderr: stderr,
Duration: duration,
Truncated: truncated,
TimedOut: timedOut,
}, nil
}
// buildCredential construye un syscall.Credential para el usuario dado.
// Acepta nombre de usuario ("www-data") o "uid:gid" ("1000:1000").
func buildCredential(userStr string) (*syscall.Credential, error) {
// Intentar formato "uid:gid".
if strings.Contains(userStr, ":") {
parts := strings.SplitN(userStr, ":", 2)
uid, err := strconv.ParseUint(parts[0], 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid uid %q: %w", parts[0], err)
}
gid, err := strconv.ParseUint(parts[1], 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid gid %q: %w", parts[1], err)
}
return &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}, nil
}
// Nombre de usuario.
u, err := user.Lookup(userStr)
if err != nil {
return nil, fmt.Errorf("user %q not found: %w", userStr, err)
}
uid, _ := strconv.ParseUint(u.Uid, 10, 32)
gid, _ := strconv.ParseUint(u.Gid, 10, 32)
return &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}, nil
}
+95
View File
@@ -0,0 +1,95 @@
---
name: shell_exec_whitelist
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ShellExecWhitelist(opts ShellExecOpts) (ShellExecResult, error)"
description: "Ejecuta argv shell con whitelist obligatoria de binarios, SIN shell expansion, timeout context-cancellable con SIGTERM+SIGKILL, stdout/stderr separados con truncate opcional. Para device_agent y otros sandboxes que reciben requests externos."
tags: [shell, exec, security, sandbox, device-agent, infra, agents, docker]
uses_functions: []
uses_types: [shell_exec_result_go_infra, error_go_core]
returns: [shell_exec_result_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: [bytes, context, fmt, os, os/exec, os/user, path/filepath, strconv, strings, syscall, time]
tested: true
tests:
- "echo whitelisted returns stdout"
- "binary not in whitelist rejected without spawn"
- "timeout kills process and sets TimedOut"
- "empty whitelist returns error"
- "stdin payload passes to process"
- "output exceeding MaxOutputBytes is truncated"
- "absolute path in whitelist matches resolved binary"
test_file_path: "functions/infra/shell_exec_whitelist_test.go"
file_path: "functions/infra/shell_exec_whitelist.go"
params:
- name: opts.Cmd
desc: "argv completo. Cmd[0] es el binario (path absoluto o nombre en PATH). Obligatorio, no puede estar vacío."
- name: opts.BinariesAllowed
desc: "Whitelist de binarios permitidos. EMPTY = rechaza todo sin spawn (defense in depth). Entry con / se compara con path resolvido; entry bare name se compara con basename del resolvido."
- name: opts.Env
desc: "Variables de entorno KEY=VAL. Si vacío, se aplica entorno mínimo: PATH=/usr/bin:/bin, HOME, USER, LANG."
- name: opts.WorkingDir
desc: "Directorio de trabajo. Si vacío usa HOME del usuario actual."
- name: opts.TimeoutSeconds
desc: "Timeout máximo en segundos. Default 30. Al expirar: SIGTERM → espera 1s → SIGKILL."
- name: opts.StdinPayload
desc: "Bytes a pasar como stdin al proceso. Nil/vacío = sin stdin."
- name: opts.MaxOutputBytes
desc: "Límite de bytes por stream (stdout y stderr por separado). Default 1 MB. Activa Truncated=true si se supera."
- name: opts.User
desc: "Usuario para ejecutar el proceso (nombre o 'uid:gid'). Requiere uid=0. Vacío = usuario actual."
output: "ShellExecResult con ExitCode, Stdout, Stderr, Duration (ms), Truncated y TimedOut."
---
## Ejemplo
```go
result, err := infra.ShellExecWhitelist(infra.ShellExecOpts{
Cmd: []string{"ls", "-la", "/tmp"},
BinariesAllowed: []string{"ls", "cat", "echo", "id"},
TimeoutSeconds: 10,
MaxOutputBytes: 64 * 1024,
})
if err != nil {
log.Fatalf("exec rejected: %v", err)
}
fmt.Printf("exit=%d duration=%dms truncated=%v timedOut=%v\n",
result.ExitCode, result.Duration, result.Truncated, result.TimedOut)
fmt.Println(result.Stdout)
```
Con stdin:
```go
result, err := infra.ShellExecWhitelist(infra.ShellExecOpts{
Cmd: []string{"cat"},
BinariesAllowed: []string{"cat"},
StdinPayload: []byte("payload from device_agent"),
})
```
Con path absoluto en whitelist:
```go
result, err := infra.ShellExecWhitelist(infra.ShellExecOpts{
Cmd: []string{"/usr/bin/id"},
BinariesAllowed: []string{"/usr/bin/id"},
})
```
## Cuando usarla
Cuando recibes requests externos (Element Matrix, webhook, agente) que especifican un comando a ejecutar en el host, y necesitas garantizar que solo binarios pre-aprobados corren, sin posibilidad de shell injection. Reemplaza `exec.Command` directa en device_agent o cualquier sandbox que acepte comandos de fuentes no confiables.
## Gotchas
- **Empty whitelist rechaza por diseño**: `BinariesAllowed: []string{}` devuelve error inmediato. NUNCA construyas la whitelist dinámicamente desde input externo.
- **PATH default mínimo** (`/usr/bin:/bin`): si tu binario está en `/usr/local/bin` u otro directorio, añádelo explícitamente a `Env` o usa el path absoluto en `Cmd[0]` y en `BinariesAllowed`.
- **SIGTERM+1s+SIGKILL**: algunos procesos pueden ignorar SIGTERM. SIGKILL es forzoso pero puede dejar recursos abiertos (ficheros, sockets). Diseña el proceso objetivo para manejar SIGTERM limpiamente.
- **Truncate aplica POST-exec**: no es streaming. Si el proceso produce 10 GB de output, el buffer crece hasta ese tamaño en RAM antes de truncar. Para procesos con output gigante usa pipes propios o un wrapper de streaming.
- **User switch requiere uid=0**: en un entorno sin root (contenedor sin privilegios, proceso normal), pasar `User != ""` siempre devuelve error. Verificar con `os.Getuid() == 0` antes si el campo es opcional.
- **`Cmd[0]` es el nombre del binario en PATH** pero la whitelist puede tener paths absolutos o bare names. Precedencia: entry con `/` compara contra el path resolvido por `LookPath`; entry sin `/` compara contra `filepath.Base` del path resolvido. Ambas formas son válidas y pueden coexistir en la misma whitelist.
@@ -0,0 +1,136 @@
package infra
import (
"strings"
"testing"
)
func TestShellExecWhitelist(t *testing.T) {
t.Run("echo whitelisted returns stdout", func(t *testing.T) {
result, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{"echo", "hola"},
BinariesAllowed: []string{"echo"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ExitCode != 0 {
t.Errorf("exit code: got %d, want 0", result.ExitCode)
}
if result.Stdout != "hola\n" {
t.Errorf("stdout: got %q, want %q", result.Stdout, "hola\n")
}
if result.TimedOut {
t.Error("should not be timed out")
}
})
t.Run("binary not in whitelist rejected without spawn", func(t *testing.T) {
_, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{"evil"},
BinariesAllowed: []string{"echo"},
})
if err == nil {
t.Fatal("expected error for non-whitelisted binary, got nil")
}
// Debe fallar por whitelist, no por spawn (el binario "evil" ni siquiera existe).
if !strings.Contains(err.Error(), "not in whitelist") &&
!strings.Contains(err.Error(), "not found in PATH") {
t.Errorf("unexpected error message: %v", err)
}
})
t.Run("timeout kills process and sets TimedOut", func(t *testing.T) {
result, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{"sleep", "10"},
BinariesAllowed: []string{"sleep"},
TimeoutSeconds: 1,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.TimedOut {
t.Error("expected TimedOut=true")
}
if result.ExitCode == 0 {
t.Error("expected non-zero exit code on timeout")
}
})
t.Run("empty whitelist returns error", func(t *testing.T) {
_, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{"echo", "test"},
BinariesAllowed: []string{},
})
if err == nil {
t.Fatal("expected error for empty whitelist, got nil")
}
if !strings.Contains(err.Error(), "no binaries whitelisted") {
t.Errorf("unexpected error message: %v", err)
}
})
t.Run("stdin payload passes to process", func(t *testing.T) {
result, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{"cat"},
BinariesAllowed: []string{"cat"},
StdinPayload: []byte("hello registry"),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ExitCode != 0 {
t.Errorf("exit code: got %d, want 0", result.ExitCode)
}
if result.Stdout != "hello registry" {
t.Errorf("stdout: got %q, want %q", result.Stdout, "hello registry")
}
})
t.Run("output exceeding MaxOutputBytes is truncated", func(t *testing.T) {
// Genera ~100 bytes, limite de 10 → debe truncar.
result, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{"echo", strings.Repeat("x", 100)},
BinariesAllowed: []string{"echo"},
MaxOutputBytes: 10,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Truncated {
t.Error("expected Truncated=true")
}
if len(result.Stdout) > 10 {
t.Errorf("stdout length %d exceeds MaxOutputBytes 10", len(result.Stdout))
}
})
t.Run("absolute path in whitelist matches resolved binary", func(t *testing.T) {
// Buscar el path absoluto de "true" para usarlo en la whitelist.
// /usr/bin/true o /bin/true según la distro.
candidates := []string{"/usr/bin/true", "/bin/true"}
truePath := ""
for _, c := range candidates {
if _, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{c},
BinariesAllowed: []string{c},
}); err == nil {
truePath = c
break
}
}
if truePath == "" {
t.Skip("could not find absolute path for 'true'; skipping absolute-path whitelist test")
}
result, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{truePath},
BinariesAllowed: []string{truePath},
})
if err != nil {
t.Fatalf("unexpected error with absolute path whitelist: %v", err)
}
if result.ExitCode != 0 {
t.Errorf("exit code: got %d, want 0", result.ExitCode)
}
})
}
+323
View File
@@ -0,0 +1,323 @@
package infra
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
// SynapseAdminClient wraps the Synapse Admin API (/_synapse/admin/...) for user and room management.
type SynapseAdminClient struct {
HomeserverURL string // e.g. https://matrix-af2f3d.organic-machine.com
AdminToken string // access_token of a user with admin:true in Synapse
HTTPClient *http.Client // optional; default 30s timeout
}
// NewSynapseAdminClient creates a client with sensible defaults.
func NewSynapseAdminClient(homeserver, adminToken string) *SynapseAdminClient {
return &SynapseAdminClient{
HomeserverURL: homeserver,
AdminToken: adminToken,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
}
}
// AdminUser represents a Synapse user as returned by the admin API.
type AdminUser struct {
UserID string `json:"name"`
DisplayName string `json:"displayname"`
AvatarURL string `json:"avatar_url"`
Admin bool `json:"admin"`
Deactivated bool `json:"deactivated"`
IsGuest bool `json:"is_guest"`
CreationTs int64 `json:"creation_ts"`
LastSeenTs int64 `json:"last_seen_ts"`
}
// ListUsersFilter controls pagination and filtering for ListUsers.
type ListUsersFilter struct {
From int // pagination offset
Limit int // default 100
SearchTerm string // filter by name / user_id
Deactivated *bool // nil = both, true/false to filter
Admins *bool // nil = both, true/false to filter
}
// ListUsersResult holds a page of users plus pagination metadata.
type ListUsersResult struct {
Users []AdminUser
TotalCount int
NextToken *int // nil if last page
}
// AdminRoom represents a Synapse room as returned by the admin API.
type AdminRoom struct {
RoomID string `json:"room_id"`
Name string `json:"name"`
CanonicalAlias string `json:"canonical_alias"`
JoinedMembers int `json:"joined_members"`
JoinedLocal int `json:"joined_local_members"`
Version string `json:"version"`
Encrypted bool `json:"encryption_enabled"`
Federatable bool `json:"federatable"`
Public bool `json:"public"`
}
// AdminDevice represents a device belonging to a Synapse user.
type AdminDevice struct {
DeviceID string `json:"device_id"`
DisplayName string `json:"display_name"`
LastSeenIP string `json:"last_seen_ip"`
LastSeenTs int64 `json:"last_seen_ts"`
}
// synapseError is the error envelope returned by Synapse for 4xx/5xx responses.
type synapseError struct {
ErrCode string `json:"errcode"`
ErrMsg string `json:"error"`
}
// client returns the HTTPClient, falling back to a 30-second default.
func (c *SynapseAdminClient) client() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return &http.Client{Timeout: 30 * time.Second}
}
// do executes an authenticated request and returns the raw response body.
// Returns an error for HTTP >= 400, including the Synapse errcode when present.
func (c *SynapseAdminClient) do(ctx context.Context, method, path string, body interface{}) ([]byte, error) {
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("synapse_admin: marshal request body: %w", err)
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.HomeserverURL+path, bodyReader)
if err != nil {
return nil, fmt.Errorf("synapse_admin: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.AdminToken)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.client().Do(req)
if err != nil {
return nil, fmt.Errorf("synapse_admin: http %s %s: %w", method, path, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("synapse_admin: read response: %w", err)
}
if resp.StatusCode >= 500 {
var se synapseError
if jsonErr := json.Unmarshal(data, &se); jsonErr == nil && se.ErrCode != "" {
return nil, fmt.Errorf("synapse_admin: synapse internal %d %s: %s", resp.StatusCode, se.ErrCode, se.ErrMsg)
}
return nil, fmt.Errorf("synapse_admin: synapse internal: %d", resp.StatusCode)
}
if resp.StatusCode >= 400 {
var se synapseError
if jsonErr := json.Unmarshal(data, &se); jsonErr == nil && se.ErrCode != "" {
return nil, fmt.Errorf("synapse_admin: %s %s → %d %s: %s", method, path, resp.StatusCode, se.ErrCode, se.ErrMsg)
}
return nil, fmt.Errorf("synapse_admin: %s %s → HTTP %d", method, path, resp.StatusCode)
}
return data, nil
}
// --- Users ---
// ListUsers returns a page of users matching the given filter.
// Use ListUsersResult.NextToken to paginate: set ListUsersFilter.From = *NextToken on the next call.
func (c *SynapseAdminClient) ListUsers(ctx context.Context, f ListUsersFilter) (*ListUsersResult, error) {
limit := f.Limit
if limit <= 0 {
limit = 100
}
q := url.Values{}
q.Set("from", strconv.Itoa(f.From))
q.Set("limit", strconv.Itoa(limit))
if f.SearchTerm != "" {
q.Set("user_id", f.SearchTerm)
}
if f.Deactivated != nil {
q.Set("deactivated", strconv.FormatBool(*f.Deactivated))
}
if f.Admins != nil {
q.Set("admins", strconv.FormatBool(*f.Admins))
}
path := "/_synapse/admin/v2/users?" + q.Encode()
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var raw struct {
Users []AdminUser `json:"users"`
Total int `json:"total"`
NextToken *int `json:"next_token"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("synapse_admin: ListUsers decode: %w", err)
}
return &ListUsersResult{
Users: raw.Users,
TotalCount: raw.Total,
NextToken: raw.NextToken,
}, nil
}
// GetUser returns the admin view of a single user by their full Matrix ID (e.g. @user:server).
func (c *SynapseAdminClient) GetUser(ctx context.Context, userID string) (*AdminUser, error) {
path := "/_synapse/admin/v2/users/" + url.PathEscape(userID)
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var u AdminUser
if err := json.Unmarshal(data, &u); err != nil {
return nil, fmt.Errorf("synapse_admin: GetUser decode: %w", err)
}
return &u, nil
}
// DeactivateUser deactivates a user account.
// If erase=true, Synapse purges all user data — IRREVERSIBLE.
func (c *SynapseAdminClient) DeactivateUser(ctx context.Context, userID string, erase bool) error {
path := "/_synapse/admin/v1/deactivate/" + url.PathEscape(userID)
_, err := c.do(ctx, http.MethodPost, path, map[string]bool{"erase": erase})
return err
}
// ResetPassword sets a new password for the given user.
// If logoutDevices=true, all existing sessions are invalidated.
func (c *SynapseAdminClient) ResetPassword(ctx context.Context, userID, newPassword string, logoutDevices bool) error {
path := "/_synapse/admin/v1/reset_password/" + url.PathEscape(userID)
body := map[string]interface{}{
"new_password": newPassword,
"logout_devices": logoutDevices,
}
_, err := c.do(ctx, http.MethodPost, path, body)
return err
}
// --- Rooms ---
// ListRooms returns a page of rooms.
// from and limit control pagination; searchTerm filters by room name/alias.
func (c *SynapseAdminClient) ListRooms(ctx context.Context, from, limit int, searchTerm string) (rooms []AdminRoom, total int, nextToken *int, err error) {
if limit <= 0 {
limit = 100
}
q := url.Values{}
q.Set("from", strconv.Itoa(from))
q.Set("limit", strconv.Itoa(limit))
q.Set("order_by", "name")
if searchTerm != "" {
q.Set("search_term", searchTerm)
}
path := "/_synapse/admin/v1/rooms?" + q.Encode()
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, 0, nil, err
}
var raw struct {
Rooms []AdminRoom `json:"rooms"`
TotalRooms int `json:"total_rooms"`
NextBatch *int `json:"next_batch"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, 0, nil, fmt.Errorf("synapse_admin: ListRooms decode: %w", err)
}
return raw.Rooms, raw.TotalRooms, raw.NextBatch, nil
}
// GetRoom returns the admin view of a single room by its room ID (e.g. !room:server).
func (c *SynapseAdminClient) GetRoom(ctx context.Context, roomID string) (*AdminRoom, error) {
path := "/_synapse/admin/v1/rooms/" + url.PathEscape(roomID)
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var r AdminRoom
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("synapse_admin: GetRoom decode: %w", err)
}
return &r, nil
}
// DeleteRoom schedules an async room deletion. Returns the delete_id for status polling.
// purge=true destroys all messages and state (IRREVERSIBLE).
// block=true prevents new users from joining after deletion.
func (c *SynapseAdminClient) DeleteRoom(ctx context.Context, roomID, reason string, purge, block bool) (deleteID string, err error) {
path := "/_synapse/admin/v2/rooms/" + url.PathEscape(roomID)
body := map[string]interface{}{
"new_room_user_id": nil,
"purge": purge,
"block": block,
"message": reason,
}
data, err := c.do(ctx, http.MethodDelete, path, body)
if err != nil {
return "", err
}
var raw struct {
DeleteID string `json:"delete_id"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return "", fmt.Errorf("synapse_admin: DeleteRoom decode: %w", err)
}
return raw.DeleteID, nil
}
// --- Devices ---
// ListUserDevices returns all devices registered for the given user.
func (c *SynapseAdminClient) ListUserDevices(ctx context.Context, userID string) ([]AdminDevice, error) {
path := "/_synapse/admin/v2/users/" + url.PathEscape(userID) + "/devices"
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var raw struct {
Devices []AdminDevice `json:"devices"`
Total int `json:"total"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("synapse_admin: ListUserDevices decode: %w", err)
}
return raw.Devices, nil
}
// DeleteUserDevice removes a specific device from a user's account.
func (c *SynapseAdminClient) DeleteUserDevice(ctx context.Context, userID, deviceID string) error {
path := "/_synapse/admin/v2/users/" + url.PathEscape(userID) + "/devices/" + url.PathEscape(deviceID)
_, err := c.do(ctx, http.MethodDelete, path, nil)
return err
}
+100
View File
@@ -0,0 +1,100 @@
---
name: synapse_admin_client
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func NewSynapseAdminClient(homeserver, adminToken string) *SynapseAdminClient"
description: "REST client for the Synapse Admin API (/_synapse/admin/v1 and v2). Wraps user management (list/get/deactivate/reset-password), room management (list/get/delete with purge), and device management (list/delete) with Bearer auth and structured error wrapping."
tags: [matrix, synapse, admin, rest, client, infra, matrix-mas]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strconv"
- "time"
tested: true
tests:
- "ListUsers parses + counts"
- "GetUser inexistente -> error contiene M_NOT_FOUND"
- "DeactivateUser ok"
- "DeleteRoom devuelve delete_id"
- "ListUserDevices parses array"
- "HTTP 403 -> error con errcode M_FORBIDDEN"
test_file_path: "functions/infra/synapse_admin_client_test.go"
file_path: "functions/infra/synapse_admin_client.go"
params:
- name: homeserver
desc: "Base URL of the Synapse homeserver, e.g. https://matrix-af2f3d.organic-machine.com (no trailing slash)"
- name: adminToken
desc: "Access token of a Synapse user with admin:true. NOT a MAS/OIDC token — must be a legacy Synapse session token"
output: "*SynapseAdminClient ready to call ListUsers, DeactivateUser, ListRooms, DeleteRoom, ListUserDevices, etc."
---
## Ejemplo
```go
ctx := context.Background()
c := NewSynapseAdminClient(
"https://matrix-af2f3d.organic-machine.com",
"syt_admin_token_xxx",
)
// List first 100 users
res, err := c.ListUsers(ctx, ListUsersFilter{Limit: 100})
if err != nil {
log.Fatal(err)
}
for _, u := range res.Users {
fmt.Printf("%s admin=%v deactivated=%v\n", u.UserID, u.Admin, u.Deactivated)
}
// Paginate with NextToken
if res.NextToken != nil {
res2, _ := c.ListUsers(ctx, ListUsersFilter{From: *res.NextToken, Limit: 100})
_ = res2
}
// Deactivate + erase a user
err = c.DeactivateUser(ctx, "@badactor:server", true)
// Delete a room with purge
deleteID, err := c.DeleteRoom(ctx, "!spamroom:server", "spam cleanup", true, true)
fmt.Println("delete_id:", deleteID) // poll /_synapse/admin/v2/rooms/delete_status/{deleteID}
// List devices for a user
devices, err := c.ListUserDevices(ctx, "@alice:server")
for _, d := range devices {
fmt.Printf(" device %s last seen %s\n", d.DeviceID, d.LastSeenIP)
}
```
## Cuando usarla
Usar en `matrix_admin_panel` (issue 0163) para construir el panel de administración del homeserver: listar usuarios, desactivar cuentas, inspeccionar rooms, purgar rooms spam, ver y eliminar dispositivos. También válida para scripts de operación del homeserver (bulk deactivation, room cleanup) que necesiten la Admin API sin pasar por el cliente Matrix regular.
## Gotchas
- **Admin Bearer NO es OIDC token**: Synapse Admin API NO acepta tokens MAS/OIDC regulares — requiere `access_token` de un usuario con `admin: true` en la tabla `users` de Synapse. Obtenerlo via una sesión creada con password legacy (antes de MSC3861) o via `mas-cli manage create-session --admin`. Con MAS activo, el flow de obtención de admin tokens cambia — ver documentación de MAS.
- **DeleteRoom es async**: devuelve `delete_id` inmediatamente. El estado real se consulta via `GET /_synapse/admin/v2/rooms/delete_status/{deleteID}` — ese endpoint NO está implementado en v0.1.0. Suficiente para lanzar la operación; la comprobación de finalización es TODO.
- **Rate limiting**: la Admin API aplica rate limits. >10 calls/s puede recibir 429. No hay retry-with-backoff en v0.1.0 — implementar en el consumidor si se hacen operaciones bulk.
- **Pagination**: iterar hasta que `NextToken == nil`. El campo `next_token` puede estar ausente en la última página — el cliente lo mapea a `nil` correctamente.
- **DeactivateUser con erase=true**: borra perfil, inhabilita el MXID permanentemente y bloquea su reúso. Operación irreversible en Synapse.
- **userID format**: usar MXID completo `@user:server`. La función aplica `url.PathEscape` automáticamente — no hace falta pre-encodear.
- **HTTPClient custom**: para timeouts distintos al default de 30s, pasar un `*http.Client` al campo `HTTPClient` del struct directamente (no hay opción en el constructor).
## Notas
Sólo usa stdlib (net/http, encoding/json, net/url, context). Sin dependencias externas.
Endpoints base siempre `/_synapse/admin` (no `/_matrix/client`).
@@ -0,0 +1,277 @@
package infra
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func newSynapseTestServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// GET /_synapse/admin/v2/users (list)
// Note: exact path match (no trailing slash) catches the list endpoint only.
mux.HandleFunc("/_synapse/admin/v2/users", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
if r.Header.Get("Authorization") == "Bearer bad" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"errcode":"M_FORBIDDEN","error":"not admin"}`))
return
}
w.Header().Set("Content-Type", "application/json")
nextToken := 2
json.NewEncoder(w).Encode(map[string]interface{}{
"users": []map[string]interface{}{
{"name": "@alice:server", "admin": true, "deactivated": false, "creation_ts": 1000},
{"name": "@bob:server", "admin": false, "deactivated": false, "creation_ts": 2000},
},
"total": 2,
"next_token": nextToken,
})
})
// GET /_synapse/admin/v2/users/{userID} (single user + devices)
mux.HandleFunc("/_synapse/admin/v2/users/", func(w http.ResponseWriter, r *http.Request) {
suffix := strings.TrimPrefix(r.URL.Path, "/_synapse/admin/v2/users/")
// devices sub-path
if strings.HasSuffix(suffix, "/devices") {
if r.Method != http.MethodGet {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"devices": []map[string]interface{}{
{"device_id": "AABBCC", "display_name": "Alice's phone", "last_seen_ip": "1.2.3.4", "last_seen_ts": 9999},
{"device_id": "DDEEFF", "display_name": "Alice's laptop", "last_seen_ip": "5.6.7.8", "last_seen_ts": 8888},
},
"total": 2,
})
return
}
// single device delete sub-path: /{userID}/devices/{deviceID}
if strings.Contains(suffix, "/devices/") {
if r.Method != http.MethodDelete {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
return
}
// single user GET
if r.Method != http.MethodGet {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
// 404 for missing user
if strings.Contains(suffix, "missing") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"errcode":"M_NOT_FOUND","error":"User not found"}`))
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminUser{
UserID: "@alice:server",
DisplayName: "Alice",
Admin: true,
})
})
// POST /_synapse/admin/v1/deactivate/{userID}
mux.HandleFunc("/_synapse/admin/v1/deactivate/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, `{"errcode":"M_BAD_JSON","error":"bad json"}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"id_server_unbind_result": "success"})
})
// GET /_synapse/admin/v1/rooms (list)
mux.HandleFunc("/_synapse/admin/v1/rooms", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"rooms": []map[string]interface{}{
{"room_id": "!abc:server", "name": "general", "joined_members": 5},
{"room_id": "!xyz:server", "name": "off-topic", "joined_members": 3},
},
"total_rooms": 2,
})
})
// GET /_synapse/admin/v1/rooms/{roomID}
mux.HandleFunc("/_synapse/admin/v1/rooms/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminRoom{RoomID: "!abc:server", Name: "general", JoinedMembers: 5})
})
// DELETE /_synapse/admin/v2/rooms/{roomID} (async delete)
mux.HandleFunc("/_synapse/admin/v2/rooms/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"delete_id": "del_001"})
})
return httptest.NewServer(mux)
}
func TestSynapseAdminClient(t *testing.T) {
srv := newSynapseTestServer(t)
defer srv.Close()
cl := NewSynapseAdminClient(srv.URL, "mxat_test_token")
ctx := context.Background()
t.Run("ListUsers parses + counts", func(t *testing.T) {
res, err := cl.ListUsers(ctx, ListUsersFilter{From: 0, Limit: 50})
if err != nil {
t.Fatalf("ListUsers: %v", err)
}
if res.TotalCount != 2 {
t.Errorf("TotalCount: got %d, want 2", res.TotalCount)
}
if len(res.Users) != 2 {
t.Fatalf("len(Users): got %d, want 2", len(res.Users))
}
if res.Users[0].UserID != "@alice:server" {
t.Errorf("Users[0].UserID: got %q, want @alice:server", res.Users[0].UserID)
}
if !res.Users[0].Admin {
t.Error("Users[0].Admin should be true")
}
if res.NextToken == nil {
t.Error("NextToken should be non-nil (test server returns next_token=2)")
} else if *res.NextToken != 2 {
t.Errorf("NextToken: got %d, want 2", *res.NextToken)
}
})
t.Run("GetUser inexistente -> error contiene M_NOT_FOUND", func(t *testing.T) {
_, err := cl.GetUser(ctx, "@missing:server")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "M_NOT_FOUND") {
t.Errorf("error should contain M_NOT_FOUND, got: %v", err)
}
})
t.Run("DeactivateUser ok", func(t *testing.T) {
// Verify via a targeted server that erase=true reaches the body.
var gotErase bool
deactivateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
json.Unmarshal(body, &req)
if v, ok := req["erase"].(bool); ok {
gotErase = v
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"id_server_unbind_result": "success"})
}))
defer deactivateSrv.Close()
clDe := NewSynapseAdminClient(deactivateSrv.URL, "tok")
if err := clDe.DeactivateUser(ctx, "@user:server", true); err != nil {
t.Fatalf("DeactivateUser: %v", err)
}
if !gotErase {
t.Error("erase=true not forwarded in request body")
}
})
t.Run("DeleteRoom devuelve delete_id", func(t *testing.T) {
var gotPurge, gotBlock bool
deleteSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, `{}`, 405)
return
}
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
json.Unmarshal(body, &req)
if v, ok := req["purge"].(bool); ok {
gotPurge = v
}
if v, ok := req["block"].(bool); ok {
gotBlock = v
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"delete_id": "del_007"})
}))
defer deleteSrv.Close()
clDel := NewSynapseAdminClient(deleteSrv.URL, "tok")
deleteID, err := clDel.DeleteRoom(ctx, "!room:server", "cleanup", true, true)
if err != nil {
t.Fatalf("DeleteRoom: %v", err)
}
if deleteID != "del_007" {
t.Errorf("deleteID: got %q, want del_007", deleteID)
}
if !gotPurge {
t.Error("purge=true not forwarded in request body")
}
if !gotBlock {
t.Error("block=true not forwarded in request body")
}
})
t.Run("ListUserDevices parses array", func(t *testing.T) {
devices, err := cl.ListUserDevices(ctx, "@alice:server")
if err != nil {
t.Fatalf("ListUserDevices: %v", err)
}
if len(devices) != 2 {
t.Fatalf("len(devices): got %d, want 2", len(devices))
}
if devices[0].DeviceID != "AABBCC" {
t.Errorf("devices[0].DeviceID: got %q, want AABBCC", devices[0].DeviceID)
}
if devices[0].LastSeenIP != "1.2.3.4" {
t.Errorf("devices[0].LastSeenIP: got %q, want 1.2.3.4", devices[0].LastSeenIP)
}
})
t.Run("HTTP 403 -> error con errcode M_FORBIDDEN", func(t *testing.T) {
badCl := NewSynapseAdminClient(srv.URL, "bad")
_, err := badCl.ListUsers(ctx, ListUsersFilter{})
if err == nil {
t.Fatal("expected error for 403, got nil")
}
if !strings.Contains(err.Error(), "M_FORBIDDEN") {
t.Errorf("error should contain M_FORBIDDEN, got: %v", err)
}
})
}
+134
View File
@@ -0,0 +1,134 @@
package infra
import (
"encoding/base64"
"fmt"
"net"
"regexp"
"strings"
qrcode "github.com/skip2/go-qrcode"
)
// wgEndpointRe matches "host:port" where host is a hostname or IP and port is 165535.
var wgEndpointRe = regexp.MustCompile(`^[a-zA-Z0-9._\-]+:\d{1,5}$`)
// WGClientConfigGen generates the wg0.conf content for a WireGuard peer (client)
// and a unicode-block QR string suitable for mobile enrollment via Element or a terminal.
//
// Pure: no I/O, fully deterministic given the inputs. Returns error on invalid inputs.
func WGClientConfigGen(in WGClientConfigInput) (WGClientConfig, error) {
if err := validateWGClientInput(in); err != nil {
return WGClientConfig{}, err
}
ka := in.PersistentKA
if ka == 0 {
ka = 25
}
var b strings.Builder
// [Interface] section
b.WriteString("[Interface]\n")
fmt.Fprintf(&b, "PrivateKey = %s\n", in.DevicePrivateKey)
fmt.Fprintf(&b, "Address = %s\n", in.DeviceAddress)
if in.DNS != "" {
fmt.Fprintf(&b, "DNS = %s\n", in.DNS)
}
b.WriteString("\n")
// [Peer] section (hub)
b.WriteString("[Peer]\n")
fmt.Fprintf(&b, "PublicKey = %s\n", in.HubPublicKey)
if in.PresharedKey != "" {
fmt.Fprintf(&b, "PresharedKey = %s\n", in.PresharedKey)
}
fmt.Fprintf(&b, "Endpoint = %s\n", in.HubEndpoint)
fmt.Fprintf(&b, "AllowedIPs = %s\n", in.HubAllowedIPs)
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", ka)
ini := b.String()
qr, err := qrcode.New(ini, qrcode.Medium)
if err != nil {
return WGClientConfig{}, fmt.Errorf("wg_client_config: qr encode: %w", err)
}
return WGClientConfig{
INI: ini,
QR: qr.ToString(false),
Filename: "wg0.conf",
}, nil
}
// validateWGClientInput checks all required fields for correctness.
func validateWGClientInput(in WGClientConfigInput) error {
if err := validateWGBase64Key("DevicePrivateKey", in.DevicePrivateKey); err != nil {
return err
}
if err := validateWGBase64Key("HubPublicKey", in.HubPublicKey); err != nil {
return err
}
if in.PresharedKey != "" {
if err := validateWGBase64Key("PresharedKey", in.PresharedKey); err != nil {
return err
}
}
// Validate DeviceAddress (CIDR)
if _, _, err := net.ParseCIDR(in.DeviceAddress); err != nil {
return fmt.Errorf("wg_client_config: DeviceAddress %q is not a valid CIDR: %w", in.DeviceAddress, err)
}
// Validate HubAllowedIPs (comma-separated CIDRs)
for _, cidr := range strings.Split(in.HubAllowedIPs, ",") {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if _, _, err := net.ParseCIDR(cidr); err != nil {
return fmt.Errorf("wg_client_config: HubAllowedIPs entry %q is not a valid CIDR: %w", cidr, err)
}
}
// Validate HubEndpoint
if !wgEndpointRe.MatchString(in.HubEndpoint) {
return fmt.Errorf("wg_client_config: HubEndpoint %q must be host:port", in.HubEndpoint)
}
parts := strings.SplitN(in.HubEndpoint, ":", 2)
port := 0
if _, err := fmt.Sscanf(parts[1], "%d", &port); err != nil || port < 1 || port > 65535 {
return fmt.Errorf("wg_client_config: HubEndpoint port %q out of range 1-65535", parts[1])
}
if in.DevicePrivateKey == "" {
return fmt.Errorf("wg_client_config: DevicePrivateKey is required")
}
if in.HubPublicKey == "" {
return fmt.Errorf("wg_client_config: HubPublicKey is required")
}
if in.HubEndpoint == "" {
return fmt.Errorf("wg_client_config: HubEndpoint is required")
}
if in.HubAllowedIPs == "" {
return fmt.Errorf("wg_client_config: HubAllowedIPs is required")
}
return nil
}
// validateWGBase64Key checks that a WireGuard key is a valid 32-byte base64-encoded string (44 chars).
func validateWGBase64Key(field, key string) error {
if len(key) != 44 {
return fmt.Errorf("wg_client_config: %s must be 44 base64 chars, got %d", field, len(key))
}
decoded, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return fmt.Errorf("wg_client_config: %s is not valid base64: %w", field, err)
}
if len(decoded) != 32 {
return fmt.Errorf("wg_client_config: %s must decode to 32 bytes, got %d", field, len(decoded))
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
package infra
// WGClientConfigInput holds all parameters needed to generate a WireGuard
// client-side wg0.conf and its QR representation.
type WGClientConfigInput struct {
DevicePrivateKey string // base64 Curve25519 private key of the peer device
DeviceAddress string // CIDR assigned to the peer, e.g. "10.42.0.10/32"
HubPublicKey string // base64 Curve25519 public key of the hub server
HubEndpoint string // "host:port" of the hub WireGuard listener, e.g. "organic-machine.com:51820"
HubAllowedIPs string // CIDRs routed through hub: "10.42.0.0/24" (mesh) or "0.0.0.0/0" (full tunnel)
PresharedKey string // base64 preshared key, optional — must match hub-side config; empty to omit
PersistentKA int // PersistentKeepalive seconds; 0 → defaults to 25 (recommended for NAT/4G)
DNS string // optional DNS server, e.g. "10.42.0.1"; empty to omit
}
// WGClientConfig is the output of WGClientConfigGen: the .conf file contents,
// a unicode-block QR string for mobile enrollment, and the suggested filename.
type WGClientConfig struct {
INI string // full contents of wg0.conf ready to write to /etc/wireguard/wg0.conf
QR string // unicode-block QR art (skip2/go-qrcode ToString) for terminal display or Element message
Filename string // suggested filename, always "wg0.conf"
}
+67
View File
@@ -0,0 +1,67 @@
package infra
import (
"bytes"
"fmt"
"os/exec"
"strings"
)
// WGKeys holds a WireGuard Curve25519 key pair and an optional preshared key,
// all encoded as base64 strings.
type WGKeys struct {
PrivateKey string // base64 Curve25519 private key
PublicKey string // base64 Curve25519 public key
PresharedKey string // base64 preshared key, empty if not requested
}
// WGKeygen generates a WireGuard key pair using `wg genkey` / `wg pubkey`.
// If withPSK is true it also runs `wg genpsk` to produce a preshared key.
// Requires the `wg` binary in PATH (install wireguard-tools).
// NEVER log PrivateKey or PresharedKey in plain text.
func WGKeygen(withPSK bool) (WGKeys, error) {
// Generate private key
privOut, err := runWG(nil, "genkey")
if err != nil {
return WGKeys{}, fmt.Errorf("wg genkey: %w", err)
}
privateKey := strings.TrimSpace(privOut)
// Derive public key from private key
pubOut, err := runWG(strings.NewReader(privateKey), "pubkey")
if err != nil {
return WGKeys{}, fmt.Errorf("wg pubkey: %w", err)
}
publicKey := strings.TrimSpace(pubOut)
keys := WGKeys{
PrivateKey: privateKey,
PublicKey: publicKey,
}
if withPSK {
pskOut, err := runWG(nil, "genpsk")
if err != nil {
return WGKeys{}, fmt.Errorf("wg genpsk: %w", err)
}
keys.PresharedKey = strings.TrimSpace(pskOut)
}
return keys, nil
}
// runWG executes `wg <subcommand>`, optionally piping stdin, and returns stdout.
// stderr is captured and included in the error when exit code != 0.
func runWG(stdin interface{ Read([]byte) (int, error) }, subcommand string) (string, error) {
cmd := exec.Command("wg", subcommand)
if stdin != nil {
cmd.Stdin = stdin
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
return stdout.String(), nil
}
+56
View File
@@ -0,0 +1,56 @@
---
name: wg_keygen
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func WGKeygen(withPSK bool) (WGKeys, error)"
description: "Genera par de claves WireGuard (Curve25519 privada+publica) en base64 via `wg genkey`/`wg pubkey`. Opcional preshared key via `wg genpsk` para defensa adicional contra futuro quantum-break."
tags: [wireguard, crypto, infra, mesh]
params:
- name: withPSK
desc: "true para incluir preshared key adicional (recomendado en mesh production)"
output: "WGKeys{PrivateKey, PublicKey, PresharedKey} todas base64. PresharedKey vacia si withPSK=false."
uses_functions: []
uses_types: [error_go_core]
returns: [WGKeys_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["bytes", "fmt", "os/exec", "strings"]
tested: true
tests: ["genera par de claves sin PSK", "genera par de claves con PSK"]
test_file_path: "functions/infra/wg_keygen_test.go"
file_path: "functions/infra/wg_keygen.go"
---
## Ejemplo
```go
// Sin preshared key (peer-to-peer simple)
keys, err := WGKeygen(false)
if err != nil {
log.Fatal(err)
}
fmt.Println("PrivateKey:", keys.PrivateKey) // NUNCA loguear en prod
fmt.Println("PublicKey:", keys.PublicKey)
// Con preshared key (recomendado en mesh production)
keys, err = WGKeygen(true)
if err != nil {
log.Fatal(err)
}
// keys.PresharedKey listo para [Peer] PresharedKey = ...
```
## Cuando usarla
Antes de configurar un nuevo peer WireGuard: genera las claves localmente y usa `PublicKey` para el bloque `[Peer]` del otro extremo. Usar `withPSK=true` en mesh de produccion para proteccion adicional frente a ataques cuanticos futuros.
## Gotchas
- Requiere `wg` binario en PATH (`wg_install` lo instala). Sin el binario retorna error inmediatamente.
- Las claves base64 tienen exactamente 44 caracteres con padding (`=`).
- NUNCA loguear `PrivateKey` ni `PresharedKey` en claro. Guardar en secreto (vault, env var cifrada).
- `PresharedKey` no es lo mismo que `PrivateKey` — es un secreto simetrico compartido entre dos peers, ambos deben configurarlo bajo `[Peer] PresharedKey`.
- Los keys generados son efimeros: si se pierde el `PrivateKey` no hay recuperacion posible.
+67
View File
@@ -0,0 +1,67 @@
package infra
import (
"encoding/base64"
"os/exec"
"strings"
"testing"
)
func TestWGKeygen(t *testing.T) {
// Skip if wg binary is not present in PATH
if _, err := exec.LookPath("wg"); err != nil {
t.Skip("wg binary not found in PATH, skipping WireGuard keygen tests")
}
t.Run("genera par de claves sin PSK", func(t *testing.T) {
keys, err := WGKeygen(false)
if err != nil {
t.Fatalf("WGKeygen(false) error: %v", err)
}
if keys.PrivateKey == "" {
t.Error("PrivateKey vacia")
}
if keys.PublicKey == "" {
t.Error("PublicKey vacia")
}
if keys.PresharedKey != "" {
t.Errorf("PresharedKey debe estar vacia sin PSK, got %q", keys.PresharedKey)
}
// WireGuard keys are 32-byte Curve25519, base64-encoded → 44 chars with padding
if len(strings.TrimSpace(keys.PrivateKey)) != 44 {
t.Errorf("PrivateKey len esperado 44, got %d", len(keys.PrivateKey))
}
if len(strings.TrimSpace(keys.PublicKey)) != 44 {
t.Errorf("PublicKey len esperado 44, got %d", len(keys.PublicKey))
}
// Validate they are valid base64
if _, err := base64.StdEncoding.DecodeString(keys.PrivateKey); err != nil {
t.Errorf("PrivateKey no es base64 valido: %v", err)
}
if _, err := base64.StdEncoding.DecodeString(keys.PublicKey); err != nil {
t.Errorf("PublicKey no es base64 valido: %v", err)
}
})
t.Run("genera par de claves con PSK", func(t *testing.T) {
keys, err := WGKeygen(true)
if err != nil {
t.Fatalf("WGKeygen(true) error: %v", err)
}
if keys.PrivateKey == "" {
t.Error("PrivateKey vacia")
}
if keys.PublicKey == "" {
t.Error("PublicKey vacia")
}
if keys.PresharedKey == "" {
t.Error("PresharedKey debe estar presente con withPSK=true")
}
if len(strings.TrimSpace(keys.PresharedKey)) != 44 {
t.Errorf("PresharedKey len esperado 44, got %d", len(keys.PresharedKey))
}
if _, err := base64.StdEncoding.DecodeString(keys.PresharedKey); err != nil {
t.Errorf("PresharedKey no es base64 valido: %v", err)
}
})
}
+408
View File
@@ -0,0 +1,408 @@
package infra
import (
"bufio"
"fmt"
"net"
"os"
"strings"
)
// WGPeerAdd añade un peer WireGuard al wg0.conf del hub y aplica la config
// en caliente con `wg syncconf` sin reiniciar la interface.
//
// Idempotente:
// - Si PublicKey ya está presente con la misma config → "already-present".
// - Si DeviceID existe con otra PublicKey → reemplaza el bloque → "reconfigured".
// - Si AllowedIPs está vacío, asigna la primera IP libre de subnetCIDR (excluyendo .1).
//
// Escribe atómicamente (tmpfile + rename) y hace chmod 600 sobre configPath.
// Si syncconf falla, restaura el backup y devuelve error.
//
// Para tests CI sin WireGuard real, establecer WG_SKIP_SYNCCONF=1.
func WGPeerAdd(spec WGPeerSpec, configPath string, subnetCIDR string) (WGPeerResult, error) {
// --- leer config actual ---
existing, err := os.ReadFile(configPath)
if err != nil && !os.IsNotExist(err) {
return WGPeerResult{}, fmt.Errorf("wg_peer_add: read config: %w", err)
}
content := string(existing)
// --- parsear peers existentes ---
peers, err := wgParsePeers(content)
if err != nil {
return WGPeerResult{}, fmt.Errorf("wg_peer_add: parse peers: %w", err)
}
// --- idempotencia: buscar peer existente ANTES de asignar IP ---
status := "added"
existingBlock, foundByKey := peers[spec.PublicKey]
_, foundByDevice := wgFindByDeviceID(peers, spec.DeviceID)
// --- determinar AllowedIPs ---
allowedIPs := spec.AllowedIPs
if allowedIPs == "" {
if foundByKey && existingBlock.allowedIPs != "" {
// reusar la IP del peer existente para idempotencia
allowedIPs = existingBlock.allowedIPs
} else {
ip, err := wgNextFreeIP(subnetCIDR, peers)
if err != nil {
return WGPeerResult{}, fmt.Errorf("wg_peer_add: assign ip: %w", err)
}
allowedIPs = ip + "/32"
}
}
// extraer IP pura para el resultado
assignedIP := allowedIPs
if idx := strings.Index(allowedIPs, "/"); idx >= 0 {
assignedIP = allowedIPs[:idx]
}
if foundByKey {
// misma PublicKey ya está — verificar si la config coincide
if existingBlock.allowedIPs == allowedIPs &&
existingBlock.presharedKey == spec.PresharedKey {
return WGPeerResult{
DeviceID: spec.DeviceID,
AssignedIP: assignedIP,
ConfigPath: configPath,
Status: "already-present",
}, nil
}
status = "reconfigured"
} else if foundByDevice {
// DeviceID existe con otra PublicKey → reconfigured (replace)
status = "reconfigured"
}
// --- construir nueva config ---
newContent, err := wgRebuildConfig(content, spec, allowedIPs, status)
if err != nil {
return WGPeerResult{}, fmt.Errorf("wg_peer_add: rebuild config: %w", err)
}
// --- backup ---
backupPath := configPath + ".bak"
if len(existing) > 0 {
if err := os.WriteFile(backupPath, existing, 0600); err != nil {
return WGPeerResult{}, fmt.Errorf("wg_peer_add: backup: %w", err)
}
}
// --- escritura atómica ---
tmpPath := configPath + ".tmp"
if err := os.WriteFile(tmpPath, []byte(newContent), 0600); err != nil {
return WGPeerResult{}, fmt.Errorf("wg_peer_add: write tmp: %w", err)
}
if err := os.Rename(tmpPath, configPath); err != nil {
_ = os.Remove(tmpPath)
return WGPeerResult{}, fmt.Errorf("wg_peer_add: rename: %w", err)
}
_ = os.Chmod(configPath, 0600)
// --- syncconf ---
iface := wgIfaceFromPath(configPath)
if iface != "" {
if err := wgSyncConfFn(iface, configPath); err != nil {
// restaurar backup
if len(existing) > 0 {
_ = os.WriteFile(configPath, existing, 0600)
}
return WGPeerResult{}, fmt.Errorf("wg_peer_add: syncconf: %w", err)
}
}
return WGPeerResult{
DeviceID: spec.DeviceID,
AssignedIP: assignedIP,
ConfigPath: configPath,
Status: status,
}, nil
}
// --- helpers internos ---
type wgPeerBlock struct {
deviceID string
publicKey string
presharedKey string
allowedIPs string
rawLines []string // líneas originales del bloque incluyendo comentario # DeviceID
}
// wgParsePeers extrae todos los bloques [Peer] indexados por PublicKey.
func wgParsePeers(content string) (map[string]*wgPeerBlock, error) {
peers := map[string]*wgPeerBlock{}
scanner := bufio.NewScanner(strings.NewReader(content))
var cur *wgPeerBlock
var pendingDeviceID string
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
// comentario de tracking: # DeviceID: <id>
if strings.HasPrefix(trimmed, "# DeviceID:") {
pendingDeviceID = strings.TrimSpace(strings.TrimPrefix(trimmed, "# DeviceID:"))
if cur != nil {
cur.rawLines = append(cur.rawLines, line)
}
continue
}
if trimmed == "[Peer]" {
cur = &wgPeerBlock{deviceID: pendingDeviceID}
cur.rawLines = append(cur.rawLines, line)
pendingDeviceID = ""
continue
}
if cur != nil {
if trimmed == "" || strings.HasPrefix(trimmed, "[") {
// fin del bloque
if cur.publicKey != "" {
peers[cur.publicKey] = cur
}
cur = nil
if strings.HasPrefix(trimmed, "[") {
// nueva sección, no es [Peer]
}
continue
}
cur.rawLines = append(cur.rawLines, line)
if k, v, ok := wgKV(trimmed); ok {
switch k {
case "PublicKey":
cur.publicKey = v
case "PresharedKey":
cur.presharedKey = v
case "AllowedIPs":
cur.allowedIPs = v
}
}
}
}
if cur != nil && cur.publicKey != "" {
peers[cur.publicKey] = cur
}
return peers, scanner.Err()
}
// wgFindByDeviceID busca un peer por DeviceID.
func wgFindByDeviceID(peers map[string]*wgPeerBlock, deviceID string) (*wgPeerBlock, bool) {
for _, p := range peers {
if p.deviceID == deviceID {
return p, true
}
}
return nil, false
}
// wgNextFreeIP encuentra la primera IP libre en subnetCIDR (excluyendo .1 del hub).
func wgNextFreeIP(subnetCIDR string, peers map[string]*wgPeerBlock) (string, error) {
ip, ipNet, err := net.ParseCIDR(subnetCIDR)
if err != nil {
return "", fmt.Errorf("invalid subnetCIDR %q: %w", subnetCIDR, err)
}
_ = ip
// recopilar IPs ya usadas
used := map[string]bool{}
for _, p := range peers {
cidr := p.allowedIPs
if idx := strings.Index(cidr, "/"); idx >= 0 {
used[cidr[:idx]] = true
}
}
// iterar desde .2 (hub es .1)
for candidate := wgIncrIP(ipNet.IP); ipNet.Contains(candidate); candidate = wgIncrIP(candidate) {
s := candidate.String()
// saltar la dirección de red (.0) y broadcast
if s == ipNet.IP.String() {
continue
}
// saltar hub (.1)
hubIP := wgIncrIP(ipNet.IP)
if s == hubIP.String() {
// esta es .1 (hub), saltar
continue
}
if !used[s] {
return s, nil
}
}
return "", fmt.Errorf("no free IPs in %s", subnetCIDR)
}
// wgIncrIP incrementa una IP en 1.
func wgIncrIP(ip net.IP) net.IP {
ip = ip.To4()
if ip == nil {
return nil
}
result := make(net.IP, 4)
copy(result, ip)
for i := 3; i >= 0; i-- {
result[i]++
if result[i] != 0 {
break
}
}
return result
}
// wgRebuildConfig reconstruye el contenido del config con el peer añadido/reemplazado.
func wgRebuildConfig(content string, spec WGPeerSpec, allowedIPs, status string) (string, error) {
// construir nuevo bloque
var newBlock strings.Builder
newBlock.WriteString(fmt.Sprintf("# DeviceID: %s\n", spec.DeviceID))
newBlock.WriteString("[Peer]\n")
newBlock.WriteString(fmt.Sprintf("PublicKey = %s\n", spec.PublicKey))
if spec.PresharedKey != "" {
newBlock.WriteString(fmt.Sprintf("PresharedKey = %s\n", spec.PresharedKey))
}
newBlock.WriteString(fmt.Sprintf("AllowedIPs = %s\n", allowedIPs))
if status == "added" {
// simplemente añadir al final
result := content
if !strings.HasSuffix(result, "\n") && len(result) > 0 {
result += "\n"
}
result += "\n" + newBlock.String()
return result, nil
}
// reconfigured: reemplazar el bloque existente (buscar por DeviceID o PublicKey)
result, err := wgReplaceBlock(content, spec, newBlock.String())
if err != nil {
return "", err
}
return result, nil
}
// wgReplaceBlock reemplaza el bloque del peer identificado por DeviceID o PublicKey.
// Estrategia: parsear el config en segmentos (pre-bloque / bloque-target / post-bloque)
// y reconstruir sustituyendo solo el bloque target.
func wgReplaceBlock(content string, spec WGPeerSpec, newBlock string) (string, error) {
type segment struct {
isPeer bool
lines []string // incluye comentario # DeviceID si lo había
pk string
did string
}
var segments []segment
scanner := bufio.NewScanner(strings.NewReader(content))
var cur *segment
var pendingComment string
flush := func() {
if cur != nil {
segments = append(segments, *cur)
cur = nil
}
}
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "# DeviceID:") {
pendingComment = line
continue
}
if trimmed == "[Peer]" {
flush()
seg := segment{isPeer: true}
if pendingComment != "" {
seg.lines = append(seg.lines, pendingComment)
seg.did = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(pendingComment), "# DeviceID:"))
pendingComment = ""
}
seg.lines = append(seg.lines, line)
cur = &seg
continue
}
if cur != nil && cur.isPeer {
if trimmed == "" || strings.HasPrefix(trimmed, "[") {
flush()
if pendingComment != "" {
segments = append(segments, segment{lines: []string{pendingComment}})
pendingComment = ""
}
if trimmed != "" {
cur = &segment{lines: []string{line}}
}
continue
}
cur.lines = append(cur.lines, line)
if k, v, ok := wgKV(trimmed); ok && k == "PublicKey" {
cur.pk = v
}
continue
}
// línea fuera de bloque peer
if pendingComment != "" {
if cur == nil {
cur = &segment{}
}
cur.lines = append(cur.lines, pendingComment)
pendingComment = ""
}
if cur == nil {
cur = &segment{}
}
cur.lines = append(cur.lines, line)
}
flush()
if err := scanner.Err(); err != nil {
return "", err
}
// reconstruir sustituyendo el target
var out strings.Builder
replaced := false
for _, seg := range segments {
if seg.isPeer && !replaced {
isTarget := (seg.pk == spec.PublicKey) ||
(spec.DeviceID != "" && seg.did == spec.DeviceID)
if isTarget {
out.WriteString("\n" + newBlock)
replaced = true
continue
}
}
for _, l := range seg.lines {
out.WriteString(l + "\n")
}
}
if !replaced {
result := out.String()
if !strings.HasSuffix(result, "\n") && len(result) > 0 {
result += "\n"
}
result += "\n" + newBlock
return result, nil
}
return out.String(), nil
}
// wgKV parsea "Key = Value" devolviendo (key, value, true) o ("", "", false).
func wgKV(line string) (string, string, bool) {
idx := strings.IndexByte(line, '=')
if idx < 0 {
return "", "", false
}
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:]), true
}
+57
View File
@@ -0,0 +1,57 @@
---
name: wg_peer_add
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func WGPeerAdd(spec WGPeerSpec, configPath, subnetCIDR string) (WGPeerResult, error)"
description: "Hub-side: anade peer WireGuard al wg0.conf con IP asignada del pool, syncconf en caliente sin reiniciar interface. Idempotente por PublicKey + DeviceID. Mantiene comentario # DeviceID:<id> sobre cada bloque [Peer] para tracking inverso."
tags: [wireguard, hub, peer, mesh, infra]
params:
- name: spec
desc: "WGPeerSpec con DeviceID (identificador logico), PublicKey (base64), PresharedKey (base64, opcional), AllowedIPs (CIDR; vacio = autoasignar del pool)"
- name: configPath
desc: "Ruta absoluta al wg0.conf del hub, ej /etc/wireguard/wg0.conf"
- name: subnetCIDR
desc: "Subnet del pool de IPs WireGuard, ej '10.42.0.0/24'. La .1 se reserva para el hub y se excluye del pool."
output: "WGPeerResult con DeviceID, AssignedIP (pura sin CIDR), ConfigPath y Status ('added'|'already-present'|'reconfigured')"
uses_functions: []
uses_types: [wg_peer_spec_go_infra, wg_peer_result_go_infra]
returns: [wg_peer_result_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests:
- "peer nuevo con AllowedIPs vacio asigna 10.42.0.2"
- "agregar segundo peer asigna 10.42.0.3"
- "agregar mismo PublicKey otra vez retorna already-present"
- "agregar DeviceID existente con clave distinta retorna reconfigured"
test_file_path: "functions/infra/wg_peer_add_test.go"
file_path: "functions/infra/wg_peer_add.go"
---
## Ejemplo
```go
spec := infra.WGPeerSpec{
DeviceID: "pc-aurgi",
PublicKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
}
res, err := infra.WGPeerAdd(spec, "/etc/wireguard/wg0.conf", "10.42.0.0/24")
// res.AssignedIP == "10.42.0.2" (primera IP libre del pool)
// res.Status == "added"
```
## Cuando usarla
Cuando un dispositivo nuevo (PC, contenedor, mobile) se une al mesh WireGuard del hub. Llamar tras generar las claves con `wg_keygen_go_infra`. Idempotente: si el DeviceID ya existe con la misma clave, devuelve `already-present` sin tocar el config.
## Gotchas
- **Race condition**: si dos llamadas concurrentes añaden peers simultáneamente, la segunda puede asignar la misma IP libre. Usar file lock (`flock`) sobre `configPath` para serializar en produccion.
- **WG_SKIP_SYNCCONF=1**: en entornos CI sin WireGuard instalado, establecer esta variable para saltarse el exec de `wg syncconf`. Los tests ya la activan en el `init()`.
- **syncconf falla → rollback automático**: si el `wg syncconf` devuelve error, se restaura el backup `.bak` y se devuelve error. El config queda intacto.
- **chmod 600**: la función hace `chmod 600` sobre `configPath` tras cada escritura. Asegúrate de que el proceso tiene permisos sobre el archivo.
- **Hub IP .1**: la función excluye `<red>.1` del pool de autoasignación. El hub debe tener esa IP. Si el hub usa otra IP, ajustar la lógica de `wgNextFreeIP`.
+160
View File
@@ -0,0 +1,160 @@
package infra
import (
"os"
"path/filepath"
"strings"
"testing"
)
const wgTestSubnet = "10.42.0.0/24"
// baseConfig es un [Interface] mínimo para simular wg0.conf vacío de peers.
const wgBaseConfig = `[Interface]
Address = 10.42.0.1/24
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
ListenPort = 51820
`
func wgTempConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "wg0.conf")
if err := os.WriteFile(p, []byte(content), 0600); err != nil {
t.Fatalf("wgTempConfig: %v", err)
}
return p
}
func init() {
// asegura que syncconf no se ejecuta en tests
os.Setenv("WG_SKIP_SYNCCONF", "1")
}
func TestWGPeerAdd(t *testing.T) {
t.Run("peer nuevo con AllowedIPs vacio asigna 10.42.0.2", func(t *testing.T) {
cfg := wgTempConfig(t, wgBaseConfig)
spec := WGPeerSpec{
DeviceID: "pc-aurgi",
PublicKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
}
res, err := WGPeerAdd(spec, cfg, wgTestSubnet)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.AssignedIP != "10.42.0.2" {
t.Errorf("AssignedIP = %q, want 10.42.0.2", res.AssignedIP)
}
if res.Status != "added" {
t.Errorf("Status = %q, want added", res.Status)
}
if res.DeviceID != "pc-aurgi" {
t.Errorf("DeviceID = %q, want pc-aurgi", res.DeviceID)
}
// verificar que el bloque está en el archivo
raw, _ := os.ReadFile(cfg)
content := string(raw)
if !strings.Contains(content, "# DeviceID: pc-aurgi") {
t.Errorf("config missing DeviceID comment, got:\n%s", content)
}
if !strings.Contains(content, "AllowedIPs = 10.42.0.2/32") {
t.Errorf("config missing AllowedIPs, got:\n%s", content)
}
})
t.Run("agregar segundo peer asigna 10.42.0.3", func(t *testing.T) {
cfg := wgTempConfig(t, wgBaseConfig)
spec1 := WGPeerSpec{
DeviceID: "pc-aurgi",
PublicKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
}
if _, err := WGPeerAdd(spec1, cfg, wgTestSubnet); err != nil {
t.Fatalf("add peer1: %v", err)
}
spec2 := WGPeerSpec{
DeviceID: "home-wsl",
PublicKey: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=",
}
res, err := WGPeerAdd(spec2, cfg, wgTestSubnet)
if err != nil {
t.Fatalf("add peer2: %v", err)
}
if res.AssignedIP != "10.42.0.3" {
t.Errorf("AssignedIP = %q, want 10.42.0.3", res.AssignedIP)
}
if res.Status != "added" {
t.Errorf("Status = %q, want added", res.Status)
}
raw, _ := os.ReadFile(cfg)
content := string(raw)
if !strings.Contains(content, "# DeviceID: home-wsl") {
t.Errorf("config missing second DeviceID comment, got:\n%s", content)
}
})
t.Run("agregar mismo PublicKey otra vez retorna already-present", func(t *testing.T) {
cfg := wgTempConfig(t, wgBaseConfig)
spec := WGPeerSpec{
DeviceID: "pc-aurgi",
PublicKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
}
if _, err := WGPeerAdd(spec, cfg, wgTestSubnet); err != nil {
t.Fatalf("first add: %v", err)
}
res, err := WGPeerAdd(spec, cfg, wgTestSubnet)
if err != nil {
t.Fatalf("second add: %v", err)
}
if res.Status != "already-present" {
t.Errorf("Status = %q, want already-present", res.Status)
}
if res.AssignedIP != "10.42.0.2" {
t.Errorf("AssignedIP = %q, want 10.42.0.2", res.AssignedIP)
}
})
t.Run("agregar DeviceID existente con clave distinta retorna reconfigured", func(t *testing.T) {
cfg := wgTempConfig(t, wgBaseConfig)
spec1 := WGPeerSpec{
DeviceID: "pc-aurgi",
PublicKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
}
if _, err := WGPeerAdd(spec1, cfg, wgTestSubnet); err != nil {
t.Fatalf("first add: %v", err)
}
// misma DeviceID, PublicKey diferente
spec2 := WGPeerSpec{
DeviceID: "pc-aurgi",
PublicKey: "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=",
AllowedIPs: "10.42.0.2/32",
}
res, err := WGPeerAdd(spec2, cfg, wgTestSubnet)
if err != nil {
t.Fatalf("reconfigure: %v", err)
}
if res.Status != "reconfigured" {
t.Errorf("Status = %q, want reconfigured", res.Status)
}
// la clave vieja no debe estar, la nueva sí
raw, _ := os.ReadFile(cfg)
content := string(raw)
if strings.Contains(content, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") {
t.Errorf("old PublicKey still present after reconfigure:\n%s", content)
}
if !strings.Contains(content, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=") {
t.Errorf("new PublicKey not found after reconfigure:\n%s", content)
}
// solo debe haber un bloque [Peer]
count := strings.Count(content, "[Peer]")
if count != 1 {
t.Errorf("[Peer] count = %d, want 1:\n%s", count, content)
}
})
}
+232
View File
@@ -0,0 +1,232 @@
package infra
import (
"fmt"
"os"
"os/exec"
"strings"
)
// WGPeerRemoveStatus indica el resultado de la operacion de borrado.
type WGPeerRemoveStatus string
const (
WGPeerRemoveStatusRemoved WGPeerRemoveStatus = "removed"
WGPeerRemoveStatusNotPresent WGPeerRemoveStatus = "not-present"
)
// WGPeerRemoveResult contiene el resultado de WGPeerRemove.
type WGPeerRemoveResult struct {
DeviceID string
Status WGPeerRemoveStatus
ConfigPath string
}
// WGPeerRemove elimina el bloque [Peer] asociado a deviceID del archivo configPath
// buscando el comentario "# DeviceID:<deviceID>" y aplica syncconf en caliente.
// Es idempotente: si el peer no existe devuelve status=not-present sin error.
//
// Formato esperado del config (el comentario puede preceder o estar dentro del bloque):
//
// # DeviceID:device-001
// [Peer]
// PublicKey = ...
// AllowedIPs = ...
func WGPeerRemove(deviceID string, configPath string) (WGPeerRemoveResult, error) {
result := WGPeerRemoveResult{
DeviceID: deviceID,
ConfigPath: configPath,
}
if strings.TrimSpace(deviceID) == "" {
return result, fmt.Errorf("wg_peer_remove: deviceID cannot be empty")
}
if strings.TrimSpace(configPath) == "" {
return result, fmt.Errorf("wg_peer_remove: configPath cannot be empty")
}
data, err := os.ReadFile(configPath)
if err != nil {
return result, fmt.Errorf("wg_peer_remove: read config %s: %w", configPath, err)
}
lines := strings.Split(string(data), "\n")
marker := fmt.Sprintf("# DeviceID:%s", deviceID)
// Localizar el marker.
markerIdx := -1
for i, line := range lines {
if strings.TrimSpace(line) == marker {
markerIdx = i
break
}
}
if markerIdx == -1 {
result.Status = WGPeerRemoveStatusNotPresent
return result, nil
}
// blockStart: la primera linea del bloque a eliminar.
// Si el marker precede al [Peer], el bloque empieza en el marker.
// Si el marker esta dentro del [Peer], retrocedemos hasta el [Peer] o su comentario previo.
blockStart := markerIdx
for j := markerIdx - 1; j >= 0; j-- {
t := strings.TrimSpace(lines[j])
if t == "[Peer]" {
blockStart = j
// Incluir tambien el comentario # DeviceID que preceda a ese [Peer].
if j > 0 && strings.HasPrefix(strings.TrimSpace(lines[j-1]), "# DeviceID:") {
blockStart = j - 1
}
break
}
if strings.HasPrefix(t, "[") && t != "" {
// Llegamos a otra seccion — el marker precede al [Peer].
break
}
}
// blockEnd: primera linea que abre el SIGUIENTE bloque tras el [Peer] actual.
// Saltamos hasta pasar el [Peer] propio antes de buscar otra seccion.
blockEnd := len(lines)
pastOwnPeer := false
for i := blockStart; i < len(lines); i++ {
t := strings.TrimSpace(lines[i])
if !pastOwnPeer {
if t == "[Peer]" {
pastOwnPeer = true
}
continue
}
if (strings.HasPrefix(t, "[") && t != "") || strings.HasPrefix(t, "# DeviceID:") {
blockEnd = i
break
}
}
// Reconstruir: segmento antes del bloque + segmento desde blockEnd.
before := lines[:blockStart]
after := lines[blockEnd:]
// Quitar lineas vacias finales del segmento anterior.
for len(before) > 0 && strings.TrimSpace(before[len(before)-1]) == "" {
before = before[:len(before)-1]
}
var newLines []string
newLines = append(newLines, before...)
if len(after) > 0 {
newLines = append(newLines, "")
}
newLines = append(newLines, after...)
newContent := strings.TrimRight(strings.Join(newLines, "\n"), "\n") + "\n"
if err := os.WriteFile(configPath, []byte(newContent), 0600); err != nil {
return result, fmt.Errorf("wg_peer_remove: write config %s: %w", configPath, err)
}
// Aplicar syncconf en caliente.
iface := wgIfaceFromPath(configPath)
if iface != "" {
if err := wgSyncConfFn(iface, configPath); err != nil {
_ = os.WriteFile(configPath, data, 0600)
return result, fmt.Errorf("wg_peer_remove: syncconf %s: %w", iface, err)
}
}
result.Status = WGPeerRemoveStatusRemoved
return result, nil
}
// wgIfaceFromPath extrae el nombre de interfaz del path del config (ej. wg0 de /etc/wireguard/wg0.conf).
func wgIfaceFromPath(configPath string) string {
parts := strings.Split(configPath, "/")
if len(parts) == 0 {
return ""
}
name := parts[len(parts)-1]
name = strings.TrimSuffix(name, ".conf")
for _, c := range name {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return ""
}
}
return name
}
// wgSyncConfFn aplica `wg syncconf <iface> <configPath>` en caliente. Variable
// para permitir override en tests (no requiere binario `wg`). WG_SKIP_SYNCCONF=1
// salta la ejecucion (CI sin wg-tools).
var wgSyncConfFn = func(iface, configPath string) error {
if os.Getenv("WG_SKIP_SYNCCONF") == "1" {
return nil
}
cmd := exec.Command("wg", "syncconf", iface, configPath)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// wgLookupPeerPublicKey busca la PublicKey del peer identificado por deviceID en configPath.
// El comentario "# DeviceID:<id>" puede preceder al [Peer] o estar dentro del bloque.
func wgLookupPeerPublicKey(deviceID, configPath string) (string, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return "", fmt.Errorf("wg_lookup_peer: read %s: %w", configPath, err)
}
lines := strings.Split(string(data), "\n")
marker := fmt.Sprintf("# DeviceID:%s", deviceID)
markerIdx := -1
for i, line := range lines {
if strings.TrimSpace(line) == marker {
markerIdx = i
break
}
}
if markerIdx == -1 {
return "", fmt.Errorf("wg_lookup_peer: peer %s not found in %s", deviceID, configPath)
}
// Buscar PublicKey hacia adelante desde el marker (saltando la linea [Peer]).
for i := markerIdx + 1; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if line == "[Peer]" {
continue
}
if (strings.HasPrefix(line, "[") && line != "") || strings.HasPrefix(line, "# DeviceID:") {
break
}
if strings.HasPrefix(line, "PublicKey") {
if idx := strings.Index(line, " = "); idx != -1 {
return strings.TrimSpace(line[idx+3:]), nil
}
if idx := strings.Index(line, "="); idx != -1 {
return strings.TrimSpace(line[idx+1:]), nil
}
}
}
// Fallback: buscar hacia atras (marker dentro del bloque).
for i := markerIdx - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "[") && line != "" {
break
}
if strings.HasPrefix(line, "PublicKey") {
if idx := strings.Index(line, " = "); idx != -1 {
return strings.TrimSpace(line[idx+3:]), nil
}
if idx := strings.Index(line, "="); idx != -1 {
return strings.TrimSpace(line[idx+1:]), nil
}
}
}
return "", fmt.Errorf("wg_lookup_peer: PublicKey not found for peer %s in %s", deviceID, configPath)
}
+51
View File
@@ -0,0 +1,51 @@
---
name: wg_peer_remove
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func WGPeerRemove(deviceID string, configPath string) (WGPeerRemoveResult, error)"
description: "Quita peer del hub wg0.conf por device_id, syncconf en caliente, idempotente. Para reconfigurar peer existente (no kill switch)."
tags: [wireguard, hub, peer, mesh, infra, audit]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: deviceID
desc: "Identificador unico del dispositivo. Debe coincidir con el comentario '# DeviceID:<id>' en el bloque [Peer] del config."
- name: configPath
desc: "Ruta absoluta al archivo de configuracion WireGuard (ej. /etc/wireguard/wg0.conf). Debe tener permisos de escritura."
output: "WGPeerRemoveResult con DeviceID, ConfigPath y Status (removed | not-present). not-present cuando el peer no existia — no es error."
tested: true
tests:
- "peer present → status=removed"
- "peer absent → status=not-present"
test_file_path: "functions/infra/wg_peer_remove_test.go"
file_path: "functions/infra/wg_peer_remove.go"
---
## Ejemplo
```go
result, err := infra.WGPeerRemove("device-laptop-alice", "/etc/wireguard/wg0.conf")
if err != nil {
log.Fatal(err)
}
// result.Status == "removed" o "not-present"
fmt.Printf("peer %s: %s\n", result.DeviceID, result.Status)
```
## Cuando usarla
Cuando necesites dar de baja temporalmente un peer del hub (dispositivo reemplazado, rotar claves, reconfigurar AllowedIPs). No deja rastro permanente — si el dispositivo vuelve a conectarse puedes añadirlo de nuevo. Para kill switch permanente (dispositivo perdido/comprometido) usa `wg_peer_revoke_go_infra`.
## Gotchas
- Requiere privilegios de root para `wg syncconf`. La funcion escribe el .conf y luego invoca `wg syncconf <iface> <path>`. Si el binario `wg` no esta disponible o el proceso no tiene permisos, el write se revierte automaticamente.
- El marker `# DeviceID:<id>` debe estar en el bloque [Peer] correcto. Si el comentario esta duplicado solo se elimina el primer bloque encontrado.
- El nombre de la interfaz se deduce del nombre del archivo (wg0.conf → wg0). Si configPath no sigue esa convencion, syncconf se omite (solo se modifica el archivo).
- La funcion NO toca la blacklist ni la audit DB — eso es exclusivo de `wg_peer_revoke`.
+87
View File
@@ -0,0 +1,87 @@
package infra
import (
"os"
"path/filepath"
"strings"
"testing"
)
const wgTestConfig = `[Interface]
Address = 10.0.0.1/24
PrivateKey = SERVERKEY==
# DeviceID:device-001
[Peer]
PublicKey = PUBKEY001==
AllowedIPs = 10.0.0.2/32
# DeviceID:device-002
[Peer]
PublicKey = PUBKEY002==
AllowedIPs = 10.0.0.3/32
`
func writeTestConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "wg0.conf")
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("write test config: %v", err)
}
return path
}
func TestWGPeerRemove(t *testing.T) {
t.Run("peer present → status=removed", func(t *testing.T) {
path := writeTestConfig(t, wgTestConfig)
// Patch syncconf to no-op for tests (wg binary not available in CI).
origSyncConf := wgSyncConfFn
wgSyncConfFn = func(iface, configPath string) error { return nil }
defer func() { wgSyncConfFn = origSyncConf }()
result, err := WGPeerRemove("device-001", path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Status != WGPeerRemoveStatusRemoved {
t.Errorf("got status=%q, want %q", result.Status, WGPeerRemoveStatusRemoved)
}
// Verify the peer block is gone from the file.
data, _ := os.ReadFile(path)
if strings.Contains(string(data), "DeviceID:device-001") {
t.Error("DeviceID:device-001 marker still present after remove")
}
if strings.Contains(string(data), "PUBKEY001==") {
t.Error("PUBKEY001 still present after remove")
}
// Other peer must remain.
if !strings.Contains(string(data), "DeviceID:device-002") {
t.Error("DeviceID:device-002 was incorrectly removed")
}
})
t.Run("peer absent → status=not-present", func(t *testing.T) {
path := writeTestConfig(t, wgTestConfig)
origSyncConf := wgSyncConfFn
wgSyncConfFn = func(iface, configPath string) error { return nil }
defer func() { wgSyncConfFn = origSyncConf }()
result, err := WGPeerRemove("device-999", path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Status != WGPeerRemoveStatusNotPresent {
t.Errorf("got status=%q, want %q", result.Status, WGPeerRemoveStatusNotPresent)
}
// File must be unchanged.
data, _ := os.ReadFile(path)
if !strings.Contains(string(data), "DeviceID:device-001") {
t.Error("existing peers were modified when removing absent peer")
}
})
}
+157
View File
@@ -0,0 +1,157 @@
package infra
import (
"crypto/sha256"
"database/sql"
_ "embed"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
//go:embed migrations/wg_revoked/001_revoked_peers.sql
var wgRevokedMigration string
// WGPeerRevokeAudit contiene el registro inmutable de una revocacion.
type WGPeerRevokeAudit struct {
DeviceID string
PublicKey string
RevokedAt int64
RevokedBy string
Reason string
PrevHash string
ThisHash string
}
// WGPeerRevoke revoca permanentemente un peer: lo elimina del config activo,
// lo registra en una audit DB con hash chain SHA256 inviolable y escribe en
// la blacklist persistente /etc/wireguard/wg_revoked.list.
//
// Reglas:
// - reason no puede estar vacio.
// - Revocar un peer ya revocado devuelve error "already revoked".
// - auditDBPath se crea con migracion embebida si no existe.
// - this_hash = SHA256(prev_hash || device_id || public_key || revoked_at || revoked_by || reason)
func WGPeerRevoke(deviceID, operator, reason string, configPath, auditDBPath string) (WGPeerRevokeAudit, error) {
audit := WGPeerRevokeAudit{
DeviceID: deviceID,
RevokedBy: operator,
Reason: reason,
}
if strings.TrimSpace(deviceID) == "" {
return audit, fmt.Errorf("wg_peer_revoke: deviceID cannot be empty")
}
if strings.TrimSpace(operator) == "" {
return audit, fmt.Errorf("wg_peer_revoke: operator cannot be empty")
}
if strings.TrimSpace(reason) == "" {
return audit, fmt.Errorf("wg_peer_revoke: reason cannot be empty")
}
if strings.TrimSpace(configPath) == "" {
return audit, fmt.Errorf("wg_peer_revoke: configPath cannot be empty")
}
if strings.TrimSpace(auditDBPath) == "" {
return audit, fmt.Errorf("wg_peer_revoke: auditDBPath cannot be empty")
}
// 1. Abrir/crear audit DB y aplicar migracion.
if err := os.MkdirAll(filepath.Dir(auditDBPath), 0700); err != nil {
return audit, fmt.Errorf("wg_peer_revoke: mkdir audit db dir: %w", err)
}
db, err := sql.Open("sqlite3", auditDBPath+"?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: open audit db: %w", err)
}
defer db.Close()
if _, err := db.Exec(wgRevokedMigration); err != nil {
return audit, fmt.Errorf("wg_peer_revoke: apply migration: %w", err)
}
// 2. Verificar que no este ya revocado ANTES del lookup (el peer puede
// haber sido eliminado del config por una revocacion previa).
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM revoked_peers WHERE device_id = ?", deviceID).Scan(&count); err != nil {
return audit, fmt.Errorf("wg_peer_revoke: check existing: %w", err)
}
if count > 0 {
return audit, fmt.Errorf("wg_peer_revoke: device %s already revoked", deviceID)
}
// 3. Lookup PublicKey (el peer debe estar aun en el config en este punto).
pubKey, err := wgLookupPeerPublicKey(deviceID, configPath)
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: lookup peer: %w", err)
}
audit.PublicKey = pubKey
// 4. Obtener prev_hash (hash del ultimo registro, o string vacio si genesis).
var prevHash string
_ = db.QueryRow("SELECT this_hash FROM revoked_peers ORDER BY revoked_at DESC LIMIT 1").Scan(&prevHash)
// 5. Calcular this_hash = SHA256(prevHash || deviceID || publicKey || revokedAt || operator || reason).
revokedAt := time.Now().Unix()
audit.RevokedAt = revokedAt
audit.PrevHash = prevHash
hashInput := fmt.Sprintf("%s|%s|%s|%d|%s|%s", prevHash, deviceID, pubKey, revokedAt, operator, reason)
thisHash := fmt.Sprintf("%x", sha256.Sum256([]byte(hashInput)))
audit.ThisHash = thisHash
// 6. WGPeerRemove (eliminar del config + syncconf).
removeResult, err := WGPeerRemove(deviceID, configPath)
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: remove peer: %w", err)
}
_ = removeResult // status puede ser removed o not-present (ya borrado, igual revocamos)
// 7. Insertar en audit DB dentro de una transaccion.
tx, err := db.Begin()
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
_, err = tx.Exec(
`INSERT INTO revoked_peers (device_id, public_key, revoked_at, revoked_by, reason, prev_hash, this_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
deviceID, pubKey, revokedAt, operator, reason, prevHash, thisHash,
)
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: insert audit record: %w", err)
}
if err := tx.Commit(); err != nil {
return audit, fmt.Errorf("wg_peer_revoke: commit audit record: %w", err)
}
// 8. Append a blacklist persistente /etc/wireguard/wg_revoked.list.
blacklistLine := fmt.Sprintf("%s %d # DeviceID:%s operator:%s reason:%s\n",
pubKey, revokedAt, deviceID, operator, reason)
if err := wgAppendBlacklistFn(blacklistLine); err != nil {
// No revertir — el audit DB ya tiene el registro. Loguear como advertencia.
return audit, fmt.Errorf("wg_peer_revoke: append blacklist (audit DB committed): %w", err)
}
return audit, nil
}
// wgAppendBlacklistFn escribe una linea al final de /etc/wireguard/wg_revoked.list (append-only).
// Variable para permitir override en tests sin requerir permisos de /etc/wireguard/.
var wgAppendBlacklistFn = func(line string) error {
const blacklistPath = "/etc/wireguard/wg_revoked.list"
f, err := os.OpenFile(blacklistPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("open blacklist %s: %w", blacklistPath, err)
}
defer f.Close()
if _, err := f.WriteString(line); err != nil {
return fmt.Errorf("write blacklist: %w", err)
}
return nil
}
+66
View File
@@ -0,0 +1,66 @@
---
name: wg_peer_revoke
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func WGPeerRevoke(deviceID, operator, reason string, configPath, auditDBPath string) (WGPeerRevokeAudit, error)"
description: "Kill switch: revoca peer permanentemente. Anade a blacklist + audit log hash-chained inviolable (SHA256 chain). Para dispositivos perdidos/comprometidos."
tags: [wireguard, hub, revoke, kill-switch, audit, security]
uses_functions:
- wg_peer_remove_go_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: deviceID
desc: "Identificador unico del dispositivo a revocar. Debe coincidir con '# DeviceID:<id>' en wg0.conf."
- name: operator
desc: "Nombre del operador que ejecuta la revocacion. Se guarda en audit log. No puede ser vacio."
- name: reason
desc: "Motivo de la revocacion (ej. 'dispositivo perdido', 'comprometido'). No puede ser vacio. No incluir PII."
- name: configPath
desc: "Ruta absoluta al wg0.conf del hub. El peer se elimina del config activo via syncconf."
- name: auditDBPath
desc: "Ruta al SQLite de audit. Se crea con schema si no existe. Contiene la cadena de hashes inviolable."
output: "WGPeerRevokeAudit con DeviceID, PublicKey, RevokedAt (unix), RevokedBy, Reason, PrevHash y ThisHash. ThisHash = SHA256(PrevHash|DeviceID|PublicKey|RevokedAt|Operator|Reason)."
tested: true
tests:
- "audit DB contiene registro con this_hash != prev_hash"
- "segunda revoke del mismo peer → error already revoked"
test_file_path: "functions/infra/wg_peer_revoke_test.go"
file_path: "functions/infra/wg_peer_revoke.go"
---
## Ejemplo
```go
audit, err := infra.WGPeerRevoke(
"device-laptop-alice",
"operator-bob",
"dispositivo perdido en viaje",
"/etc/wireguard/wg0.conf",
"/var/lib/wg-hub/revoked.db",
)
if err != nil {
log.Fatal(err)
}
fmt.Printf("revoked: device=%s pubkey=%s hash=%s\n",
audit.DeviceID, audit.PublicKey, audit.ThisHash)
```
## Cuando usarla
Cuando un dispositivo esta perdido, robado o comprometido y NO debe poder volver a conectarse nunca. Deja registro inmutable en audit DB con hash chain SHA256 y escribe la PublicKey en `/etc/wireguard/wg_revoked.list`. Para baja temporal (rotar claves, reconfigurar) usa `wg_peer_remove_go_infra`.
## Gotchas
- **Reason obligatorio**: reason vacio devuelve error antes de tocar nada.
- **Idempotencia**: revocar el mismo deviceID dos veces devuelve `already revoked`. La comprobacion es sobre la audit DB, no sobre el config.
- **Audit DB borrada**: si auditDBPath se borra, la chain se reinicia desde genesis (prev_hash vacio). El break de integridad es visible (primer registro sin prev_hash). El sistema lo acepta pero queda evidencia del gap.
- **Blacklist `/etc/wireguard/wg_revoked.list`**: requiere permisos de escritura en `/etc/wireguard/`. Si falla, el audit DB ya tiene el registro (no se revierte) pero la funcion devuelve error indicando el gap.
- **Reason no debe contener PII**: se guarda en texto plano en SQLite y en la blacklist. Usar referencias internas en vez de nombres reales.
- **syncconf**: hereda el requisito de privilegios de WGPeerRemove. Ver gotchas de `wg_peer_remove_go_infra`.
+104
View File
@@ -0,0 +1,104 @@
package infra
import (
"database/sql"
"os"
"path/filepath"
"testing"
_ "github.com/mattn/go-sqlite3"
)
const wgRevokeTestConfig = `[Interface]
Address = 10.0.0.1/24
PrivateKey = SERVERKEY==
# DeviceID:device-revoke-001
[Peer]
PublicKey = PUBKEYREVOKE001==
AllowedIPs = 10.0.0.10/32
# DeviceID:device-revoke-002
[Peer]
PublicKey = PUBKEYREVOKE002==
AllowedIPs = 10.0.0.11/32
`
func TestWGPeerRevoke(t *testing.T) {
origSyncConf := wgSyncConfFn
wgSyncConfFn = func(iface, configPath string) error { return nil }
defer func() { wgSyncConfFn = origSyncConf }()
origBlacklist := wgAppendBlacklistFn
wgAppendBlacklistFn = func(line string) error { return nil }
defer func() { wgAppendBlacklistFn = origBlacklist }()
t.Run("audit DB contiene registro con this_hash != prev_hash", func(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "wg0.conf")
auditDBPath := filepath.Join(dir, "revoked.db")
if err := os.WriteFile(configPath, []byte(wgRevokeTestConfig), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
audit, err := WGPeerRevoke("device-revoke-001", "operator-alice", "dispositivo perdido", configPath, auditDBPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if audit.ThisHash == "" {
t.Error("this_hash is empty")
}
// En el primer registro prev_hash es vacio — this_hash debe diferir siempre.
if audit.ThisHash == audit.PrevHash {
t.Errorf("this_hash == prev_hash (%q), expected different values", audit.ThisHash)
}
if audit.PublicKey != "PUBKEYREVOKE001==" {
t.Errorf("public_key=%q, want PUBKEYREVOKE001==", audit.PublicKey)
}
// Verificar en la BD directamente.
db, err := sql.Open("sqlite3", auditDBPath)
if err != nil {
t.Fatalf("open audit db: %v", err)
}
defer db.Close()
var storedHash, storedPubKey string
if err := db.QueryRow("SELECT this_hash, public_key FROM revoked_peers WHERE device_id = ?",
"device-revoke-001").Scan(&storedHash, &storedPubKey); err != nil {
t.Fatalf("query audit record: %v", err)
}
if storedHash != audit.ThisHash {
t.Errorf("stored hash=%q, want %q", storedHash, audit.ThisHash)
}
if storedPubKey != "PUBKEYREVOKE001==" {
t.Errorf("stored public_key=%q, want PUBKEYREVOKE001==", storedPubKey)
}
})
t.Run("segunda revoke del mismo peer → error already revoked", func(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "wg0.conf")
auditDBPath := filepath.Join(dir, "revoked.db")
if err := os.WriteFile(configPath, []byte(wgRevokeTestConfig), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
// Primera revocacion.
if _, err := WGPeerRevoke("device-revoke-002", "operator-bob", "comprometido", configPath, auditDBPath); err != nil {
t.Fatalf("first revoke unexpected error: %v", err)
}
// Segunda revocacion del mismo deviceID → debe fallar por audit DB, no por config.
_, err := WGPeerRevoke("device-revoke-002", "operator-bob", "segundo intento", configPath, auditDBPath)
if err == nil {
t.Fatal("expected error on second revoke, got nil")
}
if !contains(err.Error(), "already revoked") {
t.Errorf("expected 'already revoked' error, got: %v", err)
}
})
}
+17
View File
@@ -0,0 +1,17 @@
package infra
// WGPeerSpec describe un peer WireGuard a añadir al hub.
type WGPeerSpec struct {
DeviceID string // identificador logico ("pc-aurgi", "home-wsl", "android-egu")
PublicKey string // base64 — clave publica del peer
PresharedKey string // base64 — opcional, "" para omitir
AllowedIPs string // CIDR a rutear a este peer, ej "10.42.0.10/32"; "" para autoasignar
}
// WGPeerResult es el resultado de añadir o verificar un peer en wg0.conf.
type WGPeerResult struct {
DeviceID string // identificador logico del peer
AssignedIP string // IP asignada, ej "10.42.0.10"
ConfigPath string // ruta absoluta al config aplicado
Status string // "added" | "already-present" | "reconfigured"
}
+8 -3
View File
@@ -12,10 +12,15 @@ require (
github.com/jackc/pgx/v5 v5.9.1
github.com/marcboeker/go-duckdb v1.8.5
github.com/mattn/go-sqlite3 v1.14.44
github.com/microcosm-cc/bluemonday v1.0.27
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/yuin/goldmark v1.8.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.51.0
golang.org/x/net v0.54.0
golang.org/x/sync v0.20.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.28.0
nhooyr.io/websocket v1.8.17
)
@@ -25,6 +30,7 @@ require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apache/arrow-go/v18 v18.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
@@ -42,6 +48,7 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/flatbuffers v25.1.24+incompatible // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -56,19 +63,18 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/paulmach/orb v0.12.0 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/zerolog v1.35.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.mau.fi/util v0.9.9 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
@@ -81,5 +87,4 @@ require (
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
maunium.net/go/mautrix v0.28.0 // indirect
)
+14 -18
View File
@@ -4,6 +4,8 @@ github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoy
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
@@ -12,6 +14,8 @@ github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
@@ -68,6 +72,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -104,10 +110,10 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
@@ -122,6 +128,8 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -140,6 +148,8 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -166,6 +176,8 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
@@ -185,18 +197,12 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -204,8 +210,6 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -222,12 +226,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -235,16 +235,12 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+156
View File
@@ -0,0 +1,156 @@
atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU=
atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ=
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bitfield/script v0.24.0/go.mod h1:fv+6x4OzVsRs6qAlc7wiGq8fq1b5orhtQdtW0dwjUHI=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dmarkham/enumer v1.6.3/go.mod h1:DyjXaqCglj4GhELF73oWiparNkYkXvmOBLza/o4kO74=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hamba/avro/v2 v2.27.0/go.mod h1:jN209lopfllfrz7IGoZErlDz+AyUJ3vrBePQFZwYf5I=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/itchyny/gojq v0.12.13/go.mod h1:JzwzAqenfhrPUuwbmEz3nu3JQmFLlQTQMUcOdnu/Sf4=
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/jackmordaunt/icns v1.0.0/go.mod h1:7TTQVEuGzVVfOPPlLNHJIkzA6CoV7aH1Dv9dW351oOo=
github.com/jaypipes/ghw v0.13.0/go.mod h1:In8SsaDqlb1oTyrbmTC14uy+fbBMvp+xdqX51MidlD8=
github.com/jaypipes/pcidb v1.0.1/go.mod h1:6xYUz/yYEyOkIkUt2t2J2folIuZ4Yg6uByCGFXMCeE4=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leaanthony/clir v1.3.0/go.mod h1:k/RBkdkFl18xkkACMCLt09bhiZnrGORoxmomeMvDpE0=
github.com/leaanthony/winicon v1.0.0/go.mod h1:en5xhijl92aphrJdmRPlh4NI1L6wq3gEm0LpXAPghjU=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mkevac/debugcharts v0.0.0-20191222103121-ae1c48aa8615/go.mod h1:Ad7oeElCZqA1Ufj0U9/liOF4BtVepxRcTvr2ey7zTvM=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pascaldekloe/name v1.0.1/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pterm/pterm v0.12.80/go.mod h1:c6DeF9bSnOSeFPZlfs4ZRAFcf5SCoTwvwQ5xaKGQlHo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M=
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/substrait-io/substrait v0.62.0/go.mod h1:MPFNw6sToJgpD5Z2rj0rQrdP/Oq8HG7Z2t3CAEHtkHw=
github.com/substrait-io/substrait-go/v3 v3.2.1/go.mod h1:F/BIXKJXddJSzUwbHnRVcz973mCVsTfBpTUvUNX7ptM=
github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/wzshiming/ctc v1.2.3/go.mod h1:2tVAtIY7SUyraSk0JxvwmONNPFL4ARavPuEsg5+KA28=
github.com/wzshiming/winseq v0.0.0-20200112104235-db357dc107ae/go.mod h1:VTAq37rkGeV+WOybvZwjXiJOicICdpLCN8ifpISjK20=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=
+43
View File
@@ -0,0 +1,43 @@
---
name: docker_container_info
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type DockerContainerInfo struct {
ID string
Names []string
Image string
State string
Status string
Ports []string
Networks []string
Labels map[string]string
}
description: "Container Docker enriquecido retornado por DockerContainerList. Campos richer que ContainerInfo: Names y Ports como slices, Networks, Labels como map."
tags: [docker, docker-agent, container, infra]
uses_types: []
file_path: "functions/infra/docker_container_list.go"
---
## Ejemplo
```go
c := infra.DockerContainerInfo{
ID: "abc123def456",
Names: []string{"/my-app"},
Image: "nginx:latest",
State: "running",
Status: "Up 2 hours",
Ports: []string{"0.0.0.0:8080->80/tcp"},
Networks: []string{"bridge"},
Labels: map[string]string{"app": "my-app", "env": "prod"},
}
// Mostrar nombre sin slash inicial
fmt.Println(strings.TrimPrefix(c.Names[0], "/"))
```
## Notas
Difiere de `ContainerInfo` (container_info_go_infra) en que `Names` y `Ports` son `[]string` en lugar de `string`, y añade `Networks`. Esto refleja el JSON nativo del Docker Engine API (`/containers/json`) donde estos campos son arrays. Retornado por `docker_container_list_go_infra`.
+36
View File
@@ -0,0 +1,36 @@
---
name: docker_exec_result
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type DockerExecResult struct {
ExitCode int
Stdout string
Stderr string
Duration int64
}
description: "Resultado de ejecutar un comando dentro de un container Docker via Engine API. ExitCode es el codigo de salida del proceso; Stdout/Stderr estan demuxeados del stream multiplexado; Duration es la duracion real en milisegundos."
tags: [docker, docker-agent, exec, infra]
uses_types: []
file_path: "functions/infra/docker_container_exec.go"
---
## Ejemplo
```go
result := infra.DockerExecResult{
ExitCode: 0,
Stdout: "uid=0(root) gid=0(root) groups=0(root)\n",
Stderr: "",
Duration: 42,
}
if result.ExitCode != 0 {
log.Printf("command failed (exit %d): %s", result.ExitCode, result.Stderr)
}
```
## Notas
`ExitCode` refleja el exit code real del proceso ejecutado dentro del container, no el de la llamada HTTP. Si el proceso no termino (timeout, error de red), la funcion retorna error y `DockerExecResult` queda vacio. `Duration` mide tiempo wall-clock incluyendo overhead de red y demux.
+31
View File
@@ -0,0 +1,31 @@
---
name: docker_log_line
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type DockerLogLine struct {
Stream string
Timestamp string
Line string
}
description: "Linea de log de un contenedor Docker con su stream de origen (stdout/stderr), timestamp RFC3339 opcional y contenido."
tags: [docker, docker-agent, logs, infra]
uses_types: []
file_path: "functions/infra/docker_log_line.go"
---
## Ejemplo
```go
line := DockerLogLine{
Stream: "stdout",
Timestamp: "2026-05-23T12:00:00.000000000Z",
Line: "server started on :8080",
}
```
## Notas
`Timestamp` solo se rellena si `DockerLogsOpts.Timestamps = true`. El valor viene del prefijo que el daemon Docker antepone a cada linea antes del demux de frame.
+41
View File
@@ -0,0 +1,41 @@
---
name: docker_logs_opts
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type DockerLogsOpts struct {
ContainerID string
Tail int
Since string
Stdout bool
Stderr bool
Timestamps bool
DockerHost string
}
description: "Parametros para la peticion de logs al engine API de Docker. Usado por DockerContainerLogs y DockerContainerLogsStream."
tags: [docker, docker-agent, logs, infra]
uses_types: []
file_path: "functions/infra/docker_log_line.go"
---
## Ejemplo
```go
opts := DockerLogsOpts{
ContainerID: "my-app",
Tail: 100,
Since: "10m",
Stdout: true,
Stderr: true,
Timestamps: true,
}
```
## Notas
- `Tail = 0` efectivamente usa el default de 100 lineas. `Tail = -1` devuelve todas.
- `Since` acepta unix timestamp en string ("1716400000"), RFC3339 o duracion Go ("10m", "1h30m").
- Si tanto `Stdout` como `Stderr` son false, la funcion los activa ambos por defecto.
- `DockerHost` vacio conecta al socket Unix `/var/run/docker.sock`.
+28
View File
@@ -0,0 +1,28 @@
---
name: shell_exec_opts
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type ShellExecOpts struct {
Cmd []string
BinariesAllowed []string
Env []string
WorkingDir string
TimeoutSeconds int
StdinPayload []byte
MaxOutputBytes int
User string
}
description: "Parametros de configuracion para ShellExecWhitelist. Define el comando, whitelist de binarios, entorno, timeout, stdin y limite de output."
tags: [shell, exec, security, sandbox, device-agent, infra, agents]
uses_types: []
file_path: "functions/infra/shell_exec_whitelist.go"
---
## Notas
- `BinariesAllowed` vacío rechaza todo sin spawn. Nunca construir dinámicamente desde input externo.
- `Env` vacío activa entorno mínimo: `PATH=/usr/bin:/bin`, `HOME`, `USER`, `LANG=C.UTF-8`.
- `MaxOutputBytes` aplica por separado a stdout y stderr. Default 1 MB cada uno.
+27
View File
@@ -0,0 +1,27 @@
---
name: shell_exec_result
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type ShellExecResult struct {
ExitCode int
Stdout string
Stderr string
Duration int64
Truncated bool
TimedOut bool
}
description: "Resultado de ejecutar un comando shell con ShellExecWhitelist. Contiene stdout/stderr separados, exit code, duracion en ms, y flags de truncado y timeout."
tags: [shell, exec, security, sandbox, device-agent, infra, agents]
uses_types: []
file_path: "functions/infra/shell_exec_whitelist.go"
---
## Notas
- `ExitCode`: codigo de salida del proceso. -1 si el proceso fue matado por SIGKILL (timeout).
- `Truncated`: true si stdout o stderr fue recortado por `MaxOutputBytes`.
- `TimedOut`: true si el proceso fue terminado por timeout antes de completar.
- `Duration`: tiempo real de ejecucion en milisegundos (incluye tiempo hasta SIGKILL si aplica).
+35
View File
@@ -0,0 +1,35 @@
---
name: wg_client_config
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type WGClientConfigInput struct {
DevicePrivateKey string
DeviceAddress string
HubPublicKey string
HubEndpoint string
HubAllowedIPs string
PresharedKey string
PersistentKA int
DNS string
}
type WGClientConfig struct {
INI string
QR string
Filename string
}
description: "Par de tipos producto para generar wg0.conf del peer cliente: WGClientConfigInput reune todos los parametros de configuracion del device, WGClientConfig devuelve el .conf listo para instalar, el QR unicode y el filename sugerido."
tags: [wireguard, client, config, qr, mesh, vpn]
uses_types: []
file_path: "functions/infra/wg_client_config_types.go"
---
## Notas
`WGClientConfigInput.PersistentKA` con valor 0 se interpreta como "usar default 25s". Valor 25 es el recomendado para peers detras de NAT carrier-grade (movil/4G).
`WGClientConfigInput.PresharedKey` vacio omite la linea PSK del .conf. Si se usa, debe coincidir exactamente con el valor configurado en el hub para este peer.
`WGClientConfig.QR` usa bloques unicode (skip2/go-qrcode ToString) — visible en terminal y en mensajes Element. Para display en pantallas pequenas, considerar `ToSmallString`.
+21
View File
@@ -0,0 +1,21 @@
---
name: WGKeys
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type WGKeys struct {
PrivateKey string
PublicKey string
PresharedKey string
}
description: "Par de claves WireGuard Curve25519 (privada y publica) en base64, con preshared key opcional para defensa quantum-safe en mesh."
tags: [wireguard, crypto, infra, mesh]
uses_types: []
file_path: "functions/infra/wg_keygen.go"
---
## Notas
Todos los campos son cadenas base64 de 44 caracteres (32 bytes Curve25519 codificados). `PresharedKey` esta vacio cuando `WGKeygen` se llama con `withPSK=false`. NUNCA persistir `PrivateKey` ni `PresharedKey` en logs ni en texto plano.
+22
View File
@@ -0,0 +1,22 @@
---
name: wg_peer_result
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type WGPeerResult struct {
DeviceID string
AssignedIP string
ConfigPath string
Status string
}
description: "Resultado de añadir o verificar un peer WireGuard en el hub. Status puede ser 'added', 'already-present' o 'reconfigured'. AssignedIP es la IP pura sin prefijo CIDR."
tags: [wireguard, hub, peer, mesh, infra]
uses_types: []
file_path: "functions/infra/wg_peer_types.go"
---
## Notas
Retornado por `wg_peer_add_go_infra`. Status "already-present" indica idempotencia exitosa — no se modifico el config. "reconfigured" indica que se reemplazo el bloque del peer (cambio de PublicKey o AllowedIPs).
+22
View File
@@ -0,0 +1,22 @@
---
name: wg_peer_spec
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type WGPeerSpec struct {
DeviceID string
PublicKey string
PresharedKey string
AllowedIPs string
}
description: "Especificacion de un peer WireGuard a añadir al hub. DeviceID es el identificador logico del dispositivo. PublicKey y PresharedKey son base64. AllowedIPs es el CIDR a rutear; si vacio se autoasigna del pool."
tags: [wireguard, hub, peer, mesh, infra]
uses_types: []
file_path: "functions/infra/wg_peer_types.go"
---
## Notas
Usado como input de `wg_peer_add_go_infra`. PresharedKey es opcional — pasar string vacio para omitirlo. AllowedIPs vacio activa la autoasignacion de IP dentro del subnetCIDR pasado a WGPeerAdd.