From 044a27aa1ab358e433c884fde1d97866a60a76cc Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Thu, 23 Apr 2026 17:44:25 +0200 Subject: [PATCH] =?UTF-8?q?Orchestrateur=20go=20pour=20lancer=20la=20d?= =?UTF-8?q?=C3=A9mo=20et=20mettre=20en=20place=20plusieurs=20instances=20d?= =?UTF-8?q?ocker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/.env.example | 27 ++++ demo/.gitignore | 2 + demo/README.md | 99 +++++++++++++ demo/docker-compose.infra.yml | 78 ++++++++++ demo/orchestrator/Dockerfile | 30 ++++ demo/orchestrator/config.go | 64 ++++++++ demo/orchestrator/docker.go | 247 +++++++++++++++++++++++++++++++ demo/orchestrator/go.mod | 8 + demo/orchestrator/go.sum | 2 + demo/orchestrator/main.go | 231 +++++++++++++++++++++++++++++ demo/orchestrator/preparing.html | 127 ++++++++++++++++ demo/orchestrator/ratelimit.go | 72 +++++++++ demo/orchestrator/sessions.go | 177 ++++++++++++++++++++++ 13 files changed, 1164 insertions(+) create mode 100644 demo/.env.example create mode 100644 demo/.gitignore create mode 100644 demo/README.md create mode 100644 demo/docker-compose.infra.yml create mode 100644 demo/orchestrator/Dockerfile create mode 100644 demo/orchestrator/config.go create mode 100644 demo/orchestrator/docker.go create mode 100644 demo/orchestrator/go.mod create mode 100644 demo/orchestrator/go.sum create mode 100644 demo/orchestrator/main.go create mode 100644 demo/orchestrator/preparing.html create mode 100644 demo/orchestrator/ratelimit.go create mode 100644 demo/orchestrator/sessions.go diff --git a/demo/.env.example b/demo/.env.example new file mode 100644 index 0000000..3bd89a2 --- /dev/null +++ b/demo/.env.example @@ -0,0 +1,27 @@ +# Copie en .env sur le serveur (jamais commite). + +# Registre et tag des images core / brain a spawner par session. +REGISTRY=git.igmlcreation.fr +TAG=latest + +# Secret partage entre core et brain (genere aleatoirement au build de chaque +# session, mais un defaut est utile pour les checks de sante au boot). +BRAIN_INTERNAL_SECRET_DEFAULT=change-me-on-server + +# Capacite +MAX_SESSIONS=10 +SESSION_TTL_MINUTES=20 + +# Rate limiting : 1 creation de session par IP par fenetre (secondes). +RATE_LIMIT_WINDOW_SECONDS=60 + +# Limites par conteneur de session (Docker API) +CORE_MEMORY_MB=700 +BRAIN_MEMORY_MB=300 +POSTGRES_MEMORY_MB=200 + +# Nom du reseau Docker externe Traefik (doit exister avant docker compose up) +TRAEFIK_NETWORK=traefik + +# Domaine expose par Traefik +DEMO_HOST=loremind-demo.igmlcreation.fr diff --git a/demo/.gitignore b/demo/.gitignore new file mode 100644 index 0000000..2334d82 --- /dev/null +++ b/demo/.gitignore @@ -0,0 +1,2 @@ +.env +*.log diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..dc34d8f --- /dev/null +++ b/demo/README.md @@ -0,0 +1,99 @@ +# Demo publique LoreMind + +Instance de demo ephemere hebergee sur `loremind-demo.igmlcreation.fr`. + +## Principe + +Chaque visiteur recoit un trio isole `postgres + core + brain` spawne a la volee +par un orchestrateur Go, detruit au bout de 20 min. Donnees en memoire (tmpfs). +Max 10 sessions concurrentes. + +Le mode demo est active via `DEMO_MODE=true` cote core : +- Settings et Export VTT masques cote frontend +- Endpoints `GET`/`PUT /api/settings` verrouilles (403) cote serveur + +## Architecture + +``` +Internet + | + v +Traefik (service permanent sur le serveur, reseau "traefik") + | + v +Orchestrateur (Go, reseau "traefik" + "loremind-demo-sessions") + | + v (via Docker API, cree un trio par visiteur) ++------------------- reseau "loremind-demo-sessions" ---------------------+ +| demo--postgres demo--core demo--brain | ++-------------------------------------------------------------------------+ +``` + +L'orchestrateur fait aussi reverse proxy : il sert l'Angular statique et +transfere les `/api/*` vers le `core` de la session (via cookie). + +## Deploiement + +Prerequis sur le serveur : +- Reseau Traefik existant (`docker network ls | grep traefik`) +- Images `core` et `brain` pushees au registre (`TAG=latest` par defaut) + +Procedure : + +```bash +# Sur le serveur +git clone +cd LoreMind/demo +cp .env.example .env +# Editer .env si besoin +docker compose -f docker-compose.infra.yml up -d --build +``` + +Le premier `--build` compile l'orchestrateur + build l'Angular (5-10 min). +Les builds suivants sont incrementaux. + +## Mise a jour + +```bash +# Pull des nouvelles images core/brain +docker compose -f docker-compose.infra.yml pull +# Rebuild orchestrateur si son code a change +docker compose -f docker-compose.infra.yml up -d --build +``` + +Les sessions en cours sont tuees au redemarrage de l'orchestrateur. + +## Observations + +- `docker logs loremind-demo-orchestrator -f` +- `docker ps --filter "name=demo-"` (conteneurs de sessions actifs) + +Pas d'endpoint de monitoring HTTP expose : volontaire pour eviter de leaker +des informations sur les sessions actives a des scrapers externes. + +## Securite — points mis en place + +- `dockerproxy` (tecnativa/docker-socket-proxy) expose l'API Docker en lecture + seule sauf pour Containers/Images/Networks ; pas d'EXEC, VOLUMES, BUILD. +- Rate limiting : 1 creation de session par IP / fenetre de 60s. +- Cookie session : HttpOnly + Secure + SameSite=Lax, 128 bits d'entropie. +- Conteneurs de session : `no-new-privileges`, `PidsLimit=200`, limites RAM/CPU. +- Timeouts HTTP anti-slowloris sur l'orchestrateur. +- Body max 10 Mo sur les proxys `/api/*`. +- Reseau `socket-proxy` interne (pas d'acces au host reseau). + +## Securite — a surveiller / ameliorer plus tard + +- Fail2ban cote host avec regle sur les 429/503 de Traefik. +- Rotation des logs Docker (`log-opts: max-size: 10m` dans `/etc/docker/daemon.json`). +- CapDrop sur les conteneurs de session (non configure : risque de casser + core/brain sans audit prealable). +- Ne JAMAIS mettre de vraie cle API LLM dans l'environnement des brain de demo. + +## Desactiver la demo + +```bash +docker compose -f docker-compose.infra.yml down +# Nettoyer les conteneurs de session residuels +docker ps -q --filter "name=demo-" | xargs -r docker rm -f +``` diff --git a/demo/docker-compose.infra.yml b/demo/docker-compose.infra.yml new file mode 100644 index 0000000..32bb390 --- /dev/null +++ b/demo/docker-compose.infra.yml @@ -0,0 +1,78 @@ +# ========================================================================== +# LoreMind Demo - Infra permanente +# -------------------------------------------------------------------------- +# - dockerproxy : expose un subset restreint de l'API Docker a l'orchestrateur +# (lecture seule sauf containers/images/networks). Remplace le mount direct +# de /var/run/docker.sock : meme avec RCE sur l'orchestrateur, un attaquant +# ne peut pas exec sur l'hote, creer des volumes, ni lire le daemon. +# - orchestrator : sert l'Angular et proxy les /api/* vers les sessions. +# +# Les conteneurs de session sont crees dynamiquement par l'orchestrateur. +# ========================================================================== + +services: + dockerproxy: + image: tecnativa/docker-socket-proxy:latest + container_name: loremind-demo-dockerproxy + restart: unless-stopped + environment: + # Minimum requis par l'orchestrateur. + CONTAINERS: 1 + IMAGES: 1 + NETWORKS: 1 + POST: 1 + # Tout le reste reste a 0 (defaut) : pas d'EXEC, VOLUMES, BUILD, AUTH, + # SYSTEM, INFO, SWARM, SECRETS, CONFIGS, NODES, etc. + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - socket-proxy + # Pas de ports exposes : accessible uniquement via le reseau socket-proxy. + + orchestrator: + container_name: loremind-demo-orchestrator + depends_on: + - dockerproxy + build: + context: ../ + dockerfile: demo/orchestrator/Dockerfile + restart: unless-stopped + environment: + # L'orchestrateur parle a dockerproxy au lieu du socket direct. + DOCKER_HOST: tcp://dockerproxy:2375 + REGISTRY: ${REGISTRY:-git.igmlcreation.fr} + TAG: ${TAG:-latest} + MAX_SESSIONS: ${MAX_SESSIONS:-10} + SESSION_TTL_MINUTES: ${SESSION_TTL_MINUTES:-20} + CORE_MEMORY_MB: ${CORE_MEMORY_MB:-700} + BRAIN_MEMORY_MB: ${BRAIN_MEMORY_MB:-300} + POSTGRES_MEMORY_MB: ${POSTGRES_MEMORY_MB:-200} + SESSIONS_NETWORK: loremind-demo-sessions + BRAIN_INTERNAL_SECRET_DEFAULT: ${BRAIN_INTERNAL_SECRET_DEFAULT:-change-me} + # Rate limit : 1 creation par IP par fenetre (en secondes). + RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-60} + networks: + - traefik + - sessions + - socket-proxy + labels: + - "traefik.enable=true" + - "traefik.http.routers.loremind-demo.rule=Host(`${DEMO_HOST:-loremind-demo.igmlcreation.fr}`)" + - "traefik.http.routers.loremind-demo.entrypoints=websecure" + - "traefik.http.routers.loremind-demo.tls.certresolver=letsencrypt" + - "traefik.http.services.loremind-demo.loadbalancer.server.port=80" + +networks: + traefik: + external: true + name: ${TRAEFIK_NETWORK:-traefik} + sessions: + # Reseau interne pour les trios de session. Pas d'acces Internet direct + # (sauf via le DNS Docker), pas expose au host. + name: loremind-demo-sessions + driver: bridge + socket-proxy: + # Reseau prive entre dockerproxy et orchestrateur. Isole du reste. + name: loremind-demo-socket-proxy + driver: bridge + internal: true diff --git a/demo/orchestrator/Dockerfile b/demo/orchestrator/Dockerfile new file mode 100644 index 0000000..c3ca2e5 --- /dev/null +++ b/demo/orchestrator/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1.6 +# Build context attendu : racine du repo LoreMind. +# Appele depuis demo/docker-compose.infra.yml avec context: ../ + +# --- Etage 1 : build Angular statique --- +FROM node:20-alpine AS web-build +WORKDIR /build +COPY web/package*.json ./ +RUN npm ci +COPY web/ . +RUN npm run build -- --configuration production + +# --- Etage 2 : build orchestrateur Go --- +FROM golang:1.22-alpine AS go-build +WORKDIR /src +COPY demo/orchestrator/ ./ +# go mod tidy resout le go.sum au build pour eviter d'avoir a le committer. +RUN go mod tidy && CGO_ENABLED=0 go build -o /orchestrator . + +# --- Etage final : runtime minimal --- +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=go-build /orchestrator /app/orchestrator +COPY --from=web-build /build/dist/web /app/static +COPY demo/orchestrator/preparing.html /app/preparing.html +EXPOSE 80 +ENV STATIC_DIR=/app/static \ + PREPARING_PAGE=/app/preparing.html +ENTRYPOINT ["/app/orchestrator"] diff --git a/demo/orchestrator/config.go b/demo/orchestrator/config.go new file mode 100644 index 0000000..8346ab0 --- /dev/null +++ b/demo/orchestrator/config.go @@ -0,0 +1,64 @@ +package main + +import ( + "log" + "os" + "strconv" + "time" +) + +// Config centralise les parametres lus depuis les variables d'env au boot. +type Config struct { + Registry string + Tag string + MaxSessions int + SessionTTL time.Duration + CoreMemoryBytes int64 + BrainMemoryBytes int64 + PostgresMemoryBytes int64 + SessionsNetwork string + BrainSecretDefault string + StaticDir string + PreparingPage string + RateLimitWindow time.Duration + MaxBodyBytes int64 +} + +func loadConfig() *Config { + return &Config{ + Registry: envStr("REGISTRY", "git.igmlcreation.fr"), + Tag: envStr("TAG", "latest"), + MaxSessions: envInt("MAX_SESSIONS", 10), + SessionTTL: time.Duration(envInt("SESSION_TTL_MINUTES", 20)) * time.Minute, + CoreMemoryBytes: int64(envInt("CORE_MEMORY_MB", 700)) * 1024 * 1024, + BrainMemoryBytes: int64(envInt("BRAIN_MEMORY_MB", 300)) * 1024 * 1024, + PostgresMemoryBytes: int64(envInt("POSTGRES_MEMORY_MB", 200)) * 1024 * 1024, + SessionsNetwork: envStr("SESSIONS_NETWORK", "loremind-demo-sessions"), + BrainSecretDefault: envStr("BRAIN_INTERNAL_SECRET_DEFAULT", "change-me"), + StaticDir: envStr("STATIC_DIR", "/app/static"), + PreparingPage: envStr("PREPARING_PAGE", "/app/preparing.html"), + RateLimitWindow: time.Duration(envInt("RATE_LIMIT_WINDOW_SECONDS", 60)) * time.Second, + // 10 Mo : aligne avec la limite d'upload d'image cote core. + MaxBodyBytes: int64(envInt("MAX_BODY_MB", 10)) * 1024 * 1024, + } +} + +func envStr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envInt(key string, def int) int { + v := os.Getenv(key) + if v == "" { + return def + } + i, err := strconv.Atoi(v) + if err != nil { + log.Printf("warning: env %s=%q not a number, using default %d", key, v, def) + return def + } + return i +} diff --git a/demo/orchestrator/docker.go b/demo/orchestrator/docker.go new file mode 100644 index 0000000..921f2e7 --- /dev/null +++ b/demo/orchestrator/docker.go @@ -0,0 +1,247 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net/http" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" +) + +// DockerClient encapsule les operations Docker necessaires a la demo. +type DockerClient struct { + cli *client.Client +} + +func newDockerClient() (*DockerClient, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, fmt.Errorf("docker client: %w", err) + } + return &DockerClient{cli: cli}, nil +} + +// SpawnTrio cree les 3 conteneurs d'une session (postgres, brain, core) et +// les branche sur le reseau interne. Ils partagent un label "demo-session=" +// pour faciliter le nettoyage. +func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Config) error { + pgName := "demo-" + sessionID + "-postgres" + brainName := "demo-" + sessionID + "-brain" + coreName := "demo-" + sessionID + "-core" + pgPassword := randomHex(16) + brainSecret := randomHex(32) + adminPassword := randomHex(16) + + labels := map[string]string{ + "demo-session": sessionID, + "demo-role": "", // rempli par conteneur + } + + // --- Postgres (tmpfs => ephemere) --- + pgLabels := copyLabels(labels, "postgres") + if err := d.runContainer(ctx, runSpec{ + Name: pgName, + Image: "postgres:16-alpine", + Env: []string{"POSTGRES_DB=loremind", "POSTGRES_USER=loremind", "POSTGRES_PASSWORD=" + pgPassword}, + Labels: pgLabels, + Memory: cfg.PostgresMemoryBytes, + Tmpfs: map[string]string{"/var/lib/postgresql/data": "rw,size=200m"}, + Net: cfg.SessionsNetwork, + Alias: pgName, + }); err != nil { + return fmt.Errorf("spawn postgres: %w", err) + } + + // --- Brain --- + brainLabels := copyLabels(labels, "brain") + if err := d.runContainer(ctx, runSpec{ + Name: brainName, + Image: cfg.Registry + "/ietm64/brain:" + cfg.Tag, + Env: []string{ + "INTERNAL_SHARED_SECRET=" + brainSecret, + // Pas de provider LLM configure en demo : les features IA repondront + // en erreur, la demo sert principalement a explorer l'edition. + "LLM_PROVIDER=ollama", + "OLLAMA_BASE_URL=http://localhost:1", // endpoint mort volontairement + }, + Labels: brainLabels, + Memory: cfg.BrainMemoryBytes, + Net: cfg.SessionsNetwork, + Alias: brainName, + }); err != nil { + return fmt.Errorf("spawn brain: %w", err) + } + + // --- Core --- + coreLabels := copyLabels(labels, "core") + if err := d.runContainer(ctx, runSpec{ + Name: coreName, + Image: cfg.Registry + "/ietm64/core:" + cfg.Tag, + Env: []string{ + "SPRING_DATASOURCE_URL=jdbc:postgresql://" + pgName + ":5432/loremind", + "SPRING_DATASOURCE_USERNAME=loremind", + "SPRING_DATASOURCE_PASSWORD=" + pgPassword, + "BRAIN_BASE_URL=http://" + brainName + ":8000", + "BRAIN_INTERNAL_SECRET=" + brainSecret, + "ADMIN_USERNAME=admin", + "ADMIN_PASSWORD=" + adminPassword, + "DEMO_MODE=true", + "CORS_ALLOWED_ORIGINS=*", + // MinIO volontairement non fourni : le client init en lazy, seul + // l'upload d'images echouera (500). A masquer plus tard si besoin. + }, + Labels: coreLabels, + Memory: cfg.CoreMemoryBytes, + Net: cfg.SessionsNetwork, + Alias: coreName, + }); err != nil { + return fmt.Errorf("spawn core: %w", err) + } + + return nil +} + +// runSpec regroupe les parametres d'un container pour runContainer. +type runSpec struct { + Name string + Image string + Env []string + Labels map[string]string + Memory int64 + Tmpfs map[string]string + Net string + Alias string +} + +// runContainer pull l'image si absente puis cree et demarre le conteneur. +func (d *DockerClient) runContainer(ctx context.Context, s runSpec) error { + // Pull silencieux si image absente localement. Ignore les erreurs (l'image + // peut exister localement sans etre atteignable au registre, ex: builds dev). + if reader, err := d.cli.ImagePull(ctx, s.Image, types.ImagePullOptions{}); err == nil { + // Drain le body, sinon le pull n'est pas termine quand on continue. + _, _ = io.Copy(io.Discard, reader) + reader.Close() + } + + pidsLimit := int64(200) + hostCfg := &container.HostConfig{ + RestartPolicy: container.RestartPolicy{Name: "no"}, + Resources: container.Resources{ + Memory: s.Memory, + NanoCPUs: 1_000_000_000, // 1 vCPU par conteneur + PidsLimit: &pidsLimit, // Anti fork-bomb : max 200 threads/processus. + }, + Tmpfs: s.Tmpfs, + // no-new-privileges : interdit a un processus du conteneur de gagner + // plus de privileges que son parent (bloque les exploits setuid courants). + SecurityOpt: []string{"no-new-privileges:true"}, + // CapDrop/CapAdd volontairement non configures : les images core (JVM + // Spring Boot) et brain (Python) n'ont pas ete auditees pour un + // fonctionnement avec capabilities restreintes ; un drop trop agressif + // peut casser le demarrage de maniere non triviale. A revoir si besoin. + } + + netCfg := &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + s.Net: {Aliases: []string{s.Alias}}, + }, + } + + resp, err := d.cli.ContainerCreate(ctx, &container.Config{ + Image: s.Image, + Env: s.Env, + Labels: s.Labels, + }, hostCfg, netCfg, nil, s.Name) + if err != nil { + return fmt.Errorf("create %s: %w", s.Name, err) + } + if err := d.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { + return fmt.Errorf("start %s: %w", s.Name, err) + } + return nil +} + +// WaitReady poll l'endpoint /api/config du core pendant timeout, retourne true +// des qu'il repond 200. Utilise par le manager pour passer en Status=ready. +func (d *DockerClient) WaitReady(ctx context.Context, sessionID string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + url := "http://demo-" + sessionID + "-core:8080/api/config" + httpClient := &http.Client{Timeout: 2 * time.Second} + for time.Now().Before(deadline) { + resp, err := httpClient.Get(url) + if err == nil { + resp.Body.Close() + if resp.StatusCode == 200 { + return true + } + } + select { + case <-ctx.Done(): + return false + case <-time.After(2 * time.Second): + } + } + return false +} + +// KillTrio arrete et supprime tous les conteneurs avec le label demo-session=. +func (d *DockerClient) KillTrio(ctx context.Context, sessionID string) error { + f := filters.NewArgs() + f.Add("label", "demo-session="+sessionID) + list, err := d.cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f}) + if err != nil { + return err + } + for _, c := range list { + _ = d.cli.ContainerRemove(ctx, c.ID, container.RemoveOptions{Force: true}) + } + return nil +} + +// ListSessionIDs retourne les IDs de session detectes dans les labels Docker. +// Utile au demarrage pour nettoyer les orphelins (conteneurs d'une vie +// anterieure de l'orchestrateur). +func (d *DockerClient) ListSessionIDs(ctx context.Context) ([]string, error) { + f := filters.NewArgs() + f.Add("label", "demo-session") + list, err := d.cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f}) + if err != nil { + return nil, err + } + seen := make(map[string]bool) + for _, c := range list { + if v, ok := c.Labels["demo-session"]; ok && v != "" { + seen[v] = true + } + } + out := make([]string, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + return out, nil +} + +// --- helpers --- + +func copyLabels(base map[string]string, role string) map[string]string { + out := make(map[string]string, len(base)) + for k, v := range base { + out[k] = v + } + out["demo-role"] = role + return out +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/demo/orchestrator/go.mod b/demo/orchestrator/go.mod new file mode 100644 index 0000000..35bbc5a --- /dev/null +++ b/demo/orchestrator/go.mod @@ -0,0 +1,8 @@ +module github.com/loremind/demo-orchestrator + +go 1.22 + +require ( + github.com/docker/docker v27.3.1+incompatible + github.com/google/uuid v1.6.0 +) diff --git a/demo/orchestrator/go.sum b/demo/orchestrator/go.sum new file mode 100644 index 0000000..bdeded4 --- /dev/null +++ b/demo/orchestrator/go.sum @@ -0,0 +1,2 @@ +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/demo/orchestrator/main.go b/demo/orchestrator/main.go new file mode 100644 index 0000000..c86fd3e --- /dev/null +++ b/demo/orchestrator/main.go @@ -0,0 +1,231 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const cookieName = "loremind-demo-session" + +func main() { + cfg := loadConfig() + + docker, err := newDockerClient() + if err != nil { + log.Fatalf("docker init: %v", err) + } + + mgr := newManager(docker, cfg) + limiter := newRateLimiter(cfg.RateLimitWindow) + + // Nettoyage des sessions residuelles au boot (redemarrage orchestrateur). + cleanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + mgr.CleanupOrphans(cleanCtx) + cancel() + + go mgr.RunGC(context.Background()) + + mux := http.NewServeMux() + mux.HandleFunc("/_demo/ready", readyHandler(mgr)) + mux.HandleFunc("/api/", apiHandler(mgr, cfg)) + mux.HandleFunc("/", rootHandler(mgr, limiter, cfg)) + + srv := &http.Server{ + Addr: ":80", + Handler: mux, + // Timeouts anti-slowloris. WriteTimeout laisse de la marge pour le + // streaming SSE (ai/chat/stream) qui peut durer plusieurs minutes. + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 60 * time.Second, + WriteTimeout: 10 * time.Minute, + IdleTimeout: 120 * time.Second, + // Headers max : 1 Mo (defaut Go), suffisant. + } + + log.Printf("orchestrator listening on :80 (max sessions=%d, ttl=%s, rate window=%s)", + cfg.MaxSessions, cfg.SessionTTL, cfg.RateLimitWindow) + if err := srv.ListenAndServe(); err != nil { + log.Fatalf("http server: %v", err) + } +} + +// rootHandler gere toutes les routes non-API : sert l'Angular statique si le +// visiteur a deja une session prete, sinon cree une session (sous rate limit) +// et renvoie la page de preparation. +func rootHandler(mgr *Manager, limiter *rateLimiter, cfg *Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sess := currentSession(r, mgr) + + // Visiteur connu et session prete -> sert l'app normalement. + if sess != nil && sess.Status == StatusReady { + serveStatic(w, r, cfg.StaticDir) + return + } + + // On ne spawn qu'a la navigation initiale (GET d'un document HTML). + // Les assets secondaires (JS/CSS/favicon) ne doivent pas declencher + // de nouvelle session. + if r.Method != http.MethodGet { + http.Error(w, "No session", http.StatusUnauthorized) + return + } + if !acceptsHTML(r) { + http.Error(w, "No session", http.StatusUnauthorized) + return + } + + // Session inexistante (ou expiree) -> en creer une, sous rate limit. + if sess == nil { + ip := clientIP(r) + if !limiter.Allow(ip) { + http.Error(w, "Trop de tentatives. Merci d'attendre "+ + strconv.Itoa(int(cfg.RateLimitWindow.Seconds()))+"s.", + http.StatusTooManyRequests) + return + } + newSess, err := mgr.Create(r.Context()) + if err != nil { + if errors.Is(err, ErrCapacity) { + http.Error(w, "La demo est pleine (max "+ + strconv.Itoa(cfg.MaxSessions)+ + " sessions simultanees). Merci de reessayer plus tard.", + http.StatusServiceUnavailable) + return + } + http.Error(w, "Impossible de creer la session : "+err.Error(), + http.StatusInternalServerError) + return + } + sess = newSess + setCookie(w, sess.ID, cfg.SessionTTL) + } + + servePreparingPage(w, cfg.PreparingPage) + } +} + +// apiHandler proxifie /api/* vers le core de la session. +// Bride la taille des bodies a MaxBodyBytes pour limiter les DoS memoire. +func apiHandler(mgr *Manager, cfg *Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sess := currentSession(r, mgr) + if sess == nil { + http.Error(w, "No session", http.StatusUnauthorized) + return + } + if sess.Status != StatusReady { + http.Error(w, "Session not ready", http.StatusServiceUnavailable) + return + } + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodyBytes) + } + proxy := sessionProxy(sess) + proxy.ServeHTTP(w, r) + } +} + +// sessionProxy renvoie (et cree si besoin) un reverse proxy cache dans la +// session via sync.Once : garantit une seule creation meme sous requetes +// concurrentes, sans mutex explicite. +func sessionProxy(sess *Session) *httputil.ReverseProxy { + sess.proxyOnce.Do(func() { + target, _ := url.Parse("http://" + sess.CoreHost + ":8080") + p := httputil.NewSingleHostReverseProxy(target) + p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("proxy error session=%s: %v", sess.ID, err) + http.Error(w, "Upstream error", http.StatusBadGateway) + } + sess.proxy = p + }) + return sess.proxy.(*httputil.ReverseProxy) +} + +// readyHandler renvoie l'etat de la session en JSON pour le polling client. +// N'expose aucun ID de session ni d'information sur les autres sessions. +func readyHandler(mgr *Manager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sess := currentSession(r, mgr) + w.Header().Set("Content-Type", "application/json") + if sess == nil { + json.NewEncoder(w).Encode(map[string]any{"status": "none"}) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "status": string(sess.Status), + "error": sess.Err, + }) + } +} + +// currentSession lit le cookie et retrouve la session en memoire. +// Si le cookie pointe vers une session disparue (redemarrage orchestrateur ou +// TTL expire), retourne nil -> le handler traitera comme un nouveau visiteur. +func currentSession(r *http.Request, mgr *Manager) *Session { + c, err := r.Cookie(cookieName) + if err != nil || c.Value == "" { + return nil + } + return mgr.Get(c.Value) +} + +func setCookie(w http.ResponseWriter, id string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: id, + Path: "/", + HttpOnly: true, + Secure: true, // Traefik termine le TLS ; le browser ne doit envoyer ce cookie qu'en HTTPS. + SameSite: http.SameSiteLaxMode, + MaxAge: int(ttl.Seconds()), + }) +} + +// serveStatic sert les fichiers de l'Angular build avec fallback sur index.html +// pour que le routeur cote client fonctionne (SPA). +// Le check HasPrefix apres Join + Clean empeche les path traversals (..). +func serveStatic(w http.ResponseWriter, r *http.Request, dir string) { + reqPath := r.URL.Path + if reqPath == "/" || reqPath == "" { + reqPath = "/index.html" + } + fullPath := filepath.Join(dir, filepath.Clean(reqPath)) + if !strings.HasPrefix(fullPath, dir) { + http.Error(w, "bad path", http.StatusBadRequest) + return + } + if info, err := os.Stat(fullPath); err == nil && !info.IsDir() { + http.ServeFile(w, r, fullPath) + return + } + http.ServeFile(w, r, filepath.Join(dir, "index.html")) +} + +// servePreparingPage sert la page de chargement statique. Le cookie vient +// d'etre pose, le JS de la page utilisera sessionId implicitement via le +// cookie pour poller /_demo/ready. +func servePreparingPage(w http.ResponseWriter, path string) { + data, err := os.ReadFile(path) + if err != nil { + http.Error(w, "Preparing page not found", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +func acceptsHTML(r *http.Request) bool { + accept := r.Header.Get("Accept") + return strings.Contains(accept, "text/html") || accept == "" || accept == "*/*" +} diff --git a/demo/orchestrator/preparing.html b/demo/orchestrator/preparing.html new file mode 100644 index 0000000..bbd145a --- /dev/null +++ b/demo/orchestrator/preparing.html @@ -0,0 +1,127 @@ + + + + + + LoreMind — Demo en preparation + + + +
+ +
THE DIGITAL CODEX
+

