diff --git a/.env.example b/.env.example index a71b389..05448fd 100644 --- a/.env.example +++ b/.env.example @@ -19,13 +19,18 @@ OLLAMA_MODEL=qwen3:cloud # modèle cloud (suffixe :cloud), tool-call MCP_N8N_URL=http://n8n:5678/mcp-server/http # ou https:///mcp-server/http MCP_N8N_AUTH_TOKEN= # SECRET — "MCP Access Token" n8n (Bearer) -# ── MCP Portainer (portainer/portainer-mcp) ──────────────────────────── -PORTAINER_URL=https://portainer:9443 # interne uniquement -PORTAINER_MCP_AUTH_TOKEN= # SECRET — token Portainer à portée RESTREINTE +# ── MCP Portainer (passerelle HTTP portainer/portainer-mcp) ──────────── +PORTAINER_URL=http://portainer:9000 # API Portainer, interne (même hôte) +# Secret de PASSERELLE (front-gate) : même valeur côté backend et côté serveur +# MCP. Envoyé en Authorization: Bearer. N'autorise que l'accès à la passerelle. +PORTAINER_MCP_AUTH_TOKEN= # SECRET — secret de passerelle (au choix) +# Clé API Portainer de l'utilisateur `chlova` (RESTREINTE) : envoyée en header +# X-Portainer-API-Key. C'est elle qui cloisonne l'accès réel de CHLOVA. +PORTAINER_API_KEY= # SECRET — Access token Portainer de chlova # Phase 1 : DOIT rester true (le boot échoue sinon). Phase 2 : peut passer false # pour autoriser les écritures Portainer (sous gatekeeper + review). PORTAINER_READ_ONLY=true -MCP_PORTAINER_URL=http://mcp-portainer:3000 +MCP_PORTAINER_URL=http://mcp-portainer:17717/mcp # ── Surface Telegram (OPTIONNELLE) ───────────────────────────────────── # Si vide, la surface Telegram n'est pas démarrée. Le backend exige alors une diff --git a/CHANGELOG.md b/CHANGELOG.md index b22ba17..d6ba8bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.34.0] — 2026-06-23 — intégration réelle passerelle MCP Portainer +### Fixed +- **Image MCP Portainer** : `portainer/portainer-mcp:0.6.0` (tag inexistant) + → **`2.42.6`** (confirmé Docker Hub). Découvert au déploiement réel (pull 404). +- **Modèle d'auth de la passerelle MCP Portainer** corrigé : le serveur HTTP + (port **17717**, endpoint **`/mcp`**) attend `Authorization: Bearer ` **et** `X-Portainer-API-Key: `. Le backend + n'envoyait que le Bearer. +### Added +- `config.portainerApiKey` (`PORTAINER_API_KEY`, SECRET) : clé API Portainer + restreinte de `chlova`, envoyée en `X-Portainer-API-Key` — c'est elle qui + cloisonne l'accès. `PORTAINER_MCP_AUTH_TOKEN` devient le secret de passerelle. +- `McpServerConfig.extraHeaders` : headers additionnels par serveur MCP. +### Changed +- `docker-compose.prod.yml` : `mcp-portainer` reconfiguré (2.42.6, :17717, + `PORTAINER_MCP_ALLOWED_HOSTS`, plaintext HTTP interne, `PORTAINER_TLS_VERIFY=0`, + `PORTAINER_READ_ONLY`). **`socket-proxy` supprimé** (la passerelle parle à + l'API Portainer, plus aucun socket Docker monté = surface réduite). + `MCP_PORTAINER_URL` → `:17717/mcp`. Backend reçoit `PORTAINER_API_KEY`. +- `.env.example` + `docs/deploy.md` (§3a, tableau variables) mis à jour. +- Typecheck vert, 78 tests verts. + ## [0.33.0] — 2026-06-23 — infra locale déployée (gitea + n8n-chlova) ### Added - **`infra/gitea/docker-compose.yml`** : serveur git Gitea 1.26.4 sur l'hôte diff --git a/docs/deploy.md b/docs/deploy.md index bf4561a..898abbf 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -58,13 +58,16 @@ git push gitea main # auth : + token de l'étape 1.3 > Le MCP Portainer n'expose pas la gestion users/tokens : étapes UI. Principe : > CHLOVA n'accède qu'à ses ressources, tokens à portée minimale. -### 3a. Portainer — user `chlova` + token +### 3a. Portainer — user `chlova` + clé API + secret de passerelle 1. **Users → Add user** : `chlova`, non-admin. 2. **Environments → local → Access** : rôle le plus bas suffisant. *(CE = RBAC - par environnement, pas par stack ; le vrai verrou Phase 1 reste - `PORTAINER_READ_ONLY=true`.)* + par environnement, pas par stack ; verrous additionnels : + `PORTAINER_READ_ONLY=true` côté serveur MCP **et** la clé API étant celle de + `chlova`, l'accès réel est borné à ce que `chlova` peut voir.)* 3. Connecté en `chlova` → **My account → Access tokens → Add token** → - `PORTAINER_MCP_AUTH_TOKEN`. + `PORTAINER_API_KEY` (envoyé en `X-Portainer-API-Key`, cloisonne l'accès). +4. **`PORTAINER_MCP_AUTH_TOKEN`** = secret de **passerelle** au choix (chaîne + aléatoire) ; même valeur dans le stack `mcp-portainer` et côté backend. ### 3b. n8n-chlova — MCP token 1. `https://n8n-chlova.pogoo.app` → créer le compte propriétaire. @@ -96,7 +99,8 @@ l'opérateur, jamais par l'agent). | `OLLAMA_API_KEY` | clé Ollama cloud | **oui** | | `OLLAMA_MODEL` | `qwen3:cloud` | non | | `MCP_N8N_AUTH_TOKEN` | token MCP n8n-chlova (§3b) | **oui** | -| `PORTAINER_MCP_AUTH_TOKEN` | token user chlova (§3a) | **oui** | +| `PORTAINER_MCP_AUTH_TOKEN` | secret de passerelle (§3a.4) | **oui** | +| `PORTAINER_API_KEY` | clé API Portainer de chlova (§3a.3) | **oui** | | `CHLOVA_ADMIN_USER` | ex. `kantin` | non | | `CHLOVA_ADMIN_PASSWORD_HASH` | (§4) | **oui** | | `CHLOVA_TOTP_SECRET` | (§4) | **oui** | diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index 4703ad6..d6bb77f 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -32,44 +32,20 @@ services: - chlova-egress # AUCUN port publié : Ollama n'a pas d'auth native, jamais exposé. - # ── socket-proxy : accès Docker filtré, LECTURE SEULE (Phase 1) ───────── - socket-proxy: - image: tecnativa/docker-socket-proxy:0.3.0 - restart: unless-stopped - environment: - CONTAINERS: 1 - IMAGES: 1 - NETWORKS: 1 - VOLUMES: 1 - SERVICES: 1 - TASKS: 1 - NODES: 1 - INFO: 1 - VERSION: 1 - POST: 0 - EXEC: 0 - AUTH: 0 - SECRETS: 0 - CONFIGS: 0 - BUILD: 0 - COMMIT: 0 - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - networks: - - chlova-internal - # AUCUN port publié. - - # ── MCP Portainer (portainer/portainer-mcp) — read-only en Phase 1 ────── + # ── MCP Portainer (passerelle HTTP portainer/portainer-mcp) — read-only P1 ─ + # Parle à l'API Portainer (pas au socket Docker). Le backend s'y connecte en + # HTTP sur :17717/mcp avec le secret de passerelle (Bearer) + la clé API + # restreinte de chlova (X-Portainer-API-Key). mcp-portainer: - image: portainer/portainer-mcp:0.6.0 + image: portainer/portainer-mcp:2.42.6 restart: unless-stopped environment: PORTAINER_URL: ${PORTAINER_URL:-http://portainer:9000} # interne : Portainer sur le même hôte (local), réseau proxy - PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # token user chlova restreint - PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} # P1 : NE PAS passer à false - DOCKER_HOST: tcp://socket-proxy:2375 - depends_on: - - socket-proxy + PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # secret de passerelle (partagé avec le backend) + PORTAINER_MCP_ALLOWED_HOSTS: mcp-portainer:17717 # hôte par lequel le backend appelle + PORTAINER_MCP_DANGEROUSLY_ALLOW_PLAINTEXT_HTTP: "1" # HTTP interne (réseau Docker privé) + PORTAINER_TLS_VERIFY: "0" # Portainer interne en HTTP : pas de TLS + PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} # P1 : GET/HEAD seulement (défense en profondeur) networks: - chlova-internal # joignable par le backend - proxy # joint le serveur Portainer (http://portainer:9000) @@ -95,9 +71,10 @@ services: # — MCP n8n (natif, interne via réseau proxy) — MCP_N8N_URL: ${MCP_N8N_URL:-http://n8n-chlova:5678/mcp-server/http} MCP_N8N_AUTH_TOKEN: ${MCP_N8N_AUTH_TOKEN:?requis} - # — MCP Portainer (sidecar) — - MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000} - PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} + # — MCP Portainer (passerelle) — + MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:17717/mcp} + PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # secret de passerelle (= côté sidecar) + PORTAINER_API_KEY: ${PORTAINER_API_KEY:?requis} # clé API Portainer restreinte de chlova PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} # — Alertes (Phase 3) : vide = log-only — ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} diff --git a/orchestrator/src/config.ts b/orchestrator/src/config.ts index 4264a1e..832f1b6 100644 --- a/orchestrator/src/config.ts +++ b/orchestrator/src/config.ts @@ -35,9 +35,14 @@ const schema = z.object({ mcpN8nUrl: z.string().url(), mcpN8nAuthToken: nonEmpty, // SECRET - // MCP Portainer + // MCP Portainer (passerelle HTTP portainer/portainer-mcp) mcpPortainerUrl: z.string().url(), + // Secret de PASSERELLE (front-gate) partagé avec le serveur MCP : envoyé en + // `Authorization: Bearer`. N'autorise QUE l'accès à la passerelle. portainerMcpAuthToken: nonEmpty, // SECRET + // Clé API Portainer de l'utilisateur `chlova` (restreinte) : envoyée en header + // `X-Portainer-API-Key`. C'est ELLE qui cloisonne ce que CHLOVA peut voir/faire. + portainerApiKey: nonEmpty, // SECRET // Verrou Phase 1 : lecture seule. `false` est refusé tant que la Phase 2 // n'est pas explicitement activée (voir assertReadOnlyPhase()). portainerReadOnly: z @@ -99,6 +104,7 @@ const SECRET_KEYS = new Set([ "ollamaApiKey", "mcpN8nAuthToken", "portainerMcpAuthToken", + "portainerApiKey", "telegramBotToken", "alertWebhookUrl", "adminPasswordHash", @@ -118,6 +124,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { mcpN8nAuthToken: env.MCP_N8N_AUTH_TOKEN, mcpPortainerUrl: env.MCP_PORTAINER_URL, portainerMcpAuthToken: env.PORTAINER_MCP_AUTH_TOKEN, + portainerApiKey: env.PORTAINER_API_KEY, portainerReadOnly: env.PORTAINER_READ_ONLY, telegramBotToken: env.TELEGRAM_BOT_TOKEN, telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS, diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index 3a952a8..305879e 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -49,7 +49,9 @@ async function main(): Promise { await registry.connect({ name: "portainer", url: cfg.mcpPortainerUrl, - authToken: cfg.portainerMcpAuthToken, + authToken: cfg.portainerMcpAuthToken, // secret de passerelle (Bearer) + // Clé API Portainer restreinte de `chlova` : cloisonne l'accès réel. + extraHeaders: { "X-Portainer-API-Key": cfg.portainerApiKey }, }); // ── Outils + Guard, selon la phase ────────────────────────────────────── diff --git a/orchestrator/src/mcp/registry.ts b/orchestrator/src/mcp/registry.ts index 88a39a1..e6aec14 100644 --- a/orchestrator/src/mcp/registry.ts +++ b/orchestrator/src/mcp/registry.ts @@ -20,6 +20,8 @@ export interface McpServerConfig { name: string; url: string; authToken: string; + /** Headers additionnels (ex. X-Portainer-API-Key pour la passerelle Portainer). */ + extraHeaders?: Record; } interface ConnectedServer { @@ -36,7 +38,10 @@ export class McpRegistry { async connect(cfg: McpServerConfig): Promise { const transport = new StreamableHTTPClientTransport(new URL(cfg.url), { requestInit: { - headers: { authorization: `Bearer ${cfg.authToken}` }, + headers: { + authorization: `Bearer ${cfg.authToken}`, + ...cfg.extraHeaders, + }, }, }); const client = new Client({ name: `chlova-${cfg.name}`, version: "0.1.0" }); diff --git a/orchestrator/test/config.test.ts b/orchestrator/test/config.test.ts index ccd7304..5fc5154 100644 --- a/orchestrator/test/config.test.ts +++ b/orchestrator/test/config.test.ts @@ -12,8 +12,9 @@ const fullEnv = (): NodeJS.ProcessEnv => ({ OLLAMA_MODEL: "qwen3:cloud", MCP_N8N_URL: "http://mcp-n8n:3000", MCP_N8N_AUTH_TOKEN: "secret-n8n", - MCP_PORTAINER_URL: "http://mcp-portainer:3000", - PORTAINER_MCP_AUTH_TOKEN: "secret-portainer", + MCP_PORTAINER_URL: "http://mcp-portainer:17717/mcp", + PORTAINER_MCP_AUTH_TOKEN: "secret-gate", + PORTAINER_API_KEY: "secret-portainer-apikey", PORTAINER_READ_ONLY: "true", TELEGRAM_BOT_TOKEN: "secret-tg", TELEGRAM_ALLOWED_USER_IDS: "111, 222",