diff --git a/Caddyfile b/Caddyfile index 30dbdb1..ab5a894 100644 --- a/Caddyfile +++ b/Caddyfile @@ -35,6 +35,16 @@ alfrednobel.edudeploy.com { tls { on_demand } + @instance { + not host alfrednobel.edudeploy.com + not host headscale.alfrednobel.edudeploy.com + host *.alfrednobel.edudeploy.com + } + handle @instance { + reverse_proxy resolver:2020 { + header_up Host {host} + } + } reverse_proxy /api/websocket* server:3001 reverse_proxy server:3000 } diff --git a/SESSION_NOTES.md b/SESSION_NOTES.md index 1fd01dc..30befde 100644 --- a/SESSION_NOTES.md +++ b/SESSION_NOTES.md @@ -84,3 +84,224 @@ Sur le PC étudiant (PowerShell) : podman ps --format "table {{.Names}}`t{{.Status}}`t{{.Ports}}" podman logs -f cmqiiso9g0001mfap4wv7a690-app-1 ``` + +--- + +## 🔧 Correctif PrestaShop - 18 juin 2026 + +### Principe +Un override `Configuration.php` est monté dans `/var/www/html/override/classes/Configuration.php`. +Il surcharge `Configuration::get()` pour retourner dynamiquement : +- `PS_SHOP_DOMAIN` / `PS_SHOP_DOMAIN_SSL` = host de la requête (localhost:8080 ou sous-domaine public) +- `PS_SSL_ENABLED` = 0 quand la requête n'est pas HTTPS, pour éviter les redirections infinies en local + +L'override truste aussi les headers `X-Forwarded-Proto`, `X-Forwarded-Host` et `X-Forwarded-Port` envoyés par le proxy Next.js. + +### Fichiers modifiés +- `agent/psplugins/Configuration.php` (nouveau) +- `agent/prestashop.go` (nouveau : écriture de l'override) +- `agent/docker.go` (substitution du placeholder `{PS_OVERRIDES_DIR}`) +- `agent/build.sh` (version 0.2.8) +- `server/prisma/seed.ts` (montage du volume pour les templates PrestaShop) +- `server/app/dashboard/download/page.tsx` (version Windows 0.2.8) + +### Déploiement effectué +- Image serveur rebuildée et redémarrée (`docker compose up -d --build server`) +- Seed relancé : `docker exec edubox-server sh -c "cd /app && npx prisma db seed"` +- Binaire agent Windows v0.2.8 généré : `agent/edubox-agent-v0.2.8.exe` et copié dans `server/public/` + +### Prochaines actions manuelles +Sur le PC étudiant (OMEYA-GAMER) : +1. Arrêter l'agent v0.2.7 en cours. +2. Télécharger le nouvel agent v0.2.8 (dashboard → Téléchargements ou URL `/edubox-agent-v0.2.8.exe`). +3. Lancer le nouvel agent. +4. Depuis le dashboard, **réinitialiser** l'instance `cmqiiso9g0001mfap4wv7a690` (le reset recrée les volumes et applique l'override dès l'installation). +5. Tester : + - `curl -I http://localhost:8080` → `200 OK` + - `curl -I https://cmqiiso9g0001mfap4wv7a690.alfrednobel.edudeploy.com/` → `200 OK` + +### Problème découvert lors du test +La nouvelle instance PrestaShop (`cmqjtdige0001gtw95e7cyr3p`) s'ouvrait bien en **URL publique**, mais `localhost:8089` redirigeait toujours vers le domaine public. + +Cause : l'image PrestaShop embarque un `class_index.php` pré-généré qui ne connaît pas notre override `override/classes/Configuration.php`. L'autoloader utilise donc la classe `Configuration` originale, et `Configuration::get('PS_SHOP_DOMAIN_SSL')` retourne la valeur figée en base. + +Solution : vider les caches `app/cache/*` et `var/cache/*` après le démarrage du conteneur. + +### Fichiers ajoutés/modifiés (suite) +- `agent/prestashop.go` : ajout de `clearPrestaShopCache()` pour effacer les caches après chaque `start`/`reset` d'une instance PrestaShop +- `agent/websocket.go` : appel de `clearPrestaShopCache()` quand `msg.Type == "prestashop"` +- Rebuild du binaire Windows v0.2.8 avec ce fix + +### Action requise maintenant +Sur le PC étudiant : +1. Télécharger **à nouveau** l'agent v0.2.8 (le fichier a été regénéré avec le nettoyage de cache). +2. Lancer le nouvel agent. +3. Supprimer/réinitialiser l'instance PrestaShop en cours pour forcer une installation fraîche avec l'agent corrigé. +4. Tester : + ```powershell + curl -I http://localhost: + curl -I https://.alfrednobel.edudeploy.com/ + ``` + Les deux doivent retourner `200 OK`. + +### Problème suivant : l'override n'est toujours pas chargé +Même après suppression des caches, `Configuration::get('PS_SHOP_DOMAIN_SSL')` retourne le domaine public. L'override `override/classes/Configuration.php` est bien présent, mais PrestaShop 8 utilise l'autoloader namespacé `PrestaShop\Autoload\PrestashopAutoload`. L'appel à l'ancien `PrestaShopAutoload::generateIndex()` ne met pas à jour l'index actif. + +Solution : appeler `PrestaShop\Autoload\PrestashopAutoload::getInstance()->generateIndex()` depuis l'agent après le démarrage d'une instance PrestaShop. + +### Fichiers ajoutés/modifiés (suite) +- `agent/prestashop.go` : `clearPrestaShopCache()` supprime les caches et régénère l'index via l'autoloader namespacé +- `agent/websocket.go` : appel de `clearPrestaShopCache()` pour les instances PrestaShop +- Rebuild du binaire Windows v0.2.8 + +### Action requise maintenant +Sur le PC étudiant : +1. **Télécharger à nouveau** l'agent v0.2.8 (regénéré avec le fix d'index). +2. Lancer le nouvel agent. +3. Supprimer/réinitialiser l'instance PrestaShop pour qu'elle soit recréée avec l'agent corrigé. +4. Tester : + ```powershell + curl -I http://localhost: + curl -I https://.alfrednobel.edudeploy.com/ + ``` + +### Test immédiat sur l'instance actuelle +Sans réinstaller, on peut forcer la régénération de l'index dans le conteneur : +```powershell +podman exec cmqjtdige0001gtw95e7cyr3p-app-1 php -r "require '/var/www/html/config/config.inc.php'; PrestaShop\Autoload\PrestashopAutoload::getInstance()->generateIndex();" +``` +Puis vérifier : +```powershell +podman exec cmqjtdige0001gtw95e7cyr3p-app-1 php -r "require '/var/www/html/config/config.inc.php'; echo Configuration::get('PS_SHOP_DOMAIN_SSL').PHP_EOL;" +``` +Le résultat doit être `localhost:8089` au lieu du domaine public. + +### Commits à faire +- `feat(agent): override PrestaShop dynamique pour HTTP_HOST` +- `fix(agent): régénère l'index PrestaShop pour charger l'override` +- `fix(server/seed): monte l'override PrestaShop dans les templates` +- `chore(agent): bump v0.2.8` + +--- + +## ✅ Fix final - 18 juin 2026 + +### Problème réel identifié +`curl -I https://.alfrednobel.edudeploy.com/` retournait `200` (HEAD n'est pas redirigé), mais un GET dans le navigateur provoquait une boucle `ERR_TOO_MANY_REDIRECTS`. + +Cause : PrestaShop reçoit les requêtes publiques en HTTP via le proxy Next.js (`upstream = http://:`). Il ne truste pas le header `X-Forwarded-Proto: https`, donc `Tools::usingSecureMode()` retourne `false`. La redirection canonique/compare la requête `http://domaine/` avec l'URL canonique `https://domaine/`, ce qui crée une boucle HTTPS ↔ HTTP. + +### Solution retenue +Monter une configuration Apache dans le conteneur PrestaShop qui définit `HTTPS=on` quand `X-Forwarded-Proto` vaut `https` : +```apache +SetEnvIf X-Forwarded-Proto https HTTPS=on +SetEnvIf X-Forwarded-Proto https SERVER_PORT=443 +``` + +### Fichiers modifiés +- `agent/apache/proxy.conf` (nouveau) +- `agent/prestashop.go` (nouveau : écrit la config Apache dans le dossier données de l'agent) +- `agent/docker.go` : substitution du placeholder `{PS_APACHE_CONF_DIR}` +- `server/prisma/seed.ts` : les templates PrestaShop montent `proxy.conf` dans `/etc/apache2/conf-enabled/edubox-proxy.conf` +- `agent/build.sh` : version 0.2.8 +- `server/app/dashboard/download/page.tsx` : version Windows 0.2.8 + +### Déploiement effectué +- Image serveur rebuildée et redémarrée +- Seed relancé +- Binaire agent Windows v0.2.8 regénéré et copié dans `server/public/` + +### Action requise sur le PC étudiant +1. Télécharger l'agent v0.2.8 propre : + ```powershell + Invoke-WebRequest -Uri "https://alfrednobel.edudeploy.com/edubox-agent-v0.2.8.exe" -OutFile "edubox-agent.exe" + ``` +2. Arrêter l'agent précédent et lancer celui-ci. +3. Supprimer ou réinitialiser l'instance PrestaShop depuis le dashboard pour qu'elle soit recréée avec la config Apache montée. +4. Tester dans le navigateur : + - `https://.alfrednobel.edudeploy.com/` doit s'ouvrir sans `ERR_TOO_MANY_REDIRECTS` + +### État final +- Accès étudiant/professeur via l'URL publique. +- `localhost:` redirige toujours vers l'URL publique (comportement PrestaShop normal), mais ce n'est pas bloquant. +- Linux/Mac seront ajoutés plus tard quand Windows sera stable. + +### Commits à faire +- `fix(agent): ajoute la conf Apache PrestaShop pour trust X-Forwarded-Proto` +- `fix(server/seed): monte la conf Apache dans les templates PrestaShop` +- `chore(agent): bump v0.2.8` + +--- + +## ✅ Fix final - 18 juin 2026 (v2) + +### Analyse +- J'ai lu le code source de PrestaShop 8.1 directement dans l'image Docker (`/var/www/html/classes/Tools.php`). +- `Tools::usingSecureMode()` truste bien `HTTP_X_FORWARDED_PROTO: https`. +- J'ai testé localement avec notre config Apache montée : PHP reçoit bien `X-Forwarded-Proto` et `HTTPS=on`. +- **Conclusion** : la boucle ne vient pas d'un défaut de PrestaShop sur la détection HTTPS en lui-même. Elle vient du fait que, dans la vraie chaîne proxy, PrestaShop est installé avec `PS_ENABLE_SSL=1`, ce qui fige le protocole canonique en `https://`. La moindre incohérence entre ce qui est reçu (HTTP interne) et ce qui est attendu (HTTPS canonique) déclenche une redirection canonique, et le navigateur retombe sur la même URL → boucle infinie. +- PrestaShop 9 (branche `develop`) a exactement la même logique `usingSecureMode()`, donc le même problème se produirait avec le même setup. + +### Solution retenue +Ne plus se battre avec les headers : on installe PrestaShop avec `PS_ENABLE_SSL: "0"`. Le conteneur vit en HTTP interne, le proxy public reste en HTTPS, et le proxy Next.js réécrit les liens `http://` en `https://`. + +C'est le même principe que WordPress (HTTP interne + réécriture publique), sans avoir besoin d'override/module PrestaShop. + +### Fichiers modifiés +- `server/prisma/seed.ts` : + - `PS_ENABLE_SSL: "0"` pour les templates PrestaShop. + - Suppression du montage inutile `{PS_APACHE_CONF_DIR}/proxy.conf`. +- `server/app/api/proxy/[[...path]]/route.ts` : + - Ajout de `http://${cleanHost}` dans la réécriture des headers. + - Ajout de `http://${cleanHost}` et `//${cleanHost}` dans la réécriture du body. + +### Déploiement effectué +- Image serveur rebuildée et redémarrée. +- Seed relancé (`npx prisma db seed`). + +### Action requise sur le PC étudiant +1. L'agent v0.2.8 actuel peut être conservé (aucune modification agent nécessaire). +2. Depuis le dashboard, **supprimer ou réinitialiser** l'instance PrestaShop en cours. Cela force une réinstallation fraîche avec `PS_ENABLE_SSL=0` et sans le montage Apache. +3. Tester dans le navigateur : + - `https://.alfrednobel.edudeploy.com/` doit s'ouvrir sans `ERR_TOO_MANY_REDIRECTS`. + - L'accès `localhost:` redirigera probablement vers l'URL publique (comportement PrestaShop normal quand `PS_DOMAIN` est le domaine public), mais ce n'est pas bloquant. + +### Commits à faire +- `fix(server/seed): installe PrestaShop avec SSL désactivé` +- `fix(server/proxy): réécrit les liens http:// en https` +- `chore(notes): documente le fix final PrestaShop` + +--- + +## ❌ Fix final - 18 juin 2026 (v2) : échec + +### Test utilisateur +Après déploiement du serveur et réinitialisation de l'instance PrestaShop, l'URL publique retourne toujours `ERR_TOO_MANY_REDIRECTS`. + +L'utilisateur (Yacine) demande d'arrêter de tâtonner et de consigner l'échec proprement. + +### Hypothèses sur la cause persistante +Le fix `PS_ENABLE_SSL=0` + réécriture proxy aurait dû fonctionner si PrestaShop générait des liens `http://` et que le proxy les réécrivait. Le fait que la boucle persiste suggère l'une de ces causes : + +1. **Cache navigateur** : le client a conservé une redirection 301/302 en cache, donnant l'impression que la boucle continue alors que le serveur a corrigé. +2. **PrestaShop génère quand même des redirections** : + - Même avec `PS_ENABLE_SSL=0`, PrestaShop peut rediriger `/` vers l'URL canonique (ex. `index.php` ↔ `/`). + - Le `.htaccess` généré par PrestaShop contient des règles de rewrite vers `index.php` qui peuvent interagir avec le proxy. +3. **Le proxy ne réécrit pas le header `Location`** : le header `Location` peut contenir une URL que le pattern `http://${cleanHost}` ne capture pas (port explicite `:80`, encodage, ou `//`). +4. **La réinstallation n'a pas réellement pris le nouveau `ComposeConfig`** : l'instance existante a peut-être conservé des données/volumes, ou l'agent n'a pas reçu le nouveau template. +5. **Caddy redirige HTTP → HTTPS** en amont : si PrestaShop renvoie un `Location: http:///`, Caddy le re-rewrites en `https://`, mais PrestaShop continue d'émettre la redirection `http://` → boucle. + +### Ce qu'il faudrait vérifier calmement (plus tard) +- Vider le cache navigateur / tester en navigation privée. +- Vérifier dans le conteneur que `PS_ENABLE_SSL` vaut bien `0` en base : + ```powershell + podman exec -app-1 mysql -h db -u root -prootpassword prestashop -e "SELECT name, value FROM ps_configuration WHERE name LIKE 'PS_SSL%';" + ``` +- Vérifier le contenu du `.htaccess` généré et chercher une règle `RewriteRule ... [R]`. +- Faire un `curl -v -L --max-redirs 5 https://.alfrednobel.edudeploy.com/` pour voir la chaîne exacte des `Location`. + +### Décision +On met PrestaShop de côté pour l'instant. WordPress fonctionne. On reprendra PrestaShop avec une investigation plus approfondie et méthodique quand l'utilisateur sera disponible. + +### Commits à faire (quand on reprendra) +- À déterminer après investigation. diff --git a/agent/build.sh b/agent/build.sh index 8871624..d0bad1d 100755 --- a/agent/build.sh +++ b/agent/build.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -VERSION="0.2.7" +VERSION="0.3.0" LDFLAGS="-X main.version=${VERSION}" echo "Building EduBox Agent v${VERSION}..." diff --git a/agent/edubox-agent-v0.2.8 b/agent/edubox-agent-v0.2.8 new file mode 100755 index 0000000..eae6243 Binary files /dev/null and b/agent/edubox-agent-v0.2.8 differ diff --git a/agent/edubox-agent-v0.2.8-mac b/agent/edubox-agent-v0.2.8-mac new file mode 100755 index 0000000..d9bcc12 Binary files /dev/null and b/agent/edubox-agent-v0.2.8-mac differ diff --git a/agent/edubox-agent-v0.3.0 b/agent/edubox-agent-v0.3.0 new file mode 100755 index 0000000..a4a24e8 Binary files /dev/null and b/agent/edubox-agent-v0.3.0 differ diff --git a/agent/edubox-agent-v0.3.0-mac b/agent/edubox-agent-v0.3.0-mac new file mode 100755 index 0000000..f3e9bfa Binary files /dev/null and b/agent/edubox-agent-v0.3.0-mac differ diff --git a/agent/main.go b/agent/main.go index 8e072fb..18a9ccf 100644 --- a/agent/main.go +++ b/agent/main.go @@ -2,7 +2,6 @@ package main import ( "flag" - "fmt" "log" "os" "path/filepath" @@ -12,6 +11,8 @@ import ( // version is injected at build time via -ldflags "-X main.version=X.Y.Z" var version = "dev" +const AGENT_VERSION = "0.3.0" + var ( serverAddr = flag.String("server", "ws://localhost:3001", "Adresse WebSocket du serveur") nodeID = flag.String("node-id", defaultNodeID(), "ID du nœud (défaut: hostname)") @@ -48,7 +49,7 @@ func main() { log.Fatalf("Cannot create data-dir: %v", err) } - fmt.Printf("EduBox Agent v%s - node: %s - data-dir: %s\n", version, *nodeID, *dataDir) + log.Printf("[EduBox Agent] version=%s node=%s data-dir=%s", AGENT_VERSION, *nodeID, *dataDir) if *uiEnabled { go startUI(*dataDir, *nodeID, *serverAddr) diff --git a/agent/websocket.go b/agent/websocket.go index c69399a..e59fe61 100644 --- a/agent/websocket.go +++ b/agent/websocket.go @@ -166,16 +166,16 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) if err := saveActivation(dataDir, act); err != nil { log.Printf("saveActivation error: %v", err) } else { - log.Printf("Activated as %s", msg.StudentName) + log.Printf("Activated as %s", act.StudentName) } } notifyUI(map[string]interface{}{ "action": "activated", "studentName": msg.StudentName, }) - case "registered": - // Server acknowledged our register message; nothing to do. - return + case "registered": + // Server acknowledged our register message; nothing to do. + return case "activation_failed": log.Printf("handleMessage: activation_failed received, error=%s", msg.Error) notifyUI(map[string]interface{}{ diff --git a/docker-compose.yml b/docker-compose.yml index bb0369d..b26641f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,25 @@ services: - edubox + resolver: + build: + context: ./resolver + dockerfile: Dockerfile + container_name: edubox-resolver + restart: unless-stopped + cap_add: + - NET_ADMIN + command: > + sh -c "ip route add 100.64.0.0/10 via \$$(ip route | awk '/default/ {print \$$3}') || true && exec ./resolver" + environment: + DATABASE_URL: ${DATABASE_URL} + MAIN_DOMAIN: ${MAIN_DOMAIN} + depends_on: + postgres: + condition: service_healthy + networks: + - edubox + caddy: image: caddy:2-alpine container_name: edubox-caddy diff --git a/resolver/Dockerfile b/resolver/Dockerfile new file mode 100644 index 0000000..9473264 --- /dev/null +++ b/resolver/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.23-alpine AS builder +WORKDIR /app +COPY go.mod go.sum* ./ +RUN go mod download +COPY main.go . +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o resolver . + +FROM alpine:latest +RUN apk --no-cache add ca-certificates +WORKDIR /root/ +COPY --from=builder /app/resolver . +EXPOSE 2020 +CMD ["./resolver"] diff --git a/resolver/go.mod b/resolver/go.mod new file mode 100644 index 0000000..9470110 --- /dev/null +++ b/resolver/go.mod @@ -0,0 +1,5 @@ +module resolver + +go 1.23 + +require github.com/jackc/pgx/v5 v5.7.1 diff --git a/resolver/main.go b/resolver/main.go new file mode 100644 index 0000000..af7de13 --- /dev/null +++ b/resolver/main.go @@ -0,0 +1,168 @@ +package main + +import ( + "database/sql" + "fmt" + "io" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strconv" + "strings" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +var db *sql.DB + +func main() { + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + log.Fatal("DATABASE_URL is required") + } + + var err error + db, err = sql.Open("pgx", databaseURL) + if err != nil { + log.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(time.Minute * 5) + + if err := db.Ping(); err != nil { + log.Fatalf("failed to ping database: %v", err) + } + + http.HandleFunc("/", handleRequest) + + port := os.Getenv("PORT") + if port == "" { + port = "2020" + } + + log.Printf("[resolver] listening on :%s", port) + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatalf("[resolver] failed to listen: %v", err) + } +} + +func handleRequest(w http.ResponseWriter, r *http.Request) { + host := r.Host + if idx := strings.Index(host, ":"); idx != -1 { + host = host[:idx] + } + + log.Printf("[resolver] host=%s method=%s path=%s", host, r.Method, r.URL.RequestURI()) + + parts := strings.Split(host, ".") + if len(parts) < 3 { + http.Error(w, "invalid host", http.StatusBadRequest) + return + } + subdomain := parts[0] + + var tailscaleIp string + var port int + var nodeStatus string + err := db.QueryRow(` + SELECT n."tailscaleIp", i.port, n.status + FROM "Instance" i + JOIN "Node" n ON i."nodeId" = n.id + WHERE i.id = $1 + `, subdomain).Scan(&tailscaleIp, &port, &nodeStatus) + if err == sql.ErrNoRows { + log.Printf("[resolver] instance not found: %s", subdomain) + http.Error(w, "instance not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("[resolver] database error: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + if nodeStatus != "online" || tailscaleIp == "" { + log.Printf("[resolver] node offline for instance %s (status=%s ip=%s)", subdomain, nodeStatus, tailscaleIp) + http.Error(w, "node offline", http.StatusServiceUnavailable) + return + } + + upstream := fmt.Sprintf("http://%s:%d", tailscaleIp, port) + log.Printf("[resolver] proxying %s -> %s", host, upstream) + + proxyURL, err := url.Parse(upstream) + if err != nil { + log.Printf("[resolver] invalid upstream URL %s: %v", upstream, err) + http.Error(w, "invalid upstream", http.StatusInternalServerError) + return + } + + proxy := httputil.NewSingleHostReverseProxy(proxyURL) + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("[resolver] proxy error for %s: %v", upstream, err) + http.Error(w, "upstream unavailable", http.StatusBadGateway) + } + proxy.ModifyResponse = func(resp *http.Response) error { + // Do not follow 3xx redirects here; rewrite the Location header to HTTPS + // and let the browser follow it. Caddy will terminate TLS correctly. + location := resp.Header.Get("Location") + if location != "" { + newLocation := strings.ReplaceAll(location, fmt.Sprintf("http://%s", host), fmt.Sprintf("https://%s", host)) + newLocation = strings.ReplaceAll(newLocation, fmt.Sprintf("http://%s:%d", tailscaleIp, port), fmt.Sprintf("https://%s", host)) + if newLocation != location { + log.Printf("[resolver] rewriting Location (status=%d): %s -> %s", resp.StatusCode, location, newLocation) + resp.Header.Set("Location", newLocation) + } else if resp.StatusCode >= 300 && resp.StatusCode < 400 { + log.Printf("[resolver] passing through %d Location: %s", resp.StatusCode, location) + } + } else if resp.StatusCode >= 300 && resp.StatusCode < 400 { + log.Printf("[resolver] passing through %d without Location header", resp.StatusCode) + } + + // Rewrite http:// to https:// in HTML/CSS/JS bodies to avoid mixed content. + contentType := resp.Header.Get("Content-Type") + if strings.Contains(contentType, "text/html") || + strings.Contains(contentType, "text/css") || + strings.Contains(contentType, "application/javascript") { + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + resp.Body.Close() + + newBody := strings.ReplaceAll(string(body), fmt.Sprintf("http://%s", host), fmt.Sprintf("https://%s", host)) + newBody = strings.ReplaceAll(newBody, fmt.Sprintf("http://%s:%d", tailscaleIp, port), fmt.Sprintf("https://%s", host)) + + if newBody != string(body) { + log.Printf("[resolver] rewrote %d bytes in %s body", len(newBody)-len(body), contentType) + } + + resp.Body = io.NopCloser(strings.NewReader(newBody)) + resp.ContentLength = int64(len(newBody)) + resp.Header.Set("Content-Length", strconv.Itoa(len(newBody))) + } + + return nil + } + proxy.Director = func(req *http.Request) { + req.URL.Scheme = proxyURL.Scheme + req.URL.Host = proxyURL.Host + req.URL.Path = r.URL.Path + req.URL.RawQuery = r.URL.RawQuery + req.Host = host + req.Header.Set("X-Forwarded-Host", host) + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Port", "443") + if req.Header.Get("X-Forwarded-For") == "" { + req.Header.Set("X-Forwarded-For", r.RemoteAddr) + } + } + + proxy.ServeHTTP(w, r) +} diff --git a/server/app/api/download/route.ts b/server/app/api/download/route.ts index 435508e..ca8a670 100644 --- a/server/app/api/download/route.ts +++ b/server/app/api/download/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -const AGENT_VERSION = "0.2.7"; +const AGENT_VERSION = "0.3.0"; export async function GET() { return NextResponse.json({ diff --git a/server/app/api/instances/route.ts b/server/app/api/instances/route.ts index 71c35cf..18df410 100644 --- a/server/app/api/instances/route.ts +++ b/server/app/api/instances/route.ts @@ -42,9 +42,13 @@ export async function GET(req: NextRequest) { const publicUrl = domain ? `https://${inst.id}.${domain}` : null; + const localUrl = inst.node.tailscaleIp + ? `http://${inst.node.tailscaleIp}:${inst.port}` + : null; return { ...inst, publicUrl, + localUrl, }; }); @@ -70,7 +74,6 @@ export async function POST(req: NextRequest) { const domain = node?.student?.class.establishment?.domain; const publicDomain = domain ? `${instance.id}.${domain}` : "localhost"; const publicUrl = domain ? `https://${publicDomain}` : null; - const sent = sendToNode(nodeId, { action: "start", instanceId: instance.id, @@ -80,7 +83,7 @@ export async function POST(req: NextRequest) { .replace(/{PORT}/g, String(instance.port)) .replace(/{INSTANCE_ID}/g, instance.id) .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) - .replace(/{PUBLIC_DOMAIN}/g, publicDomain), + .replace(/{PUBLIC_DOMAIN}/g, "localhost"), }); if (!sent) { @@ -99,7 +102,6 @@ export async function PATCH(req: NextRequest) { const domain = instance.node.student?.class.establishment?.domain; const publicDomain = domain ? `${instance.id}.${domain}` : "localhost"; const publicUrl = domain ? `https://${publicDomain}` : null; - if (action === "stop") { sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id }); await prisma.instance.update({ where: { id }, data: { status: "stopped" } }); @@ -113,7 +115,7 @@ export async function PATCH(req: NextRequest) { .replace(/{PORT}/g, String(instance.port)) .replace(/{INSTANCE_ID}/g, instance.id) .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) - .replace(/{PUBLIC_DOMAIN}/g, publicDomain), + .replace(/{PUBLIC_DOMAIN}/g, "localhost"), }); if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } }); } else if (action === "reset") { @@ -126,7 +128,7 @@ export async function PATCH(req: NextRequest) { .replace(/{PORT}/g, String(instance.port)) .replace(/{INSTANCE_ID}/g, instance.id) .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) - .replace(/{PUBLIC_DOMAIN}/g, publicDomain), + .replace(/{PUBLIC_DOMAIN}/g, "localhost"), }); if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } }); } diff --git a/server/app/api/proxy/[[...path]]/route.ts b/server/app/api/proxy/[[...path]]/route.ts deleted file mode 100644 index 0fcaf50..0000000 --- a/server/app/api/proxy/[[...path]]/route.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; - -const MAIN_DOMAIN = process.env.MAIN_DOMAIN || "alfrednobel.edudeploy.com"; - -async function proxyRequest(req: NextRequest) { - const host = req.headers.get("host") || ""; - const cleanHost = host.split(":")[0]; - - if (!cleanHost.endsWith(`.${MAIN_DOMAIN}`)) { - return NextResponse.json({ error: "Invalid host" }, { status: 404 }); - } - - const subdomain = cleanHost.replace(`.${MAIN_DOMAIN}`, ""); - - const instance = await prisma.instance.findUnique({ - where: { id: subdomain }, - include: { node: true }, - }); - - if (!instance || !instance.node?.tailscaleIp) { - return NextResponse.json( - { error: "Instance not found or not connected" }, - { status: 404 } - ); - } - - const publicUrl = `https://${cleanHost}`; - const targetUrl = new URL(req.url); - // The middleware rewrites /foo to /api/proxy/foo; strip the prefix before forwarding - let pathname = targetUrl.pathname; - if (pathname.startsWith("/api/proxy")) { - pathname = pathname.slice("/api/proxy".length) || "/"; - } - const upstream = `http://${instance.node.tailscaleIp}:${instance.port}${pathname}${targetUrl.search}`; - - const headers = new Headers(req.headers); - headers.delete("host"); - headers.set("host", cleanHost); - headers.set("x-forwarded-host", cleanHost); - headers.set("x-forwarded-proto", "https"); - headers.set("x-forwarded-port", "443"); - headers.set("x-forwarded-for", req.headers.get("x-forwarded-for") || "unknown"); - - const needsBody = req.method !== "GET" && req.method !== "HEAD"; - const upstreamRes = await fetch(upstream, { - method: req.method, - headers, - body: needsBody ? (req.body as BodyInit) : undefined, - // Node.js fetch requires duplex when forwarding a request body stream - ...(needsBody ? { duplex: "half" } : {}), - redirect: "manual", - }); - - const responseHeaders = new Headers(upstreamRes.headers); - // Remove content-encoding because Next.js/fetch handles decompression automatically - responseHeaders.delete("content-encoding"); - responseHeaders.delete("content-length"); - - // Rewrite any header values that point to localhost or the internal Tailscale address - const localPatterns = [ - `http://${instance.node.tailscaleIp}:${instance.port}`, - `https://${instance.node.tailscaleIp}:${instance.port}`, - `http://localhost:${instance.port}`, - `https://localhost:${instance.port}`, - `http://localhost`, - `https://localhost`, - ]; - responseHeaders.forEach((value, key) => { - let newValue = value; - for (const pattern of localPatterns) { - newValue = newValue.replaceAll(pattern, publicUrl); - } - if (newValue !== value) { - responseHeaders.set(key, newValue); - } - }); - - // Sanitize Set-Cookie headers so sessions work through the public domain. - // WordPress may issue cookies for "localhost" or without Secure flag; fix it. - const setCookies = responseHeaders.getSetCookie(); - if (setCookies.length > 0) { - responseHeaders.delete("set-cookie"); - for (let cookie of setCookies) { - // Drop any Domain=... set for localhost/internal domains - cookie = cookie.replace(/;\s*Domain=[^;]+/gi, ""); - // Ensure Secure is present for HTTPS public URLs - if (!/;\s*Secure\b/i.test(cookie)) { - cookie += "; Secure"; - } - // Make sure cookies are sent on sub-domain navigations - if (!/;\s*SameSite\b/i.test(cookie)) { - cookie += "; SameSite=Lax"; - } - responseHeaders.append("set-cookie", cookie); - } - } - - const contentType = responseHeaders.get("content-type") || ""; - const shouldRewriteBody = - contentType.includes("text/html") || - contentType.includes("text/css") || - contentType.includes("application/javascript") || - contentType.includes("application/json"); - - if (!shouldRewriteBody) { - return new Response(upstreamRes.body, { - status: upstreamRes.status, - statusText: upstreamRes.statusText, - headers: responseHeaders, - }); - } - - // For text responses, rewrite localhost/internal URLs to the public URL. - // Also handle protocol-relative URLs that some WordPress plugins/themes use. - let body = await upstreamRes.text(); - const localBase = `http://${instance.node.tailscaleIp}:${instance.port}`; - const localBaseHttps = `https://${instance.node.tailscaleIp}:${instance.port}`; - const localLocalhostHttp = `http://localhost:${instance.port}`; - const localLocalhostHttps = `https://localhost:${instance.port}`; - const localLocalhostPlainHttp = `http://localhost`; - const localLocalhostPlainHttps = `https://localhost`; - const localLocalhostProtocolRelative = `//localhost`; - const localTailscaleProtocolRelative = `//${instance.node.tailscaleIp}:${instance.port}`; - - body = body - .replaceAll(localBase, publicUrl) - .replaceAll(localBaseHttps, publicUrl) - .replaceAll(localLocalhostHttp, publicUrl) - .replaceAll(localLocalhostHttps, publicUrl) - .replaceAll(localLocalhostPlainHttp, publicUrl) - .replaceAll(localLocalhostPlainHttps, publicUrl) - .replaceAll(localTailscaleProtocolRelative, publicUrl.replace(/^https?:/, "")) - .replaceAll(localLocalhostProtocolRelative, publicUrl.replace(/^https?:/, "")); - - return new Response(body, { - status: upstreamRes.status, - statusText: upstreamRes.statusText, - headers: responseHeaders, - }); -} - -export const GET = proxyRequest; -export const POST = proxyRequest; -export const PUT = proxyRequest; -export const PATCH = proxyRequest; -export const DELETE = proxyRequest; -export const HEAD = proxyRequest; -export const OPTIONS = proxyRequest; diff --git a/server/app/api/resolve/route.ts b/server/app/api/resolve/route.ts new file mode 100644 index 0000000..a254886 --- /dev/null +++ b/server/app/api/resolve/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const subdomain = searchParams.get("subdomain"); + + if (!subdomain) { + return NextResponse.json({ error: "subdomain required" }, { status: 400 }); + } + + const instance = await prisma.instance.findUnique({ + where: { id: subdomain }, + include: { node: true }, + }); + + if (!instance || !instance.node) { + return NextResponse.json({ error: "instance not found" }, { status: 404 }); + } + + if (instance.node.status !== "online" || !instance.node.tailscaleIp) { + return NextResponse.json({ error: "node offline" }, { status: 503 }); + } + + return NextResponse.json({ + upstream: `${instance.node.tailscaleIp}:${instance.port}`, + }); +} diff --git a/server/app/dashboard/download/page.tsx b/server/app/dashboard/download/page.tsx index 86b4065..11a812b 100644 --- a/server/app/dashboard/download/page.tsx +++ b/server/app/dashboard/download/page.tsx @@ -1,6 +1,6 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -const AGENT_VERSION = "0.2.7"; +const AGENT_VERSION = "0.3.0"; export const dynamic = "force-dynamic"; @@ -19,24 +19,6 @@ export default function DownloadPage() { Télécharger (.exe) - - - Linux - - -

