clean: suppression complète PrestaShop
This commit is contained in:
@@ -35,6 +35,16 @@ alfrednobel.edudeploy.com {
|
|||||||
tls {
|
tls {
|
||||||
on_demand
|
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 /api/websocket* server:3001
|
||||||
reverse_proxy server:3000
|
reverse_proxy server:3000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,3 +84,224 @@ Sur le PC étudiant (PowerShell) :
|
|||||||
podman ps --format "table {{.Names}}`t{{.Status}}`t{{.Ports}}"
|
podman ps --format "table {{.Names}}`t{{.Status}}`t{{.Ports}}"
|
||||||
podman logs -f cmqiiso9g0001mfap4wv7a690-app-1
|
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:<PORT>
|
||||||
|
curl -I https://<id>.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:<PORT>
|
||||||
|
curl -I https://<id>.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://<id>.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://<tailscale-ip>:<port>`). 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://<id>.alfrednobel.edudeploy.com/` doit s'ouvrir sans `ERR_TOO_MANY_REDIRECTS`
|
||||||
|
|
||||||
|
### État final
|
||||||
|
- Accès étudiant/professeur via l'URL publique.
|
||||||
|
- `localhost:<PORT>` 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://<public-domain>` en `https://<public-domain>`.
|
||||||
|
|
||||||
|
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://<id>.alfrednobel.edudeploy.com/` doit s'ouvrir sans `ERR_TOO_MANY_REDIRECTS`.
|
||||||
|
- L'accès `localhost:<PORT>` 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://<public-domain> 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://<public-domain>` 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 `//<domain>`).
|
||||||
|
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://<public-domain>/`, 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 <id>-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://<id>.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.
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
VERSION="0.2.7"
|
VERSION="0.3.0"
|
||||||
LDFLAGS="-X main.version=${VERSION}"
|
LDFLAGS="-X main.version=${VERSION}"
|
||||||
|
|
||||||
echo "Building EduBox Agent v${VERSION}..."
|
echo "Building EduBox Agent v${VERSION}..."
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
+3
-2
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -12,6 +11,8 @@ import (
|
|||||||
// version is injected at build time via -ldflags "-X main.version=X.Y.Z"
|
// version is injected at build time via -ldflags "-X main.version=X.Y.Z"
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
|
const AGENT_VERSION = "0.3.0"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
serverAddr = flag.String("server", "ws://localhost:3001", "Adresse WebSocket du serveur")
|
serverAddr = flag.String("server", "ws://localhost:3001", "Adresse WebSocket du serveur")
|
||||||
nodeID = flag.String("node-id", defaultNodeID(), "ID du nœud (défaut: hostname)")
|
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)
|
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 {
|
if *uiEnabled {
|
||||||
go startUI(*dataDir, *nodeID, *serverAddr)
|
go startUI(*dataDir, *nodeID, *serverAddr)
|
||||||
|
|||||||
+4
-4
@@ -166,16 +166,16 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
if err := saveActivation(dataDir, act); err != nil {
|
if err := saveActivation(dataDir, act); err != nil {
|
||||||
log.Printf("saveActivation error: %v", err)
|
log.Printf("saveActivation error: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Activated as %s", msg.StudentName)
|
log.Printf("Activated as %s", act.StudentName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notifyUI(map[string]interface{}{
|
notifyUI(map[string]interface{}{
|
||||||
"action": "activated",
|
"action": "activated",
|
||||||
"studentName": msg.StudentName,
|
"studentName": msg.StudentName,
|
||||||
})
|
})
|
||||||
case "registered":
|
case "registered":
|
||||||
// Server acknowledged our register message; nothing to do.
|
// Server acknowledged our register message; nothing to do.
|
||||||
return
|
return
|
||||||
case "activation_failed":
|
case "activation_failed":
|
||||||
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
|
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
|
||||||
notifyUI(map[string]interface{}{
|
notifyUI(map[string]interface{}{
|
||||||
|
|||||||
@@ -47,6 +47,25 @@ services:
|
|||||||
- edubox
|
- 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:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
container_name: edubox-caddy
|
container_name: edubox-caddy
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module resolver
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require github.com/jackc/pgx/v5 v5.7.1
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
const AGENT_VERSION = "0.2.7";
|
const AGENT_VERSION = "0.3.0";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -42,9 +42,13 @@ export async function GET(req: NextRequest) {
|
|||||||
const publicUrl = domain
|
const publicUrl = domain
|
||||||
? `https://${inst.id}.${domain}`
|
? `https://${inst.id}.${domain}`
|
||||||
: null;
|
: null;
|
||||||
|
const localUrl = inst.node.tailscaleIp
|
||||||
|
? `http://${inst.node.tailscaleIp}:${inst.port}`
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
...inst,
|
...inst,
|
||||||
publicUrl,
|
publicUrl,
|
||||||
|
localUrl,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,7 +74,6 @@ export async function POST(req: NextRequest) {
|
|||||||
const domain = node?.student?.class.establishment?.domain;
|
const domain = node?.student?.class.establishment?.domain;
|
||||||
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
||||||
const publicUrl = domain ? `https://${publicDomain}` : null;
|
const publicUrl = domain ? `https://${publicDomain}` : null;
|
||||||
|
|
||||||
const sent = sendToNode(nodeId, {
|
const sent = sendToNode(nodeId, {
|
||||||
action: "start",
|
action: "start",
|
||||||
instanceId: instance.id,
|
instanceId: instance.id,
|
||||||
@@ -80,7 +83,7 @@ export async function POST(req: NextRequest) {
|
|||||||
.replace(/{PORT}/g, String(instance.port))
|
.replace(/{PORT}/g, String(instance.port))
|
||||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
.replace(/{PUBLIC_DOMAIN}/g, publicDomain),
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
@@ -99,7 +102,6 @@ export async function PATCH(req: NextRequest) {
|
|||||||
const domain = instance.node.student?.class.establishment?.domain;
|
const domain = instance.node.student?.class.establishment?.domain;
|
||||||
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
||||||
const publicUrl = domain ? `https://${publicDomain}` : null;
|
const publicUrl = domain ? `https://${publicDomain}` : null;
|
||||||
|
|
||||||
if (action === "stop") {
|
if (action === "stop") {
|
||||||
sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
|
sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
|
||||||
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
|
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(/{PORT}/g, String(instance.port))
|
||||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
.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" } });
|
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||||
} else if (action === "reset") {
|
} else if (action === "reset") {
|
||||||
@@ -126,7 +128,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
.replace(/{PORT}/g, String(instance.port))
|
.replace(/{PORT}/g, String(instance.port))
|
||||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
.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" } });
|
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -19,24 +19,6 @@ export default function DownloadPage() {
|
|||||||
<a href={`/edubox-agent-v${AGENT_VERSION}.exe`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a>
|
<a href={`/edubox-agent-v${AGENT_VERSION}.exe`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Linux</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour Linux (64 bits)</p>
|
|
||||||
<a href={`/edubox-agent-v${AGENT_VERSION}`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger</a>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>macOS</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour macOS (Intel & Apple Silicon)</p>
|
|
||||||
<a href={`/edubox-agent-v${AGENT_VERSION}-mac`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger</a>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default async function InstancesPage() {
|
|||||||
return {
|
return {
|
||||||
...inst,
|
...inst,
|
||||||
publicUrl: domain ? `https://${inst.id}.${domain}` : null,
|
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() {
|
|||||||
<TableHead>Port</TableHead>
|
<TableHead>Port</TableHead>
|
||||||
<TableHead>Statut</TableHead>
|
<TableHead>Statut</TableHead>
|
||||||
<TableHead>URL publique</TableHead>
|
<TableHead>URL publique</TableHead>
|
||||||
|
<TableHead>Accès prof</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -70,6 +72,9 @@ export default async function InstancesPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
{inst.publicUrl ? <a href={inst.publicUrl} target="_blank" rel="noopener" className="text-blue-600 hover:underline">{inst.publicUrl}</a> : "-"}
|
{inst.publicUrl ? <a href={inst.publicUrl} target="_blank" rel="noopener" className="text-blue-600 hover:underline">{inst.publicUrl}</a> : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{inst.localUrl ? <a href={inst.localUrl} target="_blank" rel="noopener" className="text-blue-600 hover:underline">{inst.localUrl}</a> : "-"}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<InstanceActions instanceId={inst.id} status={inst.status} />
|
<InstanceActions instanceId={inst.id} status={inst.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -77,7 +82,7 @@ export default async function InstancesPage() {
|
|||||||
))}
|
))}
|
||||||
{instances.length === 0 && (
|
{instances.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="text-center text-muted-foreground">Aucune instance</TableCell>
|
<TableCell colSpan={9} className="text-center text-muted-foreground">Aucune instance</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
+3
-21
@@ -1,28 +1,10 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
const MAIN_DOMAIN = process.env.MAIN_DOMAIN || "alfrednobel.edudeploy.com";
|
export function middleware(_req: NextRequest) {
|
||||||
|
return NextResponse.next();
|
||||||
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 const config = {
|
export const config = {
|
||||||
matcher: ["/((?!api/proxy|api/check-domain|_next|static|favicon.ico).*)",],
|
matcher: ["/((?!_next|static|favicon.ico).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-46
@@ -19,6 +19,9 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove obsolete PrestaShop templates from previous seeds
|
||||||
|
await prisma.template.deleteMany({ where: { type: "prestashop" } });
|
||||||
|
|
||||||
const templates = [
|
const templates = [
|
||||||
{
|
{
|
||||||
name: "WordPress latest vierge",
|
name: "WordPress latest vierge",
|
||||||
@@ -50,63 +53,20 @@ async function main() {
|
|||||||
dbPassword: "wordpress",
|
dbPassword: "wordpress",
|
||||||
dbRootPassword: "rootpassword",
|
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) {
|
for (const t of templates) {
|
||||||
const dbHost = "db";
|
const dbHost = "db";
|
||||||
const dbPort = "3306";
|
const dbPort = "3306";
|
||||||
const isPrestaShop = t.type === "prestashop";
|
|
||||||
|
|
||||||
const appEnv = isPrestaShop
|
const appEnv = ` WORDPRESS_DB_HOST: ${dbHost}:${dbPort}
|
||||||
? ` 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}
|
|
||||||
WORDPRESS_DB_NAME: ${t.dbName}
|
WORDPRESS_DB_NAME: ${t.dbName}
|
||||||
WORDPRESS_DB_USER: ${t.dbUser}
|
WORDPRESS_DB_USER: ${t.dbUser}
|
||||||
WORDPRESS_DB_PASSWORD: ${t.dbPassword}
|
WORDPRESS_DB_PASSWORD: ${t.dbPassword}
|
||||||
WORDPRESS_DB_PREFIX: wp_
|
WORDPRESS_DB_PREFIX: wp_
|
||||||
# No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`;
|
# No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`;
|
||||||
|
|
||||||
const appVolumes = isPrestaShop
|
const appVolumes = ` volumes:
|
||||||
? ` volumes:
|
|
||||||
- app_data:/var/www/html`
|
|
||||||
: ` volumes:
|
|
||||||
- app_data:/var/www/html
|
- app_data:/var/www/html
|
||||||
- {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`;
|
- {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:
|
app:
|
||||||
image: ${t.dockerImage}
|
image: ${t.dockerImage}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:{PORT}:80"
|
- "{PORT}:80"
|
||||||
environment:
|
environment:
|
||||||
${appEnv}
|
${appEnv}
|
||||||
INSTANCE_ID: {INSTANCE_ID}
|
INSTANCE_ID: {INSTANCE_ID}
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user