Preparation de votre demo…

+

+ Nous initialisons une instance isolee rien que pour vous. + Cela prend generalement 20 a 40 secondes. +

+

+ Votre session sera automatiquement reinitialisee au bout de 20 minutes. +

+
+
+
+ + + diff --git a/demo/orchestrator/ratelimit.go b/demo/orchestrator/ratelimit.go new file mode 100644 index 0000000..a81c1cd --- /dev/null +++ b/demo/orchestrator/ratelimit.go @@ -0,0 +1,72 @@ +package main + +import ( + "net" + "net/http" + "strings" + "sync" + "time" +) + +// rateLimiter autorise au plus une action par IP dans une fenetre glissante. +// Pas de token bucket : pour un endpoint de creation de session, "1 par +// fenetre" est largement suffisant et plus simple a raisonner. +type rateLimiter struct { + mu sync.Mutex + lastSeen map[string]time.Time + window time.Duration +} + +func newRateLimiter(window time.Duration) *rateLimiter { + rl := &rateLimiter{ + lastSeen: make(map[string]time.Time), + window: window, + } + go rl.cleanupLoop() + return rl +} + +// Allow renvoie true si l'IP n'a pas deja declenche d'action dans la fenetre. +// Sur true, l'horloge de l'IP est reinitialisee. +func (rl *rateLimiter) Allow(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + now := time.Now() + if last, ok := rl.lastSeen[ip]; ok && now.Sub(last) < rl.window { + return false + } + rl.lastSeen[ip] = now + return true +} + +// cleanupLoop purge les entrees plus anciennes que 2x la fenetre pour eviter +// la croissance non bornee de la map sous trafic varie. +func (rl *rateLimiter) cleanupLoop() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + cutoff := time.Now().Add(-2 * rl.window) + rl.mu.Lock() + for ip, t := range rl.lastSeen { + if t.Before(cutoff) { + delete(rl.lastSeen, ip) + } + } + rl.mu.Unlock() + } +} + +// clientIP extrait l'IP reelle en prenant la derniere entree de X-Forwarded-For. +// Justification : Traefik APPEND l'IP du peer au header existant, donc la +// derniere valeur est celle que Traefik a observe directement (le vrai client). +// Prendre la premiere serait une faille : un attaquant peut preremplir le header. +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + return strings.TrimSpace(parts[len(parts)-1]) + } + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} diff --git a/demo/orchestrator/sessions.go b/demo/orchestrator/sessions.go new file mode 100644 index 0000000..012e0bc --- /dev/null +++ b/demo/orchestrator/sessions.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "log" + "sync" + "time" +) + +// SessionStatus reflete l'etat du cycle de vie d'un trio de session. +type SessionStatus string + +const ( + StatusStarting SessionStatus = "starting" + StatusReady SessionStatus = "ready" + StatusFailed SessionStatus = "failed" +) + +// Session represente une demo isolee pour un visiteur. +// CoreHost est le hostname Docker interne du conteneur core de cette session +// (ex: "demo-abc123-core"), vers lequel l'orchestrateur proxifie les /api/*. +type Session struct { + ID string + CreatedAt time.Time + Status SessionStatus + CoreHost string + Err string + // proxy et proxyOnce : reverse-proxy cache, cree au plus une fois via + // sync.Once (evite la race entre deux requetes concurrentes sur la meme + // session). proxy est typee any pour ne pas contraindre sessions.go a + // importer net/http/httputil. + proxy any + proxyOnce sync.Once +} + +// Manager gere le cycle de vie des sessions (creation, acces, cleanup). +// Thread-safe : le mutex protege la map contre les acces concurrents (HTTP +// handlers + goroutine de GC). +type Manager struct { + mu sync.Mutex + sessions map[string]*Session + docker *DockerClient + cfg *Config +} + +func newManager(docker *DockerClient, cfg *Config) *Manager { + return &Manager{ + sessions: make(map[string]*Session), + docker: docker, + cfg: cfg, + } +} + +// ErrCapacity est retournee quand MAX_SESSIONS est atteint. +var ErrCapacity = errors.New("demo at capacity") + +// Create reserve un slot et lance le spawn des conteneurs en arriere-plan. +// Retourne immediatement avec Status=starting. L'etat bascule a "ready" quand +// les conteneurs sont up et que core repond a /api/config. +func (m *Manager) Create(ctx context.Context) (*Session, error) { + m.mu.Lock() + if len(m.sessions) >= m.cfg.MaxSessions { + m.mu.Unlock() + return nil, ErrCapacity + } + id := newShortID() + sess := &Session{ + ID: id, + CreatedAt: time.Now(), + Status: StatusStarting, + CoreHost: "demo-" + id + "-core", + } + m.sessions[id] = sess + m.mu.Unlock() + + // Spawn asynchrone : l'utilisateur voit immediatement la page "preparation". + go func() { + spawnCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if err := m.docker.SpawnTrio(spawnCtx, id, m.cfg); err != nil { + log.Printf("session %s spawn failed: %v", id, err) + m.mu.Lock() + sess.Status = StatusFailed + sess.Err = err.Error() + m.mu.Unlock() + // Nettoyage best-effort des conteneurs partiellement crees. + _ = m.docker.KillTrio(context.Background(), id) + return + } + // Attente que core reponde (sinon proxy retourne 502 aux premieres requetes). + if m.docker.WaitReady(spawnCtx, id, 90*time.Second) { + m.mu.Lock() + sess.Status = StatusReady + m.mu.Unlock() + log.Printf("session %s ready", id) + } else { + log.Printf("session %s never became ready", id) + m.mu.Lock() + sess.Status = StatusFailed + sess.Err = "timeout waiting for core" + m.mu.Unlock() + _ = m.docker.KillTrio(context.Background(), id) + } + }() + + return sess, nil +} + +// Get renvoie la session associee a un ID, ou nil si elle n'existe plus. +func (m *Manager) Get(id string) *Session { + m.mu.Lock() + defer m.mu.Unlock() + return m.sessions[id] +} + +// RunGC boucle toutes les minutes pour supprimer les sessions expirees. +// A lancer en goroutine au demarrage. +func (m *Manager) RunGC(ctx context.Context) { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + m.gcOnce() + } + } +} + +func (m *Manager) gcOnce() { + cutoff := time.Now().Add(-m.cfg.SessionTTL) + m.mu.Lock() + var expired []string + for id, s := range m.sessions { + if s.CreatedAt.Before(cutoff) { + expired = append(expired, id) + } + } + for _, id := range expired { + delete(m.sessions, id) + } + m.mu.Unlock() + + for _, id := range expired { + log.Printf("session %s expired, killing containers", id) + if err := m.docker.KillTrio(context.Background(), id); err != nil { + log.Printf("kill %s: %v", id, err) + } + } +} + +// CleanupOrphans tue les conteneurs demo-* qui ne correspondent a aucune +// session en memoire. Appele au demarrage pour gerer un redemarrage brutal. +func (m *Manager) CleanupOrphans(ctx context.Context) { + ids, err := m.docker.ListSessionIDs(ctx) + if err != nil { + log.Printf("list orphans: %v", err) + return + } + for _, id := range ids { + log.Printf("cleaning orphan session %s", id) + _ = m.docker.KillTrio(ctx, id) + } +} + +// newShortID genere un identifiant hexadecimal de 32 caracteres (128 bits). +// 128 bits d'entropie rendent les collisions et le brute-force statistiquement +// impossibles, meme si un attaquant pouvait tenter des millions de cookies. +func newShortID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +}