Skip to content

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:

  1. 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).
  2. Externos directos (familia/amigos): Mac mini Carmelo (M4 headless con openclaw-vm Ubuntu), futuros nodos para hermanos/padres con Tailscale.
  3. 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.md desactualizado en días).
  • SSH keys ad-hoc: ~/.ssh/authorized_keys copiado a mano vía ssh-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)

  1. One-command onboarding — instalar agente en host nuevo (cualquier OS) y registrarlo en el dashboard en <60s, sin pasos manuales post-instalación.
  2. 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.
  3. SSH key lifecycle automatizado — generación, registro, distribución, revocación de SSH pubkeys gestionados centralmente, agrupables por ACL (prod / family / clients).
  4. 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.
  5. 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).
  6. Phone-home auditable — heartbeats, métricas, inventory diff, ejecución de comandos remotos — todo loggeable, replicable, auditado en Postgres + Loki.
  7. Open-sourceable — agente FOSS para que terceros (clientes iarq, familia, futuros usuarios) instalen sin exponer inventario homelab. Privacy by default.
  8. 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: tailscaled system instalado en LXC 280, login via tailscale up --hostname=rp-server --advertise-tags=tag:rp-server. tailscale serve --bg --https=443 --set-path / http://127.0.0.1:8080 expone FastAPI tailnet-only e inyecta automáticamente Tailscale-User-Login, Tailscale-User-Name, Tailscale-Headers que 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 bindeando 0.0.0.0:8443 solo para /v1/enroll y /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-Login header inyectado por tailscale 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 tailnet monxas pueden POST métricas; cada device solo puede escribir métricas para su propio tailscale_node_id.
  • Bootstrap via Enrollment JWT: humano (Ramón) genera token via rp admin enroll --group prod --ttl 24h --max-uses 1 en CLI server → JWT con claims {enrollment_id, group, exp, max_uses} firmado HS512 con RP_ENROLLMENT_SECRET (SOPS). Agente lo presenta a POST /v1/enroll, server entrega Tailscale auth-key efímero + agent config inicial. Token consumido.
  • Humanos web (dashboard externo): Caddy forward_auth con PocketID. Tras OIDC, header X-Forwarded-User → server resuelve a user_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 "", registra POST /v1/keys con {pubkey, fingerprint, host_id, user="root"}.
  • Server mantiene tabla ssh_keys con (id, host_id, user, pubkey, fingerprint, created_at, revoked_at, groups[]).
  • Grupos declarados en homelab-infra/services/remote-pulse-server/config/groups.yml:
    groups:
      prod:
        members: [pmx-50, pmx-51, vm-208, lxc-101, lxc-100]
        access: [ramon, hermes]
      family:
        members: [carmelo, padre-laptop]
        access: [ramon]
      iarq:
        members: [iarq-rag, iarq-locator]
        access: [ramon]
    
  • rp keys distribute <group> → server calcula authorized_keys final por host → push vía agente (escribe /etc/rp/authorized_keys.d/managed que sshd lee con AuthorizedKeysFile).
  • Revocación: rp keys revoke <fingerprint> → server marca revoked_at → próximo push omite la pubkey. Notificación Telegram automática a @Veraclawd_bot en 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 → preferir tailscale ssh si target tiene Tailscale SSH habilitado en ACLs, sino ssh -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 + configura Settings → Network → Direct IP access enabled, sin rendezvous server. ID = 100.x.x.x (Tailscale IP) o MagicDNS name. Password generado al instalar y rotado por rp 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-smirp install sunshine baja 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 con textual-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) y rp-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 /metrics con rp_host_cpu_pct{host=...}, rp_host_up{host=...}, etc.). Provisioned como JSON en homelab-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:
    curl -fsSL https://rp.monxas.casa/install | sh -s -- --token=eyJhbGc...
    
  • Windows one-liner (PowerShell):
    iwr -useb https://rp.monxas.casa/install.ps1 | iex
    # interactive token prompt si no en env $env:RP_TOKEN
    
  • 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 | sh o equivalente Windows).
  • POST /v1/enroll con {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 flag human_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-pulse creado (LICENSE Apache-2.0, README placeholder, pyproject.toml con uv).
  • Agente Python rp con 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.service instalado 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-server con 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() lee Tailscale-User-Login header (rechaza request si falta — significa que no vino via tailscale serve).
  • Bootstrap script instala Tailscale si falta (curl https://tailscale.com/install.sh | sh).
  • Enrollment flow real: POST /v1/enroll valida JWT, devuelve tailscale_authkey efímero (Tailscale API POST /api/v2/tailnet/-/keys con ephemeral=true, reusable=false, expiry=24h).
  • Caddy route rp.monxas.casa → LXC 280:8443 (solo /v1/enroll y /v1/agent/reauth expuestos, resto 401).
  • Verificación: tailscale status desde nuevo host muestra rp-server reachable; 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-plotext integration → 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, commands tables + audit log inmutable (trigger Postgres block_mutations()).
  • Agente: ssh-keygen ed25519 al primer arranque, registra pubkey via POST /v1/keys.
  • Server: groups.yml declarativo 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-exec flag file requerido para exec_shell en grupos prod/iarq.
  • restart-whitelist patterns para service_restart.
  • read-allowlist patterns para file_read.
  • allow-remote-write flag para file_write (default OFF en prod/iarq).
  • CLI: rp keys list, rp keys distribute <group>, rp keys revoke <fingerprint>.
  • Comando exec_shell via WS bidireccional (server→agent), audit log a Postgres + Loki (doble sink).
  • ACL: solo admin/operator roles pueden invocar exec_shell, todo loggeado con user_id + tailscale_node_id que originó.
  • Telegram approval flow n8n: webhook approval_required → mensaje Ramón con inline buttons → respuesta → server reenvía comando con human_approved=true (TTL 5min). Implementar como nuevo workflow en n8n existing.

Validación:

  1. Revoco la pubkey de pmx-51 con rp keys revoke <fp> → no puedo SSH a pmx-51 desde mi laptop en 60s → restoro con rp keys distribute prod → SSH funciona again.
  2. Simulo "server compromise": en LXC 280 ejecuto manualmente rp admin exec --host=pmx-50 --skip-approval --cmd="rm -rf /etc/important" → agente rechaza porque allow-remote-exec no existe en pmx-50, audit log registra rejected_reason=local_policy_deny. Telegram alerta dispara.
  3. reboot en grupo prod requiere 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.casa con forward_auth PocketID.
  • OIDC client en PocketID remote-pulse-web.
  • Multi-user real:
  • Tabla users poblada vía sync PocketID (cron 1h o webhook on-login).
  • Cada user tiene role (admin/operator/viewer) y accessible_groups[] declarados en users.yml (o derivados de PocketID groups si están).
  • Web dashboard filtra hosts por accessible_groups del user logged. Hermano logged ve solo family; cliente iarq logged ve solo iarq.
  • 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 /metrics Prometheus 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:

  1. Desde iPad fuera de casa via Tailscale → https://dash.rp.monxas.casa → PocketID passkey → dashboard renderiza con sparklines uPlot todos hosts (yo soy admin).
  2. Hermano logged → ve solo Carmelo y su laptop, no pmx-50.
  3. 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 rustdesk baja cliente RustDesk, configura Settings → Network → Direct IP access = enabled, password generado y guardado en server (rotable por rp 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 sunshine baja MSI silent → instala como service → admin UI accesible https://<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: prefer tailscale ssh si target tiene Tailscale SSH, fallback ssh con 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 (--show imprime script sin ejecutar).
  • Windows packaging (rediseñado):
  • Path A (preferred): winget install Monxas.RemotePulse — publicar a microsoft/winget-pkgs (gratis). Manifest YAML signed por mí; SmartScreen confianza se construye con descargas. Para family Windows, instrucción es winget install Monxas.RemotePulse no 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-pulse para 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 Pages rp.monxas.casa/docs + sección Troubleshooting Windows Defender explícita.
  • rp.monxas.casa/install endpoint 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:

  1. Envío link a hermano (sin homelab access). Su Mac aparece en /v1/hosts con grupo family, RustDesk Direct IP funciona desde mi iPad.
  2. Pruebo winget install Monxas.RemotePulse en Windows 11 limpia → no requiere SmartScreen excepción tras manifest aprobado.
  3. 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/reauth y volver a heartbeat. Documentar en runbook docs/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=1 ejecuta 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-prev para 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:

  1. Terceros pueden seguir docs y onboard un nodo en <5min sin acceso al homelab privado.
  2. 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).
  3. DR drill exitoso: LXC 280 destruida → restaurada → fleet vuelve a operación en <30min.
  4. 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)

  1. No Kubernetes para el server. LXC + systemd suficiente. Migrar a k8s/k3s solo si fleet >200 hosts (no en horizonte).
  2. 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.
  3. No agente en Rust/Go. Python+uv prioriza cohesión homelab; reescritura solo si bottleneck CPU agente (improbable, I/O bound).
  4. 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.
  5. 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.
  6. No reinventar dashboards. TUI custom (Textual) y Web custom (HTMX) son thin layers sobre datos del server; Grafana ya existe para histórico.
  7. 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.
  8. No SaaS RMM (NinjaOne, Atera, ConnectWise). Vendor lock, costes recurrentes, privacy familia y clientes iarq.
  9. 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).
  10. 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.
  11. No GUI installer en v1.0. Bootstrap one-liner cubre 95% casos. GUI installer reconsiderado parcialmente vía winget install para Windows family (driver #1 persona). Tauri/Electron full-GUI considerar v2.0 si onboarding family friction real.
  12. 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).
  13. No tsnet sidecar Go. tailscaled + tailscale serve daemon 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 unificadorp ssh <host>, rp screen <host> reemplaza 4 herramientas distintas.
  • Reusabilidad públicamonxas/remote-pulse viable 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-pulse permite 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_shell con 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 trufflehog en CI.
  • Server compromise blast radius limited: ningún comando destructivo (exec_shell, file_write, reboot) en grupos prod/iarq ejecutable 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)

  1. ¿Postgres en LXC 280 o LXC dedicado 282? — Default LXC 280 mismo. Split solo si Postgres crece sin control (revisar fase 3).
  2. ¿TimescaleDB licencia community vs apache? — Community license (TSL) cubre features needed (continuous aggregates, retention policies). No requerimos enterprise.
  3. ¿Bundle Promtail en agente o role Ansible separado? — Bundle como flag opcional --with-logs en Linux. macOS/Windows: alternativa fluent-bit o solo audit-log forward (Promtail multi-OS frágil — review H7).
  4. ¿Signing keys cosign o solo SHA256SUMS? — Empezar SHA256SUMS, cosign en F8 si tiempo (no blocker).
  5. ¿Agente en Alpine/musl? — F1-F7 glibc-only. Alpine via PyInstaller con --musl flag en F8 si demand (HA addon? Karakeep host? Reviewable).
  6. ¿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í).
  7. ¿Integración con homelab-ctl.py para auto-add hostname Caddy? — Defer post-v1.0; Caddy routes deben ser opt-in manual para no auto-exponer cada host.
  8. ¿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.
  9. ¿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.
  10. ¿DR drill cadence definitiva? — Propuesta inicial: trimestral. Si baja churn (sin incidents 6 meses), bajar a semestral.
  11. ¿Repos split público/privado desde día 1 vs deferred?Review D1 sugiere defer: todo en homelab-infra privado 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.
  12. ¿Telegram approval bot Veraclawd vs nuevo @RemotePulse_bot? — Reusar Veraclawd evita más bots, pero acopla destinos de alertas. Defer decisión a F4.
  13. ¿custom_metrics agente — plugin system o config-driven? — Empezar config-driven (/etc/rp/custom-metrics.yml con 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)

