Plataforma autohospedada para exponer Ollama corriendo en un Mac mini M1 a Internet de forma segura, con panel de administración, gestión de tokens, cola de consultas, Wake-on-LAN (WOL), enrutado por path sin puertos visibles y soporte multi-máquina (worker remoto en un MacBook M1 Max).
Construir un servicio de inferencia LLM personal y compartible:
- Hardware principal: Mac mini M1 / 16 GB RAM (cableado por Ethernet a un FRITZ!Box 6660 Cable).
- Hardware secundario opcional: MacBook Pro M1 Max / 64 GB RAM (worker remoto en la LAN, accesible vía WOL).
- Acceso a modelos pequeños y medianos (1.5B–14B en M1, hasta ~70B Q4 en M1 Max) desde:
- LAN (cualquier dispositivo de la red local).
- Internet (mediante DynDNS + reverse proxy con HTTPS y autenticación por token).
- Panel web de administración con:
- Registro/login (registro desactivado por defecto, solo el admin lo habilita).
- Gestión de tokens de API por usuario.
- Consola de pruebas integrada (basada en
ai-console.html). - WOL desde dentro y desde fuera de la red.
- Configuración de workers remotos (otros Macs en la LAN).
- Estadísticas de uso, modelos disponibles por host y estado de cada nodo.
- Cola de consultas con concurrencia configurable (por defecto: 1 simultánea) y timeout configurable (por defecto: 5 min).
- URLs sin puerto visible desde el exterior:
https://midominio.dyndns.org/ollama/...se traduce internamente al puerto correspondiente. - Reaprovecha código del repo base:
patchamama/LocalForge_Ollama-Local-SSL— en particularwol-proxy.pyyai-console.html.
⚠ Premisa crítica: el Mac mini queda expuesto a Internet. La seguridad es el eje del diseño — sin excepciones.
Internet
│
▼
┌─────────────────┐
│ DynDNS (FQDN) │ midominio.dyndns.org
└────────┬────────┘
│ (443/TCP)
▼
┌──────────────────────┐
│ FRITZ!Box 6660 │
│ Port forward 443 │──── 7 (UDP) WOL desde Internet
│ → 192.168.178.X:443 │
└──────────┬───────────┘
│ LAN cable (Ethernet)
▼
┌──────────────────────────────────────────────────┐
│ Mac mini M1 (192.168.178.X) │
│ ┌──────────────────────────────────────────────┐ │
│ │ Caddy (reverse proxy, TLS, path routing) │ │ :443
│ │ /api/admin/ → FastAPI │ │
│ │ /api/auth/ → FastAPI │ │
│ │ /ollama/* → FastAPI gateway → Ollama │ │
│ │ /panel/ → React SPA │ │
│ │ /wol/ → wol-proxy.py │ │
│ └──────────┬───────────────────────────────────┘ │
│ │ │
│ ┌──────────▼───────────┐ ┌──────────────────┐ │
│ │ FastAPI backend │ │ Ollama 127.0.0.1 │ │
│ │ - auth JWT │──▶ :11434 │ │
│ │ - cola + semaforo │ └──────────────────┘ │
│ │ - tokens │ │
│ │ - timeout │ ┌──────────────────┐ │
│ │ - audit log │ │ wol-proxy.py │ │
│ │ - router multi-host │──▶ :8765 │ │
│ └──────────┬───────────┘ └──────────────────┘ │
│ │ │
│ ┌──────────▼───────────┐ │
│ │ SQLite / Postgres │ usuarios, tokens, │
│ │ │ jobs, audit │
│ └──────────────────────┘ │
└──────────────┬───────────────────────────────────┘
│ LAN (mTLS + WOL)
▼
┌──────────────────────┐
│ MacBook M1 Max (64GB)│ worker remoto
│ Ollama :11434 │ (despierta por WOL)
└──────────────────────┘
| Capa | Tecnología | Función |
|---|---|---|
| DNS dinámico | MyFRITZ! o ddclient con DuckDNS / No-IP |
FQDN estable apuntando a la IP pública del router |
| Edge / TLS | Caddy 2 | HTTPS automático (Let's Encrypt), path routing, headers, rate limit |
| Gateway / API | FastAPI (Python 3.12) | Auth, cola, timeouts, audit, multi-host routing |
| Inferencia | Ollama (nativo en macOS, Metal) | Inferencia LLM |
| WOL | wol-proxy.py adaptado del repo base |
Magic packet directo + FRITZ!Box TR-064 |
| Frontend admin | React + Vite + Tailwind (+ AI Console) | Panel admin, login, tokens, consola, estadísticas |
| Base de datos | SQLite (fase 1) → opcional Postgres | Usuarios, tokens, jobs, audit |
| Observabilidad | Loki + Promtail + Grafana (Docker) | Logs centralizados, dashboards, alertas |
| Seguridad red | pf (macOS) + Fail2ban-equivalente custom |
Firewall, bloqueo de IPs maliciosas |
| Monitoreo seg. | osquery + audit logs propios |
Detección de anomalías |
- WOL en Apple Silicon solo desde sleep, no desde shutdown. Documentado por Apple y validado por la comunidad: los Mac M1+ no soportan wake desde apagado total. ⇒ El Mac mini nunca se apaga, solo se duerme (
pmset womp 1). - Ollama no tiene autenticación nativa. ⇒ Toda autenticación se delega a Caddy + FastAPI. Ollama escucha únicamente en
127.0.0.1:11434. - Una consulta por defecto (configurable). El M1 con 16 GB no soporta paralelismo serio en modelos medianos sin swap agresivo. Las consultas extra entran en cola FIFO.
- Timeout configurable (por defecto 300 s). Si una consulta excede el límite, se cancela el stream y se libera el slot.
- URLs sin puertos visibles desde Internet — solo
443/TCPabierto en el FRITZ!Box. Todo el ruteo interno lo hace Caddy por path. - Worker remoto opcional: el Mac mini actúa como puente/proxy hacia el M1 Max (y futuros nodos). El usuario externo nunca conecta directo al worker.
- Registro desactivado por defecto. Solo el admin habilita registro o crea cuentas/tokens manualmente.
- Idioma del proyecto: documentación en castellano; código, identificadores, comentarios y strings de UI en inglés (estándar de mantenibilidad).
Cada fase es autocontenida, deja un estado funcional verificable, y no rompe lo anterior. Las fases se entregan en este orden estricto.
Objetivo: dejar el hardware y la red listos antes de tocar código.
- Asignar IP fija al Mac mini en el FRITZ!Box (DHCP reservation por MAC).
- Verificar conexión cableada del Mac mini y del MacBook M1 Max al FRITZ!Box.
- Activar en el Mac mini:
Preferencias del Sistema → Energía → Activar para acceso a la red. - Validar con
pmset -g | grep womp→ debe mostrarwomp 1. Si no,sudo pmset -a womp 1. - Documentar MAC + IP de ambos Macs en
infra/hosts.yml. - Decidir nombre de subdominio dyndns (
midominio.dyndns.org).
Entregable: docs/01-inventario.md con MACs, IPs, puertos y diagramas.
Objetivo: FQDN público estable apuntando al router.
- Activar MyFRITZ! o configurar
ddclienten el Mac mini si se usa otro proveedor (DuckDNS, No-IP, Cloudflare DNS API). - Validar resolución externa:
dig +short midominio.dyndns.orgdesde una red 4G. - NO abrir puertos todavía. Primero el proxy y la app deben estar listos.
Entregable: docs/02-dyndns.md con el método elegido y comandos de verificación.
Objetivo: Ollama estable, escuchando solo en localhost, con autoarranque.
- Instalar Ollama nativo (no Docker — usa Metal en M1).
- Configurar variables:
OLLAMA_HOST=127.0.0.1:11434 OLLAMA_KEEP_ALIVE=10m OLLAMA_NUM_PARALLEL=1 OLLAMA_MAX_QUEUE=64 - Crear
LaunchAgent(~/Library/LaunchAgents/ai.ollama.plist) para autoarranque al login. - Descargar modelos base:
llama3.1:8b,qwen2.5:7b,mistral:7b,nomic-embed-text. - Probar inferencia local:
ollama run llama3.1:8b "test".
Entregable: services/ollama/ con plist + script de bootstrap.
Objetivo: HTTPS público con certificado válido, sin exponer puertos extras, con ruteo por path.
- Instalar Caddy 2 nativo (Homebrew).
- Crear
Caddyfile:midominio.dyndns.org { encode zstd gzip # Panel SPA handle_path /panel/* { root * /opt/ollama-hub/frontend/dist file_server } # API administrativa y de auth handle /api/* { reverse_proxy 127.0.0.1:8000 } # Gateway Ollama (con cola, auth, timeout) handle /ollama/* { reverse_proxy 127.0.0.1:8000 { flush_interval -1 transport http { compression off response_header_timeout 10m } } } # WOL micro-servicio handle /wol/* { reverse_proxy 127.0.0.1:8765 } # Headers de seguridad header { Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "no-referrer" Permissions-Policy "interest-cohort=()" } # Rate limit por IP (plugin caddy-ratelimit) rate_limit { zone external { key {remote_host} events 60 window 1m } } }
- Abrir solo el puerto 443/TCP en el FRITZ!Box (
Internet → Filtros → Liberaciones de puertos) hacia la IP del Mac mini. - Validar HTTPS desde Internet:
curl -I https://midominio.dyndns.org/.
Entregable: services/caddy/Caddyfile versionado + docs/03-tls.md.
Objetivo: gateway que protege Ollama con autenticación, cola serializada y timeout.
class User: id, email, password_hash, role(admin|user), is_active, created_at
class ApiToken: id, user_id, name, token_hash, scopes[], expires_at, last_used_at, is_revoked
class Job: id, user_id, token_id, host_id, model, status, prompt_hash,
started_at, finished_at, duration_ms, tokens_in, tokens_out, error
class Host: id, name, base_url, mac, ip, is_local, requires_wol, is_enabled, models_cache
class Setting: key, value # registro_abierto, max_concurrency, timeout_s, ...
class AuditEvent: id, ts, ip, user_id, action, target, success, details(jsonb)| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/auth/login |
— | Email + password → JWT corto (15 min) |
| POST | /api/auth/refresh |
refresh | Renueva access token |
| POST | /api/auth/register |
— | Solo si Setting.registro_abierto=true |
| GET | /api/me |
JWT | Datos del usuario |
| GET | /api/admin/users |
admin | Listado y gestión |
| POST | /api/admin/users/:id/activate |
admin | Activar/desactivar cuenta |
| POST | /api/admin/settings |
admin | Editar concurrency, timeout, registro abierto |
| GET | /api/admin/hosts |
admin | Listar workers configurados |
| POST | /api/admin/hosts |
admin | Añadir worker (M1 Max, etc.) |
| POST | /api/admin/hosts/:id/wake |
admin | WOL manual |
| POST | /api/admin/hosts/:id/test |
admin | Ping + listar modelos |
| GET | /api/admin/stats |
admin | Métricas agregadas |
| POST | /api/tokens |
JWT | Crear token de API |
| GET | /api/tokens |
JWT | Listar tokens propios |
| DELETE | /api/tokens/:id |
JWT | Revocar |
| GET | /api/models |
token | Modelos disponibles (agregado de todos hosts) |
| POST | /ollama/v1/chat/completions |
token | Inferencia (entra a la cola) |
| GET | /ollama/api/tags |
token | Modelos del host activo |
| GET | /health |
— | Liveness (sin info sensible) |
# core/queue.py
import asyncio
from contextlib import asynccontextmanager
class InferenceQueue:
def __init__(self, max_concurrent: int = 1):
self.sem = asyncio.Semaphore(max_concurrent)
self.waiting = 0
@asynccontextmanager
async def slot(self, timeout: float):
self.waiting += 1
try:
await asyncio.wait_for(self.sem.acquire(), timeout=timeout)
finally:
self.waiting -= 1
try:
yield
finally:
self.sem.release()- El parámetro
max_concurrentse lee desdeSetting.max_concurrencyen caliente. - Timeout total por request gestionado con
asyncio.wait_forenvolviendo el stream a Ollama. - Cuando expira, se cierra el stream HTTP al cliente con
{"error": "timeout", "elapsed_s": ...}.
Estrategia por defecto: least-busy disponible que tenga el modelo.
seleccionar_host(model):
candidatos = hosts where is_enabled and model in models_cache
ordenar por carga actual (jobs activos) ASC
para cada h en candidatos:
si h.is_local: return h
si h disponible (TCP probe) : return h
si h.requires_wol:
enviar WOL, esperar hasta READY_TIMEOUT (~60s)
si OK return h
-> 503 No backend available
- Cada request autenticado se registra con: timestamp, IP (desde
X-Forwarded-Forcon allowlist Caddy), user_id, token_id, ruta, status, latencia, host elegido. - Eventos especiales: login fallido, token inválido, rate limit excedido, WOL disparado, timeout, error de backend.
- Rotación diaria con compresión.
Entregable: backend/ con tests unitarios mínimos (auth, cola, timeout, router).
Objetivo: reutilizar wol-proxy.py del repo base, encapsularlo y exponerlo solo internamente.
- Copiar
wol-proxy.pyaservices/wol/wol-proxy.py. - Crear
LaunchAgentque lo arranque en127.0.0.1:8765. - Añadir auth simple por header
X-Internal-Token(compartido solo con el backend FastAPI). - El backend llama:
POST /wol/wakecon MAC del worker.POST /wol/fritzboxcuando se necesita WOL desde fuera (vía TR-064).
- Documentar credenciales FRITZ!Box TR-064 en
.envcifrado.
Entregable: services/wol/ + tests de magic packet en LAN aislada.
Objetivo: SPA en React que cubre todos los flujos.
| Ruta | Acceso | Contenido |
|---|---|---|
/panel/login |
público | Login + (opcional) registro si está habilitado |
/panel/ |
user | Dashboard: estado nodos, jobs propios, modelos |
/panel/tokens |
user | CRUD tokens, scopes, copiar al crear (solo una vez) |
/panel/console |
user | AI Console adaptada de ai-console.html |
/panel/admin |
admin | Usuarios, settings globales, hosts |
/panel/admin/hosts |
admin | Añadir/editar workers, probar conexión, WOL manual |
/panel/admin/stats |
admin | Gráficos: jobs/día, latencia media, top modelos, errores |
/panel/admin/audit |
admin | Auditoría con filtros (IP, user, acción, rango fechas) |
La consola se integra dentro del panel, no se reescribe:
- Migrar la UI a un componente React (
<AiConsole />) preservando: Marked, Highlight.js, historial enlocalStorage, detección de razonamiento (<think>), métricas de latencia/VRAM. - Reemplazar el campo "API Key" por selector de token propio (el panel ya conoce los del usuario).
- Añadir selector de host (auto, mac-mini, m1-max).
- Cookies de sesión
HttpOnly; Secure; SameSite=Strictpara el JWT del panel. - Tokens de API nunca se guardan en
localStoragedel panel — se muestran una sola vez. - CSP estricta servida por Caddy:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self';
Entregable: frontend/ build estático servido por Caddy.
Objetivo: el M1 Max actúa como nodo opcional al que el Mac mini delega cuando está disponible.
- Instalar Ollama en el M1 Max con la misma config (bind a
0.0.0.0:11434solo si la LAN es de confianza; preferible bind a la IP LAN concreta). - Generar par de certificados mTLS (CA propia con
step-caomkcert). El M1 Max requiere certificado de cliente del Mac mini. - Configurar Caddy en el M1 Max solo escuchando en la IP LAN, con
client_authmTLS hacia el Mac mini. - El backend del Mac mini guarda el certificado de cliente en el secret store y lo presenta cuando rutea a
host=m1-max. - Configurar WOL del M1 Max (MAC +
pmset womp 1). - El backend mantiene un
models_cachepor host, refrescado cada N min con/api/tags.
Entregable: services/worker-template/ con guía de onboarding de cualquier nodo nuevo.
Objetivo: reducir superficie y detectar anomalías.
- Bloquear todo entrante salvo
tcp/443desde Internet ytcp/22desde la LAN (si se necesita SSH). - Limitar conexiones concurrentes por IP origen:
max-src-conn 30, max-src-conn-rate 100/10. - Plantilla
services/firewall/pf.conf.
- Script
auditor.pyque tail-ea los logs de Caddy + FastAPI y banea IPs con:- Más de N (default 5) fallos de auth en M (default 10) min → ban temporal con
pfctl -t blocklist -T add. - Más de X peticiones a
/api/auth/*por minuto. - Path traversal patterns,
..%2f, payloads SQLi/XSS conocidos.
- Más de N (default 5) fallos de auth en M (default 10) min → ban temporal con
- Whitelist de IPs propias persistente en
services/firewall/whitelist.yml.
- Todos los secretos en
.envcifrado consops+ edad/key. - Rotación periódica documentada:
docs/sec-rotation.md. - Tokens de API guardados como hash bcrypt(cost=12) + prefijo visible para identificación (
olh_xxxxxx...). - JWT firmado con clave RS256 rotable cada 90 días.
sudo systemsetup -setremotelogin offsalvo durante mantenimiento.- FileVault activo.
- Cuenta de admin separada de la cuenta que corre el servicio (
ollamasvc, sin shell). - Permisos restrictivos en
/opt/ollama-hub(ownerollamasvc:_ollamasvc, mode0750).
- HSTS con preload.
- CSP estricta + Trusted Types en panel.
- Cookies
Secure; HttpOnly; SameSite=Strict. - CSRF con doble submit token.
- Rate limit en Caddy + segundo nivel en FastAPI con
slowapi. - Timeouts agresivos por endpoint (no infinito).
Entregable: services/security/ + checklist docs/04-seguridad.md.
Objetivo: trazabilidad completa para análisis forense post-incidente.
- Docker Compose con Loki + Promtail + Grafana.
- Promtail recoge:
- Logs de Caddy (acceso + errores).
- Logs estructurados de FastAPI (JSON).
- Logs de
auditor.py. wol-proxy.py.
- Dashboards de Grafana:
- Tráfico por path, status code, geo-IP.
- Jobs/min, latencia p50/p95/p99, errores, timeouts.
- Top tokens, top usuarios, top modelos.
- Intentos de auth fallidos, IPs baneadas, eventos de seguridad.
- Alertas:
- Pico de 4xx/5xx.
- Más de N bans en X minutos.
- Disco lleno, CPU/RAM sostenidas > 90 %.
- Ollama caído o no responde > 60 s.
- Retención: 30 días en disco, export semanal a almacenamiento frío.
Entregable: observability/ con compose + dashboards versionados.
Objetivo: probar que la exposición real es segura antes y después de cada cambio mayor.
- Escaneo de puertos externo:
Solo debe responder
nmap -Pn -sS -sV -p- midominio.dyndns.org443/tcp. - SSL Labs: A+ requerido (HSTS, TLS 1.2+, no protocolos viejos).
- testssl.sh local: sin RSA <2048, sin SHA1, OCSP stapling activo.
- OWASP ZAP en modo baseline contra
https://midominio.dyndns.org/panel/. - Nikto: identificar misconfiguraciones HTTP.
- Pentest manual de la API: tokens revocados, expirados, scopes, IDOR en
/api/admin/users/:id. - Fuzz del endpoint
/ollama/*con payloads de prompt injection y verificar que no exfiltra tokens internos. - Verificación de logs: cada intento debe quedar en audit.
Entregable: tests/security/ con scripts y reportes datados.
- Script idempotente
bootstrap.shque instala todo en un Mac mini limpio. - Backup diario cifrado de la SQLite + audit logs a un destino externo (rclone a B2 o S3).
- Verificación de DynDNS automatizada (alerta si la IP pública cambia y el DNS no se actualiza en < 5 min).
- Documento de respuesta a incidentes: pasos para aislar, rotar secretos, revocar todos los tokens, analizar logs (
docs/05-incidentes.md). - Documento de runbook operativo: encender/dormir Macs, actualizar Ollama, añadir modelo, añadir usuario, añadir host (
docs/06-runbook.md).
HomeLlamaHub/
├── README.md ← este archivo
├── docs/
│ ├── 01-inventario.md
│ ├── 02-dyndns.md
│ ├── 03-tls.md
│ ├── 04-seguridad.md
│ ├── 05-incidentes.md
│ ├── 06-runbook.md
│ └── arquitectura.md
├── backend/
│ ├── app/
│ │ ├── main.py
│ │ ├── api/ (routers: auth, admin, ollama, wol, tokens)
│ │ ├── core/ (config, security, queue, audit)
│ │ ├── models/ (SQLModel)
│ │ ├── services/ (host_router, wol_client, ollama_client)
│ │ └── schemas/
│ ├── tests/
│ └── pyproject.toml
├── frontend/
│ ├── src/
│ │ ├── pages/ (Login, Dashboard, Tokens, Console, Admin/*)
│ │ ├── components/ (AiConsole adaptada)
│ │ └── lib/
│ └── package.json
├── services/
│ ├── caddy/Caddyfile
│ ├── ollama/ai.ollama.plist
│ ├── wol/wol-proxy.py
│ ├── worker-template/
│ ├── firewall/pf.conf
│ └── security/auditor.py
├── observability/
│ ├── docker-compose.yml
│ ├── promtail-config.yml
│ └── dashboards/
├── tests/
│ └── security/
│ ├── nmap.sh
│ ├── zap-baseline.sh
│ └── testssl.sh
├── infra/
│ ├── hosts.yml
│ └── secrets.sops.yaml
└── scripts/
├── bootstrap.sh
└── backup.sh
# Backend
APP_ENV=production
DATABASE_URL=sqlite:///data/hub.db
JWT_PRIVATE_KEY_PATH=/opt/ollama-hub/secrets/jwt.pem
JWT_PUBLIC_KEY_PATH=/opt/ollama-hub/secrets/jwt.pub
JWT_ACCESS_TTL_S=900
JWT_REFRESH_TTL_S=2592000
OLLAMA_LOCAL_URL=http://127.0.0.1:11434
WOL_PROXY_URL=http://127.0.0.1:8765
WOL_PROXY_TOKEN=...
DEFAULT_MAX_CONCURRENCY=1
DEFAULT_REQUEST_TIMEOUT_S=300
RATE_LIMIT_PER_MIN=60
REGISTRATION_OPEN=false
# FRITZ!Box TR-064
FRITZBOX_HOST=fritz.box
FRITZBOX_USER=...
FRITZBOX_PASSWORD=...
# DynDNS
DYNDNS_FQDN=midominio.dyndns.orgTodo este fichero debe vivir cifrado con sops; nunca en claro en el repo.
| Amenaza | Mitigación |
|---|---|
| Escaneo masivo / fuerza bruta auth | Caddy rate limit + auditor.py con ban automático |
| Robo de token | Tokens con scopes, expiración, rotación, revocación inmediata por admin |
| Compromiso del Mac mini | FileVault, cuenta de servicio separada, secretos cifrados, backup externo |
| Exfiltración por prompt injection | Backend nunca incluye secretos en el prompt; logs separados del flujo LLM |
| MITM en LAN hacia worker | mTLS obligatorio entre Mac mini y M1 Max |
| DoS por consulta larga | Timeout duro + cola limitada + rate limit por token e IP |
| WOL abuso (paquetes mágicos) | /wol/* solo accesible vía 127.0.0.1 o con token interno + auth admin |
| DNS poisoning / IP cambia | Verificación periódica + alerta + HSTS para evitar downgrade |
| Ollama actualizado con vuln | Pin de versión + revisión de changelog antes de actualizar |
Comandos concretos para levantar, verificar y diagnosticar cada componente. Ejecutar en orden en un Mac mini limpio; en un sistema ya configurado usarlos como referencia de diagnóstico.
Ruta base asumida:
/opt/ollama-hub/— ajustar si se usa otra.
brew install python@3.12
pip install uv
cd backend && uv synccp .env.example .env # completar con valores reales
# Variables mínimas obligatorias:
# SECRET_KEY, DATABASE_URL, PUBLIC_FQDN, WOL_PROXY_TOKENcd backend
uv run uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload# El plist debe apuntar a uvicorn con --workers 2 y sin --reload
launchctl load ~/Library/LaunchAgents/ai.homellamahub.plist
launchctl list | grep homellamahub # debe aparecer con PIDcurl http://127.0.0.1:8000/health
# {"status":"ok","queue_active":0,"queue_waiting":0}
# Login y obtener JWT:
curl -s -X POST http://127.0.0.1:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@localhost","password":"<tu-password"}' | python3 -m json.toolcd backend
uv run pytest -v # todos los tests
uv run pytest tests/test_auth.py -v # solo auth
uv run pytest tests/test_queue.py -v # solo colabrew install node # Node 20+
cd frontend && npm installcd frontend
npm run dev # Vite dev server en http://localhost:5173cd frontend
npm run build # genera frontend/dist/
# Caddy sirve frontend/dist/ en /panel/*ls -lh frontend/dist/ # debe existir index.html + assets/
# Verificar que el hash de los assets cambió respecto al build anterior# Copiar el servicio
cp -r services/wol/ /opt/ollama-hub/services/wol/
# Generar token (guardar en .env y en el plist)
python3 -c "import secrets; print(secrets.token_hex(32))"
# Editar el plist con el token y credenciales FRITZ!Box
nano services/wol/ai.wol-proxy.plist # reemplazar los REPLACE_WITH_*
# Instalar LaunchAgent
cp services/wol/ai.wol-proxy.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/ai.wol-proxy.plist# Health check
curl http://127.0.0.1:8765/health
# {"ok": true}
# Smoke test completo (requiere MAC y token)
./services/wol/test-magic-packet.sh AA:BB:CC:DD:EE:FF <token>launchctl list | grep wol-proxy # verificar que corre
tail -f /tmp/wol-proxy.log # logs en tiempo real
tail -f /tmp/wol-proxy.err # errores
# Verificar que solo escucha en localhost:
lsof -nP -iTCP:8765 | grep LISTEN
# Debe mostrar 127.0.0.1:8765, nunca 0.0.0.0 o *# Preparar archivos de tablas vacíos
sudo touch /etc/pf.blocklist /etc/pf.whitelist
sudo chmod 600 /etc/pf.blocklist /etc/pf.whitelist
# Copiar configuración
sudo cp services/firewall/pf.conf /etc/pf.conf
# Cargar tu whitelist (editar services/firewall/whitelist.yml primero)
sudo ./services/firewall/load-whitelist.sh
# Activar pf
sudo pfctl -ef /etc/pf.confsudo pfctl -s info # Status: Enabled
sudo pfctl -s rules # listar reglas activas
sudo pfctl -t blocklist -T show # IPs baneadas
sudo pfctl -t whitelist -T show # IPs en whitelist
# Test desde red externa (solo 443 debe responder):
nmap -Pn -p 22,443,8000,8765,11434 <tu-fqdn># Añadir IP a whitelist manualmente (temporal, sin editar el yml):
sudo pfctl -t whitelist -T add 1.2.3.4
# Banear IP manualmente:
sudo pfctl -t blocklist -T add 1.2.3.4
# Desbanear IP:
sudo pfctl -t blocklist -T delete 1.2.3.4
# Recargar reglas sin perder el estado:
sudo pfctl -f /etc/pf.conf
# Ver estadísticas de tráfico:
sudo pfctl -s info | grep -A5 "State Table"# El auditor corre como root (necesita pfctl)
sudo cp services/security/ai.auditor.plist /Library/LaunchDaemons/
sudo launchctl load /Library/LaunchDaemons/ai.auditor.plistsudo launchctl list | grep auditor # debe aparecer con PID
tail -f /var/log/auditor.log # actividad en tiempo real
# Simular 5 fallos de auth para probar el ban:
for i in $(seq 1 6); do
curl -s -X POST http://127.0.0.1:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"x@x.com","password":"wrong"}' > /dev/null
done
# Verificar que la IP quedó baneada:
sudo pfctl -t blocklist -T showtail -f /var/log/auditor.err # errores del proceso
sudo launchctl start ai.auditor # forzar restart
grep "BANNING" /var/log/auditor.log # historial de bans
grep "attack-pattern" /var/log/auditor.log # ataques detectados| Variable | Default | Descripción |
|---|---|---|
AUTH_FAIL_THRESHOLD |
5 | Fallos de auth antes de banear |
AUTH_FAIL_WINDOW_S |
600 | Ventana de tiempo en segundos |
AUTH_RATE_THRESHOLD |
20 | Req/min a /api/auth/* antes de banear |
SCAN_404_THRESHOLD |
30 | 404s/min antes de banear (scanner) |
BAN_DURATION_S |
3600 | Duración del ban en segundos |
# Paso 1 — generar certificados mTLS (en el Mac mini hub)
chmod +x services/worker-template/setup-mtls.sh
./services/worker-template/setup-mtls.sh 192.168.178.20 # IP del M1 Max
# Paso 2 — copiar archivos al worker
scp -r services/worker-template/ usuario@192.168.178.20:~/worker-template/
# Paso 3 — ejecutar setup en el worker (SSH al M1 Max)
ssh usuario@192.168.178.20
chmod +x ~/worker-template/setup-worker.sh
~/worker-template/setup-worker.sh --ip 192.168.178.20 --hub-ip 192.168.178.10# Obtener JWT admin primero
ADMIN_JWT=$(curl -s -X POST http://127.0.0.1:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@localhost","password":"<password>"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
# Registrar el host
curl -X POST http://127.0.0.1:8000/api/admin/hosts \
-H "Authorization: Bearer $ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "m1-max",
"base_url": "https://192.168.178.20:11435",
"ip": "192.168.178.20",
"mac": "AA:BB:CC:DD:EE:FF",
"is_local": false,
"requires_wol": true,
"is_enabled": true
}'CERTS=/opt/ollama-hub/secrets/mtls
curl --cacert $CERTS/ca/root_ca.crt \
--cert $CERTS/hub-client/client.crt \
--key $CERTS/hub-client/client.key \
https://192.168.178.20:11435/api/tags
# Debe devolver la lista de modelos del worker
# Test WOL manual:
ADMIN_JWT=<token>
curl -X POST http://127.0.0.1:8000/api/admin/hosts/<id>/wake \
-H "Authorization: Bearer $ADMIN_JWT"brew install docker # Docker Desktop o OrbStackcd observability
cp .env.example .env # cambiar GRAFANA_PASSWORD
docker compose up -d
docker compose ps # verificar que los 3 servicios están healthy# Opción A — túnel SSH (desarrollo):
ssh -L 3000:127.0.0.1:3000 usuario@mac-mini
# Abrir http://localhost:3000 (admin / <GRAFANA_PASSWORD>)
# Opción B — vía Caddy (producción, añadir al Caddyfile):
# handle_path /grafana/* {
# reverse_proxy 127.0.0.1:3000
# }# Caddy debe estar escribiendo logs JSON:
ls -lh /var/log/caddy/access.log
# Verificar que Promtail está enviando:
curl http://127.0.0.1:9080/metrics | grep promtail_sent_entries
# Query directa a Loki (últimas 10 líneas de Caddy):
curl -G http://127.0.0.1:3100/loki/api/v1/query \
--data-urlencode 'query={job="caddy"}' \
--data-urlencode 'limit=10' | python3 -m json.tool# Ver logs en tiempo real (sin Grafana):
docker compose logs -f promtail
# Reiniciar un servicio:
docker compose restart grafana
# Ver espacio usado por Loki:
docker volume inspect observability_loki_data
# Detener todo (datos persisten en volúmenes):
docker compose down
# Borrar todo incluyendo datos:
docker compose down -v # ⚠ irreversible# Clonar el repo
git clone https://github.com/patchamama/HomeLlamaHub-.git /opt/ollama-hub-src
cd /opt/ollama-hub-src
# Ejecutar bootstrap (idempotente — seguro de re-ejecutar)
sudo ./scripts/bootstrap.sh --fqdn midominio.dyndns.org --repo /opt/ollama-hubEl script instala Python, Node, Caddy, Ollama, step, age, sops, rclone, crea la cuenta ollamasvc, despliega configs, genera secretos, carga pf y arranca todos los servicios.
# 1. Completar .env con credenciales reales
nano /opt/ollama-hub/.env # FRITZBOX_USER, FRITZBOX_PASSWORD, PUBLIC_FQDN
# 2. Cifrar .env con sops+age
age-keygen | tee ~/.config/sops/age/keys.txt | age-keygen -y > /opt/ollama-hub/secrets/backup-age.pub
AGE_KEY=$(age-keygen -y ~/.config/sops/age/keys.txt)
sops --encrypt --age "$AGE_KEY" /opt/ollama-hub/.env > /opt/ollama-hub/.env.enc
rm /opt/ollama-hub/.env # eliminar el plaintext
# 3. Cambiar la contraseña admin por defecto
# → Abrir https://<fqdn>/panel/ → Login → cambiar contraseña# Generar clave age para cifrado de backups (si no existe)
age-keygen -y ~/.config/sops/age/keys.txt > /opt/ollama-hub/secrets/backup-age.pub
# Editar el plist con el destino rclone (opcional)
nano /opt/ollama-hub/scripts/ai.backup.plist # set BACKUP_RCLONE_DEST
# Instalar el LaunchDaemon (corre a las 03:00 diariamente)
sudo cp /opt/ollama-hub/scripts/ai.backup.plist /Library/LaunchDaemons/
sudo launchctl load /Library/LaunchDaemons/ai.backup.plist# Ejecutar ahora
sudo /opt/ollama-hub/scripts/backup.sh
# Verificar que el último backup es restaurable
sudo /opt/ollama-hub/scripts/backup.sh --verify
# Ver backups existentes
ls -lh /opt/ollama-hub/backups/
# Ver log del último backup
tail -30 /var/log/backup.log# Verificación manual
./scripts/check-dyndns.sh midominio.dyndns.org
# Configurar chequeo automático cada 5 minutos (root crontab)
sudo crontab -e
# Añadir:
# */5 * * * * /opt/ollama-hub/scripts/check-dyndns.sh midominio.dyndns.org
# Ver alertas de IP mismatch
tail -f /var/log/dyndns-alert.logbrew install nmap testssl # escaneo de puertos y TLS
# ZAP usa Docker — no requiere instalación localcd tests/security
./run-all.sh midominio.dyndns.org admin@localhost <password> olh_<token>
# Los reportes datados se guardan en tests/security/reports/# 1. Escaneo de puertos — solo 443 debe responder
./nmap.sh midominio.dyndns.org
./nmap.sh midominio.dyndns.org --full # scan completo, ~10 min
# 2. Calidad TLS — debe pasar todas las comprobaciones (POODLE, BEAST, OCSP...)
./testssl.sh midominio.dyndns.org
# 3. OWASP ZAP baseline contra el panel
./zap-baseline.sh midominio.dyndns.org
./zap-baseline.sh midominio.dyndns.org olh_<token> # con auth
# 4. API pentest — tokens, scopes, IDOR, SQLi
./api-pentest.sh https://midominio.dyndns.org admin@localhost <password>
# 5. Prompt injection — 9 payloads, verifica que no filtra secretos
./prompt-injection.sh https://midominio.dyndns.org olh_<token>
./prompt-injection.sh https://midominio.dyndns.org olh_<token> llama3.1:8b
# 6. Verificación de audit log — confirma que los eventos quedan registrados
./audit-verify.sh https://midominio.dyndns.org admin@localhost <password>
./audit-verify.sh https://localhost:8000 admin@localhost <password> /opt/ollama-hub/data/hub.dbls -lt tests/security/reports/ # ordenados por fecha
# Cada script genera: nmap-YYYYMMDD.txt, testssl-*.txt/json,
# zap-*.html/json, api-pentest-*.txt, prompt-injection-*.txt, audit-verify-*.txtLos procedimientos completos están documentados en:
-
docs/05-incidentes.md— qué hacer cuando hay un incidente: detectar, contener (bloquear IP, aislar servicio, cortar acceso), rotar todos los secretos (tokens, WOL token, SECRET_KEY, mTLS), analizar logs, restaurar desde backup, checklist post-incidente. -
docs/06-runbook.md— operaciones del día a día: wake/sleep de los Macs, actualizar Ollama, añadir/quitar modelos, crear usuarios, registrar nuevos hosts/workers, rotar secretos, backup manual, verificación de DynDNS, calendario de mantenimiento (diario / semanal / mensual / trimestral / anual).
# Banear una IP de inmediato
sudo pfctl -t blocklist -T add <ip>
# Cortar acceso externo (mantiene LAN)
sudo pfctl -f /dev/stdin <<'EOF'
block all
pass quick on lo0 all
pass out quick all
EOF
# Revocar TODOS los tokens de API (emergencia)
sqlite3 /opt/ollama-hub/data/hub.db \
"UPDATE apitoken SET is_revoked=1, revoked_at=CURRENT_TIMESTAMP;"
# Restaurar reglas normales
sudo pfctl -ef /etc/pf.confComandos para una revisión rápida de que todo el stack está saludable:
#!/usr/bin/env bash
# Ejecutar periódicamente o ante cualquier duda
echo "=== Backend ==="
curl -sf http://127.0.0.1:8000/health | python3 -m json.tool
echo "=== WOL Proxy ==="
curl -sf http://127.0.0.1:8765/health | python3 -m json.tool
echo "=== Ollama ==="
curl -sf http://127.0.0.1:11434/api/version | python3 -m json.tool
echo "=== pf ==="
sudo pfctl -s info | grep Status
sudo pfctl -t blocklist -T show | wc -l | xargs echo "IPs baneadas:"
echo "=== Servicios LaunchAgent/Daemon ==="
launchctl list | grep -E "wol-proxy|homellamahub"
sudo launchctl list | grep -E "auditor"
echo "=== Observabilidad ==="
docker compose -f /opt/ollama-hub/observability/docker-compose.yml ps- Ollama detrás de un reverse proxy con Caddy o Nginx
- Securely Exposing Ollama Service to the Public Internet
- Exposed Ollama Servers — Security Boulevard
- Caddy — Path-based reverse proxy
- Caddyfile
reverse_proxydirective - FRITZ!Box 6660 — Port forwarding
- FRITZ!Box — Wake on LAN desde Internet
- Wake on LAN en macOS (
pmset womp) - Apple Silicon: limitaciones de WOL desde apagado
- FastAPI — concurrencia con semáforos
- Ollama FAQ —
OLLAMA_NUM_PARALLELyOLLAMA_MAX_QUEUE - Repo base:
patchamama/LocalForge_Ollama-Local-SSL
- Idea y diseño general — este README.
- Fase 0 — Inventario. (hardware — hacer manualmente)
- Fase 1 — DynDNS. (red — hacer manualmente)
- Fase 2 — Ollama nativo. (instalar en el Mac mini)
- Fase 3 — Caddy + TLS. (desplegar
services/caddy/Caddyfile) - Fase 4 — Backend FastAPI. (
backend/completo con tests) - Fase 5 — WOL integrado. (
services/wol/) - Fase 6 — Frontend React SPA. (
frontend/) - Fase 7 — Worker remoto M1 Max. (
services/worker-template/) - Fase 8 — Seguridad endurecida. (
services/firewall/,services/security/,docs/04-seguridad.md) - Fase 9 — Observabilidad. (
observability/) - Fase 10 — Tests de seguridad. (
tests/security/) - Fase 11 — Despliegue y mantenimiento. (
scripts/,docs/05-incidentes.md,docs/06-runbook.md)
Empezar por Fase 0 (inventario) y Fase 2 (Ollama nativo) en paralelo — ambas son no-destructivas y bloquean al resto.
Antes de abrir el puerto 443 en el FRITZ!Box, Fase 3 + Fase 4 + Fase 8 deben estar verificadas en local con un dominio interno.
Regla de oro: el puerto 443 no se abre al mundo hasta que las fases 3, 4 y 8 estén cerradas y los tests de la Fase 10 pasen contra el servicio en LAN.