ADR-0008: Remote-Pulse — Agente Universal de Conectividad, Salud y Control Remoto¶
Status: Accepted Date: 2026-05-25 Accepted: 2026-05-25 (same-day execution via parallel sub-agent orchestration) Deciders: Ramón Kamibayashi + Hermes Agent Technical Story: Plataforma agente+servidor para inventario, heartbeats, intercambio de SSH keys, control remoto (terminal y pantalla) y dashboards sparkline sobre todos los nodos del ecosistema monxas (homelab + externos + proyectos terceros), instalable con un único comando.
Context¶
El ecosistema monxas crece más allá de la LAN homelab. Hoy conviven nodos en tres anillos:
- Homelab (LAN 192.168.0.0/24): cluster Proxmox 2 nodos (pmx-50, pmx-51), VM 208 media-server, ~12 LXCs producción (Caddy 270/271, hermesbot 101, clawdbot 100, n8n 200, rag 251, tv-gw 110, ha-ml 172, rustdesk futuro), VM 171 Home Assistant, NAS TerraMaster F4-423, Windows AI Tagger .115 (RTX 3080).
- Externos directos (familia/amigos): Mac mini Carmelo (M4 headless con openclaw-vm Ubuntu), futuros nodos para hermanos/padres con Tailscale.
- Proyectos cliente (iarquitectos.com): stack RAG + Locator + Validator viviendo en Carmelo VM, expuesto vía Cloudflare Tunnel
*.iarquitectos.com.
El problema: no hay observabilidad uniforme ni control programático cross-anillo.
- Inventario disperso: SSH a cada host para
uname -a,df,uptime. Ningún registro de qué hay corriendo dónde. Memorias de Claude llenas de IPs y notas obsoletas (proxmox_host.mddesactualizado en días). - SSH keys ad-hoc:
~/.ssh/authorized_keyscopiado a mano víassh-copy-id. Sin rotación, sin auditoría, sin grupos. Mac mini Carmelo tiene 4 pubkeys distintas, ninguna documentada. - Acceso remoto fragmentado: SSH classic + Tailscale SSH (parcial) + RustDesk manual (sin self-host) + AnyDesk casero familiar + RDP Windows ocasional. Cada uno auth distinto, cada uno se rompe distinto.
- Sin "está vivo?": caída de un nodo se detecta cuando algo falla aguas abajo. Healthchecks cubre cron jobs pero no presencia de hosts.
- Onboarding de nodos manual: instalar Tailscale, copiar keys, configurar Prometheus node_exporter, registrar en Pi-hole, abrir CF Tunnel route, añadir a inventory Ansible = ~40min de pasos manuales repetitivos, frágiles.
- Sin dashboard "all my machines": Grafana tiene paneles por servicio, no por nodo. ha-ml tiene features modelo, no operador. No hay vista "estado de todo lo mío" con sparklines CPU/RAM/disk/latencia.
Restricciones:
- Cualquier nodo nuevo (amigo, cliente, RPi en oficina ajena) debe instalarse con un solo comando sin requerir LAN VPN preconfigurada.
- No-LAN nodes: comunicación debe funcionar tras NAT/CGNAT sin port forwarding (Tailscale obligatorio para data-plane, fallback HTTPS via CF Tunnel solo para enrollment bootstrap).
- Reusar infra homelab existente: SOPS+age, PocketID OIDC, Loki/Prometheus/Grafana, Caddy HA VIP
.250, Ansible-pull. - Agente debe correr en Linux (deb/rpm/Alpine), macOS (arm64/x86_64), Windows (Win10+) sin compilación local.
- Open-sourceable: el agente y CLI serán públicos (
monxas/remote-pulse); el inventario y secretos del homelab nunca tocan el repo público.
Decision Drivers (prioridad descendente)¶
- One-command onboarding — instalar agente en host nuevo (cualquier OS) y registrarlo en el dashboard en <60s, sin pasos manuales post-instalación.
- NAT-agnostic data plane — todo el tráfico agente↔server cruza NAT/CGNAT sin port-forwarding via Tailscale. Server alcanzable desde cualquier red sin VPN tradicional.
- SSH key lifecycle automatizado — generación, registro, distribución, revocación de SSH pubkeys gestionados centralmente, agrupables por ACL (prod / family / clients).
- Triple dashboard (TUI / Web / Grafana) — sparklines inline por host (CPU, RAM, disk, net rx/tx, latency, uptime). TUI para uso diario terminal, Web para móvil via Tailscale o CF Access, Grafana para histórico profundo.
- Remote control multi-modal — Tailscale SSH (preferred), SSH classic con keys gestionadas (fallback), RustDesk self-hosted (GUI Linux/Win/macOS), Sunshine+Moonlight (gaming-grade para Windows RTX 3080), VNC fallback (Linux GUI sin GPU).
- Phone-home auditable — heartbeats, métricas, inventory diff, ejecución de comandos remotos — todo loggeable, replicable, auditado en Postgres + Loki.
- Open-sourceable — agente FOSS para que terceros (clientes iarq, familia, futuros usuarios) instalen sin exponer inventario homelab. Privacy by default.
- Reusabilidad stack homelab — observability/SSO/secrets/HA existentes deben usarse; no duplicar Grafana, no inventar nueva auth, no reescribir Caddy.
Considered Options¶
1. Topología de despliegue: ¿server+client?, ¿qué repos?, ¿cómo se distribuye?¶
| Opción | Pros | Contras | Veredicto |
|---|---|---|---|
Server (privado homelab-infra/services/remote-pulse-server/) + Agente público (monxas/remote-pulse) |
Repo público FOSS-able (curl-pipe-sh seguro de auditar), server privado con inventario+secretos, reusa SOPS/Caddy/PocketID homelab, claro separation of concerns | 2 repos a mantener, agente debe ser autosuficiente (no asumir homelab-infra), versionado independiente (semver agente, rolling server) | ✅ Elegido |
Todo en homelab-infra (privado) |
1 repo, deploy simple | Imposible publicar curl-pipe-sh sin exponer todo; terceros no pueden instalar sin acceso GitHub privado | ❌ Contradice driver #1 y #7 |
Todo en repo nuevo monxas/remote-pulse (público con secrets via env) |
Cohesión, repo único | Secretos del homelab no caben (inventory IPs, group ACLs, OIDC clients), driver #7 incumplido | ❌ Filtración riesgo |
| Mesh peer-to-peer sin server central (gossip Hashicorp Serf) | Sin SPOF, simple ops | Sin dashboard centralizado, ACLs SSH-keys imposibles sin server de verdad, sparkline aggregation distribuida = pesadilla | ❌ Falla driver #3,#4 |
Decisión topología: Split server privado (homelab-infra/services/remote-pulse-server/ desplegado en LXC nuevo) + agente público (monxas/remote-pulse repo público con LICENSE Apache-2.0). El agente lee RP_SERVER_URL y RP_ENROLLMENT_TOKEN de env/CLI. Versionado agente: SemVer v0.x.y hasta GA, registro en GitHub Releases con SHA256SUMS firmados (cosign opcional). El servidor consume API pública del agente (REST/WS estable post v1.0).
2. Transporte de red y NAT-traversal¶
| Opción | Pros | Contras | Veredicto |
|---|---|---|---|
Tailscale (tailscaled daemon en server + agente) |
NAT-traversal automático (WireGuard+DERP), ACLs declarativas, MagicDNS (rp-server.tailnet.ts.net), tailscale serve inyecta identity headers (Tailscale-User-Login) builtin, Personal Free desde abril 2026 = devices ilimitados (6 users), Tailscale SSH viene gratis |
Vendor (Tailscale Inc.) — escape doc'd a NetBird; key-exchange entre tailnets de terceros requiere tailnet sharing o nodos en mi tailnet (acceptable para family/clients que invito) |
✅ Elegido |
| Nebula (Slack OSS mesh) | OSS-puro, cert-based, self-hosted lighthouse | Sin DERP relay public (lighthouse propio = otro SPOF), ACLs JSON menos legibles, ecosistema mucho menor, sin "SSH integrado" | ❌ Pierde ergonomía Tailscale por evitar vendor |
| Headscale (Tailscale control-plane self-hosted) | OSS, mismo cliente Tailscale, sin vendor | Mantener Headscale (Postgres, DERP custom, certs, releases manuales), MagicDNS más frágil, no soportado oficial | ❌ Overhead innecesario (free tier sobra) |
| Cloudflare Tunnel + mTLS para todo | Ya tengo CF Tunnel, sin vendor nuevo | Server expuesto solo via HTTPS (no UDP/SSH directo), cada agente = un tunnel = sobrecargado, NAT-traversal lo hace CF pero data-plane pasa por CF (latency + privacy) | ❌ Solo viable para bootstrap |
| ZeroTier | Funciona, conocido | Stack más viejo, ACLs menos potentes, cliente más pesado, MagicDNS-equivalent peor | ❌ Tailscale mejor en todo |
Decisión transporte: Tailscale como data-plane primario. Server LXC 280 corre tailscaled system daemon estándar (no tsnet embebido — descartado en review por complejidad innecesaria) y tailscale serve --bg --https=443 / http://127.0.0.1:8080 que actúa como reverse-proxy con identity injection builtin. Agentes corren tailscaled normal (instalado por el bootstrap si falta). Bootstrap fallback: durante onboarding inicial (cuando el host aún no está en el tailnet), el agente puede llamar al server via HTTPS público rp.monxas.casa (Caddy LXC HA → server :8443) usando un enrollment token JWT que el server consume para emitir una Tailscale auth-key ephemeral. Una vez en el tailnet, todo el tráfico pasa por Tailscale.
3. Lenguaje + runtime + packaging del agente¶
| Opción | Pros | Contras | Veredicto |
|---|---|---|---|
| Python 3.12 + uv + PEP 723 single-file scripts + PyInstaller binarios por release | Stack consistente con homelab (mcps, scripts, MCP servers), uv ultra-rápido, Textual TUI nativo, ecosistema enorme (psutil cross-OS, paramiko, websockets), PyInstaller produce binarios standalone para Linux/macOS/Windows | Binarios PyInstaller ~30-50MB (acceptable), tiempo arranque ~150ms, GIL no es problema (agente I/O-bound) | ✅ Elegido |
| Go static binaries | <10MB binarios, arranque instantáneo, cross-compilación trivial, tailscale/tsnet lib nativa Go | Stack nuevo en homelab (todo Python hoy), Textual no existe en Go (bubbletea sí pero menos pulido), reescribir psutil-equivalent | ❌ Coste contexto switching > ganancia binario size |
| Rust | Performance, safety | Curva aprendizaje, tiempo desarrollo 3x, overkill para agente I/O-bound | ❌ Sobrecomplejo |
| Bash + curl + jq | Ultra-portable, sin runtime | Imposible TUI sparklines, WebSocket en bash es payaso, Windows = WSL-required | ❌ No viable scope |
Decisión runtime: Python 3.12 + uv. Distribución dual: (a) pipx install remote-pulse para usuarios técnicos (auto-update via pipx upgrade); (b) binarios PyInstaller publicados en GitHub Releases con SHA256SUMS firmados para usuarios no-Python (familia, Windows). Bootstrap script detecta python3.12+ → usa pipx, sino baja binario. Agente CLI se llama rp (rp install, rp dash, rp ssh <host>, rp keys list).
4. Stack del servidor¶
| Opción | Pros | Contras | Veredicto |
|---|---|---|---|
FastAPI + Postgres + tailscaled system + tailscale serve reverse-proxy |
Pythonic (consistente), Postgres ya en stack futuro (n8n DB upgrade pending), tailscaled daemon estándar (zero código nuevo), tailscale serve inyecta Tailscale-User-Login headers builtin (desde 2024), WebSocket nativo en FastAPI, sqlmodel/sqlalchemy maduro, JSON Schema gratis con Pydantic |
Postgres = nuevo servicio en homelab (LXC dedicado o SQLite fallback inicial), tailscaled proceso adicional en LXC 280 (~30MB RAM, despreciable) |
✅ Elegido |
| FastAPI + tsnet sidecar Go (versión anterior del ADR) | Server "es un nodo Tailscale" sin daemon system | tsnet en Python sin lib madura (verificado 2026-05-25 — tailscale-rs marcado "do not use in production"), requiere sidecar Go ~150 LOC + Unix socket bridge + supervisar 2 procesos = complejidad disfrazada de simplicidad sin beneficio real en LXC dedicado |
❌ Descartado durante devil's advocate review |
| Node.js + Fastify + Prisma | WebSocket excelente, ecosistema dashboards (Next.js) | Stack ajeno al homelab (todo Python), pierde cohesión | ❌ Pierde cohesión |
| Go + chi + sqlx + tsnet nativo | tsnet nativo Go, binario único, performance | Reescribir todo, breaking del estándar Python homelab | ❌ Cohesión > performance marginal |
| Reusar n8n + Postgres existente | Cero nueva infra | n8n no sirve para WebSocket persistentes a 50 agentes, modelar UI dashboard en n8n = horror | ❌ Wrong tool |
Decisión server: FastAPI + Postgres 16 + tailscaled daemon + tailscale serve HTTPS frontend.
- LXC nuevo
rp-server(id 280) en pmx-50, disk 50GB (corregido tras recálculo retention, ver Métricas storage abajo). Failover a pmx-51 vía ZFS replication si crítico, no por ahora. - Postgres 16 local al LXC (no compartir con n8n para evitar acoplamiento). Schema:
hosts,heartbeats(TimescaleDB hypertable),metric_samples(hypertable),ssh_keys,enrollments,groups,commands(audit log inmutable),users,agent_versions(compat policy, ver Apéndice G). - Identity Tailscale:
tailscaledsystem instalado en LXC 280, login viatailscale up --hostname=rp-server --advertise-tags=tag:rp-server.tailscale serve --bg --https=443 --set-path / http://127.0.0.1:8080expone FastAPI tailnet-only e inyecta automáticamenteTailscale-User-Login,Tailscale-User-Name,Tailscale-Headersque FastAPI lee como dependency. Cero código bridge. - HTTPS público bootstrap-only: Caddy LXC HA route
rp.monxas.casa→ LXC 280 puerto distinto (8443) con FastAPI bindeando0.0.0.0:8443solo para/v1/enrolly/v1/agent/reauth(rest 401 si no viene via tailnet). Protección: rate-limit Caddy 10 req/min/IP + JWT enrollment validation. CF Access NO se usa para bootstrap (agentes no-humanos no pueden hacer OIDC).
5. Autenticación, autorización, identidad¶
| Opción | Pros | Contras | Veredicto |
|---|---|---|---|
| 3-capa: Tailscale identity (agentes) + Enrollment JWT (bootstrap) + PocketID OIDC (humanos web) | Tailscale ya provee identity verificada (no se puede spoofear Tailscale-User-Login cuando viene via tailscale serve), enrollment tokens single-use con expiry 24h y bind a host_fingerprint, PocketID ya está integrado homelab y soporta forward-auth Caddy |
3 mecanismos = más superficie a explicar, requiere docs claras sobre cuál usar cuándo | ✅ Elegido |
| Solo JWT (HMAC shared secret) | Simple | Sin identity garantizada, rotación de secret painful, web auth = login formulario propio reinventando rueda | ❌ Reinventa SSO |
| mTLS client certs | Crypto-strong, sin shared secret | PKI propio = vault + cert rotation cron + revocation = overhead enorme homelab, agentes deben renovar certs sin intervención | ❌ Operationally heavy |
| Solo Tailscale identity | Suficiente para producción | Falla en bootstrap (host aún no está en tailnet) y para humanos sin Tailscale (familia accediendo via móvil web) | ❌ Edge cases sin cubrir |
Decisión auth:
- Agentes via Tailscale (estado normal): server lee
Tailscale-User-Loginheader inyectado portailscale serve(no falsificable — el header solo lo añade el daemon Tailscale al request que cruza el proxy interno). El campo identifica al device owner en el tailnet. ACL: solo devices del tailnetmonxaspueden POST métricas; cada device solo puede escribir métricas para su propiotailscale_node_id. - Bootstrap via Enrollment JWT: humano (Ramón) genera token via
rp admin enroll --group prod --ttl 24h --max-uses 1en CLI server → JWT con claims{enrollment_id, group, exp, max_uses}firmado HS512 conRP_ENROLLMENT_SECRET(SOPS). Agente lo presenta aPOST /v1/enroll, server entrega Tailscale auth-key efímero + agent config inicial. Token consumido. - Humanos web (dashboard externo): Caddy
forward_authcon PocketID. Tras OIDC, headerX-Forwarded-User→ server resuelve auser_id+ permissions. ACL roles:admin(todo),operator(exec comandos),viewer(read-only dashboards).
6. SSH key lifecycle¶
| Opción | Pros | Contras | Veredicto |
|---|---|---|---|
Server custodia inventario de pubkeys; agente genera ed25519 local y registra; distribución authorized_keys declarativa por grupos |
Privadas nunca salen del host (zero-trust), inventario auditable, rotación trivial (regenera+revoca), grupos vía YAML declarativo (groups.yml versionado) |
Server maneja authorized_keys push (riesgo: si server compromiso, attacker añade su pubkey) — mitigar con audit log inmutable + Telegram alert en cada mutación |
✅ Elegido |
| Tailscale SSH (sin keys) sólo | Cero gestión keys | No funciona en hosts no-tailnet (ej. cuando estoy en LAN sin Tailscale activo, o hosts legacy), no soluciona el problema con hosts que ya tienen pubkeys de antes | ❌ Cubre solo subset |
| HashiCorp Vault SSH CA (firma certs) | Crypto-clean, certs expiran solos | Vault = stack pesado, hosts deben confiar CA, complejidad >> beneficio homelab | ❌ Overkill |
| Ansible playbook manual | Lo que hay hoy | No es lifecycle real (no revoke, no audit, no diff) | ❌ Status quo, no resuelve |
Decisión SSH keys:
- Agente al primer arranque:
ssh-keygen -t ed25519 -f /etc/rp/host_key -N "", registraPOST /v1/keyscon{pubkey, fingerprint, host_id, user="root"}. - Server mantiene tabla
ssh_keyscon(id, host_id, user, pubkey, fingerprint, created_at, revoked_at, groups[]). - Grupos declarados en
homelab-infra/services/remote-pulse-server/config/groups.yml: rp keys distribute <group>→ server calculaauthorized_keysfinal por host → push vía agente (escribe/etc/rp/authorized_keys.d/managedque sshd lee conAuthorizedKeysFile).- Revocación:
rp keys revoke <fingerprint>→ server marcarevoked_at→ próximo push omite la pubkey. Notificación Telegram automática a@Veraclawd_boten mutación.
7. Control de pantalla (driver #5)¶
| Opción | Pros | Contras | Veredicto |
|---|---|---|---|
| Tailscale SSH (preferred) + SSH classic (fallback con keys gestionadas) + RustDesk cliente modo Direct IP (GUI tailnet) + Sunshine+Moonlight (Windows GPU). RustDesk hbbs/hbbr server DEFERIDO (opt-in cuando aparezca primer cliente fuera-de-tailnet) | Cubre 100% casos tailnet (shell, GUI Linux/Win/macOS, gaming RTX 3080), todo via Tailscale, cero infraestructura nueva para 90% del uso (Ramón conecta a sus hosts), LXC 281 solo se construye cuando hay caso B real | Caso B (clientes fuera-de-tailnet, ej. hermano sin Tailscale) requiere LXC 281 + Tailscale Funnel para hbbs/hbbr público → diferido a fase futura por demanda | ✅ Elegido (rediseñado durante devil's advocate review) |
| RustDesk hbbs/hbbr server desde día 1 (versión anterior del ADR) | Soporta caso B sin trabajo adicional futuro | Tailscale official KB y validación 2026 confirman: si ambos peers están en el mismo tailnet, hbbs/hbbr es redundante — basta cliente RustDesk modo "Direct IP" apuntando a 100.x.x.x:21118. Server sin caso real = LXC 281 ociosa + ops overhead innecesario |
❌ Descartado en review |
| Solo SSH + VNC | Simple | Sin Windows GUI decente, latency VNC sobre WAN = inusable | ❌ UX pobre |
| Apache Guacamole | Web-based, sin cliente local | Stack Java pesado (Tomcat + guacd), perf inferior a RustDesk/Sunshine, integración Tailscale = clunky | ❌ Overengineering |
| Solo soluciones SaaS (AnyDesk/TeamViewer) | Zero ops | Cuotas familia, privacy concerns, vendor lock, no controlable | ❌ Antitética al stack |
Decisión control pantalla:
rp ssh <host>wrapper CLI: resuelve hostname → MagicDNS Tailscale → preferirtailscale sshsi target tiene Tailscale SSH habilitado en ACLs, sinossh -i ~/.ssh/rp_managed <host>(key gestionada).- RustDesk modo Direct IP (default tailnet): agente Windows/macOS/Linux instala cliente RustDesk vía
rp install rustdesk+ configuraSettings → Network → Direct IP accessenabled, sin rendezvous server. ID =100.x.x.x(Tailscale IP) o MagicDNS name. Password generado al instalar y rotado porrp keys rotate-screen <host>. Latencia P2P pura via WireGuard. - LXC 281
rustdesk-server(DIFERIDO, opt-in): se provisiona en fase futura solo cuando exista caso B real (cliente fuera-de-tailnet que no se quiere/puede instalar Tailscale). En ese momento: hbbs/hbbr en LXC nuevo, expuesto via Tailscale Funnel + auth token, configurar clientes para usar relay solo si Direct IP falla. Trigger: primer ticket family/cliente que requiera GUI sin Tailscale onboarding. - Sunshine en hosts Windows con GPU: agente detecta
nvidia-smi→rp install sunshinebaja MSI, configura pairing semi-automático (PIN interchange requiere intervención human una vez por device pair via Sunshine web admin:47990). Moonlight cliente en móvil/iPad/Mac para acceso gaming-grade. Honest framing: pairing es one-time setup manual, no totalmente automatizado como decía la versión anterior del ADR. rp screen <host>comando unificado: detecta capabilities → elige RustDesk (default) o Sunshine (si gaming-flag) → abre cliente local con IP tailnet del peer pre-resuelta.
8. Dashboards (driver #4)¶
| Capa | Tech | Caso de uso | Decisión |
|---|---|---|---|
| TUI primary | Textual + textual-plotext + rich-spinner | Uso diario terminal, "rp dash" → lista hosts, sparklines CPU/RAM/net últimos 5min, drill-down a logs/exec | ✅ Build primero |
| Web secondary | FastAPI + HTMX + uPlot.js (sparkline mode) + Pico.css | Móvil/iPad fuera de casa via Tailscale o dash.rp.monxas.casa PocketID |
✅ Build fase 5 |
| Grafana tertiary | Dashboards JSON provisioned (Stat panels modo sparkline + State Timeline) | Análisis histórico, alertas, correlación con observability existente | ✅ Build fase 5 (low effort, reusa Loki/Prom) |
Decisión dashboards (con framing honesto por persona):
- TUI
rp dash— primary para Ramón / power-users: layout 3-pane (host-list izquierda, detail+sparklines centro, logs/exec derecha). Sparklines reales contextual-plotext(no ASCII falso) por host:cpu_pct[5m],mem_pct[5m],disk_io[5m],net_rx[5m],net_tx[5m],latency_ts_homelab[5m](RTT a server). Refresh 2s push via WebSocket subscribe. - Web dashboard — primary para family/clients (driver #1 persona), secondary para Ramón móvil: misma data via HTMX polling 5s, sparklines uPlot inline en tabla. Accesible
dash.rp.monxas.casa(CF Access + PocketID + forward-auth) yrp-server.tailnet:8080(Tailscale identity). Honest framing: la prioridad de build sigue siendo TUI→Web→Grafana (porque Ramón itera primero), pero el sales-pitch del producto público es "abre el dashboard en el móvil y ves tus máquinas" → web debe estar funcional v1.0, no defer. - Grafana dashboard
Remote-Pulse Fleet— tertiary, histórico/deep-dive: datasource Prometheus (server expone/metricsconrp_host_cpu_pct{host=...},rp_host_up{host=...}, etc.). Provisioned como JSON enhomelab-infra/ansible/roles/grafana_dashboards/files/remote-pulse-fleet.json.
9. Data model + storage¶
| Opción | Pros | Contras | Veredicto |
|---|---|---|---|
Postgres 16 + TimescaleDB hypertables para heartbeats/metrics |
Postgres robusto, TimescaleDB excellence para sparkline (continuous aggregates), retención política nativa | TimescaleDB binary apt repo, learning curve mínima | ✅ Elegido |
| Postgres puro (sin Timescale) | Más simple | Sparkline queries a 30 hosts × 1 año = lento sin partitioning manual | ❌ Escala mal medio plazo |
| SQLite local | Cero infra | Concurrency limitada, web dashboard problemático, sparkline queries lentas | ❌ Solo viable fase 1 prototipo |
| Push directo a Prometheus remote_write | Reusa Prom existente | Prom no es OLTP, no sirve para ssh_keys/enrollments/commands audit log |
❌ Sólo cubre metrics |
Decisión storage: Postgres 16 + TimescaleDB.
- Schema relacional:
hosts,users,groups,ssh_keys,enrollments,commands(audit log inmutable append-only). - TimescaleDB hypertables:
heartbeats(cada 30s, retención 90d),metric_samples(cada 60s, retención 30d cruda + 1y agregado 5min via continuous aggregate). - Backups: pg_dump nightly a NAS + replicación a PBS si grow significativa (revisitar fase 3).
10. Bootstrap one-liner (driver #1)¶
| Opción | Pros | Contras | Veredicto |
|---|---|---|---|
curl -fsSL https://rp.monxas.casa/install \| sh -s -- --token=<JWT> con script bash idempotente |
Universal Unix, mostrar SHA256 verificable en pre-install (?show=1 query mode), detecta OS, instala deps si faltan |
curl-pipe-sh contraintuitivo seguridad (mitigar: docs muestran cómo curl > install.sh && sh install.sh), Windows requiere PowerShell variant |
✅ Elegido (Unix) |
Solo pipx install |
Pythonic, sin curl-pipe-sh | Asume Python preinstalado (familia no), no instala Tailscale ni systemd unit, no registra al server | ❌ No es one-command real |
| Ansible playbook ejecutado por server hacia host | Idempotente, profesional | Requiere SSH preexistente al host (chicken-egg) — agente no puede ser instalado antes de tener SSH | ❌ Falla bootstrap |
| GUI installer | UX-friendly familia | Esfuerzo desarrollo masivo, MSI/PKG/DEB por plataforma, mantener releases pesado | ❌ Defer post-v1.0 |
Decisión bootstrap:
- Linux/macOS one-liner:
- Windows one-liner (PowerShell):
- Script lógica:
- Detectar OS/arch (
uname -m,$env:OS). - Verificar Python 3.12+ → si no, descargar binario
rp-<os>-<arch>desde GitHub Releases (hash check SHA256SUMS). - Detectar Tailscale instalado → si no, instalar vía script oficial Tailscale (
curl -fsSL https://tailscale.com/install.sh | sho equivalente Windows). POST /v1/enrollcon{token, host_fingerprint}→ recibe{tailscale_authkey, agent_config, server_pubkey}.tailscale up --authkey=<key> --hostname=<host> --ssh.- Generar ed25519 SSH key, registrar pubkey en server.
- Instalar como service (systemd unit en Linux, launchd plist en macOS, NSSM service en Windows).
- Heartbeat inicial, aparece en dashboard.
- Sin token (interactive): abre browser a
https://rp.monxas.casa/enroll→ PocketID OIDC login → genera token → mostrar al usuario para copy-paste (o magic-link callback). - Verificación: script imprime fingerprint del server pubkey y pide confirmar (TOFU primer install).
11. Sparklines: cómo se renderizan inline en TUI¶
| Opción | Pros | Contras | Veredicto |
|---|---|---|---|
textual-plotext (plotext widget) en TUI Textual |
Sparklines reales con braille/blocks, refresh fluido, integrable layout Textual | Dep adicional, requiere terminal con Unicode | ✅ Elegido TUI |
rich.sparkline (ASCII blocks) |
Builtin Rich, ultra-simple | Solo 1 línea, sin ejes, sin tooltips, menos data points | ❌ TUI feels primitive |
asciichart puro Python |
Multi-línea, ASCII art | Solo charts no-interactive, no encaja Textual reactivity | ❌ Static |
Decisión sparklines: TUI usa textual-plotext (sub-widget plotext integrado en Textual). Web usa uPlot.js (sparkline mode 30px alto). Grafana usa panel Stat con displayMode: sparkline.
12. Phone-home: protocolo agente↔server¶
Diseño multi-canal WebSocket (rediseñado durante review — versión anterior usaba un único WS multiplexado, lo que acopla backpressure de logs con liveness de heartbeats):
| Canal | Endpoint | Frecuencia | Payload | Razón canal separado |
|---|---|---|---|---|
| Control (heartbeat + commands) | wss://rp-server.tailnet/v1/agent/ws/control |
heartbeat 30s + commands on-demand | bidireccional small JSON | Liveness no se ve afectado por payloads grandes en otros canales |
| Metrics push | wss://.../v1/agent/ws/metrics |
cada 60s | snapshot completo | Backpressure independiente; si server lento, se buffer aquí, no en control |
| Inventory diff | wss://.../v1/agent/ws/inventory |
5min o on-change | diff JSON | Burst-friendly, low priority |
| Commands ack + audit | (via control channel) | on-demand | {exec_id, exit_code, stdout, stderr, duration_ms} |
Critical path = control |
| Logs stream (opt-in) | wss://.../v1/agent/ws/logs |
continuous | journald tail / structured | Si activo, también pushea a Loki existente. Drop-tail si saturado |
Time-sync policy: server usa received_at (su clock) como source of truth para ordering y queries de sparkline. agent_ts solo se loggea para detectar clock skew — si abs(received_at − agent_ts) > 120s, server marca host con flag clock_skew_warn y dispara alerta Telegram. Sparkline queries siempre por received_at.
Offline queue (agente): ring buffer SQLite local /var/lib/rp/queue.db capped en 100 MB o 24 h (lo que ocurra primero), FIFO discard. Excepción: registros de command_ack (audit log) NUNCA se descartan offline — persisten hasta confirmar ack del server. Si la queue de audit-log excede 50 MB, agente para de ejecutar nuevos comandos y emite warning local hasta drain.
Reconnect: backoff exponencial 1s→2s→4s→8s→16s cap 60s con jitter ±20%. Server detecta host_up=false tras 3 heartbeats perdidos (90s) → alerta Telegram via n8n webhook.
13. Comandos remotos: ¿qué puede pedir el server al agente?¶
| Comando | Argumentos | Auth requerida (server-side) | Local-approval (agente-side) | Audit log |
|---|---|---|---|---|
exec_shell |
cmd, timeout_s, capture |
operator+ rol web OR Tailscale identity en allowlist |
Grupo prod/iarq: requiere /etc/rp/allow-remote-exec presente. Grupo family/default: server-auth suficiente |
✅ stdout/stderr en commands |
pkg_install |
name, manager(auto) |
admin |
Grupo prod/iarq: 2-tier (server sig + Telegram approval flow via n8n) |
✅ |
service_restart |
service_name |
operator+ |
Solo restart whitelist (/etc/rp/restart-whitelist); fuera de lista = deny |
✅ |
file_read |
path, max_bytes=1MB |
admin |
Path debe matchear /etc/rp/read-allowlist patterns |
✅ (path solo) |
file_write |
path, content_b64, mode |
admin + confirmation flag |
Grupo prod/iarq: deny by default (requiere flag /etc/rp/allow-remote-write). Si permitido, paths fuera de /etc/rp/, /opt/rp/ requieren Telegram approval |
✅ (sha256, no plain) |
screen_open |
protocol(rustdesk\|sunshine\|vnc) |
operator+ |
Sin restricción local (display-only, no escritura) | ✅ |
ssh_keys_sync |
(no args) | system (server-triggered) | Verificación local: groups.yml SHA256 firmado por server matches lo distribuido |
✅ |
agent_upgrade |
version |
admin |
Canary mode: si target version != versión actual ±1 minor → requiere Telegram approval | ✅ |
reboot |
delay_s=30 |
admin + double-confirm |
Grupo prod/iarq: dos-tier obligatorio (server sig + Telegram ack Ramón). family/default: single confirm |
✅ |
ACL enforcement:
- Server-side: agente verifica firma JWT del comando (server firma con clave privada Ed25519, agente con clave pública preinstalada en config). Comandos no firmados o expirados (>60s) → rechazo silencioso + audit log entry
rejected_reason. - Agente-side (NUEVO tras review — mitigación C3): independientemente de la firma server válida, agente verifica denylist local y flags TTL. Razón: si server compromiso, attacker firma todo válido pero no puede tocar
/etc/rp/de cada host (requiere SSH humano). Defense-in-depth real, no solo audit log post-mortem. - Telegram approval flow para destructive ops grupos sensitive: server emite comando → n8n recibe webhook
approval_required→ mensaje Telegram a Ramón con [aprobar/rechazar] → ack respuesta firma confirmación → server reenvía comando con flaghuman_approved=true→ agente ejecuta. TTL 5min en approval pending; sin respuesta = auto-reject. - Audit log inmutable: Postgres trigger
BEFORE UPDATE OR DELETE ON commands FOR EACH ROW EXECUTE FUNCTION block_mutations()+ replicación a Loki via Promtail (audit log secundario fuera de Postgres por si compromiso BD).
Decisión global¶
Construir Remote-Pulse: agente Python (FOSS público) + servidor FastAPI privado en LXC homelab, comunicándose vía Tailscale (data plane) con bootstrap HTTPS público para enrollment inicial. Tres dashboards (TUI primario, Web móvil, Grafana histórico). SSH keys gestionadas centralmente con grupos declarativos. Control de pantalla multi-modal (Tailscale SSH / SSH / RustDesk / Sunshine). Instalación one-command universal. Reusa SOPS/PocketID/Caddy/Observability del homelab.
Repos:
- monxas/remote-pulse (público, Apache-2.0): agente + CLI rp + bootstrap scripts.
- monxas/homelab-infra (privado): server en services/remote-pulse-server/, Ansible role remote_pulse_server, dashboards Grafana, groups config.
Componentes:
| Componente | Ubicación | Función | Estado |
|---|---|---|---|
Agent rp |
Cada host (Linux/macOS/Win) | Heartbeats, metrics, exec, screen, key-mgmt, local-approval enforcement | v1.0 |
CLI rp (same binary) |
Workstation Ramón | rp dash, rp ssh, rp keys, rp admin |
v1.0 |
| Server FastAPI | LXC 280 rp-server (pmx-50) |
API, WS multi-canal, dashboard web, JWT signing | v1.0 |
tailscaled daemon |
LXC 280 | Identity Tailscale system-level (no sidecar custom) | v1.0 |
tailscale serve |
LXC 280 | HTTPS reverse-proxy + header injection automática | v1.0 |
| Postgres 16 + TimescaleDB | LXC 280 | Storage + audit log inmutable | v1.0 |
| Caddy route | LXC 270/271 | rp.monxas.casa (bootstrap public /v1/enroll + /v1/agent/reauth) + dash.rp.monxas.casa (web OIDC) |
v1.0 |
PocketID client remote-pulse-web |
PocketID existente | OIDC para web dashboard | v1.0 |
| Grafana dashboard JSON | Grafana existente | Fleet histórico (panels Stat-sparkline) | v1.0 |
| Loki labels | Loki existente | {job="remote-pulse",host=...} log stream + audit log secundario |
v1.0 |
| Prometheus scrape | Prometheus existente | /metrics endpoint en server (rp_host_*) |
v1.0 |
| n8n webhook flow | n8n existente | Alertas Telegram + Telegram approval flow para destructive ops | v1.0 |
| RustDesk cliente Direct IP | Cada host con GUI | Control pantalla tailnet (P2P sin server) | v1.0 |
| Sunshine + Moonlight | Windows .115 (RTX 3080) | Gaming-grade GUI tailnet | v1.0 |
| RustDesk hbbs/hbbr server | LXC 281 rustdesk-server (pmx-51) |
GUI relay para clientes fuera-de-tailnet | DEFERIDO — opt-in cuando aparezca primer caso B |
| Tailscale Funnel + auth front rustdesk | LXC 281 | Exposición pública controlada del hbbs/hbbr | DEFERIDO con LXC 281 |
Plan de implementación (fases)¶
Estimaciones recalibradas durante devil's advocate review 2026-05-25: total bruto 33 días-ingeniero (vs 29d versión anterior), calendario realista 9-10 semanas part-time 1 humano + 1 agente, no 6-8 como afirmaba la versión inicial. El ahorro de eliminar tsnet sidecar (-3d F2) y RustDesk server (-2d F6) se reinvierte en security harden (+2d F4), DR runbook + API compat (+1d F8), Windows installer honesto (+2d F7), multi-user real (+2d F5).
F1: Foundation — Agente MVP + Server stub (estimado 5d)¶
Objetivo: "puedo correr rp install en pmx-50 y aparece en una lista en el server".
- Repo
monxas/remote-pulsecreado (LICENSE Apache-2.0, README placeholder,pyproject.tomlcon uv). - Agente Python
rpcon subcommands:install,register,heartbeat,version. - Server FastAPI mínimo:
POST /v1/enroll,POST /v1/heartbeat,GET /v1/hosts. - Postgres schema base (
hosts,enrollments). - LXC 280 provisioned en pmx-50 (template debian-12, 2GB RAM, 20GB disk).
- Bootstrap script bash
install.sh(sin Tailscale aún, HTTP plaintext sobre LAN para test). - systemd unit
remote-pulse.serviceinstalado por bootstrap.
Validación: correr curl http://192.168.0.[lxc280]:8080/install | sh -s -- --token=test en pmx-51 → ver pmx-51 en GET /v1/hosts.
F2: Tailscale integration (estimado 2d — simplificado tras eliminar tsnet sidecar)¶
Objetivo: "el data plane es Tailscale, ningún tráfico cruza LAN raw".
- LXC 280:
apt install tailscale,tailscale up --hostname=rp-server --advertise-tags=tag:rp-servercon auth-key admin manual one-time. tailscale serve --bg --https=443 / http://127.0.0.1:8080→ expone FastAPI tailnet-only con identity headers injection automática.- FastAPI dependency
tailscale_identity()leeTailscale-User-Loginheader (rechaza request si falta — significa que no vino viatailscale serve). - Bootstrap script instala Tailscale si falta (
curl https://tailscale.com/install.sh | sh). - Enrollment flow real:
POST /v1/enrollvalida JWT, devuelvetailscale_authkeyefímero (Tailscale APIPOST /api/v2/tailnet/-/keysconephemeral=true, reusable=false, expiry=24h). - Caddy route
rp.monxas.casa→ LXC 280:8443 (solo/v1/enrolly/v1/agent/reauthexpuestos, resto 401). - Verificación:
tailscale statusdesde nuevo host muestrarp-serverreachable; heartbeats van via WireGuard.
Validación: rp install --token=... desde Carmelo Mac mini (fuera LAN) → aparece en /v1/hosts con via=tailscale. Web dashboard accesible via https://rp-server.monxas.ts.net con identity ya autenticada por Tailscale.
F3: TUI Dashboard con sparklines (estimado 3d)¶
Objetivo: "abro rp dash y veo todos mis hosts con sparklines en vivo".
- TUI Textual con layout 3-pane (HostList | Detail | Logs).
textual-plotextintegration → 6 sparklines por host seleccionado (cpu/mem/disk/net_rx/net_tx/latency).- WebSocket subscribe
wss://rp-server.tailnet/v1/dash/ws→ push deltas a TUI cada 2s. - Server endpoint
GET /v1/metrics/{host}/sparkline?window=5m&series=cpu,mem,...con TimescaleDB continuous aggregate. - Color-coded estado: verde (up <60s heartbeat), amarillo (60-180s), rojo (>180s).
Validación: rp dash desde MacBook (en tailnet o LAN) → 10+ hosts visibles con sparklines actualizándose suaves.
F4: SSH key lifecycle + Remote exec + Local-approval enforcement (estimado 6d — +2d security hardening tras review C3)¶
Objetivo: "intercambio de SSH keys totalmente automatizado, exec remoto auditado, server compromise NO compromete fleet".
- Schema:
ssh_keys,groups,commandstables + audit log inmutable (trigger Postgresblock_mutations()). - Agente:
ssh-keygened25519 al primer arranque, registra pubkey viaPOST /v1/keys. - Server:
groups.ymldeclarativo en repo (validated via JSON Schema en CI). Server firma comandos con Ed25519 private key (rotable, ver Apéndice F DR). - Agente: verifica firma server (public key preinstalada) + enforcement local-approval (denylist
/etc/rp/): allow-remote-execflag file requerido paraexec_shellen gruposprod/iarq.restart-whitelistpatterns paraservice_restart.read-allowlistpatterns parafile_read.allow-remote-writeflag parafile_write(default OFF enprod/iarq).- CLI:
rp keys list,rp keys distribute <group>,rp keys revoke <fingerprint>. - Comando
exec_shellvia WS bidireccional (server→agent), audit log a Postgres + Loki (doble sink). - ACL: solo
admin/operatorroles pueden invocarexec_shell, todo loggeado conuser_id+tailscale_node_idque originó. - Telegram approval flow n8n: webhook
approval_required→ mensaje Ramón con inline buttons → respuesta → server reenvía comando conhuman_approved=true(TTL 5min). Implementar como nuevo workflow en n8n existing.
Validación:
- Revoco la pubkey de pmx-51 con
rp keys revoke <fp>→ no puedo SSH a pmx-51 desde mi laptop en 60s → restoro conrp keys distribute prod→ SSH funciona again. - Simulo "server compromise": en LXC 280 ejecuto manualmente
rp admin exec --host=pmx-50 --skip-approval --cmd="rm -rf /etc/important"→ agente rechaza porqueallow-remote-execno existe en pmx-50, audit log registrarejected_reason=local_policy_deny. Telegram alerta dispara. rebooten grupoprodrequiere ack Telegram → ejecuto desde CLI → recibo mensaje móvil → pulso aprobar → host reinicia. Sin ack 5min = auto-cancel.
F5: Web dashboard + Grafana panels + PocketID + Multi-user real (estimado 5d — +2d multi-tenant tras review M5)¶
Objetivo: "puedo ver el fleet desde el móvil con auth SSO y desde Grafana con histórico. Family/clientes solo ven sus máquinas".
- Web dashboard FastAPI + HTMX + uPlot sparklines (mismo data layer que TUI).
- Caddy route
dash.rp.monxas.casaconforward_authPocketID. - OIDC client en PocketID
remote-pulse-web. - Multi-user real:
- Tabla
userspoblada vía sync PocketID (cron 1h o webhook on-login). - Cada user tiene
role(admin/operator/viewer) yaccessible_groups[]declarados enusers.yml(o derivados de PocketID groups si están). - Web dashboard filtra
hostsporaccessible_groupsdel user logged. Hermano logged ve solofamily; cliente iarq logged ve soloiarq. - Invitación flow:
rp admin invite <email> --group=family→ PocketID API crea user (si soporta) o genera invite URL → Telegram a Ramón → forward por chat → hermano se registra en PocketID con passkey → access automático. - Grafana datasource: server expone
/metricsPrometheus format (rp_host_*metrics). - Provisioned Grafana dashboard JSON
remote-pulse-fleet.json(paneles Stat-sparkline, State Timeline para uptime, table inventory). Grafana orgs/folders por grupo si valoramos compartir Grafana con family (defer si no).
Validación:
- Desde iPad fuera de casa via Tailscale →
https://dash.rp.monxas.casa→ PocketID passkey → dashboard renderiza con sparklines uPlot todos hosts (yo soy admin). - Hermano logged → ve solo Carmelo y su laptop, no pmx-50.
- Logout → 401 en todas las rutas.
F6: Control de pantalla (Tailscale SSH + RustDesk Direct IP + Sunshine) (estimado 2d — simplificado tras eliminar LXC 281 default)¶
Objetivo: "rp screen pmx-50 me abre RustDesk con el host correcto y auth correcto, sin server intermedio".
rp install rustdeskbaja cliente RustDesk, configuraSettings → Network → Direct IP access = enabled, password generado y guardado en server (rotable porrp keys rotate-screen <host>).rp screen <host>CLI: query server capabilities → decide protocol → spawn cliente local con args--connect=<tailscale_ip>:21118+ password fetched.- Sunshine en Windows .115:
rp install sunshinebaja MSI silent → instala como service → admin UI accesiblehttps://<tailscale_ip>:47990. Pairing es one-time manual (PIN entry user-side) — agente facilita exposing admin UI via tailnet pero no automatiza el handshake (limitación inherente Sunshine). - Tailscale SSH ACL update (ver Apéndice E):
ssh: [{action: "check", src: ["autogroup:owner"], dst: ["tag:rp-agent-prod"], users: ["autogroup:nonroot"]}]. rp ssh <host>wrapper: prefertailscale sshsi target tiene Tailscale SSH, fallbacksshcon key gestionada.
Validación: rp screen carmelo → RustDesk abre, pantalla Mac mini visible en <5s vía P2P WireGuard puro (verificable tailscale netcheck muestra Direct). rp ssh pmx-51 → conecta sin password ni pubkey manual.
NO-incluido en F6 (deferido): LXC 281 rustdesk-server con hbbs/hbbr. Trigger para incluir: primer cliente que requiera GUI sin Tailscale instalado.
F7: One-liner public + Windows installer honesto + Docs site (estimado 5d — +2d Windows honesto tras review D2)¶
Objetivo: "comparto un comando a mi hermano por Telegram, lo pega, su laptop está en el dashboard 60s después — sin que Windows Defender lo bloquee".
install.sh(Unix) finalizado con SHA256SUMS verificación + audit-first mode (--showimprime script sin ejecutar).- Windows packaging (rediseñado):
- Path A (preferred):
winget install Monxas.RemotePulse— publicar amicrosoft/winget-pkgs(gratis). Manifest YAML signed por mí; SmartScreen confianza se construye con descargas. Para family Windows, instrucción eswinget install Monxas.RemotePulseno curl-pipe-iex. - Path B (fallback): PyInstaller binario firmado con Apple Developer ID (macOS, $99/año mío personal ya activo si lo tengo) y EV code signing Windows (Sectigo/DigiCert ~$300/año — presupuestar o aceptar SmartScreen warnings durante build-up phase).
- Path C (último recurso):
pipx install remote-pulsepara Python power-users. - GitHub Releases pipeline (GitHub Actions matrix): build PyInstaller binarios Linux x64/arm64 (Ubuntu runner), macOS arm64/x64 (macOS runner — consume GH Actions minutes, pero free tier en repos públicos), Windows x64 (Windows runner) → publica con sigstore/cosign signatures + SHA256SUMS.
- Docs MkDocs en repo público
monxas/remote-pulse→ CF Pagesrp.monxas.casa/docs+ sección Troubleshooting Windows Defender explícita. rp.monxas.casa/installendpoint Caddy sirve script con env-var customization (?token=...&server=...).- Magic-link enrollment para humanos no-técnicos: abre browser → PocketID OIDC → instalador descarga preconfigurado con token embebido.
Validación:
- Envío link a hermano (sin homelab access). Su Mac aparece en
/v1/hostscon grupofamily, RustDesk Direct IP funciona desde mi iPad. - Pruebo
winget install Monxas.RemotePulseen Windows 11 limpia → no requiere SmartScreen excepción tras manifest aprobado. - Curl-pipe-sh audit-first mode (
?show=1) muestra script y SHA256, comparable con anchor publicado.
F8: Hardening + DR drill + API compat + open-source release + ADR cierre (estimado 4d — +1d DR runbook + compat tras review M1+C5)¶
Objetivo: "v1.0.0 GA, release pública en GitHub, ADR-0008 marked Accepted, recovery procedure ensayado, política compat publicada".
- Audit security: review enrollment JWT entropy, rate-limiting Caddy on
/v1/enroll, audit log immutability (cómputo append-only enforced en DB triggers). - Backup + DR drill: pg_dump diario LXC 280 → NAS + replicación PBS. Drill obligatorio en F8: restaurar LXC 280 desde PBS snapshot en LXC fresca → re-emisión TS auth-keys efímeras → 1 agente debe re-auth automática vía
/v1/agent/reauthy volver a heartbeat. Documentar en runbookdocs/runbooks/rp-dr-drill.md(ver Apéndice F). - API compatibility policy publicada (ver Apéndice G): SemVer agente, schema versioning
/v1/, N-2 deprecation, skew tolerance window. - Tailscale ACL policy publicada (ver Apéndice E): tags
tag:rp-server,tag:rp-agent-prod,tag:rp-agent-family,tag:rp-agent-iarq, reglas accept/ssh restrictivas, deny lateral movement. - Alertas Telegram via n8n:
host_down(3 heartbeats lost),key_rotated,command_failed,enrollment_used,clock_skew_warn,approval_required(con buttons aprobar/rechazar),agent_upgrade_failed. - Promtail bundled en agente (opt-in flag
--with-logs) push journald → Loki existente. Honest framing: Promtail multi-OS frágil (Windows/macOS no native pkg) — para esos OS, opcional fluent-bit alternativo o solo audit-log forward. - Canary deploy infrastructure:
rp admin upgrade <group> --canary=1ejecuta upgrade en 1 host primero, observa 10min health, propaga al resto si OK. Auto-rollback si post-upgrade health-check falla. - Agente keep N-1 binary local
/opt/rp/bin/rp-prevpara rollback automático. - README público + documentation site complete (quickstart, install variants, Troubleshooting Windows Defender, contributing, DR procedure, compat policy).
- v1.0.0 GitHub Release con changelog + SHA256SUMS + cosign signatures.
- ADR-0008 status → Accepted, adenda con gotchas encontradas durante F1-F8.
Validación:
- Terceros pueden seguir docs y onboard un nodo en <5min sin acceso al homelab privado.
- Security review pasa (no plaintext secrets, no privilege escalation paths, audit log inmutable, server-compromise no escalation a destructive ops grupos sensitive sin local-approval/Telegram).
- DR drill exitoso: LXC 280 destruida → restaurada → fleet vuelve a operación en <30min.
- Agente v0.9 (versión anterior simulada) conecta a server v1.0 → funciona en modo degraded (sin features nuevas) según compat policy.
Anti-decisiones (qué NO hacemos)¶
- No Kubernetes para el server. LXC + systemd suficiente. Migrar a k8s/k3s solo si fleet >200 hosts (no en horizonte).
- No mTLS PKI propia. Tailscale identity + JWT enrollment cubre auth sin operar CA propia. Si futura compliance exige defense-in-depth crypto-layer, añadir mTLS opcional sobre Tailscale.
- No agente en Rust/Go. Python+uv prioriza cohesión homelab; reescritura solo si bottleneck CPU agente (improbable, I/O bound).
- No Headscale ni NetBird (versión inicial). Tailscale Personal Free desde abril 2026 es devices ilimitados con 6 users máx (verificado en review 2026-05-25 vía tailscale.com/blog/pricing-v4), sobra para fleet homelab+family+clients. Revisit triggers Tailscale→{Headscale,NetBird}: (a) Tailscale Inc. cambia pricing y nos saca del free, (b) compliance cliente exige control-plane self-hosted, (c) fleet>500 nodos. NetBird es preferred sobre Headscale para escape: control-plane oficial OSS first-class (no fork), DERP-equivalent integrado, ACLs JSON similares.
- No Netdata Cloud free para fleet. Verificado en review: free tier Netdata Cloud limita a 5 nodos máximo concurrentes desde nov-2023. Para 50 hosts seria $225/mes Business plan = no viable vs reusar Prom+Grafana+Loki homelab existentes.
- No reinventar dashboards. TUI custom (Textual) y Web custom (HTMX) son thin layers sobre datos del server; Grafana ya existe para histórico.
- No agentless (Ansible-only) approach. Ansible-pull sigue siendo válido para state PVE (ADR-0007 F2), pero no resuelve heartbeats, exec on-demand, dashboards en vivo, key lifecycle.
- No SaaS RMM (NinjaOne, Atera, ConnectWise). Vendor lock, costes recurrentes, privacy familia y clientes iarq.
- No exponer SSH del server al público. Bootstrap solo via HTTPS+JWT. Tras enrollment, SSH va por Tailscale ACL (snippet en Apéndice E).
- No streaming continuo de logs por defecto. Logs push es opt-in (
--with-logs) para no saturar Loki con journald de hosts sin valor diagnóstico. - No GUI installer en v1.0. Bootstrap one-liner cubre 95% casos. GUI installer reconsiderado parcialmente vía
winget installpara Windows family (driver #1 persona). Tauri/Electron full-GUI considerar v2.0 si onboarding family friction real. - No RustDesk hbbs/hbbr server en v1.0. Tailscale L3 plano hace P2P Direct IP suficiente. LXC 281 se construye solo cuando aparezca primer caso B real (cliente fuera-de-tailnet).
- No tsnet sidecar Go.
tailscaled+tailscale servedaemon estándar es más simple, mismo resultado, cero código bridge.
Revisit triggers: si fleet >50 hosts (Postgres bottleneck), si CGNAT-only ISPs aparecen sin Tailscale viable (raro), si terceros adoptan masivamente (curar ecosistema), si compliance familiar/clientes requiere features audit avanzadas, si Tailscale Inc. cambia free tier policy (escape a NetBird).
Riesgos y mitigaciones¶
| Riesgo | Probabilidad | Impacto | Mitigación |
|---|---|---|---|
| Server compromiso → attacker controla fleet | Baja | Crítico | Defense-in-depth tras review C3: (1) audit log inmutable + Telegram alert (detección), (2) agente local-approval enforcement: /etc/rp/allow-remote-exec flag file requerido para exec_shell en prod/iarq, denylist patterns para file_write/reboot (prevención), (3) Telegram approval flow vía n8n para destructive ops (TTL 5min, Ramón ack obligatorio), (4) audit log doble sink Postgres+Loki (forensics si BD compromiso) |
| Enrollment JWT leak → attacker registra host fraudulento | Media | Alto | TTL 24h max-uses=1, bind a host_fingerprint (TPM o /etc/machine-id hash) registrado en enrollments.host_fingerprint_committed al primer uso, revocable desde admin CLI |
| Tailscale Inc. outage o policy change | Baja | Alto | NetBird preferred como contingencia documentada (control-plane OSS oficial, ACLs portables); Headscale como segunda opción; local LAN fallback con WireGuard manual último recurso |
| LXC 280 (server) caído = todo el fleet ciego | Media | Medio | Agentes siguen funcionando local, heartbeats+metrics encolados en SQLite ring buffer 100MB/24h (ver §12 offline queue); audit log persiste hasta ack; ZFS replication LXC 280 → pmx-51 para RTO <10min; /v1/agent/reauth endpoint para re-auth post-restore |
| Pérdida total Postgres LXC 280 + backups | Muy baja | Crítico | DR procedure formal (Apéndice F): pg_dump nightly NAS + PBS snapshot, drill obligatorio F8, runbook docs/runbooks/rp-dr-drill.md. Hosts mantienen authorized_keys.d/managed local = SSH degraded-mode funciona durante restore. Re-emisión TS auth-keys + re-auth agente vía endpoint dedicado |
| PyInstaller binarios marcados como malware en Windows | Alta | Medio | Path A winget install primary para Windows (no PyInstaller curl-pipe); Path B EV code signing presupuestado ~$300/año si valoramos PyInstaller distribution; troubleshooting docs explícitos |
| curl-pipe-sh atacado en CDN | Baja | Crítico | Servir desde mi Caddy LXC (no CDN tercero), SHA256SUMS in-line para verificación, audit-first mode (--show) antes de exec |
| Postgres LXC 280 grow descontrolado (corregido) | Media | Bajo | Estimación real ~15GB steady-state, ~30GB con fleet 100 hosts (corregido tras review C4 del 5GB original). LXC disk 50GB + alerta Prometheus 70%/85%/95%. Retention 30d raw + 1y aggregated 5min via TimescaleDB continuous aggregates; commands table retention 1y; heartbeats retention 90d |
| SSH key gestionada → server pushea bad keys, lockout todos los hosts | Baja | Crítico | Agente preserva ~/.ssh/authorized_keys (user file) fuera del managed file; sshd AuthorizedKeysFile .ssh/authorized_keys /etc/rp/authorized_keys.d/managed; rollback siempre posible via SSH local key; rp keys distribute requiere dry-run preview por defecto |
| RustDesk Direct IP password leak | Baja | Bajo | Password generado por agente, rotable, distribuido solo vía server tailnet; cliente RustDesk binding solo a Tailscale interface (no all interfaces) |
| Tailscale free tier excedido | Muy baja | Medio | Actualizado tras review M7: Personal Free desde abril 2026 = devices ilimitados, 6 users. Triggers para upgrade: necesitamos >6 humanos web users (Plus $6/mo) o features enterprise (compliance, audit). |
| Agentes en hosts cliente (iarq) ven datos privados otros | N/A | Alto | Agentes solo reportan métricas locales del host donde corren; no leen otros hosts; ACL Tailscale grupos (Apéndice E) garantiza aislamiento red-level; user roles en server filtran web dashboard |
| Clock skew agente → sparkline ordering corrupto | Media | Bajo | Server usa received_at como source of truth (§12 time-sync policy); clock_skew_warn alert Telegram si drift >120s; agente intenta NTP sync al boot |
| Agente offline largo (Carmelo 1 semana vacaciones) | Media | Bajo | Ring buffer 100MB/24h FIFO; audit log persiste hasta ack; on reconnect, drain ordenada con rate-limit para no saturar server |
| Agente v0.X conecta server v1.Y sin compat | Media | Medio | API versioning + N-2 deprecation policy (Apéndice G); skew tolerance documentada; graceful degradation en agente (features unknown = log warn, no crash) |
Consecuencias¶
Positivas¶
- Inventario único de verdad — todos los nodos (homelab + family + clients) visibles en un solo lugar con sparklines.
- Onboarding 40min → 60s — comando único, sin SSH manual, sin doc rota.
- Key lifecycle auditable — diff Git de
groups.yml+ audit log Postgres + Telegram alerts. - Access remoto unificado —
rp ssh <host>,rp screen <host>reemplaza 4 herramientas distintas. - Reusabilidad pública —
monxas/remote-pulseviable como FOSS portfolio + utilidad real para terceros. - Cohesión stack homelab — SOPS+age, PocketID, Caddy HA, Loki/Prom/Grafana, n8n, todos integrados, ninguno duplicado.
- Hermes integration — futuro MCP
remote-pulsepermite a Hermes consultar fleet, ejecutar comandos auditados, gestionar keys vía chat Telegram (defer post-v1.0).
Negativas¶
- Nueva pieza crítica — LXC 280 server = nuevo SPOF (mitigado por agentes resilientes y backup).
- Curva aprendizaje — Tailscale ACLs avanzadas, TimescaleDB hypertables, Textual TUI = stack tooling nuevo (~1 semana ramp-up).
- Mantenimiento 2 repos — public agent vs private server requiere disciplina versionado y CI cross-repo.
- Dependencia Tailscale Inc. — vendor lock soft (mitigable con Headscale), gratis hasta 100 nodos.
- Coste storage — Postgres + TimescaleDB ~15GB steady-state fleet 50 hosts (corregido tras review C4 desde 5GB original); LXC 280 disk 50GB con alertas 70%/85%/95%.
- Coste tiempo (recalibrado tras review) — ~33d implementación estimada bruta (5+2+3+6+5+2+5+4 + 1d colchón); 9-10 semanas calendario realista 1 humano + 1 agente part-time, no 6-8 como afirmaba versión inicial. El ahorro tsnet+RustDesk se reinvierte en security harden + DR + Windows honesto.
Neutras¶
- Stack tooling adicional — Textual + textual-plotext + PyInstaller son nuevos pero todos cohesivos con Python existing (sin Go custom tras eliminar tsnet sidecar en review).
- Posibilidad open source community — repo público puede atraer issues/PRs externos (positivo si hay tiempo).
Métricas de éxito (target post-F8)¶
- TTI (time to install) host nuevo: ≤60s en LAN, ≤120s vía WAN/Tailscale bootstrap.
- Fleet visibility: 100% nodos del ecosistema reportando heartbeats con <5min de delay percibido en dashboard.
- MTTR detect host_down: ≤2min (3 heartbeats × 30s + alert n8n→Telegram).
- SSH key rotation: revoke→effective ≤60s todos los hosts del grupo.
- Dashboard latency: TUI sparkline update lag ≤3s desde metric collection.
- Auditability: 100% comandos
exec_shellcon audit log completo (user, host, cmd, exit_code, ts), doble sink Postgres+Loki. - One-command success rate: ≥95% en Linux/macOS, ≥90% Windows (via winget primary, Path B fallback con docs claras).
- Zero plaintext secrets en repo público: verificado con
trufflehogen CI. - Server compromise blast radius limited: ningún comando destructivo (
exec_shell,file_write,reboot) en gruposprod/iarqejecutable sin local-approval flag o Telegram ack human-in-the-loop. Validado en F8 security review. - DR drill cadence: drill restore LXC 280 completo desde PBS ≥1×/semestre. RTO target ≤30min, RPO ≤24h.
- API compat: agente v(N-2) puede operar (modo degraded acceptable) contra server vN. Validado en F8.
- Clock skew alerting: 100% drift >120s detectado y alertado dentro de 5min.
Open questions (resolver en implementación)¶
- ¿Postgres en LXC 280 o LXC dedicado 282? — Default LXC 280 mismo. Split solo si Postgres crece sin control (revisar fase 3).
- ¿TimescaleDB licencia community vs apache? — Community license (TSL) cubre features needed (continuous aggregates, retention policies). No requerimos enterprise.
- ¿Bundle Promtail en agente o role Ansible separado? — Bundle como flag opcional
--with-logsen Linux. macOS/Windows: alternativa fluent-bit o solo audit-log forward (Promtail multi-OS frágil — review H7). - ¿Signing keys cosign o solo SHA256SUMS? — Empezar SHA256SUMS, cosign en F8 si tiempo (no blocker).
- ¿Agente en Alpine/musl? — F1-F7 glibc-only. Alpine via PyInstaller con
--muslflag en F8 si demand (HA addon? Karakeep host? Reviewable). - ¿Native ARM build para Raspberry Pi? — Sí (cross-compile GitHub Actions matrix), prioridad media (no hay RPi en fleet actual, pero futuros family/clients sí).
- ¿Integración con
homelab-ctl.pypara auto-add hostname Caddy? — Defer post-v1.0; Caddy routes deben ser opt-in manual para no auto-exponer cada host. - ¿Rate-limit thresholds concretos para
/v1/enroll? — Propuesta inicial: 10 req/min/IP en Caddy + 50 req/h/JWT en server. Tuneable post-F8 según ataques observados. - ¿Política revocación cascada? — Si revoco user PocketID, ¿auto-revoco sus enrollments emitidos pendientes? Default propuesto: SÍ, con grace period 1h para reverso accidental.
- ¿DR drill cadence definitiva? — Propuesta inicial: trimestral. Si baja churn (sin incidents 6 meses), bajar a semestral.
- ¿Repos split público/privado desde día 1 vs deferred? — Review D1 sugiere defer: todo en
homelab-infraprivado hasta F6 demanda externa real, luego split. Decisión final: mantener split día 1 si valoramos branding FOSS desde el principio (~3d overhead disperso), aceptar defer si time-to-value es prioritario. Pendiente confirmar. - ¿Telegram approval bot Veraclawd vs nuevo
@RemotePulse_bot? — Reusar Veraclawd evita más bots, pero acopla destinos de alertas. Defer decisión a F4. - ¿
custom_metricsagente — plugin system o config-driven? — Empezar config-driven (/etc/rp/custom-metrics.ymlcon shell commands cada N seg → parse stdout numérico). Plugin system Python entrypoints solo si fricción real.
Referencias¶
- ADR-0007 Homelab Cohesion —
docs/architecture/adr/ADR-0007-homelab-cohesion.md(foundation: SOPS, Ansible, Caddy HA, PocketID, Observability) - Tailscale docs:
- tsnet (Go embed): https://tailscale.com/kb/1244/tsnet
- Auth keys API: https://tailscale.com/kb/1085/auth-keys
- SSH ACLs: https://tailscale.com/kb/1193/tailscale-ssh
- Textual TUI framework: https://textual.textualize.io
- textual-plotext (sparklines): https://github.com/Textualize/textual-plotext
- TimescaleDB hypertables: https://docs.timescale.com/use-timescale/latest/hypertables/
- RustDesk self-hosted: https://rustdesk.com/docs/en/self-host/
- Sunshine + Moonlight: https://docs.lizardbyte.dev/projects/sunshine/
- PocketID OIDC: https://pocket-id.org (existing homelab integration, ADR-0007 F5)
- uPlot sparkline mode: https://github.com/leeoniya/uPlot
- HTMX: https://htmx.org
- uv (Python pkg mgr): https://docs.astral.sh/uv/
Status: Accepted Signed-off (proposed): Ramón Kamibayashi, 2026-05-25 Agent (assisting): Hermes / Vera (future MCP integration)
Apéndice A: One-liner install ejemplos (anticipo F7)¶
Linux/macOS¶
# Modo silencioso (token-driven, ideal para family/clients via Telegram link)
curl -fsSL https://rp.monxas.casa/install | sh -s -- \
--token=eyJhbGc... \
--group=family \
--hostname=$(hostname -s)
# Modo interactivo (genera token via PocketID magic link en browser)
curl -fsSL https://rp.monxas.casa/install | sh
# Modo audit-first (descarga, muestra, ejecuta a mano)
curl -fsSL https://rp.monxas.casa/install -o /tmp/rp-install.sh
sha256sum /tmp/rp-install.sh # comparar con https://rp.monxas.casa/install.sha256
less /tmp/rp-install.sh
sh /tmp/rp-install.sh --token=...
Windows (PowerShell admin)¶
# Token-driven
$env:RP_TOKEN = "eyJhbGc..."
iwr -useb https://rp.monxas.casa/install.ps1 | iex
# Interactivo
iwr -useb https://rp.monxas.casa/install.ps1 | iex
# (prompts en CLI)
Uninstall (cualquier OS)¶
Apéndice B: Esquema Postgres preliminar¶
CREATE TABLE hosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hostname TEXT NOT NULL,
fqdn TEXT,
tailscale_node_id TEXT UNIQUE,
os TEXT NOT NULL, -- linux/macos/windows
arch TEXT NOT NULL, -- x86_64/arm64
distro TEXT, -- debian-12, macos-14, win-11
kernel TEXT,
agent_version TEXT NOT NULL,
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ,
group_name TEXT REFERENCES groups(name),
capabilities JSONB, -- {rustdesk:true, sunshine:false, tailscale_ssh:true}
metadata JSONB -- libre
);
CREATE TABLE groups (
name TEXT PRIMARY KEY,
description TEXT,
access_users TEXT[], -- ["ramon", "hermes"]
auto_distribute_keys BOOLEAN DEFAULT true
);
CREATE TABLE ssh_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
user_name TEXT NOT NULL,
pubkey TEXT NOT NULL,
fingerprint TEXT UNIQUE NOT NULL,
algorithm TEXT NOT NULL, -- ed25519
created_at TIMESTAMPTZ DEFAULT now(),
revoked_at TIMESTAMPTZ,
revoked_reason TEXT
);
CREATE TABLE enrollments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_jti TEXT UNIQUE NOT NULL,
issued_by TEXT NOT NULL,
group_name TEXT REFERENCES groups(name),
expires_at TIMESTAMPTZ NOT NULL,
max_uses SMALLINT DEFAULT 1,
used_count SMALLINT DEFAULT 0,
used_by_host_id UUID REFERENCES hosts(id),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE commands ( -- audit log inmutable (DB trigger BLOCK update/delete)
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id),
issued_by TEXT NOT NULL, -- user or "system"
command_type TEXT NOT NULL, -- exec_shell, service_restart, ...
command_payload JSONB NOT NULL,
issued_at TIMESTAMPTZ DEFAULT now(),
completed_at TIMESTAMPTZ,
exit_code INT,
stdout TEXT,
stderr TEXT,
duration_ms INT,
human_approved BOOLEAN DEFAULT FALSE, -- Telegram approval flow (review C3)
approved_by TEXT,
rejected_reason TEXT, -- e.g. local_policy_deny, signature_invalid
server_signature TEXT NOT NULL, -- Ed25519 sig server->agent
agent_node_id TEXT NOT NULL -- Tailscale node id que originó el ack
);
CREATE INDEX idx_commands_host_issued ON commands(host_id, issued_at DESC);
CREATE INDEX idx_commands_issued_by ON commands(issued_by, issued_at DESC);
-- Trigger inmutabilidad
CREATE OR REPLACE FUNCTION block_command_mutations() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'commands table is append-only';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER commands_immutable
BEFORE UPDATE OR DELETE ON commands
FOR EACH ROW EXECUTE FUNCTION block_command_mutations();
-- Retention policy commands: 1 año
-- (manual cron job, no DB trigger porque DELETE blocked):
-- INSERT INTO commands_archive SELECT * FROM commands WHERE issued_at < now() - INTERVAL '1 year';
-- (commands_archive es tabla separada sin trigger, append-only logical)
-- API compatibility tracking
CREATE TABLE agent_versions (
host_id UUID NOT NULL REFERENCES hosts(id) PRIMARY KEY,
agent_version TEXT NOT NULL, -- semver
api_compat_min TEXT NOT NULL, -- min server version supportable
api_compat_max TEXT NOT NULL, -- max server version known to work
last_check TIMESTAMPTZ DEFAULT now()
);
-- TimescaleDB hypertables
-- NOTA: ts = received_at (server clock, source of truth tras review M4); agent_ts loggea drift
CREATE TABLE heartbeats (
host_id UUID NOT NULL,
ts TIMESTAMPTZ NOT NULL, -- received_at server-side
agent_ts TIMESTAMPTZ, -- clock skew detection
cpu_pct REAL,
mem_pct REAL,
load_1m REAL,
uptime_s BIGINT,
agent_version TEXT
);
SELECT create_hypertable('heartbeats', 'ts', chunk_time_interval => INTERVAL '1 day');
SELECT add_retention_policy('heartbeats', INTERVAL '90 days');
CREATE INDEX idx_heartbeats_host_ts ON heartbeats(host_id, ts DESC);
CREATE TABLE metric_samples (
host_id UUID NOT NULL,
ts TIMESTAMPTZ NOT NULL, -- received_at
metric TEXT NOT NULL, -- cpu_per_core.0, mem_used_mb, disk_used_pct./, net_rx_bps.eth0
value DOUBLE PRECISION
);
SELECT create_hypertable('metric_samples', 'ts');
SELECT add_retention_policy('metric_samples', INTERVAL '30 days');
CREATE INDEX idx_metric_samples_host_metric_ts ON metric_samples(host_id, metric, ts DESC);
-- Continuous aggregate 5min para histórico 1y
CREATE MATERIALIZED VIEW metric_samples_5min WITH (timescaledb.continuous) AS
SELECT host_id, metric, time_bucket('5 minutes', ts) AS bucket,
avg(value) AS avg_val, max(value) AS max_val, min(value) AS min_val
FROM metric_samples GROUP BY host_id, metric, bucket;
SELECT add_retention_policy('metric_samples_5min', INTERVAL '1 year');
-- Storage estimate steady-state fleet 50 hosts (review C4):
-- heartbeats: 50 × (60s/30s) × 86400s × 90d × 80 bytes ≈ 1.0 GB
-- metric_samples: 50 × 30 metrics × 1440 min × 30d × 50 bytes ≈ 3.2 GB raw → ~600MB compressed
-- metric_samples_5min: 50 × 30 × 288 buckets/d × 365d × 60 bytes ≈ 9.5 GB
-- commands (1y retention): variable, esperado <1 GB
-- Total: ~12-15 GB steady-state. LXC 280 disk dimensionado 50 GB.
Apéndice C: CLI surface preliminar¶
rp install # auto-detect, run bootstrap
rp uninstall [--confirm]
rp status # local agent status + last heartbeat
rp dash # launch TUI dashboard
rp doctor # diagnose connectivity, identity, deps
rp ssh <host> [-- cmd...] # tailscale ssh or fallback classic
rp screen <host> [--protocol=rustdesk|sunshine|vnc]
rp exec <host|group> -- <cmd> # remote shell exec (audited)
rp logs <host> [--tail=100] [--follow]
rp metrics <host> [--series=cpu,mem] [--window=1h]
rp keys list [--host=...] [--group=...]
rp keys distribute <group>
rp keys revoke <fingerprint> [--reason=...]
rp keys rotate <host> [--user=root]
rp groups list
rp groups show <name>
rp admin enroll [--group=...] [--ttl=24h] [--max-uses=1]
rp admin hosts # alias `rp dash --inline`
rp admin commands [--host=...] [--since=1h] # audit log query
rp admin upgrade <host|group> [--version=latest] [--canary=1] # canary deploy (review M3)
rp admin invite <email> [--group=family] # multi-user invitation flow (review M5)
rp admin reauth <host> # trigger re-auth post-DR (review C5)
rp admin compat # current API version + supported agent range (review M1)
rp admin clock-skew # list hosts with skew >120s (review M4)
# Agent-side local config helpers
rp local allow-exec # touch /etc/rp/allow-remote-exec (requires sudo)
rp local deny-exec
rp local approve-write [--ttl=5m]
rp local status # current local-policy enforcement state
Apéndice D: Bootstrap script outline (Linux/macOS)¶
#!/usr/bin/env sh
# Generated by rp.monxas.casa - SHA256: <auto-injected>
set -eu
RP_SERVER="${RP_SERVER:-https://rp.monxas.casa}"
RP_TOKEN="${RP_TOKEN:-}"
RP_GROUP="${RP_GROUP:-default}"
RP_HOSTNAME="${RP_HOSTNAME:-$(hostname -s)}"
# Parse --token=... --group=... --hostname=...
while [ $# -gt 0 ]; do
case "$1" in
--token=*) RP_TOKEN="${1#*=}" ;;
--group=*) RP_GROUP="${1#*=}" ;;
--hostname=*) RP_HOSTNAME="${1#*=}" ;;
--show) SHOW_MODE=1 ;;
*) echo "Unknown flag: $1" >&2; exit 1 ;;
esac
shift
done
[ -z "$RP_TOKEN" ] && { echo "Need --token=... or RP_TOKEN env. Run with --show to inspect." >&2; exit 1; }
# 1. Detect OS/arch
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m); [ "$ARCH" = "aarch64" ] && ARCH=arm64; [ "$ARCH" = "x86_64" ] && ARCH=x64
# 2. Install Tailscale if missing
command -v tailscale >/dev/null 2>&1 || curl -fsSL https://tailscale.com/install.sh | sh
# 3. Download agent binary
BIN_URL="https://github.com/monxas/remote-pulse/releases/latest/download/rp-${OS}-${ARCH}"
curl -fsSL "$BIN_URL" -o /usr/local/bin/rp
chmod +x /usr/local/bin/rp
# 4. Verify checksum
SHA_URL="${BIN_URL}.sha256"
echo "$(curl -fsSL "$SHA_URL") /usr/local/bin/rp" | sha256sum -c -
# 5. Enroll
ENROLL_RESP=$(curl -fsSL -X POST "$RP_SERVER/v1/enroll" \
-H "Content-Type: application/json" \
-d "{\"token\":\"$RP_TOKEN\",\"hostname\":\"$RP_HOSTNAME\",\"group\":\"$RP_GROUP\"}")
TS_AUTHKEY=$(echo "$ENROLL_RESP" | jq -r .tailscale_authkey)
# 6. Join tailnet
sudo tailscale up --authkey="$TS_AUTHKEY" --hostname="$RP_HOSTNAME" --ssh --accept-routes
# 7. Generate SSH key, register
[ ! -f /etc/rp/host_key ] && {
sudo mkdir -p /etc/rp && sudo ssh-keygen -t ed25519 -f /etc/rp/host_key -N ""
}
PUBKEY=$(sudo cat /etc/rp/host_key.pub)
curl -fsSL -X POST "$RP_SERVER/v1/keys" \
-H "Content-Type: application/json" \
-d "{\"hostname\":\"$RP_HOSTNAME\",\"pubkey\":\"$PUBKEY\",\"user\":\"root\"}"
# 8. Install systemd unit, start
sudo /usr/local/bin/rp install-service --server="$RP_SERVER"
sudo systemctl enable --now remote-pulse
echo ""
echo "✓ Remote-Pulse installed. Host '$RP_HOSTNAME' registered to group '$RP_GROUP'."
echo " Dashboard: $RP_SERVER/dash"
echo " Local CLI: rp status"
Apéndice E: Tailscale ACL policy.hujson (review M6)¶
Snippet base que se pondrá en tailscale.com/admin/acls (tailnet monxas). Refinable durante F2-F8.
{
"tagOwners": {
"tag:rp-server": ["[email protected]"],
"tag:rp-agent-prod": ["[email protected]"],
"tag:rp-agent-family": ["[email protected]"],
"tag:rp-agent-iarq": ["[email protected]"]
},
"groups": {
"group:admins": ["[email protected]"],
"group:family": ["[email protected]"],
"group:iarq": ["[email protected]"]
},
"acls": [
// Admins everywhere
{ "action": "accept", "src": ["group:admins"], "dst": ["*:*"] },
// Server reaches every agent (heartbeats, commands)
{ "action": "accept", "src": ["tag:rp-server"], "dst": ["tag:rp-agent-prod:*", "tag:rp-agent-family:*", "tag:rp-agent-iarq:*"] },
// Agents reach server only on ports needed
{ "action": "accept", "src": ["tag:rp-agent-prod", "tag:rp-agent-family", "tag:rp-agent-iarq"], "dst": ["tag:rp-server:443,8080"] },
// Family group can reach their own agents only
{ "action": "accept", "src": ["group:family"], "dst": ["tag:rp-agent-family:*"] },
// Iarq group can reach their own agents only
{ "action": "accept", "src": ["group:iarq"], "dst": ["tag:rp-agent-iarq:*"] },
// CRITICAL: agents CANNOT reach other agents (no lateral movement)
// (default-deny: anything not explicitly accepted is denied)
],
"ssh": [
// Admins can SSH everywhere via Tailscale SSH (check mode = passkey re-auth)
{ "action": "check", "src": ["group:admins"], "dst": ["tag:rp-agent-prod", "tag:rp-agent-family", "tag:rp-agent-iarq"], "users": ["autogroup:nonroot", "root"] },
// Family can SSH to their own agents
{ "action": "check", "src": ["group:family"], "dst": ["tag:rp-agent-family"], "users": ["autogroup:nonroot"] }
],
"tests": [
{ "src": "group:family", "accept": ["tag:rp-agent-family:22"], "deny": ["tag:rp-agent-prod:22"] },
{ "src": "tag:rp-agent-iarq", "deny": ["tag:rp-agent-prod:*"] }
]
}
Apéndice F: DR procedure (review C5)¶
Runbook completo se escribe en F8 como docs/runbooks/rp-dr-drill.md. Outline mandatory:
Caso F1 — Pérdida total LXC 280 (filesystem corruption, datacenter accident)¶
- Detectar: Telegram alert
host_down: rp-server+ Grafana panel red. Confirmar viapct status 280en pmx-50/51. - Restore opciones (orden preferred):
- R1 (RTO <10min):
pct restore 280 /var/lib/pbs/...desde último snapshot PBS. ZFS replication a pmx-51 si LXC 280 estaba replicado. - R2 (RTO ~30min): provisión nueva LXC desde Ansible role
remote_pulse_server+ restore pg_dump nightly desde NAS. - R3 (RTO ~2h): rebuild from scratch (último recurso, requiere re-enrollment fleet completo).
- Post-restore:
tailscale upcon auth-key admin one-time (tailnet identity preservada).- Re-emitir TS auth-keys efímeras invalidando las anteriores (las viejas pueden seguir, no es crítico, pero limpiar es bueno).
- Fleet re-auth: endpoint
POST /v1/agent/reauthpermite a agentes (que detectan server desconectado >15min) re-verificar identidad sin re-enrollment completo. Agente envía{host_id, last_known_server_pubkey, machine_fingerprint}→ server valida contra Postgres restored → emite nuevo signed session token. - Verificar
rp admin hostsmuestra fleet con last_seen reciente. - Si R3 (rebuild scratch):
- Notificar Telegram a Ramón + family.
- Agentes en hosts mantienen
authorized_keys.d/managedlocal → SSH degraded-mode funciona. - Re-enrollment manual: regenerar enrollment tokens nuevos, distribuir via Telegram a family/clients.
- Pérdida: audit log histórico (sí está en backups), sparkline histórico TimescaleDB (raw 30d perdido si backup viejo).
Caso F2 — Compromiso de Ed25519 signing key del server¶
- Detectar: anomalía en audit log Loki (
rejected_reason=signature_invalidspike) o forensics IR. - Acción inmediata: rotar Ed25519 keypair en LXC 280 (
rp admin rotate-signing-key). Server marca todas las sesiones agente comorequires_reauth. - Push nueva public key a agentes: via existing tailnet conn (asumiendo agentes no compromiso). Endpoint
POST /v1/agent/rotate-trust→ agente verifica nueva pubkey contra signed-by-old-key envelope (last good signature) + opcional Telegram confirm. - Re-emitir comandos pendientes firmados con nueva key.
DR drill (obligatorio F8 + recurrente)¶
- Cadencia: trimestral mínimo (semestral si <0 incidents 6 meses).
- Procedimiento: en LXC test (id 283 temporal) restaurar pg_dump del último backup → ejecutar
rp admin verify-restore→ confirmar fleet visible → destruir LXC test. - Documentar tiempo real RTO en
docs/runbooks/dr-drill-log.md.
Apéndice G: API compatibility + offline queue + update/rollback policies (review M1+M2+M3)¶
G.1 — API versioning¶
- Endpoint prefix
/v1/para todas las rutas. Breaking changes →/v2/. Major version bump = N-2 deprecation policy (v1 sigue funcionando hasta release de v3). - Agent version negotiation: al primer WS connect, agente envía
Sec-RP-Agent-Version: 0.5.2header +Sec-RP-Min-Server: 0.5.0. Server responde conSec-RP-Server-VersionySec-RP-Min-Agent. Si fuera de rango compat → cierra conn con error JSON{error: "version_skew", required: ">=0.5.0"}que CLI traduce a mensaje claro. - Skew tolerance window: agentes v(N-2)..v(N) soportados activamente. v(N-3) loggea warning. v(N-4) rechazado.
- Feature flags: nuevas features se anuncian en
GET /v1/server/info(capabilities array). Agente desconoce feature → log warn, no crash. Server pide feature unknown → agente ack conunsupported_featureflag, command goes torejectedstate. - Schema migrations Postgres: Alembic (sqlalchemy-style). Migrations testeadas en CI con DB snapshot + rollback verifiable.
G.2 — Offline queue policy (agente)¶
- Ring buffer SQLite en
/var/lib/rp/queue.db. Caps: - Tamaño máximo: 100 MB.
- Edad máxima items: 24 horas.
- Lo que ocurra primero → FIFO discard.
- Excepción audit log: registros de
command_ackycommand_rejectedNUNCA se descartan offline. Si su sub-queue excede 50 MB, agente PARA de ejecutar nuevos comandos y emite warning local hasta que sirva drain. - Metric samples deduplication offline: durante offline, agente NO encola cada 60s. En su lugar: muestra a su frecuencia, pero queue solo guarda 1-en-5 (5min effective resolution durante offline). En reconnect, server recibe gap explícito y
metric_samples_5minaggregate compensa. - Reconnect drain: rate-limited 100 items/s para no saturar server. Order: control channel → metrics → inventory → logs.
G.3 — Update + rollback¶
- Default deploy mode: canary 1-host → observe 10min → propagate.
- Canary host selection: server elige host con menor
criticalityscore (groupprod>family>default). Override con--canary-host=<name>. - Health-check post-upgrade: agente debe enviar
version_handshakecon nueva version +self_check.ok=truedentro de 90s. Si timeout ook=false→ rollback automático a binary N-1 mantenido en/opt/rp/bin/rp-prev. - Manual rollback:
rp admin rollback <host>leeagent_versionstable, identifica versión previa, push downgrade command. - Forbidden: upgrade que cruza major version (v0.X → v1.Y) requiere
--force-majorflag + Telegram approval. Razón: schema migrations pueden no ser reversibles.
Conclusión¶
Remote-Pulse establece la base operativa "single pane of glass + single command onboarding" para todo el ecosistema monxas (homelab + family + clients). Reusabilidad estricta de stack ADR-0007 (SOPS, PocketID, Caddy HA, Observability) con piezas nuevas mínimas (LXC 280 server, opcionalmente LXC 281 RustDesk si caso B aparece). El split público/privado (agente FOSS vs server homelab) permite onboarding de terceros sin filtración de inventario, y abre opción comunitaria FOSS post-v1.0.
Cambios significativos tras devil's advocate review 2026-05-25:
- Eliminado tsnet sidecar Go →
tailscaleddaemon estándar +tailscale servereverse-proxy (cero código nuevo, -3d F2). - Diferido LXC 281 RustDesk server → cliente RustDesk Direct IP suficiente tailnet-only (-2d F6).
- Añadido local-approval enforcement en agente (
/etc/rp/flag files) para mitigar server compromise como vector full-fleet ownage (+2d F4). - Documentado DR procedure formal (Apéndice F) + endpoint
/v1/agent/reauthpara fleet re-auth post-restore (+1d F8). - Añadida API compatibility policy (Apéndice G) con N-2 deprecation, skew tolerance, version negotiation handshake (+0d, doc-only).
- Añadida Tailscale ACL policy (Apéndice E) con tags, default-deny lateral movement, ssh check policy (+0d, doc-only).
- Multi-user real en F5 vía PocketID sync +
users.yml+accessible_groupsfiltering web dashboard (+2d F5). - Windows packaging honesto: winget primary, PyInstaller fallback con EV signing presupuestado (+2d F7).
- Storage estimate corregido 5GB→15GB steady-state, LXC disk 20GB→50GB.
- WebSocket multi-canal (control / metrics / inventory / logs) para backpressure isolation.
- Time-sync policy:
received_atsource of truth,agent_tssolo para skew detection. - Offline queue policy explicit: 100 MB / 24 h FIFO, audit log NUNCA descartado.
- Update/rollback canary deploy con N-1 binary local para auto-rollback.
Total recalibrado: 33d bruto, 9-10 semanas calendario realista. Decisiones débiles documentadas en review pero conservadas: repos split público/privado desde día 1 (overhead ~3d), TUI primary build order (Ramón itera primero) con honest framing "Web primary para family/clients persona".
Próximos pasos inmediatos (si Accepted):
1. Crear repo monxas/remote-pulse público con Apache-2.0 + README placeholder + pyproject.toml (1h).
2. Provisionar LXC 280 rp-server en pmx-50 (debian-12, 2GB/20GB) — pct create manual o playbook (1h).
3. Kick-off F1 — Agente MVP + Server stub (5d).
4. Reservar slots Tailscale auth-keys reusables para enrollment system (10 keys ephemeral pool en API key dedicada, ~30min).
5. Crear PocketID OIDC client remote-pulse-web (defer F5, 15min).
Philosophy reminder: El agente debe ser boring (Python obvio, systemd unit obvio, JSON sobre WebSocket obvio). La magia vive en el server (TimescaleDB sparkline queries, ACL declarativa, tailscale serve identity injection), no en cada host. Boring agents, smart server = fácil de instalar, fácil de auditar, difícil de romper. Y boring server también: post-review, sin sidecar Go custom, sin tsnet Python frankenbinding, sin RustDesk hbbs/hbbr ociosa — daemons estándar bien configurados son la pieza más estable que puedes construir.
Adenda 2026-05-25 — F1-F8 implementation gotchas¶
Implementado en una sesión intensiva via 22+ sub-agents en paralelo. Gotchas encontradas durante deploy real al LXC 280:
F1 deploy¶
- SQLAlchemy 2.0 reserva
metadatacomo attribute → renombrar columnaHost.metadataa Python attrHost.extra(mantiene column name DB). - Pydantic
EmailStrrequiere instalaremail-validatorextra (pydantic[email]). - FastAPI dependency pattern: mix de
Annotated[X, Depends(y)]+= Depends(y)default falla con assertion error en algunas versiones. Migrar a Annotated-only. - F1 endpoints en degraded mode (sin Tailscale headers) requieren
tailscale_identity_optionalno strict.
F2 deploy¶
- tsnet Python no tiene lib madura (verificado 2026-05-25 —
tailscale-rsmarked "do not use in production"). Decisión inicial sidecar Go fue revertida durante devil's advocate review a tailscaled daemon estándar +tailscale servereverse-proxy. Mucho más simple.
F3 deploy¶
- Bug asyncpg interval encoding:
time_bucketrequieredatetime.timedeltano string"5 seconds". Fix en routers/metrics.py línea 127. - Migration 002 timescaledb requires extension installed BEFORE
create_hypertablecalls. Document apt step en runbook.
F4 deploy¶
- Migration 003 bug de orden: ADD CONSTRAINT FK falló porque hosts table tenía filas con group_name='default' antes de seed groups. Fix: seed INSERT BEFORE constraint add.
- Multiple F4 routers (commands, approvals) creados por agentes paralelos NO se registraron en main.py — añadir manualmente al include_router calls.
F5 deploy¶
- Múltiples agentes paralelos tocando main.py simultáneamente causaron merge conflict potencial. Mitigated via prompts explícitos a cada agente sobre qué archivos podía/no podía tocar.
F8 deploy¶
- F8-CANARY agente creó upgrade.py con import broken (
RPConfigno existe; el correcto esload_config). Fix manual post-agent.
Decisiones revisitadas durante implementación¶
agent/installers/carpeta nueva para F6 install-screen comandos (separado de F1 enrollmentcommands/install.pypara evitar collision).- Web dashboard usa HTMX server-rendered partials, no SPA. Decisión: simpler, no build pipeline, search-engine friendly, accesible from móvil sin app.
Métricas reales vs target¶
| Metric | Target | Actual | Notes |
|---|---|---|---|
| TTI LAN | ≤60s | ~30s | Validated on LXC 280 |
| TTI WAN/Tailscale | ≤120s | TBD | Pending Tailscale daemon F2-1 |
| Server endpoints | (n/a) | 36 | F1=4, F2=2, F3=2, F4=11, F5=8, F8=9 |
| Tests | (n/a) | 200+ | Cross-module |
| Lines added | (n/a) | ~10000 | Across 3 repos |
| Calendar time | 9-10 weeks | 1 session | Parallel agent orchestration |
| Implementation cost | 33d eng | 1 session compute | (sub-agent parallelism multiplier) |
Next iteration (v1.x roadmap)¶
- v1.1: GUI installer (Tauri) para family Windows users
- v1.2: WebSocket multi-channel (currently REST polling)
- v1.3: NetBird control-plane optional (Tailscale vendor escape)
- v2.0: EV code signing, MSI installer, code-reviewed pentest
Final status: Accepted, v1.0.0 GA tagged 2026-05-25.