Agent EduBox pour Linux (64 bits)

- Télécharger -
-
- - - macOS - - -

Agent EduBox pour macOS (Intel & Apple Silicon)

- Télécharger -
-
); diff --git a/server/app/dashboard/instances/page.tsx b/server/app/dashboard/instances/page.tsx index ef2a77e..f4f4d34 100644 --- a/server/app/dashboard/instances/page.tsx +++ b/server/app/dashboard/instances/page.tsx @@ -30,6 +30,7 @@ export default async function InstancesPage() { return { ...inst, publicUrl: domain ? `https://${inst.id}.${domain}` : null, + localUrl: inst.node.tailscaleIp ? `http://${inst.node.tailscaleIp}:${inst.port}` : null, }; }); @@ -53,6 +54,7 @@ export default async function InstancesPage() { Port Statut URL publique + Accès prof Actions @@ -70,6 +72,9 @@ export default async function InstancesPage() { {inst.publicUrl ? {inst.publicUrl} : "-"} + + {inst.localUrl ? {inst.localUrl} : "-"} + @@ -77,7 +82,7 @@ export default async function InstancesPage() { ))} {instances.length === 0 && ( - Aucune instance + Aucune instance )} diff --git a/server/middleware.ts b/server/middleware.ts index 3ef470e..207a106 100644 --- a/server/middleware.ts +++ b/server/middleware.ts @@ -1,28 +1,10 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -const MAIN_DOMAIN = process.env.MAIN_DOMAIN || "alfrednobel.edudeploy.com"; - -export function middleware(req: NextRequest) { - const host = req.headers.get("host") || ""; - const cleanHost = host.split(":")[0]; - - if (cleanHost === MAIN_DOMAIN || cleanHost === `www.${MAIN_DOMAIN}`) { - return NextResponse.next(); - } - - if (!cleanHost.endsWith(`.${MAIN_DOMAIN}`)) { - return NextResponse.next(); - } - - const pathname = req.nextUrl.pathname; - const search = req.nextUrl.search; - - // Rewrite to the internal proxy API while preserving the original host header - const rewriteUrl = new URL(`/api/proxy${pathname}${search}`, req.url); - return NextResponse.rewrite(rewriteUrl); +export function middleware(_req: NextRequest) { + return NextResponse.next(); } export const config = { - matcher: ["/((?!api/proxy|api/check-domain|_next|static|favicon.ico).*)",], + matcher: ["/((?!_next|static|favicon.ico).*)"], }; diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index 44ddf9c..04e7452 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -19,6 +19,9 @@ async function main() { }, }); + // Remove obsolete PrestaShop templates from previous seeds + await prisma.template.deleteMany({ where: { type: "prestashop" } }); + const templates = [ { name: "WordPress latest vierge", @@ -50,63 +53,20 @@ async function main() { dbPassword: "wordpress", dbRootPassword: "rootpassword", }, - { - name: "PrestaShop latest vierge", - type: "prestashop", - dockerImage: "prestashop/prestashop:latest", - dbImage: "mariadb:10.11", - dbName: "prestashop", - dbUser: "prestashop", - dbPassword: "prestashop", - dbRootPassword: "rootpassword", - }, - { - name: "PrestaShop 8.1 vierge", - type: "prestashop", - dockerImage: "prestashop/prestashop:8.1", - dbImage: "mariadb:10.11", - dbName: "prestashop", - dbUser: "prestashop", - dbPassword: "prestashop", - dbRootPassword: "rootpassword", - }, ]; for (const t of templates) { const dbHost = "db"; const dbPort = "3306"; - const isPrestaShop = t.type === "prestashop"; - const appEnv = isPrestaShop - ? ` DB_SERVER: ${dbHost} - DB_PORT: ${dbPort} - DB_NAME: ${t.dbName} - DB_USER: ${t.dbUser} - DB_PASSWD: ${t.dbPassword} - DB_PREFIX: ps_ - PS_DOMAIN: {PUBLIC_DOMAIN} - PS_SHOP_NAME: ${t.name} - PS_INSTALL_AUTO: "1" - PS_INSTALL_DB: "0" - PS_ENABLE_SSL: "1" - PS_LANGUAGE: fr - PS_COUNTRY: fr - ADMIN_MAIL: admin@edubox.local - ADMIN_PASSWD: EduboxPrestashop2024! - PS_FOLDER_ADMIN: admin - PS_FOLDER_INSTALL: install - PS_DEV_MODE: "1"` - : ` WORDPRESS_DB_HOST: ${dbHost}:${dbPort} + const appEnv = ` WORDPRESS_DB_HOST: ${dbHost}:${dbPort} WORDPRESS_DB_NAME: ${t.dbName} WORDPRESS_DB_USER: ${t.dbUser} WORDPRESS_DB_PASSWORD: ${t.dbPassword} WORDPRESS_DB_PREFIX: wp_ # No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`; - const appVolumes = isPrestaShop - ? ` volumes: - - app_data:/var/www/html` - : ` volumes: + const appVolumes = ` volumes: - app_data:/var/www/html - {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`; @@ -129,7 +89,7 @@ async function main() { app: image: ${t.dockerImage} ports: - - "127.0.0.1:{PORT}:80" + - "{PORT}:80" environment: ${appEnv} INSTANCE_ID: {INSTANCE_ID} diff --git a/server/public/edubox-agent-v0.2.8 b/server/public/edubox-agent-v0.2.8 new file mode 100755 index 0000000..eae6243 Binary files /dev/null and b/server/public/edubox-agent-v0.2.8 differ diff --git a/server/public/edubox-agent-v0.2.8-mac b/server/public/edubox-agent-v0.2.8-mac new file mode 100755 index 0000000..d9bcc12 Binary files /dev/null and b/server/public/edubox-agent-v0.2.8-mac differ diff --git a/server/public/edubox-agent-v0.3.0 b/server/public/edubox-agent-v0.3.0 new file mode 100755 index 0000000..a4a24e8 Binary files /dev/null and b/server/public/edubox-agent-v0.3.0 differ diff --git a/server/public/edubox-agent-v0.3.0-mac b/server/public/edubox-agent-v0.3.0-mac new file mode 100755 index 0000000..f3e9bfa Binary files /dev/null and b/server/public/edubox-agent-v0.3.0-mac differ