rp uninstall --confirm    # detiene service, revoca key del server, borra config

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)

  1. Detectar: Telegram alert host_down: rp-server + Grafana panel red. Confirmar via pct status 280 en pmx-50/51.
  2. Restore opciones (orden preferred):
  3. R1 (RTO <10min): pct restore 280 /var/lib/pbs/... desde último snapshot PBS. ZFS replication a pmx-51 si LXC 280 estaba replicado.
  4. R2 (RTO ~30min): provisión nueva LXC desde Ansible role remote_pulse_server + restore pg_dump nightly desde NAS.
  5. R3 (RTO ~2h): rebuild from scratch (último recurso, requiere re-enrollment fleet completo).
  6. Post-restore:
  7. tailscale up con auth-key admin one-time (tailnet identity preservada).
  8. Re-emitir TS auth-keys efímeras invalidando las anteriores (las viejas pueden seguir, no es crítico, pero limpiar es bueno).
  9. Fleet re-auth: endpoint POST /v1/agent/reauth permite 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.
  10. Verificar rp admin hosts muestra fleet con last_seen reciente.
  11. Si R3 (rebuild scratch):
  12. Notificar Telegram a Ramón + family.
  13. Agentes en hosts mantienen authorized_keys.d/managed local → SSH degraded-mode funciona.
  14. Re-enrollment manual: regenerar enrollment tokens nuevos, distribuir via Telegram a family/clients.
  15. 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

  1. Detectar: anomalía en audit log Loki (rejected_reason=signature_invalid spike) o forensics IR.
  2. Acción inmediata: rotar Ed25519 keypair en LXC 280 (rp admin rotate-signing-key). Server marca todas las sesiones agente como requires_reauth.
  3. 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.
  4. 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.2 header + Sec-RP-Min-Server: 0.5.0. Server responde con Sec-RP-Server-Version y Sec-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 con unsupported_feature flag, command goes to rejected state.
  • 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_ack y command_rejected NUNCA 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_5min aggregate 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 criticality score (group prod > family > default). Override con --canary-host=<name>.
  • Health-check post-upgrade: agente debe enviar version_handshake con nueva version + self_check.ok=true dentro de 90s. Si timeout o ok=false → rollback automático a binary N-1 mantenido en /opt/rp/bin/rp-prev.
  • Manual rollback: rp admin rollback <host> lee agent_versions table, identifica versión previa, push downgrade command.
  • Forbidden: upgrade que cruza major version (v0.X → v1.Y) requiere --force-major flag + 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:

  1. Eliminado tsnet sidecar Gotailscaled daemon estándar + tailscale serve reverse-proxy (cero código nuevo, -3d F2).
  2. Diferido LXC 281 RustDesk server → cliente RustDesk Direct IP suficiente tailnet-only (-2d F6).
  3. Añadido local-approval enforcement en agente (/etc/rp/ flag files) para mitigar server compromise como vector full-fleet ownage (+2d F4).
  4. Documentado DR procedure formal (Apéndice F) + endpoint /v1/agent/reauth para fleet re-auth post-restore (+1d F8).
  5. Añadida API compatibility policy (Apéndice G) con N-2 deprecation, skew tolerance, version negotiation handshake (+0d, doc-only).
  6. Añadida Tailscale ACL policy (Apéndice E) con tags, default-deny lateral movement, ssh check policy (+0d, doc-only).
  7. Multi-user real en F5 vía PocketID sync + users.yml + accessible_groups filtering web dashboard (+2d F5).
  8. Windows packaging honesto: winget primary, PyInstaller fallback con EV signing presupuestado (+2d F7).
  9. Storage estimate corregido 5GB→15GB steady-state, LXC disk 20GB→50GB.
  10. WebSocket multi-canal (control / metrics / inventory / logs) para backpressure isolation.
  11. Time-sync policy: received_at source of truth, agent_ts solo para skew detection.
  12. Offline queue policy explicit: 100 MB / 24 h FIFO, audit log NUNCA descartado.
  13. 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 metadata como attribute → renombrar columna Host.metadata a Python attr Host.extra (mantiene column name DB).
  • Pydantic EmailStr requiere instalar email-validator extra (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_optional no strict.

F2 deploy

  • tsnet Python no tiene lib madura (verificado 2026-05-25 — tailscale-rs marked "do not use in production"). Decisión inicial sidecar Go fue revertida durante devil's advocate review a tailscaled daemon estándar + tailscale serve reverse-proxy. Mucho más simple.

F3 deploy

  • Bug asyncpg interval encoding: time_bucket requiere datetime.timedelta no string "5 seconds". Fix en routers/metrics.py línea 127.
  • Migration 002 timescaledb requires extension installed BEFORE create_hypertable calls. 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 (RPConfig no existe; el correcto es load_config). Fix manual post-agent.

Decisiones revisitadas durante implementación

  • agent/installers/ carpeta nueva para F6 install-screen comandos (separado de F1 enrollment commands/install.py para 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.