Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc61404271 | |||
| 3c519629d2 | |||
| 0f07a2d2a3 | |||
| d2c3edea2f | |||
| 41929be34c | |||
| adab165274 | |||
| 33d89c66c0 | |||
| e946b22a42 | |||
| cf8b66340a | |||
| a414f03a59 | |||
| 331187e9b5 | |||
| 281c7c9a19 | |||
| 3a3e3ed202 | |||
| d090f67bff | |||
| 03b2f1267d | |||
| 73b561ed33 | |||
| 8a9deb8ebc | |||
| df77caf64a | |||
| b4344e9d66 | |||
| 124543d658 | |||
| dd49993157 | |||
| 20baf3878f | |||
| 2b31ec7258 | |||
| f2d184976b | |||
| 2d57857221 | |||
| 5c4c9f3531 | |||
| c1d1038a52 | |||
| a292bebf89 | |||
| 49a820bb8a | |||
| a94b7526f7 | |||
| 5cb70cd307 | |||
| 86b06dc417 | |||
| b383b11ae2 | |||
| 2feea2d063 | |||
| 852171cc59 | |||
| 2dc9ba7b55 | |||
| c9ca22bafc | |||
| 47ca55adbb | |||
| 6485019785 | |||
| 7ecc2abe0a |
@@ -5,6 +5,11 @@ NEXTAUTH_URL=http://localhost
|
|||||||
SUPERADMIN_EMAIL=admin@edudeploy.fr
|
SUPERADMIN_EMAIL=admin@edudeploy.fr
|
||||||
SUPERADMIN_PASSWORD=CHANGE_ME
|
SUPERADMIN_PASSWORD=CHANGE_ME
|
||||||
HEADSCALE_URL=http://headscale:8080
|
HEADSCALE_URL=http://headscale:8080
|
||||||
|
# Legacy reusable pre-auth key (kept for manual/debug setups).
|
||||||
HEADSCALE_AUTH_KEY=CHANGE_ME
|
HEADSCALE_AUTH_KEY=CHANGE_ME
|
||||||
|
# Headscale API key used by the server to generate ephemeral pre-auth keys.
|
||||||
|
HEADSCALE_API_KEY=CHANGE_ME
|
||||||
|
HEADSCALE_RESOLVER_AUTH_KEY=CHANGE_ME
|
||||||
|
INTERNAL_API_KEY=CHANGE_ME
|
||||||
GITEA_URL=http://gitea:3000
|
GITEA_URL=http://gitea:3000
|
||||||
GITEA_TOKEN=CHANGE_ME
|
GITEA_TOKEN=CHANGE_ME
|
||||||
|
|||||||
+16
@@ -2,6 +2,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.next/
|
.next/
|
||||||
*.log
|
*.log
|
||||||
|
studioE5-data/
|
||||||
edubox-data/
|
edubox-data/
|
||||||
dist/
|
dist/
|
||||||
coverage/
|
coverage/
|
||||||
@@ -9,7 +10,22 @@ coverage/
|
|||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
agent/studioE5-agent
|
||||||
|
agent/studioE5-agent.exe
|
||||||
|
agent/studioE5-agent-mac
|
||||||
|
agent/studioE5-agent-v*
|
||||||
agent/edubox-agent
|
agent/edubox-agent
|
||||||
agent/edubox-agent.exe
|
agent/edubox-agent.exe
|
||||||
agent/edubox-agent-mac
|
agent/edubox-agent-mac
|
||||||
|
agent/edubox-agent-v*
|
||||||
|
server/public/studioE5-agent*
|
||||||
|
server/public/edubox-agent*
|
||||||
agent/ui/*.go.html
|
agent/ui/*.go.html
|
||||||
|
headscale/*.sqlite*
|
||||||
|
headscale/*.key
|
||||||
|
headscale/*.state
|
||||||
|
agent/resolv.conf
|
||||||
|
agent/tailscale-bin/
|
||||||
|
agent/studioE5-agent-test
|
||||||
|
agent/.cache-go/
|
||||||
|
server/tsconfig.tsbuildinfo
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
# Analyse : Intégration de PrestaShop dans EduBox
|
||||||
|
|
||||||
|
## 1. Architecture actuelle d'EduBox (vue d'ensemble)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Serveur cloud (ex: alfrednobel.edudeploy.com) │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Caddy │──▶│ Next.js │──▶│ Resolver Go │──▶│ PostgreSQL │ │
|
||||||
|
│ │ TLS on-demand│ │ (dashboard) │ │ (proxy inst.)│ │ (état) │ │
|
||||||
|
│ └─────────────┘ └──────────────┘ └──────────────┘ └─────────────┘ │
|
||||||
|
│ │ │ WebSocket 3001 │
|
||||||
|
│ │ ▼ │
|
||||||
|
│ │ Agent EduBox (Go) sur PC étudiant via Tailscale/Headscale │
|
||||||
|
│ │ ┌─────────────┐ │
|
||||||
|
│ └─────────────▶│ WordPress │ (mu-plugin edubox-public-url.php) │
|
||||||
|
│ │ Docker/Podman │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flux d'une requête publique
|
||||||
|
|
||||||
|
1. Le navigateur demande `https://<instance-id>.alfrednobel.edudeploy.com/`
|
||||||
|
2. Caddy termine TLS et transmet au resolver Go (`:443 → resolver:2020`)
|
||||||
|
3. Le resolver lit PostgreSQL pour trouver le nœud (Tailscale IP) et le port de l'instance
|
||||||
|
4. Le resolver fait du reverse proxy HTTP vers `http://<tailscale-ip>:<port>`
|
||||||
|
5. Le resolver ajoute les headers `X-Forwarded-Proto: https`, `X-Forwarded-Host`, `X-Forwarded-Port: 443`
|
||||||
|
6. L'agent Go sur le PC étudiant a lancé le conteneur WordPress sur `localhost:<port>` (bind `{PORT}:80`)
|
||||||
|
7. WordPress reçoit la requête en HTTP interne mais le **mu-plugin** `edubox-public-url.php` détecte `HTTP_HOST`/`X-Forwarded-Proto` et redéfinit `WP_HOME`/`WP_SITEURL` à la volée.
|
||||||
|
|
||||||
|
### Pourquoi WordPress fonctionne
|
||||||
|
|
||||||
|
- WordPress permet de redéfinir `WP_HOME` et `WP_SITEURL` via `wp-config.php` ou un mu-plugin.
|
||||||
|
- Le mu-plugin intercepte chaque requête et calcule l'URL publique depuis les headers proxy.
|
||||||
|
- WordPress accepte d'être servi depuis plusieurs domaines simultanément (localhost + sous-domaine public).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Pourquoi PrestaShop est différent (et plus difficile)
|
||||||
|
|
||||||
|
### 2.1 Le domaine est stocké en base de données
|
||||||
|
|
||||||
|
PrestaShop enregistre l'URL canonique à plusieurs endroits :
|
||||||
|
|
||||||
|
- `ps_configuration` :
|
||||||
|
- `PS_SHOP_DOMAIN`
|
||||||
|
- `PS_SHOP_DOMAIN_SSL`
|
||||||
|
- `PS_SSL_ENABLED`
|
||||||
|
- `ps_shop_url` :
|
||||||
|
- `domain`
|
||||||
|
- `domain_ssl`
|
||||||
|
|
||||||
|
Ces valeurs sont écrites **une seule fois lors de l'installation automatique** (`PS_INSTALL_AUTO=1`) via `index_cli.php --domain=<PS_DOMAIN>`.
|
||||||
|
|
||||||
|
### 2.2 Redirections canoniques strictes
|
||||||
|
|
||||||
|
Dans `classes/controller/FrontController.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
$match_url = Tools::getCurrentUrlProtocolPrefix() . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
|
||||||
|
if (!preg_match('/^' . Tools::pRegexp(rawurldecode($canonical_url), '/') . '([&?].*)?$/', $match_url)) {
|
||||||
|
// ... redirect vers $canonical_url
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Si l'URL demandée ne correspond pas exactement à l'URL canonique stockée, PrestaShop envoie une 301/302.
|
||||||
|
- L'URL canonique est générée avec `Tools::getShopDomainSsl(true)` qui combine :
|
||||||
|
- `ShopUrl::getMainShopDomainSSL()` (domaine figé en base)
|
||||||
|
- `Configuration::get('PS_SSL_ENABLED')` (protocole figé en base)
|
||||||
|
|
||||||
|
### 2.3 Détection SSL
|
||||||
|
|
||||||
|
`Tools::usingSecureMode()` truste bien `HTTP_X_FORWARDED_PROTO: https` (testé et confirmé dans `classes/Tools.php`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
|
||||||
|
return Tools::strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) == 'https';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Donc le resolver transmet correctement le protocole. Le problème ne vient **pas** d'un défaut de PrestaShop sur la détection SSL en lui-même.
|
||||||
|
|
||||||
|
### 2.4 Le vrai problème : l'URL canonique est figée et ne correspond pas à l'URL demandée
|
||||||
|
|
||||||
|
Scénario avec `PS_ENABLE_SSL=1` et `PS_DOMAIN=<id>.alfrednobel.edudeploy.com` :
|
||||||
|
|
||||||
|
- URL demandée : `https://<id>.alfrednobel.edudeploy.com/`
|
||||||
|
- Requête interne : `http://<tailscale-ip>:<port>/` avec `X-Forwarded-Proto: https`
|
||||||
|
- PrestaShop détecte : secure mode = true
|
||||||
|
- URL canonique : `https://<id>.alfrednobel.edudeploy.com/`
|
||||||
|
- `match_url` : `https://<id>.alfrednobol.edudeploy.com/`
|
||||||
|
- Devrait correspondre… mais dans les tests, une boucle a quand même eu lieu.
|
||||||
|
|
||||||
|
Causes probables de la boucle :
|
||||||
|
|
||||||
|
1. **Port dans `HTTP_HOST`** : le resolver envoie `req.Host = host` (sans `:443`), mais Apache/PHP peut parfois enrichir `HTTP_HOST` avec le port interne (`<id>.domain:80`) selon la configuration.
|
||||||
|
2. **Cache navigateur** : une 301 est mise en cache, masquant le fix.
|
||||||
|
3. **Apache `.htaccess` généré par PrestaShop** : contient des règles de rewrite qui peuvent interagir avec le proxy (ex: rediriger `/` vers `index.php` avec un protocole différent).
|
||||||
|
4. **Cookie/SESSION** : la session PHP ou un cookie sécurisé peut forcer une reconnexion/redirection.
|
||||||
|
|
||||||
|
### 2.5 Accès `localhost:<port>` redirige vers le domaine public
|
||||||
|
|
||||||
|
C'est le comportement normal de PrestaShop quand `PS_DOMAIN` est le domaine public. Pour EduBox, ce n'est pas bloquant si l'accès étudiant se fait via l'URL publique ou via le Tailscale IP du professeur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Pistes de solutions
|
||||||
|
|
||||||
|
### Solution A — Utiliser le mécanisme natif `PS_HANDLE_DYNAMIC_DOMAIN=1` (recommandée à tester en premier)
|
||||||
|
|
||||||
|
L'image Docker officielle `prestashop/prestashop` embarque un script `docker_updt_ps_domains.php` qui est copié à la racine et utilisé comme `DirectoryIndex` quand `PS_HANDLE_DYNAMIC_DOMAIN=1`.
|
||||||
|
|
||||||
|
Ce script fait :
|
||||||
|
|
||||||
|
```php
|
||||||
|
$domain = Tools::getHttpHost();
|
||||||
|
$url = ShopUrl::getShopUrls(Configuration::get('PS_SHOP_DEFAULT'))->where('main', '=', 1)->getFirst();
|
||||||
|
if ($url) {
|
||||||
|
$url->domain = $domain;
|
||||||
|
$url->domain_ssl = $domain;
|
||||||
|
$url->save();
|
||||||
|
Configuration::updateValue('PS_SHOP_DOMAIN', $domain);
|
||||||
|
Configuration::updateValue('PS_SHOP_DOMAIN_SSL', $domain);
|
||||||
|
Tools::generateHtaccess();
|
||||||
|
Tools::generateRobotsFile();
|
||||||
|
Tools::clearSmartyCache();
|
||||||
|
Media::clearCache();
|
||||||
|
}
|
||||||
|
Tools::redirect("index.php");
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantages**
|
||||||
|
- Mécanisme officiel, pas de module à maintenir.
|
||||||
|
- Met à jour le domaine dynamiquement à chaque requête sur `/`.
|
||||||
|
|
||||||
|
**Inconvénients**
|
||||||
|
- Exécution PHP + requêtes SQL + régénération `.htaccess` à chaque requête sur `/` → latence perceptible.
|
||||||
|
- Ne gère pas nativement le HTTPS vs HTTP (domain_ssl = domain, sans tenir compte de X-Forwarded-Proto).
|
||||||
|
- Nécessite d'être combiné avec une confiance des headers proxy.
|
||||||
|
|
||||||
|
**Implémentation proposée**
|
||||||
|
|
||||||
|
Dans le `composeConfig` du template PrestaShop :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
PS_DOMAIN: {PUBLIC_DOMAIN}
|
||||||
|
PS_ENABLE_SSL: "1"
|
||||||
|
PS_HANDLE_DYNAMIC_DOMAIN: "1"
|
||||||
|
PS_INSTALL_AUTO: "1"
|
||||||
|
PS_INSTALL_DB: "0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Et monter un fichier Apache `proxy.conf` dans `/etc/apache2/conf-enabled/` :
|
||||||
|
|
||||||
|
```apache
|
||||||
|
SetEnvIf X-Forwarded-Proto https HTTPS=on
|
||||||
|
SetEnvIf X-Forwarded-Proto https SERVER_PORT=443
|
||||||
|
SetEnvIf X-Forwarded-Host ^(.+)$ HTTP_HOST=$1
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela permet à `Tools::usingSecureMode()` de retourner `true` et à `Tools::getHttpHost()` de retourner le bon host public.
|
||||||
|
|
||||||
|
**Risques**
|
||||||
|
- Boucle possible si `PS_ENABLE_SSL=1` mais Apache ne reçoit pas `HTTPS=on` (d'où l'importance du `SetEnvIf`).
|
||||||
|
- Performance : le `docker_updt_ps_domains.php` est exécuté à chaque hit sur `/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Solution B — Créer un module/override PrestaShop "EduBox Public URL" (équivalent du mu-plugin WordPress)
|
||||||
|
|
||||||
|
Créer un override `override/classes/Configuration.php` (ou un module hooké tôt) qui surcharge `Configuration::get()` pour les clés `PS_SHOP_DOMAIN`, `PS_SHOP_DOMAIN_SSL` et `PS_SSL_ENABLED`.
|
||||||
|
|
||||||
|
Exemple d'override :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
class Configuration extends ConfigurationCore {
|
||||||
|
public static function get($id_lang = null, $id_shop_group = null, $id_shop = null) {
|
||||||
|
$key = func_num_args() > 0 && is_string(func_get_arg(0)) ? func_get_arg(0) : $id_lang;
|
||||||
|
if ($key === 'PS_SHOP_DOMAIN' || $key === 'PS_SHOP_DOMAIN_SSL') {
|
||||||
|
return Tools::getHttpHost(false, false, true); // host sans port
|
||||||
|
}
|
||||||
|
if ($key === 'PS_SSL_ENABLED') {
|
||||||
|
return Tools::usingSecureMode() ? '1' : '0';
|
||||||
|
}
|
||||||
|
return call_user_func_array(['ConfigurationCore', 'get'], func_get_args());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantages**
|
||||||
|
- Le plus proche du mu-plugin WordPress : aucune réécriture de réponses, pas de latence sur `/`.
|
||||||
|
- Une fois l'override chargé, toutes les URLs générées par PrestaShop utilisent le domaine de la requête courante.
|
||||||
|
|
||||||
|
**Inconvénients / pièges**
|
||||||
|
- L'override doit être pris en compte par l'autoloader. Sous PrestaShop 8, il faut vider `app/cache/prod/class_index.php`, `var/cache/*` et régénérer l'index avec `PrestaShop\Autoload\PrestashopAutoload::getInstance()->generateIndex()`.
|
||||||
|
- L'image Docker officielle embarque un `class_index.php` pré-généré qu'il faut invalider.
|
||||||
|
- Si l'installation se fait avec un override déjà présent, PrestaShop peut ne pas l'activer immédiatement.
|
||||||
|
|
||||||
|
**Implémentation proposée**
|
||||||
|
|
||||||
|
1. Créer `agent/psplugins/Configuration.php` (embarqué dans l'agent).
|
||||||
|
2. Au `start`/`reset` d'une instance PrestaShop, l'agent :
|
||||||
|
- copie l'override dans `/var/www/html/override/classes/Configuration.php`
|
||||||
|
- vide les caches (`rm -rf app/cache/* var/cache/*`)
|
||||||
|
- régénère l'autoloader (`php -r "require 'config/config.inc.php'; PrestaShop\Autoload\PrestashopAutoload::getInstance()->generateIndex();"`)
|
||||||
|
3. Ajouter le montage de l'override dans le `composeConfig` PrestaShop via un placeholder `{PS_OVERRIDES_DIR}`.
|
||||||
|
4. Garder la config Apache `SetEnvIf X-Forwarded-Proto https HTTPS=on` pour la détection SSL.
|
||||||
|
|
||||||
|
**Risques**
|
||||||
|
- Fragilité des versions : l'override dépend de la signature de `ConfigurationCore::get()` qui peut changer.
|
||||||
|
- Nécessite de bien gérer les caches à chaque redémarrage du conteneur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Solution C — Installation "localhost" + réécriture complète par le proxy
|
||||||
|
|
||||||
|
Installer PrestaShop avec `PS_DOMAIN=localhost:{PORT}` et `PS_ENABLE_SSL=0`.
|
||||||
|
Le conteneur vit en HTTP interne. Le resolver réécrit :
|
||||||
|
|
||||||
|
- Le header `Location` : `http://localhost:<port>/...` → `https://<id>.domain/...`
|
||||||
|
- Le body HTML/CSS/JS : toutes les occurrences de `http://localhost:<port>` et `//localhost:<port>`
|
||||||
|
|
||||||
|
C'est l'approche "WordPress-like".
|
||||||
|
|
||||||
|
**Avantages**
|
||||||
|
- Pas besoin de modifier PrestaShop.
|
||||||
|
- Le conteneur est totalement agnostique du domaine public.
|
||||||
|
|
||||||
|
**Inconvénients**
|
||||||
|
- PrestaShop génère énormément d'URLs absolues (assets, liens admin, webhooks, modules, API). La réécriture body n'est jamais exhaustive.
|
||||||
|
- Les requêtes AJAX/fetch peuvent pointer vers `localhost:<port>` et échouer côté client.
|
||||||
|
- Le back-office (`/admin`) génère des redirections complexes.
|
||||||
|
- Très fragile sur le long terme.
|
||||||
|
|
||||||
|
**Verdict** : **non recommandée** pour PrestaShop (contrairement à WordPress).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Solution D — Image Docker PrestaShop personnalisée (patch durable)
|
||||||
|
|
||||||
|
Créer un `Dockerfile` dérivé de `prestashop/prestashop:8.1` qui :
|
||||||
|
|
||||||
|
1. Applique un patch à `classes/Configuration.php` ou `classes/Tools.php` pour supporter nativement `X-Forwarded-Host`/`X-Forwarded-Proto`.
|
||||||
|
2. Ou embarque directement l'override EduBox + un script d'init qui vide les caches.
|
||||||
|
3. Configure Apache pour trust les headers proxy.
|
||||||
|
|
||||||
|
**Avantages**
|
||||||
|
- Totalement reproductible : pas d'opération manuelle sur le conteneur en cours de vie.
|
||||||
|
- Peut être versionnée et testée indépendamment.
|
||||||
|
|
||||||
|
**Inconvénients**
|
||||||
|
- Nécessite de maintenir et publier une image Docker.
|
||||||
|
- Ajoute une étape de build CI/CD.
|
||||||
|
- Si PrestaShop sort une nouvelle version, il faut rebaser le patch.
|
||||||
|
|
||||||
|
**Implémentation possible**
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM prestashop/prestashop:8.1
|
||||||
|
COPY edubox-proxy.conf /etc/apache2/conf-enabled/
|
||||||
|
COPY edubox-override/ /var/www/html/override/
|
||||||
|
RUN chown -R www-data:www-data /var/www/html/override
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Solution E — Proxy "intelligent" avec substitution à la volée
|
||||||
|
|
||||||
|
Remplacer le resolver Go par (ou ajouter devant) un proxy qui fait de la substitution HTML/CSS/JS beaucoup plus fine, par exemple :
|
||||||
|
|
||||||
|
- Nginx avec `sub_filter`
|
||||||
|
- Apache `mod_substitute`
|
||||||
|
- Un middleware Node.js type `http-proxy-middleware` avec `selfHandleResponse`
|
||||||
|
|
||||||
|
**Avantages**
|
||||||
|
- Peut corriger les liens absolus que PrestaShop génère.
|
||||||
|
|
||||||
|
**Inconvénients**
|
||||||
|
- Même problème que la solution C : impossible d'être exhaustif.
|
||||||
|
- Ajoute de la latence et de la complexité.
|
||||||
|
- Peut casser le JS (si on remplace des chaînes dans du code minifié).
|
||||||
|
|
||||||
|
**Verdict** : complément possible, mais pas solution principale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Solution F — Désactiver les redirections canoniques et SSL forcé
|
||||||
|
|
||||||
|
Forcer PrestaShop à ne plus faire de redirections canoniques (`PS_CANONICAL_REDIRECT=0`) et à désactiver SSL partout (`PS_SSL_ENABLED=0`, `PS_SSL_ENABLED_EVERYWHERE=0`).
|
||||||
|
|
||||||
|
**Avantages**
|
||||||
|
- Élimine les boucles de redirection.
|
||||||
|
|
||||||
|
**Inconvénients**
|
||||||
|
- Nécessite un accès au back-office pour modifier les paramètres.
|
||||||
|
- Les liens générés restent en `http://` et pointent vers le domaine d'installation.
|
||||||
|
- Mauvaise expérience utilisateur (avertissements navigateur, mixed-content si certains assets passent en http).
|
||||||
|
|
||||||
|
**Verdict** : à éviter en production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Recommandation
|
||||||
|
|
||||||
|
Je recommande une **combinaison des solutions A et B**, testée méthodiquement :
|
||||||
|
|
||||||
|
### Phase 1 — Solution A (test rapide)
|
||||||
|
|
||||||
|
1. Réintroduire un template PrestaShop 8.1 dans `server/prisma/seed.ts` avec :
|
||||||
|
- `PS_DOMAIN: {PUBLIC_DOMAIN}`
|
||||||
|
- `PS_ENABLE_SSL: "1"`
|
||||||
|
- `PS_HANDLE_DYNAMIC_DOMAIN: "1"`
|
||||||
|
- montage d'une config Apache `proxy.conf` pour trust `X-Forwarded-Proto`
|
||||||
|
2. Rebuilder le serveur, relancer le seed.
|
||||||
|
3. Créer une nouvelle instance PrestaShop (pas de reset d'ancienne instance).
|
||||||
|
4. Tester avec `curl -v -L --max-redirs 5 https://<id>.domain/` et en navigation privée.
|
||||||
|
|
||||||
|
### Phase 2 — Solution B (solution cible)
|
||||||
|
|
||||||
|
Si la solution A est trop lente ou instable, passer à l'override `Configuration.php` :
|
||||||
|
|
||||||
|
1. Créer `agent/psplugins/Configuration.php`.
|
||||||
|
2. Modifier l'agent pour copier l'override et vider/régénérer les caches au démarrage d'une instance PrestaShop.
|
||||||
|
3. Ajouter le montage `{PS_OVERRIDES_DIR}` dans le compose template.
|
||||||
|
4. Conserver la config Apache `SetEnvIf X-Forwarded-Proto`.
|
||||||
|
5. Tester avec `localhost:<port>` ET `https://<id>.domain/`.
|
||||||
|
|
||||||
|
### Phase 3 (optionnel) — Solution D
|
||||||
|
|
||||||
|
Si les solutions A/B sont trop fragiles d'une version de PrestaShop à l'autre, créer une image Docker dérivée patchée et la référencer dans le template.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Points d'attention pour l'implémentation
|
||||||
|
|
||||||
|
### Headers proxy
|
||||||
|
|
||||||
|
Le resolver Go transmet déjà les bons headers. Vérifier qu'aucun middleware Next.js ne les modifie (le middleware actuel ne fait que `NextResponse.next()`).
|
||||||
|
|
||||||
|
### Binding de port
|
||||||
|
|
||||||
|
Le commit `dd49993` a changé le binding de `127.0.0.1:{PORT}:80` à `{PORT}:80`. Cela signifie que le conteneur PrestaShop est accessible depuis n'importe quelle interface sur le PC étudiant. C'est nécessaire car le Tailscale proxy de l'agent écoute sur toutes les interfaces. **Il ne faut pas revenir à `127.0.0.1` sauf si on change la chaîne de proxy.**
|
||||||
|
|
||||||
|
### Cache navigateur
|
||||||
|
|
||||||
|
Toujours tester en navigation privée et avec `curl -v -L --max-redirs 5` pour éviter les 301 mises en cache.
|
||||||
|
|
||||||
|
### Vider les caches PrestaShop
|
||||||
|
|
||||||
|
À chaque changement de domaine/config, il faut vider :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf /var/www/html/app/cache/*
|
||||||
|
rm -rf /var/www/html/var/cache/*
|
||||||
|
php -r "require '/var/www/html/config/config.inc.php'; PrestaShop\Autoload\PrestashopAutoload::getInstance()->generateIndex();"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs utiles
|
||||||
|
|
||||||
|
Sur le PC étudiant :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman logs -f <id>-app-1
|
||||||
|
podman exec <id>-app-1 cat /var/log/apache2/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Sur le serveur :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs -f edubox-resolver
|
||||||
|
docker logs -f edubox-caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Synthèse comparative
|
||||||
|
|
||||||
|
| Solution | Complexité | Robustesse | Perf. | Maintien | Recommandation |
|
||||||
|
|----------|-----------|------------|-------|----------|----------------|
|
||||||
|
| A — `PS_HANDLE_DYNAMIC_DOMAIN` | Faible | Moyenne | Moyenne (latence `/`) | Faible | **À tester en premier** |
|
||||||
|
| B — Override `Configuration` | Moyenne | Forte | Bonne | Moyen | **Solution cible** |
|
||||||
|
| C — localhost + rewrite proxy | Moyenne | Faible | Bonne | Faible | Non recommandée |
|
||||||
|
| D — Image Docker patchée | Forte | Très forte | Bonne | Fort | Option long terme |
|
||||||
|
| E — Proxy substitution | Moyenne | Faible | Moyenne | Faible | Complément seulement |
|
||||||
|
| F — Désactiver SSL/canonical | Faible | Faible | Bonne | Faible | À éviter |
|
||||||
@@ -1,9 +1,65 @@
|
|||||||
{
|
{
|
||||||
auto_https off
|
email admin@edudeploy.com
|
||||||
|
on_demand_tls {
|
||||||
|
ask http://server:3000/api/check-domain
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
reverse_proxy /api/websocket* server:3001
|
route /studioE5-agent* {
|
||||||
reverse_proxy /gitea* gitea:3000
|
file_server {
|
||||||
|
root /usr/share/caddy/agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
route /api/websocket* {
|
||||||
|
reverse_proxy server:3001
|
||||||
|
}
|
||||||
|
route /api/check-domain* {
|
||||||
|
reverse_proxy server:3000
|
||||||
|
}
|
||||||
|
route * {
|
||||||
|
redir https://{host}{uri} permanent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headscale.studioe5.edudeploy.com:443 {
|
||||||
|
reverse_proxy headscale:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
gitea.alfrednobel.edudeploy.com {
|
||||||
|
tls admin@edudeploy.com
|
||||||
|
reverse_proxy 151.80.60.98:3001
|
||||||
|
}
|
||||||
|
|
||||||
|
studioe5.edudeploy.com:443 {
|
||||||
|
route /studioE5-agent* {
|
||||||
|
file_server {
|
||||||
|
root /usr/share/caddy/agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reverse_proxy /api/websocket* server:3001
|
||||||
|
reverse_proxy server:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
:443 {
|
||||||
|
tls {
|
||||||
|
on_demand
|
||||||
|
}
|
||||||
|
route /studioE5-agent* {
|
||||||
|
file_server {
|
||||||
|
root /usr/share/caddy/agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@instance {
|
||||||
|
not host studioe5.edudeploy.com
|
||||||
|
not host headscale.studioe5.edudeploy.com
|
||||||
|
host *.studioe5.edudeploy.com
|
||||||
|
}
|
||||||
|
handle @instance {
|
||||||
|
reverse_proxy resolver:2020 {
|
||||||
|
header_up Host {host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reverse_proxy /api/websocket* server:3001
|
||||||
reverse_proxy server:3000
|
reverse_proxy server:3000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# Notes de session EduBox - 17 juin 2026
|
||||||
|
|
||||||
|
## ✅ Ce qui fonctionne
|
||||||
|
|
||||||
|
- Agent EduBox v0.2.7 déployé avec binaires Linux/Mac/Windows.
|
||||||
|
- Connexion WordPress via URL publique fonctionne (wp-admin inclus).
|
||||||
|
- Proxy Next.js gère correctement les POST (duplex: half), les cookies, les headers X-Forwarded.
|
||||||
|
- Certificats TLS on-demand Caddy fonctionnent pour les sous-domaines d'instances.
|
||||||
|
- Reset d'instance corrige : le serveur envoie maintenant le `ComposeConfig` complet.
|
||||||
|
- `PUBLIC_DOMAIN` inclut le sous-domaine de l'instance (`<id>.<domain>`).
|
||||||
|
|
||||||
|
## ⚠️ Problème en cours : PrestaShop
|
||||||
|
|
||||||
|
L'installation automatique de PrestaShop 8.1 se termine avec succès, mais l'accès via l'URL publique ou localhost provoque des redirections incorrectes :
|
||||||
|
|
||||||
|
- `curl -I http://localhost:8080` retourne `302 Location: http://cmqiiso9g0001mfap4wv7a690.alfrednobel.edudeploy.com/`
|
||||||
|
- `curl -I https://cmqiiso9g0001mfap4wv7a690.alfrednobel.edudeploy.com/` retourne `302` vers la même URL (boucle infinie).
|
||||||
|
- `curl -I https://cmqiiso9g0001mfap4wv7a690.alfrednobel.edudeploy.com/index.php` retourne `200 OK`.
|
||||||
|
|
||||||
|
### Hypothèses / pistes
|
||||||
|
|
||||||
|
1. PrestaShop stocke le domaine shop en base (`ps_configuration` : `PS_SHOP_DOMAIN`, `PS_SHOP_DOMAIN_SSL`).
|
||||||
|
- Avec `PS_ENABLE_SSL=1`, il est configuré avec l'URL publique en HTTPS.
|
||||||
|
- Quand on accède via `localhost`, il redirige vers l'URL publique.
|
||||||
|
2. L'accès à `/` redirige vers l'URL canonique, ce qui crée une boucle via le proxy.
|
||||||
|
3. Il faudrait un équivalent PrestaShop du mu-plugin WordPress pour forcer le domaine à la volée selon `HTTP_HOST`.
|
||||||
|
|
||||||
|
### Template PrestaShop actuel (seed.ts)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
DB_SERVER: db
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_NAME: prestashop
|
||||||
|
DB_USER: prestashop
|
||||||
|
DB_PASSWD: prestashop
|
||||||
|
DB_PREFIX: ps_
|
||||||
|
PS_DOMAIN: {PUBLIC_DOMAIN}
|
||||||
|
PS_SHOP_NAME: PrestaShop 8.1 vierge
|
||||||
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Prochaines étapes suggérées
|
||||||
|
|
||||||
|
1. **Option rapide** : accepter que l'accès étudiant se fasse via l'URL publique. Vérifier que `https://<id>.alfrednobel.edudeploy.com/index.php` fonctionne et que la boutique est utilisable.
|
||||||
|
2. **Option propre** : développer un module/override PrestaShop qui :
|
||||||
|
- Détecte `HTTP_HOST` / `X-Forwarded-Host`.
|
||||||
|
- Met à jour `PS_SHOP_DOMAIN` et `PS_SHOP_DOMAIN_SSL` dynamiquement.
|
||||||
|
- Permet l'accès via localhost ET via l'URL publique.
|
||||||
|
3. **Alternative** : installer PrestaShop avec `PS_DOMAIN=localhost:8080` et laisser le proxy réécrire les URLs (mais c'est complexe).
|
||||||
|
|
||||||
|
## 📁 Instance de test actuelle
|
||||||
|
|
||||||
|
- ID : `cmqiiso9g0001mfap4wv7a690`
|
||||||
|
- Type : PrestaShop 8.1 vierge
|
||||||
|
- Port : 8080
|
||||||
|
- Nœud : OMEGA-GAMER (PC étudiant de Yacine)
|
||||||
|
|
||||||
|
## 📝 Commits récents
|
||||||
|
|
||||||
|
- `5c4c9f3` fix(server/api): envoi du ComposeConfig lors d'un reset d'instance
|
||||||
|
- `2d57857` fix(server/api): PUBLIC_DOMAIN inclut le sous-domaine de l'instance
|
||||||
|
- `2b31ec7` fix(seed): PrestaShop installé avec SSL et sans dynamic domain
|
||||||
|
|
||||||
|
## 🔧 Commandes utiles pour reprendre
|
||||||
|
|
||||||
|
Sur le serveur :
|
||||||
|
```bash
|
||||||
|
cd /opt/edubox
|
||||||
|
docker logs edubox-server --tail 50
|
||||||
|
docker exec edubox-server sh -c "cd /app && npx prisma db seed"
|
||||||
|
```
|
||||||
|
|
||||||
|
Sur le PC étudiant (PowerShell) :
|
||||||
|
```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:<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.
|
||||||
@@ -0,0 +1,961 @@
|
|||||||
|
# Suivi – VPN on-demand studioE5 (client A)
|
||||||
|
|
||||||
|
## ✅ Ce qui fonctionne
|
||||||
|
|
||||||
|
1. **Agent standalone (mode console / systray)**
|
||||||
|
- Exécutable : `agent/studioE5-agent`
|
||||||
|
- Config lu depuis `<data-dir>/studioE5-config.json`
|
||||||
|
- Mode console : `-no-tray`
|
||||||
|
|
||||||
|
2. **VPN on-demand dans l'agent**
|
||||||
|
- L’agent ne démarre plus Tailscale au boot.
|
||||||
|
- Le VPN se lance automatiquement à la création/démarrage d’une instance, ou sur commande serveur.
|
||||||
|
- Implémentation basée sur les binaires `tailscaled` + `tailscale up` (pas `tsnet`, car `tsnet` ne loguait pas automatiquement avec une authkey sur un state vierge).
|
||||||
|
|
||||||
|
3. **Commandes serveur → agent**
|
||||||
|
- Endpoint de test : `POST /api/internal/send-to-node`
|
||||||
|
- Actions supportées : `start_vpn`, `stop_vpn`, `start`, `stop`, `reset`, `delete`.
|
||||||
|
|
||||||
|
4. **Resolver/serveur dans le tailnet studioe5**
|
||||||
|
- Service `resolver-vpn` (conteneur Tailscale) partage le netns du `resolver`.
|
||||||
|
- Le resolver peut joindre les IPs Tailscale des nodes (`ping 100.64.0.x` OK).
|
||||||
|
|
||||||
|
5. **Instance WordPress démarrée avec succès**
|
||||||
|
- Le resolver a renvoyé une 302 WordPress via `http://resolver:2020/`.
|
||||||
|
|
||||||
|
6. **Activation zéro-config de l’agent (modèle commercialisable)**
|
||||||
|
- L’agent démarre sans `headscale_url` ni `headscale_auth_key`.
|
||||||
|
- L’utilisateur entre seulement un code d’activation.
|
||||||
|
- Le serveur envoie la config Headscale, l’agent la sauvegarde et démarre le VPN automatiquement.
|
||||||
|
|
||||||
|
## ✅ Blocage levé
|
||||||
|
|
||||||
|
**Rate limit Let’s Encrypt pour `edudeploy.com` est levé.**
|
||||||
|
Le 2026-06-23 vers 09:35 UTC, Caddy a pu obtenir un certificat Let’s Encrypt pour `test-wp-001.studioe5.edudeploy.com` :
|
||||||
|
```
|
||||||
|
tls.obtain: certificate obtained successfully identifier=test-wp-001.studioe5.edudeploy.com issuer=acme-v02.api.letsencrypt.org-directory
|
||||||
|
```
|
||||||
|
|
||||||
|
Le flux complet HTTPS public est désormais validé :
|
||||||
|
```bash
|
||||||
|
curl -sS -I -L https://test-wp-001.studioe5.edudeploy.com/
|
||||||
|
# => HTTP/2 302 -> HTTP/2 200 (WordPress install.php)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le DNS wildcard `*.studioe5.edudeploy.com` est en place. Caddy utilise toujours `tls { on_demand }` et émet un certificat par sous-domaine d’instance.
|
||||||
|
|
||||||
|
## 🎯 Validation du flux HTTPS public
|
||||||
|
|
||||||
|
Le 2026-06-23 09:39 UTC, le flux complet a été validé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client (HTTPS) → Caddy (:443) → resolver (:2020) → Tailnet (100.64.0.8) → agent → WordPress (:8001)
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat :
|
||||||
|
```bash
|
||||||
|
$ curl -sS -I -L https://test-wp-001.studioe5.edudeploy.com/
|
||||||
|
HTTP/2 302
|
||||||
|
location: https://test-wp-001.studioe5.edudeploy.com/wp-admin/install.php
|
||||||
|
...
|
||||||
|
HTTP/2 200
|
||||||
|
```
|
||||||
|
|
||||||
|
- Certificat Let’s Encrypt obtenu automatiquement par Caddy (`tls { on_demand }`).
|
||||||
|
- Le resolver réécrit les en-têtes `Location` et le contenu HTML pour passer de `http://` à `https://`.
|
||||||
|
|
||||||
|
## 📝 Template WordPress prêt à l’emploi
|
||||||
|
|
||||||
|
Un nouveau template `wordpress-ready-wordpress-latest` a été créé et validé le 2026-06-26. Il fournit un WordPress déjà initialisé en français, prêt à l’usage en classe ou en examen.
|
||||||
|
|
||||||
|
### Contenu du template
|
||||||
|
|
||||||
|
| Élément | Valeur / État |
|
||||||
|
|---|---|
|
||||||
|
| Langue | **Français** (`fr_FR`) |
|
||||||
|
| Titre du site | **Mon site wordpress** |
|
||||||
|
| Compte administrateur | **admin / admin** |
|
||||||
|
| Thème actif | **Astra** |
|
||||||
|
| Spectra | installé et **actif** |
|
||||||
|
| Yoast SEO | installé mais **inactif** |
|
||||||
|
| Mises à jour automatiques | **désactivées** (core, plugins, thèmes) |
|
||||||
|
| DNS conteneur | `8.8.8.8` + `1.1.1.1` pour permettre l’accès à `api.wordpress.org` |
|
||||||
|
|
||||||
|
### Architecture technique
|
||||||
|
|
||||||
|
- Le modèle `Template` de Prisma dispose d’un nouveau champ `initScript` (`TEXT?`).
|
||||||
|
- Le seed génère le template avec :
|
||||||
|
- une section `dns` dans le service `app` du `docker-compose.yml` ;
|
||||||
|
- un service sidecar `wp-init` (image `wordpress:cli`) exécutant le script d’initialisation.
|
||||||
|
- L’agent écrit le script `wp-init.sh` dans le dossier de l’instance au démarrage.
|
||||||
|
- Le conteneur `wp-init` attend que WordPress soit prêt, puis exécute WP-CLI en tant que `www-data`.
|
||||||
|
- Un fichier flag `.studioe5-init-done` évite de réinitialiser l’instance à chaque redémarrage.
|
||||||
|
|
||||||
|
### Fichiers modifiés / ajoutés
|
||||||
|
|
||||||
|
- `server/prisma/schema.prisma` – champ `initScript` sur `Template`.
|
||||||
|
- `server/prisma/seed.ts` – génération du template `wordpress-ready-wordpress-latest`.
|
||||||
|
- `server/templates/wordpress-ready/wp-init.sh` – script d’initialisation WP-CLI.
|
||||||
|
- `server/app/api/instances/route.ts` – envoi de `initScript` à l’agent avec remplacement des placeholders.
|
||||||
|
- `agent/websocket.go` – réception et transmission de `InitScript`.
|
||||||
|
- `agent/docker.go` – écriture du script dans le dossier instance (`writeInitScript`).
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
Instance de test créée via l’API (`cmqv03a6v0001vg8zrpe8zqfy`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ curl -sS -I -L https://cmqv03a6v0001vg8zrpe8zqfy.studioe5.edudeploy.com/
|
||||||
|
HTTP/2 200
|
||||||
|
```
|
||||||
|
|
||||||
|
- Page d’accueil en français, titre **« Mon site wordpress »**.
|
||||||
|
- Connexion admin `/wp-login.php` avec **admin / admin** fonctionnelle.
|
||||||
|
- Tableau de bord en français.
|
||||||
|
- Plugins : Spectra actif, Yoast SEO inactif.
|
||||||
|
- `wp-config.php` contient les constantes de désactivation des mises à jour automatiques.
|
||||||
|
|
||||||
|
Les instances de test ont été nettoyées après validation.
|
||||||
|
|
||||||
|
### Template versionné WordPress 7.0.0
|
||||||
|
|
||||||
|
Un second template `wordpress-ready-wordpress-7.0.0-php8.3` a été ajouté pour figer WordPress sur la version **7.0.0** (PHP 8.3) au lieu de `latest`. Cela garantit que tous les postes déploient exactement la même version, sans dépendre du cache local de `wordpress:latest`.
|
||||||
|
|
||||||
|
| Template | Image Docker |
|
||||||
|
|---|---|
|
||||||
|
| `wordpress-ready-wordpress-latest` | `wordpress:latest` |
|
||||||
|
| `wordpress-ready-wordpress-7.0.0-php8.3` | `wordpress:7.0.0-php8.3` |
|
||||||
|
|
||||||
|
## 📁 Fichiers modifiés (non exhaustif)
|
||||||
|
|
||||||
|
- `agent/tailscale.go` – lancement `tailscaled` + `tailscale up`, gestion start/stop/status.
|
||||||
|
- `agent/websocket.go` – handlers `start_vpn` / `stop_vpn`, `ensureTailscale()`.
|
||||||
|
- `agent/docker.go` – remplacement des placeholders `{PORT}` et `{INSTANCE_ID}` dans les compose.
|
||||||
|
- `docker-compose.yml` – ajout du sidecar `resolver-vpn`, suppression des `cap_add`/`ip route` obsolètes sur `server`/`resolver`.
|
||||||
|
- `Caddyfile` – configuration on-demand TLS pour les instances.
|
||||||
|
- `.env` – clé pré-auth Headscale mise à jour (clé réutilisable).
|
||||||
|
|
||||||
|
## 🧪 Tests / environnement de test actuel
|
||||||
|
|
||||||
|
Agent de test lancé en arrière-plan :
|
||||||
|
- data-dir : `/tmp/studioe5-test-clienta`
|
||||||
|
- node-id : `vps-8fc665eb`
|
||||||
|
- tailnet IP actuelle : `100.64.0.8`
|
||||||
|
- PID : voir `/tmp/studioe5-test-clienta/agent.pid` (relancé le 2026-06-26 11:53 UTC avec l’agent v0.3.5 corrigé)
|
||||||
|
|
||||||
|
Instance de test créée :
|
||||||
|
- ID : `test-wp-001`
|
||||||
|
- Node : `vps-8fc665eb`
|
||||||
|
- Port : `8001`
|
||||||
|
- Template : `wordpress-wordpress-latest`
|
||||||
|
- État : WordPress répond sur `http://127.0.0.1:8001` **et en HTTPS public sur `https://test-wp-001.studioe5.edudeploy.com/`**.
|
||||||
|
|
||||||
|
## 🪟 Fix agent Windows v0.3.1
|
||||||
|
|
||||||
|
Problème rencontré sur le PC de test (`OMEGA-GAMER-dc166b1a`) :
|
||||||
|
- Le nœud apparaissait `online` dans le dashboard mais sans IP Tailscale.
|
||||||
|
- `tailscale.exe ip -4` retournait une erreur de connexion au socket local.
|
||||||
|
|
||||||
|
Cause racine :
|
||||||
|
- L’agent lançait `tailscaled` avec `--socket=<fichier>.sock`, mais **Tailscale sur Windows utilise des named pipes** (`\\.\pipe\...`), pas des sockets Unix.
|
||||||
|
- De plus, les commandes `podman`/`docker`/`tailscale` ouvraient une fenêtre console à chaque exécution.
|
||||||
|
|
||||||
|
Corrections apportées (`agent/tailscale.go`, `agent/docker.go`, `agent/instance.go`, `agent/systray.go`, `agent/ui.go`, `agent/main.go`) :
|
||||||
|
- Sur Windows, utilisation de la named pipe `\\.\pipe\studioe5-tailscaled`.
|
||||||
|
- Application de `hideWindow` à tous les processus enfants (Tailscale, Podman, Docker, ouverture navigateur, redémarrage agent).
|
||||||
|
- Redirection des logs agent vers `<data-dir>/agent.log` et des logs `tailscaled` vers `<data-dir>/tailscale/tailscaled.log`.
|
||||||
|
- Suppression de `--operator=root` sur Windows (non pertinent).
|
||||||
|
- Ajout de `--unattended` au `tailscale up` sur Windows pour que le daemon reste connecté après la déconnexion du client CLI.
|
||||||
|
- Correction du chemin `dataDir` passé à `startTailscale` (évitait un double dossier `tailscale/tailscale`).
|
||||||
|
|
||||||
|
Validation manuelle sur Windows :
|
||||||
|
```powershell
|
||||||
|
.\tailscaled.exe --state="C:\...\data\tailscale.state" --socket="\\.\pipe\studioe5-tailscaled" --tun=userspace-networking
|
||||||
|
.\tailscale.exe --socket="\\.\pipe\studioe5-tailscaled" status # => Logged out (NeedsLogin)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🪟 Agent v0.3.5 – forwarding entrant Windows + UI locale + cycle de vie
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
|
||||||
|
Sur Windows, Tailscale en `userspace-networking` ne forwarde pas automatiquement les connexions entrantes du Tailnet vers `localhost`. Résultat : les URLs publiques retournaient une erreur 502/timeout, bien que l’agent soit `online`.
|
||||||
|
|
||||||
|
Logs caractéristiques :
|
||||||
|
```text
|
||||||
|
client -> backend close connection: close tcp 100.64.0.12:8080->100.64.0.11:xxxxx: endpoint not connected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution : `tailscale serve` automatique
|
||||||
|
|
||||||
|
L’agent configure automatiquement un proxy TCP pour chaque instance démarrée :
|
||||||
|
```powershell
|
||||||
|
tailscale serve --bg --tcp=<port> tcp://localhost:<port>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Action agent | Commande Tailscale |
|
||||||
|
|--------------|--------------------|
|
||||||
|
| Démarrage d’instance | `serve --bg --tcp=<port> tcp://localhost:<port>` |
|
||||||
|
| Arrêt d’instance | `serve --bg --tcp=<port> off` |
|
||||||
|
| Suppression d’instance | `serve --bg --tcp=<port> off` |
|
||||||
|
| Redémarrage de l’agent | reconfiguration pour les instances déjà `running` |
|
||||||
|
|
||||||
|
Fichiers modifiés : `agent/tailscale.go`, `agent/websocket.go`, `agent/main.go`, `agent/ui.go`.
|
||||||
|
|
||||||
|
### UI locale modernisée
|
||||||
|
|
||||||
|
- Tableau de bord avec indicateurs de service.
|
||||||
|
- Liste des applications avec badges de statut.
|
||||||
|
- Boutons d’action par instance : **Démarrer**, **Arrêter**, **Redémarrer**, **Supprimer**.
|
||||||
|
- Panneau de logs et diagnostic intégré.
|
||||||
|
- Panneau de configuration (URL serveur, Headscale, node ID).
|
||||||
|
|
||||||
|
### Cycle de vie des instances
|
||||||
|
|
||||||
|
- **Arrêter** → `docker compose stop` (volumes conservés).
|
||||||
|
- **Démarrer** → `docker compose start` (ou `up -d` la première fois).
|
||||||
|
- **Redémarrer** → `docker compose down -v` + recréation (données remises à zéro).
|
||||||
|
- **Supprimer** → `docker compose down -v` + suppression des fichiers.
|
||||||
|
- À la fermeture de l’agent, les instances en cours sont arrêtées proprement (`stop`) et le serveur est notifié (`instance_stopped`).
|
||||||
|
|
||||||
|
### Démarrage du VPN après activation
|
||||||
|
|
||||||
|
L’agent redémarre `tailscaled` automatiquement au lancement, même si la clé pré-auth a déjà été utilisée. Il se base sur l’état persistant `tailscaled.state` (`tailscale up` sans `--authkey`).
|
||||||
|
|
||||||
|
### Téléchargement
|
||||||
|
|
||||||
|
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5-windows.zip`
|
||||||
|
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5.exe`
|
||||||
|
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5`
|
||||||
|
|
||||||
|
## 🪟 Agent v0.3.6 – recover() dans les goroutines de démarrage d’instance
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
|
||||||
|
Lors de la création d’une instance depuis le dashboard vers certains agents (notamment Windows), l’agent s’arrêtait brutalement. Le `recover()` présent dans `handleMessage` ne capturait pas le panic car celui-ci survenait dans les goroutines lancées par `go handleStartInstance(...)`.
|
||||||
|
|
||||||
|
### Corrections apportées
|
||||||
|
|
||||||
|
- Ajout d’un `defer recover()` dans `handleStartInstance` ; en cas de panic, l’instance passe en statut `error` et un message `instance_error` est envoyé au serveur.
|
||||||
|
- Ajout d’un `defer recover()` dans toutes les goroutines critiques du WebSocket :
|
||||||
|
- `start_vpn`
|
||||||
|
- `stop_vpn`
|
||||||
|
- `start`
|
||||||
|
- `reset`
|
||||||
|
- `startTailscaleAndReport`
|
||||||
|
- cleanup au shutdown
|
||||||
|
- Ajout de logs de traçage au début de `handleStartInstance` (`instance`, `type`, `port`, `dataDir`, `initScriptLen`).
|
||||||
|
|
||||||
|
### Téléchargement
|
||||||
|
|
||||||
|
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6-windows.zip`
|
||||||
|
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6.exe`
|
||||||
|
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6`
|
||||||
|
|
||||||
|
### Redeploiement
|
||||||
|
|
||||||
|
- Agent rebuildé en v0.3.6 pour Windows et Linux.
|
||||||
|
- Binaires versionnés copiés dans `server/public/`.
|
||||||
|
- Page `/dashboard/download` mise à jour vers la v0.3.6.
|
||||||
|
- Serveur rebuildé et redémarré.
|
||||||
|
|
||||||
|
## 🪟 Agent v0.3.7 – recover() dans les notifications UI
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
|
||||||
|
L’agent continuait de s’arrêter brutalement lors de la création d’une instance depuis le dashboard. Le crash survenait juste après les logs `Start instance ...` et `notifyUI: broadcasting to ...`, sans laisser de trace de panic. Cela pointait vers une panique dans les goroutines de notification UI ou dans l’écriture des logs vers les clients UI locaux.
|
||||||
|
|
||||||
|
### Corrections apportées
|
||||||
|
|
||||||
|
- Ajout d’un `defer recover()` dans `notifyUI` pour chaque goroutine de notification.
|
||||||
|
- Ajout d’un `defer recover()` dans `sendUILog` (logs diffusés aux clients UI).
|
||||||
|
- Ajout d’un `defer recover()` dans `broadcastUI` (messages diffusés aux clients UI).
|
||||||
|
|
||||||
|
### Téléchargement
|
||||||
|
|
||||||
|
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7-windows.zip`
|
||||||
|
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7.exe`
|
||||||
|
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7`
|
||||||
|
|
||||||
|
## 🪟 Agent v0.3.8 – DNS automatique pour Podman machine (Windows/macOS)
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
|
||||||
|
Après correction du crash, l’agent Windows avec Podman échouait au `docker compose up` avec :
|
||||||
|
```text
|
||||||
|
lookup registry-1.docker.io: Temporary failure in name resolution
|
||||||
|
```
|
||||||
|
La VM Podman machine n’avait pas de DNS fonctionnel, ce qui empêchait le téléchargement des images Docker. Le DNS des conteneurs (`dns: 8.8.8.8` dans le compose) résout le problème à l’intérieur des conteneurs, mais pas pour le pull d’images par Podman machine.
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
|
||||||
|
L’agent configure automatiquement le DNS des machines Podman en cours d’exécution au démarrage :
|
||||||
|
- Détection de Podman sur Windows/macOS.
|
||||||
|
- Liste des machines Podman (`podman machine list --format json`).
|
||||||
|
- Pour chaque machine `running`, exécution de :
|
||||||
|
```bash
|
||||||
|
podman machine ssh <name> sudo sh -c 'echo nameserver 8.8.8.8 > /etc/resolv.conf && echo nameserver 1.1.1.1 >> /etc/resolv.conf'
|
||||||
|
```
|
||||||
|
|
||||||
|
Fichier ajouté : `agent/podman.go`. Appel depuis `agent/main.go` au démarrage.
|
||||||
|
|
||||||
|
### Téléchargement
|
||||||
|
|
||||||
|
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.8-windows.zip`
|
||||||
|
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.8.exe`
|
||||||
|
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.8`
|
||||||
|
|
||||||
|
## 🐛 Fix synchronisation agent / dashboard
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
|
||||||
|
Le statut affiché dans le dashboard pouvait diverger de l’état réel de l’agent :
|
||||||
|
- Après un **Arrêter** lancé depuis le dashboard, l’instance restait affichée comme elle l’était avant, ou disparaissait avec perte des données.
|
||||||
|
- Après une **Suppression**, l’instance n’était pas retirée de la liste.
|
||||||
|
|
||||||
|
### Causes racines
|
||||||
|
|
||||||
|
1. **Action `stop` du dashboard envoyée comme `delete` à l’agent** (`server/app/api/instances/route.ts`).
|
||||||
|
L’agent exécutait alors `docker compose down -v` + suppression des fichiers, c’est-à-dire une suppression réelle, tout en marquant l’instance `stopped` en base.
|
||||||
|
2. **L’agent ne confirmait pas les actions serveur** (`agent/websocket.go`).
|
||||||
|
Les handlers `stop` et `delete` ne renvoyaient jamais les messages `instance_stopped` / `instance_deleted` au serveur ; seule l’UI locale le faisait.
|
||||||
|
3. **Le handler `stop` de l’agent utilisait `dockerComposeDown`** au lieu de `dockerComposeStop`, ne respectant pas le cycle de vie documenté (arrêt = conteneurs et volumes conservés).
|
||||||
|
|
||||||
|
### Corrections apportées
|
||||||
|
|
||||||
|
| Fichier | Changement |
|
||||||
|
|---------|------------|
|
||||||
|
| `server/app/api/instances/route.ts` | L’action dashboard `stop` envoie désormais `action: "stop"` à l’agent (et non plus `"delete"`). |
|
||||||
|
| `agent/websocket.go` | Le cas `stop` utilise `dockerComposeStop`, puis envoie `instance_stopped` au serveur. Le cas `delete` envoie `instance_deleted` au serveur. |
|
||||||
|
| `server/lib/websocket.ts` | Utilisation de `updateMany`/`deleteMany` pour ignorer silencieusement les messages d’instances déjà absentes/supprimées (évite les erreurs Prisma en double suppression). |
|
||||||
|
|
||||||
|
### Résultat
|
||||||
|
|
||||||
|
Le dashboard reflète désormais l’état réel après une action serveur-initiée, dès le rechargement de la page. Le cycle de vie respecte la sémantique attendue :
|
||||||
|
- **Arrêter** : `docker compose stop` → statut `stopped`.
|
||||||
|
- **Démarrer** : `docker compose up -d` → statut `running`.
|
||||||
|
- **Redémarrer** : `docker compose down -v` + recréation.
|
||||||
|
- **Supprimer** : `docker compose down -v` + suppression fichiers.
|
||||||
|
|
||||||
|
### Redeploiement effectué le 2026-06-26
|
||||||
|
|
||||||
|
- **Agent rebuildé** en v0.3.5 (`agent/studioE5-agent`, `.exe`, `.zip` et `server/public/` mis à jour).
|
||||||
|
- **Serveur rebuildé et redémarré** (`docker compose up -d --build server`) pour intégrer les corrections TypeScript.
|
||||||
|
- **Page `/dashboard/download` mise à jour** : passage à la version 0.3.5 et ajout des liens Windows (.exe, .zip) et Linux.
|
||||||
|
- **Corrections défensives agent** après signalement d’arrêt brutal lors d’actions dashboard :
|
||||||
|
- `sendMessage` exécuté de manière asynchrone (`go`) dans les handlers `stop`, `delete`, `stop_vpn` et cleanup, pour ne pas bloquer la boucle de lecture WebSocket.
|
||||||
|
- Ajout d’un `recover` dans `handleMessage` pour capturer d’éventuels panics sans tuer l’agent.
|
||||||
|
- Correction du cleanup `main.go` : modification de `inst[id].Status` (et non de la copie locale `info`).
|
||||||
|
- **Agent de test Linux relancé** (PID dans `/tmp/studioe5-test-clienta/agent.pid`).
|
||||||
|
- **Agents clients** : il faut redémarrer l’agent sur chaque poste, ou télécharger à nouveau le binaire v0.3.5 depuis le dashboard pour Windows.
|
||||||
|
|
||||||
|
## 🛠️ Commandes utiles pour reprendre
|
||||||
|
|
||||||
|
### Voir l’agent de test
|
||||||
|
```bash
|
||||||
|
pgrep -a studioe5-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relancer l’agent de test (si besoin)
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/studioe5-test-clienta
|
||||||
|
cat > /tmp/studioe5-test-clienta/studioE5-config.json <<EOF
|
||||||
|
{
|
||||||
|
"server": "wss://studioe5.edudeploy.com/api/websocket",
|
||||||
|
"headscale_url": "https://headscale.studioe5.edudeploy.com",
|
||||||
|
"headscale_auth_key": "$(grep HEADSCALE_AUTH_KEY /opt/studioe5-client-a/.env | cut -d= -f2)",
|
||||||
|
"node_id": "vps-8fc665eb",
|
||||||
|
"data_dir": "/tmp/studioe5-test-clienta"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
cd /opt/studioe5-client-a/agent
|
||||||
|
./studioE5-agent -no-tray -data-dir /tmp/studioe5-test-clienta
|
||||||
|
```
|
||||||
|
|
||||||
|
### Démarrer le VPN manuellement
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST https://studioe5.edudeploy.com/api/internal/send-to-node \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"nodeId":"vps-8fc665eb","message":{"action":"start_vpn"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Voir les nodes Headscale
|
||||||
|
```bash
|
||||||
|
cd /opt/studioe5-client-a
|
||||||
|
docker compose exec -T headscale headscale nodes list studioe5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tester le resolver (depuis Caddy)
|
||||||
|
```bash
|
||||||
|
cd /opt/studioe5-client-a
|
||||||
|
docker exec studioe5-caddy curl -sS -I -H "Host: test-wp-001.studioe5.edudeploy.com" http://resolver:2020/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tester en HTTPS public (dès que la limite sera levée)
|
||||||
|
```bash
|
||||||
|
curl -sS -I -L https://test-wp-001.studioe5.edudeploy.com/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 Flux complet testé via l’API web
|
||||||
|
|
||||||
|
Test réalisé le 2026-06-23 en utilisant le compte superadmin :
|
||||||
|
|
||||||
|
1. **Authentification NextAuth** sur `/api/auth/callback/credentials`.
|
||||||
|
2. **Création d’instance** via `POST /api/instances` :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodeId": "vps-8fc665eb",
|
||||||
|
"templateId": "wordpress-wordpress-latest",
|
||||||
|
"port": 8002
|
||||||
|
}
|
||||||
|
```
|
||||||
|
→ Instance créée : `cmqqgrur20001lw67t2bdgzkg`.
|
||||||
|
3. Le serveur a automatiquement envoyé l’action `start` au node via WebSocket.
|
||||||
|
4. L’agent a démarré le VPN (si besoin), écrit le compose et a lancé les conteneurs WordPress.
|
||||||
|
5. Caddy a obtenu un certificat Let’s Encrypt pour `cmqqgrur20001lw67t2bdgzkg.studioe5.edudeploy.com`.
|
||||||
|
6. **Validation HTTPS** :
|
||||||
|
```bash
|
||||||
|
curl -sS -I -L https://cmqqgrur20001lw67t2bdgzkg.studioe5.edudeploy.com/
|
||||||
|
# => HTTP/2 302 -> HTTP/2 200 (WordPress install.php)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le flux `UI → API → WebSocket → agent → Docker → VPN → Caddy → HTTPS public` est fonctionnel.
|
||||||
|
|
||||||
|
## 💻 Téléchargement de l’agent
|
||||||
|
|
||||||
|
L’agent est servi par Caddy depuis le dossier `agent/` monté dans le conteneur Caddy (`./agent:/usr/share/caddy/agent`).
|
||||||
|
|
||||||
|
### Binaires disponibles
|
||||||
|
|
||||||
|
- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.10-windows.zip`
|
||||||
|
- Contient `studioE5-agent.exe` + `tailscale-bin/windows/` (`tailscale.exe`, `tailscaled.exe`, `wintun.dll`) + `README-Windows.txt`.
|
||||||
|
- **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.10.exe`
|
||||||
|
- Nécessite d’avoir installé Tailscale Windows séparément ou d’avoir les binaires dans `tailscale-bin/windows/`.
|
||||||
|
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.10`
|
||||||
|
|
||||||
|
### Builder / préparer les binaires
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/studioe5-client-a/agent
|
||||||
|
|
||||||
|
# 1. Télécharger les binaires Tailscale Windows (nécessite msitools)
|
||||||
|
./download-tailscale-bins.sh 1.98.4
|
||||||
|
|
||||||
|
# 2. Builder l’agent pour Windows et Linux (macOS nécessite CGO)
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `build.sh` génère automatiquement `studioE5-agent-v0.3.10-windows.zip` et copie les binaires versionnés dans `server/public/`.
|
||||||
|
|
||||||
|
### Flow d’activation zéro-config (modèle commercialisable)
|
||||||
|
|
||||||
|
L’élève/employé n’a **aucune configuration technique** à saisir :
|
||||||
|
|
||||||
|
1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.10-windows.zip`).
|
||||||
|
2. **Extraire** et **lancer** `studioE5-agent.exe`.
|
||||||
|
3. **Entrer le code d’activation** à 6 caractères fourni par l’établissement (affiché dans l’UI locale `http://localhost:7070`).
|
||||||
|
4. L’agent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** :
|
||||||
|
- l’identité de l’élève (`studentName`)
|
||||||
|
- l’URL Headscale
|
||||||
|
- la clé pré-auth Headscale
|
||||||
|
5. L’agent sauvegarde ces informations localement et **démarre automatiquement le VPN**.
|
||||||
|
6. L’agent est alors visible dans le dashboard et peut recevoir des instances.
|
||||||
|
|
||||||
|
### Configuration manuelle (mode debug / admin)
|
||||||
|
|
||||||
|
Si besoin, on peut toujours forcer une config via `data/studioE5-config.json` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": "wss://studioe5.edudeploy.com/api/websocket",
|
||||||
|
"headscale_url": "https://headscale.studioe5.edudeploy.com",
|
||||||
|
"headscale_auth_key": "CLE_PREAUTH_ICI",
|
||||||
|
"node_id": "IDENTIFIANT_DU_POSTE",
|
||||||
|
"data_dir": "C:\\studioE5-agent\\data"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `headscale_auth_key` doit être une clé pré-auth réutilisable valide pour le tailnet studioe5. Ne jamais commiter cette clé.
|
||||||
|
|
||||||
|
Lancement :
|
||||||
|
```powershell
|
||||||
|
.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Durcissement du code d’activation
|
||||||
|
|
||||||
|
### Génération
|
||||||
|
|
||||||
|
- Les codes sont générés avec `crypto.randomBytes` (au lieu de `Math.random`).
|
||||||
|
- Longueur conservée à 6 caractères, alphabet sans ambiguïté (`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`).
|
||||||
|
- Un champ `activationCodeExpiresAt` a été ajouté au modèle `Student` ; les codes expirent après **60 minutes**.
|
||||||
|
|
||||||
|
### Rate-limiting
|
||||||
|
|
||||||
|
- Maximum de **5 tentatives d’activation par code** sur une fenêtre de **15 minutes**.
|
||||||
|
- Maximum de **5 tentatives par `nodeId`** sur la même fenêtre.
|
||||||
|
- Au-delà, le serveur répond `activation_failed` avec `Too many attempts`.
|
||||||
|
|
||||||
|
### Cycle de vie
|
||||||
|
|
||||||
|
- Le code est **invalide après une activation réussie** (`activationCode` et `activationCodeExpiresAt` mis à `null`).
|
||||||
|
- Un code expiré renvoie `Code expired`.
|
||||||
|
- Un code déjà utilisé renvoie `Invalid code`.
|
||||||
|
|
||||||
|
### Tests validés
|
||||||
|
|
||||||
|
- Activation valide → `activated` + token node reçu.
|
||||||
|
- Code expiré → `Code expired`.
|
||||||
|
- Code déjà utilisé → `Invalid code`.
|
||||||
|
- 5+ tentatives invalides → `Too many attempts`.
|
||||||
|
|
||||||
|
## 🔒 ACL Headscale (isolation du tailnet)
|
||||||
|
|
||||||
|
### Objectif
|
||||||
|
|
||||||
|
Par défaut, tous les nœuds du tailnet peuvent communiquer entre eux. Les ACL restreignent la connectivité au strict nécessaire :
|
||||||
|
- les agents élèves ne peuvent pas se parler entre eux ;
|
||||||
|
- le resolver peut atteindre les agents sur leurs ports d’instance ;
|
||||||
|
- les agents peuvent joindre le resolver sur son port HTTP interne.
|
||||||
|
|
||||||
|
### Mise en œuvre
|
||||||
|
|
||||||
|
- Fichier de politique : `headscale/acl_policy.hujson`.
|
||||||
|
- `headscale/config.yaml` pointe vers ce fichier via `policy.path`.
|
||||||
|
- Le resolver a été déplacé dans un utilisateur Headscale dédié `resolver` (clé `HEADSCALE_RESOLVER_AUTH_KEY`).
|
||||||
|
- Les agents utilisent l’utilisateur `studioe5` et sont tagués `tag:student-agent`.
|
||||||
|
- Les nouveaux agents recevront automatiquement le tag via la nouvelle clé pré-auth `HEADSCALE_AUTH_KEY` (créée avec `--tags tag:student-agent`).
|
||||||
|
|
||||||
|
### Contenu de la politique
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"groups": {
|
||||||
|
"group:agents": ["studioe5@studioe5.local"],
|
||||||
|
"group:resolvers": ["resolver@studioe5.local"]
|
||||||
|
},
|
||||||
|
"tagOwners": {
|
||||||
|
"tag:student-agent": ["studioe5@studioe5.local"],
|
||||||
|
"tag:resolver": ["resolver@studioe5.local"]
|
||||||
|
},
|
||||||
|
"acls": [
|
||||||
|
{ "action": "accept", "src": ["tag:resolver"], "dst": ["tag:student-agent:*"] },
|
||||||
|
{ "action": "accept", "src": ["tag:student-agent"], "dst": ["tag:resolver:2020"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests validés
|
||||||
|
|
||||||
|
| Test | Résultat |
|
||||||
|
|------|----------|
|
||||||
|
| `resolver` ping agent | ✅ OK |
|
||||||
|
| Agent → agent (port instance) | ❌ bloqué (timeout) |
|
||||||
|
| Agent → resolver:2020 | ✅ OK |
|
||||||
|
| Flux HTTPS public | ✅ HTTP 200 |
|
||||||
|
|
||||||
|
## 🔒 Authentification du canal serveur → agent
|
||||||
|
|
||||||
|
### Token d’authentification par nœud
|
||||||
|
|
||||||
|
- Le modèle `Node` dispose d’un champ `token` unique.
|
||||||
|
- L’agent envoie son token dans l’en-tête `Authorization: Bearer <token>` lors de la connexion WebSocket.
|
||||||
|
- Le serveur rejette toute connexion/register dont le token ne correspond pas au `nodeId` (fermeture `1008`).
|
||||||
|
- Lors de l’activation, le serveur génère un token aléatoire (32 octets hex) et le renvoie dans le message `activated` ; l’agent le sauvegarde dans `<data-dir>/node.token` (permissions `0600`).
|
||||||
|
- Pour les nœuds existants sans token, le serveur en génère un à la première connexion et l’envoie via `set_token`.
|
||||||
|
|
||||||
|
### Endpoint `/api/internal/send-to-node`
|
||||||
|
|
||||||
|
- Protégé par la variable d’environnement `INTERNAL_API_KEY`.
|
||||||
|
- Requiert l’en-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
|
||||||
|
- Appel sans clé → `401 Unauthorized`.
|
||||||
|
|
||||||
|
### Routes API métier
|
||||||
|
|
||||||
|
- Les routes de gestion des instances (`/api/instances`) requièrent une session NextAuth valide.
|
||||||
|
- Un administrateur ne peut agir que sur les ressources de son établissement ; le `superadmin` peut tout voir/tout faire.
|
||||||
|
|
||||||
|
### Endpoint `/api/resolve`
|
||||||
|
|
||||||
|
- Protégé par la même clé `INTERNAL_API_KEY`.
|
||||||
|
- Requiert l’en-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
|
||||||
|
- Le resolver (`resolver:2020`) ne l’utilise pas ; il interroge directement PostgreSQL. Cette route est donc réservée aux outils/scripts internes authentifiés.
|
||||||
|
|
||||||
|
### Exemples de commandes avec la clé interne
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEY=$(grep INTERNAL_API_KEY /opt/studioe5-client-a/.env | cut -d= -f2)
|
||||||
|
|
||||||
|
curl -sS -X POST https://studioe5.edudeploy.com/api/internal/send-to-node \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $KEY" \
|
||||||
|
-d '{"nodeId":"vps-8fc665eb","message":{"action":"start_vpn"}}'
|
||||||
|
|
||||||
|
curl -sS -H "Authorization: Bearer $KEY" \
|
||||||
|
"https://studioe5.edudeploy.com/api/resolve?subdomain=test-wp-001"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Clés pré-auth Headscale éphémères
|
||||||
|
|
||||||
|
### Principe
|
||||||
|
|
||||||
|
À l’activation zero-config, le serveur génère désormais une **clé pré-auth unique et à usage unique** pour chaque agent, au lieu d’envoyer la clé réutilisable `HEADSCALE_AUTH_KEY`.
|
||||||
|
|
||||||
|
Avantages :
|
||||||
|
- une clé compromise ne permet pas d’enregistrer d’autres nœuds ;
|
||||||
|
- traçabilité directe entre une activation et une clé Headscale ;
|
||||||
|
- expiration courte (15 min) ;
|
||||||
|
- la clé n’est **pas persistée** dans `studioE5-config.json` côté agent.
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
| Composant | Changement |
|
||||||
|
|-----------|------------|
|
||||||
|
| `server/lib/headscale.ts` | Nouveau helper : `getHeadscaleUserId()` + `createEphemeralPreAuthKey()` appelant `POST /api/v1/preauthkey`. |
|
||||||
|
| `server/lib/websocket.ts` | Sur `activate`, génère une clé éphémère taguée `tag:student-agent` pour l’utilisateur `studioe5`. Fallback sur `HEADSCALE_AUTH_KEY` si `HEADSCALE_API_KEY` n’est pas configurée. |
|
||||||
|
| `agent/websocket.go` | La clé reçue est utilisée immédiatement mais **n’est plus écrite** dans `studioE5-config.json`. |
|
||||||
|
| `agent/tailscale.go` | `tailscale up` fonctionne sans `--authkey` quand le state Tailscale existe déjà (reconnexion). |
|
||||||
|
| `.env.example` / `docker-compose.yml` | Ajout de `HEADSCALE_API_KEY` pour le service `server`. |
|
||||||
|
|
||||||
|
### Configuration requise
|
||||||
|
|
||||||
|
Générer une clé API Headscale (depuis le conteneur ou la CLI) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/studioe5-client-a
|
||||||
|
# Clé valable 10 ans (87600h) pour éviter un renouvellement fréquent.
|
||||||
|
docker compose exec headscale headscale apikeys create -e 87600h
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis l’ajouter dans `.env` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HEADSCALE_API_KEY=hskey-api-...
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ La clé API Headscale a une expiration par défaut de 90 jours. La clé de production a été créée avec une expiration de **10 ans** (`-e 87600h`). Les anciennes clés ont été révoquées.
|
||||||
|
|
||||||
|
### Rotation / renouvellement
|
||||||
|
|
||||||
|
Si la clé doit être changée :
|
||||||
|
|
||||||
|
1. Créer une nouvelle clé API :
|
||||||
|
```bash
|
||||||
|
docker compose exec headscale headscale apikeys create -e 87600h
|
||||||
|
```
|
||||||
|
2. Mettre à jour `.env` :
|
||||||
|
```bash
|
||||||
|
HEADSCALE_API_KEY=<nouvelle_clé>
|
||||||
|
```
|
||||||
|
3. Redémarrer le serveur :
|
||||||
|
```bash
|
||||||
|
docker compose up -d server
|
||||||
|
```
|
||||||
|
4. Révoquer l’ancienne clé :
|
||||||
|
```bash
|
||||||
|
docker compose exec headscale headscale apikeys expire --id <id_ancienne>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Déploiement effectué
|
||||||
|
|
||||||
|
- Clé API créée et ajoutée au `.env` de production.
|
||||||
|
- Image serveur rebuildée et redémarrée.
|
||||||
|
- Agents Linux/Windows rebuildés en v0.3.4 et copiés dans `server/public/`.
|
||||||
|
|
||||||
|
## 🔒 Sécurité — points restants à traiter
|
||||||
|
|
||||||
|
> Le certificat wildcard `*.studioe5.edudeploy.com` est désormais du ressort du **deployeur** (voir `docs/ONBOARDING_CLIENT.md`). Les points ci-dessous concernent l’application studioE5 proprement dite.
|
||||||
|
|
||||||
|
### Gestion et rotation des secrets
|
||||||
|
|
||||||
|
| Secret | Où ? | Action |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `INTERNAL_API_KEY` | `.env` serveur | Prévoir une procédure de rotation régulière. |
|
||||||
|
| `HEADSCALE_API_KEY` | `.env` serveur | Rotation tous les 10 ans max, stockage sécurisé. |
|
||||||
|
| `NEXTAUTH_SECRET` | `.env` serveur | Génération robuste, rotation si suspicion de fuite. |
|
||||||
|
| `DATABASE_URL` | `.env` serveur | Utilisateur DB dédié, mot de passe fort. |
|
||||||
|
| `node.token` | `<data-dir>/node.token` | Vérifier permissions `0600` sur tous les OS. |
|
||||||
|
|
||||||
|
### Durcissement des conteneurs
|
||||||
|
|
||||||
|
- Limiter les `cap_add` au strict minimum.
|
||||||
|
- Faire tourner les services avec un utilisateur non-root quand possible.
|
||||||
|
- Mettre à jour régulièrement les images de base (Caddy, Node, Postgres, Headscale).
|
||||||
|
- Scanner les images Docker pour les CVE.
|
||||||
|
|
||||||
|
### Mises à jour de sécurité
|
||||||
|
|
||||||
|
- Mise à jour des binaires Tailscale (Windows et Linux).
|
||||||
|
- Mise à jour des images Docker (`server`, `resolver`, `caddy`, `postgres`, `headscale`).
|
||||||
|
- Mise à jour de l’OS des VPS et des postes agents.
|
||||||
|
- Mécanisme de mise à jour automatique ou notification de l’agent.
|
||||||
|
|
||||||
|
### Logs d’audit
|
||||||
|
|
||||||
|
- Tracer la création / suppression d’instances.
|
||||||
|
- Tracer la génération et l’usage des codes d’activation.
|
||||||
|
- Tracer les actions admin (connexion, création d’élève, démarrage/arrêt VPN).
|
||||||
|
- Conservation et consultation des logs d’audit.
|
||||||
|
|
||||||
|
### Backups et reprise d’activité
|
||||||
|
|
||||||
|
- Backup régulier de la base PostgreSQL.
|
||||||
|
- Backup du state Headscale.
|
||||||
|
- Backup des states Tailscale côté agents.
|
||||||
|
- Procédure de restauration documentée et testée.
|
||||||
|
|
||||||
|
### Sécurité du build et distribution de l’agent
|
||||||
|
|
||||||
|
- Vérifier l’intégrité des binaires Tailscale téléchargés (checksum / signature).
|
||||||
|
- Signer l’exécutable Windows `studioE5-agent.exe` pour éviter les alertes Defender.
|
||||||
|
- Fournir un hash SHA256 des archives d’agent.
|
||||||
|
|
||||||
|
### RGPD et données personnelles
|
||||||
|
|
||||||
|
- Justifier la conservation des noms/prénoms des élèves.
|
||||||
|
- Gérer les droits d’accès, la suppression de compte et l’export de données.
|
||||||
|
- Définir la durée de conservation des logs et historiques.
|
||||||
|
|
||||||
|
### Sécurité réseau complémentaire
|
||||||
|
|
||||||
|
- Restreindre l’accès à `/api/internal/send-to-node` par IP source si possible.
|
||||||
|
- Vérifier l’exposition publique du dashboard Headscale et la durcir si nécessaire.
|
||||||
|
- Évaluer si `headscale.studioe5.edudeploy.com` doit rester public.
|
||||||
|
|
||||||
|
### Rate limiting et quotas
|
||||||
|
|
||||||
|
- Rate-limiting global sur les routes publiques (`/api/auth/*`, activation, création d’instance).
|
||||||
|
- Limitation du nombre d’instances par élève et par établissement.
|
||||||
|
- Protection contre les abus sur la génération de codes d’activation.
|
||||||
|
|
||||||
|
### Tests de sécurité
|
||||||
|
|
||||||
|
- Tests d’intrusion légers (agent → agent, accès aux endpoints internes sans clé, accès à une instance d’un autre élève).
|
||||||
|
- Tests automatisés du flux complet avant chaque release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Installateur agent professionnel
|
||||||
|
|
||||||
|
### Objectif
|
||||||
|
|
||||||
|
Créer un package d’installation unique et professionnel par OS, incluant l’agent studioE5, Tailscale et **Podman CLI**, afin de ne plus dépendre d’installations manuelles préalables par l’utilisateur.
|
||||||
|
|
||||||
|
### Choix des outils
|
||||||
|
|
||||||
|
| OS | Outil | Format | Justification |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Windows** | **Inno Setup** | `.exe` | Gratuit, open source, très répandu, personnalisable, exécution de scripts PowerShell/silencieux. |
|
||||||
|
| **macOS** | **`pkgbuild`** | `.pkg` | Outil natif Apple, gratuit, format professionnel pour la distribution macOS. |
|
||||||
|
| **Linux** | **Script shell** (+ `.deb`/`.rpm` optionnels) | `.sh` | Universel, détecte le package manager, simple à maintenir. |
|
||||||
|
|
||||||
|
### Contenu du package par OS
|
||||||
|
|
||||||
|
- **Windows** (Inno Setup) :
|
||||||
|
- Installer l’agent dans `C:\Program Files\studioE5-agent\`.
|
||||||
|
- Extraire Tailscale dans `C:\Program Files\studioE5-agent\tailscale-bin\windows\`.
|
||||||
|
- Installer Podman CLI via le MSI officiel en mode silencieux.
|
||||||
|
- Exécuter `podman machine init` puis `podman machine start`.
|
||||||
|
- Créer un raccourci de démarrage et/ou un service Windows.
|
||||||
|
|
||||||
|
- **macOS** (`pkgbuild`) :
|
||||||
|
- Installer l’agent dans `/Applications/studioE5-agent/`.
|
||||||
|
- Installer Podman CLI.
|
||||||
|
- Exécuter `podman machine init` puis `podman machine start`.
|
||||||
|
- Optionnellement créer un LaunchAgent pour démarrer l’agent au login.
|
||||||
|
|
||||||
|
- **Linux** (script shell) :
|
||||||
|
- Détecter le package manager (`apt`, `dnf`, `pacman`, etc.).
|
||||||
|
- Installer Podman et Podman Compose.
|
||||||
|
- Copier l’agent dans `/opt/studioe5-agent/`.
|
||||||
|
- Créer le service systemd `studioe5-agent.service`.
|
||||||
|
- Activer et démarrer le service.
|
||||||
|
|
||||||
|
### Adaptations nécessaires dans l’agent
|
||||||
|
|
||||||
|
- Détecter si Podman est utilisé et si une machine est requise (Windows/macOS).
|
||||||
|
- Vérifier au démarrage que la machine Podman est démarrée, et lancer `podman machine start` si besoin.
|
||||||
|
- Gérer proprement l’arrêt de la machine à la fermeture de l’agent (optionnel).
|
||||||
|
|
||||||
|
### Mise à jour de l’agent vs dépendances système
|
||||||
|
|
||||||
|
- **L’agent peut se mettre à jour lui-même** (binaire + fichiers) depuis la v0.3.10.
|
||||||
|
- **Podman / Docker / Tailscale restent gérés par l’installateur** : l’agent vérifie leur présence et alertera l’utilisateur si une dépendance est manquante ou trop ancienne, mais ne les met pas à jour automatiquement (droits élevés, risque de casser les machines Podman, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Prochaines étapes à faire
|
||||||
|
|
||||||
|
### ✅ Terminé
|
||||||
|
|
||||||
|
- [x] Rate limit Let’s Encrypt levé.
|
||||||
|
- [x] Flux HTTPS public validé (`test-wp-001.studioe5.edudeploy.com`).
|
||||||
|
- [x] Branche `feat/studioe5-vpn-ondemand` créée, commit `124543d`.
|
||||||
|
- [x] **Pousser la branche** vers Gitea : la branche est synchronisée sur `origin/feat/studioe5-vpn-ondemand` (commit `cf8b663`).
|
||||||
|
- [x] Flux complet UI → API → WebSocket → agent → Docker → VPN → Caddy validé.
|
||||||
|
- [x] Packager les binaires Tailscale pour Windows (`studioE5-agent-v0.3.5-windows.zip`).
|
||||||
|
- [x] **Sécurité – authentification du canal serveur → agent** (token par nœud, clé API interne, sessions NextAuth sur les routes API).
|
||||||
|
- [x] **Sécurité – durcissement du code d’activation** (`crypto.randomBytes`, expiration 60 min, rate-limiting, invalidation après usage).
|
||||||
|
- [x] **Sécurité – ACL Headscale** (isolation agent ↔ agent, resolver → agent autorisé).
|
||||||
|
- [x] **Sécurité – clés pré-auth Headscale éphémères** (génération côté serveur via `HEADSCALE_API_KEY`, non persistées côté agent).
|
||||||
|
- [x] **Agent v0.3.5 – forwarding entrant Windows** (`tailscale serve` automatique au démarrage de chaque instance).
|
||||||
|
- [x] **Agent v0.3.5 – UI locale moderne** (dashboard, logs, progression, actions d’instance).
|
||||||
|
- [x] **Agent v0.3.5 – cycle de vie des instances** (`stop`/`start` préservent les volumes, `reset`/`delete` effacent).
|
||||||
|
- [x] **Agent v0.3.5 – cleanup au shutdown** (arrêt propre de Tailscale et des instances, notification serveur).
|
||||||
|
- [x] **Synchronisation dashboard** (messages `instance_stopped` / `instance_deleted` traités côté serveur, et agent renvoyant correctement ces messages après un ordre serveur `stop`/`delete`).
|
||||||
|
- [x] **Template WordPress prêt à l’emploi** (`wordpress-ready-wordpress-latest`) : WordPress en français, titre « Mon site wordpress », compte admin/admin, thème Astra, Spectra actif, Yoast SEO inactif, mises à jour automatiques désactivées, DNS `8.8.8.8`/`1.1.1.1` pour `api.wordpress.org`.
|
||||||
|
- [x] **Nettoyer les instances/agent de test** (2026-06-27) : agent de test arrêté (`vps-8fc665eb`), `tailscaled` associé arrêté, data-dir `/tmp/studioe5-test-clienta` supprimé ; **13 instances de test supprimées de la base PostgreSQL** (`vps-8fc665eb` + `OMEGA-GAMER-60d7f87c`).
|
||||||
|
- [x] **Nettoyer les anciens nodes/volumes Headscale de test** (2026-06-27) : nœuds `edubox`, `prof`, `invalid-*`, anciens `vps-8fc665eb`, anciens `studioe5-resolver` et `test-node-b` supprimés ; volume Docker anonyme orphelin supprimé.
|
||||||
|
- [x] **Centralisation de la version agent** : fichier unique `agent/VERSION`, API `GET /api/agent/version`, dashboard et route `/api/download` alignés.
|
||||||
|
- [x] **Agent v0.3.10 – synchronisation agent ↔ serveur au démarrage** : protocole `sync` / `sync_response`, suppression/lancement automatique des instances décalées pendant un offline.
|
||||||
|
- [x] **Agent v0.3.10 – détails techniques dans l’UI locale** : version de l’agent, nodeId, version attendue par le serveur, notification de mise à jour.
|
||||||
|
- [x] **Agent v0.3.10 – mise à jour automatique de l’agent** : détection de nouvelle version, téléchargement, remplacement du binaire via script helper et redémarrage.
|
||||||
|
- [x] **Agent v0.3.10 – handlers asynchrones** : `start`, `stop`, `delete`, `reset` exécutés dans des goroutines pour ne plus bloquer la boucle WebSocket.
|
||||||
|
- [x] **Agent v0.3.10 – nettoyage des dossiers instances orphelins au démarrage** : supprime les répertoires résiduels laissés par des `delete` incomplets (souvent `compose.log` verrouillé sous Windows).
|
||||||
|
|
||||||
|
### ⏳ Reste à faire
|
||||||
|
|
||||||
|
- [ ] **Certificat wildcard** : transféré au deployeur (`docs/ONBOARDING_CLIENT.md`). L’étude technique reste disponible ci-dessous pour référence.
|
||||||
|
- [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, ACL, etc.).
|
||||||
|
- [ ] **Installateur agent professionnel (Windows / macOS / Linux)** : créer un package d’installation unique incluant l’agent studioE5, Tailscale et **Podman CLI**. Voir la section « 🖥️ Installateur agent professionnel » ci-dessous pour le détail des outils (Inno Setup, pkgbuild, script shell) et du contenu par OS.
|
||||||
|
- [ ] **Template WordPress prêt à l’emploi (usage examen/classe)** :
|
||||||
|
- Forcer le DNS (`8.8.8.8`, `1.1.1.1`) dans le `docker-compose.yml` pour permettre l’accès à la bibliothèque de plugins/mises à jour depuis le conteneur.
|
||||||
|
- Pré-installer WordPress en **français** via WP-CLI avec le titre **“Mon site wordpress”** et le compte **admin / admin**.
|
||||||
|
- Désactiver les **mises à jour automatiques** (core, plugins, thèmes) pour figer l’environnement.
|
||||||
|
- Installer et activer le **thème Astra**.
|
||||||
|
- Installer **Yoast SEO** (inactif) et **Spectra** (actif).
|
||||||
|
- [ ] **Barre de progression basée sur les logs d’installation** : enrichir la barre de progression agent/dashboard en lisant les logs Podman/Docker (`podman logs -f`) pendant le premier démarrage d’une instance. Définir des patterns de logs par template (ex. `Installation successful` pour PrestaShop) et relayer les étapes réelles au dashboard via WebSocket.
|
||||||
|
- [ ] **Étude – interface de déploiement multi-clients** : outil de provisionning d’un nouveau serveur client + agent générique (option A : URL serveur déterminée à l’activation).
|
||||||
|
- [ ] **Sécurité – gestion et rotation des secrets** (`INTERNAL_API_KEY`, `HEADSCALE_API_KEY`, `NEXTAUTH_SECRET`, `DATABASE_URL`).
|
||||||
|
- [ ] **Sécurité – durcissement des conteneurs** (`cap_add`, utilisateurs non-root, scans CVE).
|
||||||
|
- [ ] **Sécurité – mises à jour de sécurité** (Tailscale, images Docker, OS agents).
|
||||||
|
- [ ] **Sécurité – logs d’audit** (instances, codes d’activation, actions admin).
|
||||||
|
- [ ] **Sécurité – backups et reprise d’activité** (DB, state Headscale, states agents).
|
||||||
|
- [ ] **Sécurité – intégrité et signature de l’agent** (checksum Tailscale, signature Windows, hash SHA256).
|
||||||
|
- [ ] **Sécurité – conformité RGPD** (données élèves, suppression de compte, export).
|
||||||
|
- [ ] **Sécurité – restriction réseau** (endpoint interne, dashboard Headscale).
|
||||||
|
- [ ] **Sécurité – rate limiting et quotas** (routes publiques, instances par élève/établissement).
|
||||||
|
- [ ] **Sécurité – tests de sécurité** (intrusion légère, tests automatisés avant release).
|
||||||
|
|
||||||
|
## 💡 Améliorations UI
|
||||||
|
|
||||||
|
### ✅ Console / log intégrée dans l’agent (v0.3.5)
|
||||||
|
|
||||||
|
Les logs de l’agent sont redirigés vers `<data-dir>/agent.log` et diffusés en temps réel dans l’UI locale (`http://localhost:7070`) via le WebSocket existant.
|
||||||
|
|
||||||
|
### ✅ Barre de progression (v0.3.5)
|
||||||
|
|
||||||
|
L’agent envoie des messages `progress` au frontend pendant le démarrage d’une instance :
|
||||||
|
|
||||||
|
| Étape | Poids |
|
||||||
|
|-------|-------|
|
||||||
|
| Préparation de l’application | 10 % |
|
||||||
|
| Configuration de l’application | 30 % |
|
||||||
|
| Application en cours de démarrage | 60 % |
|
||||||
|
| Connexion sécurisée active | 80 % |
|
||||||
|
| Finalisation de l’installation | 90 % |
|
||||||
|
| Application prête | 100 % |
|
||||||
|
|
||||||
|
### Boutons d’action par instance (v0.3.5)
|
||||||
|
|
||||||
|
L’UI locale affiche désormais des boutons **Démarrer**, **Arrêter**, **Redémarrer** et **Supprimer** pour chaque instance.
|
||||||
|
|
||||||
|
## 🚀 Scalabilité commerciale — déploiement multi-clients
|
||||||
|
|
||||||
|
### Objectif
|
||||||
|
|
||||||
|
Permettre de déployer facilement une stack studioE5 complète pour un nouvel établissement/client sur un VPS dédié, sans intervention technique lourde.
|
||||||
|
|
||||||
|
### Architecture cible
|
||||||
|
|
||||||
|
- **Un serveur = un client** : chaque établissement a sa propre stack Docker Compose, sa base PostgreSQL, son Headscale et son Caddy.
|
||||||
|
- **Agent générique (option A)** : un seul binaire agent pour tous les clients. L’URL du serveur cible est déterminée au moment de l’activation, pas hardcodée dans l’agent.
|
||||||
|
- Pistes : code d’activation résolu par un hub central, code structuré contenant l’identifiant du serveur, ou champ URL serveur saisi dans l’UI locale.
|
||||||
|
- **Interface de déploiement** : dashboard superadmin (hub) permettant de créer un client, provisionner le VPS, générer les secrets et retourner les informations de connexion.
|
||||||
|
|
||||||
|
### Prérequis techniques à préparer
|
||||||
|
|
||||||
|
Avant de pouvoir déployer un nouveau client en quelques clics, il faut encore préparer les éléments suivants :
|
||||||
|
|
||||||
|
| # | Élément | État | Détail |
|
||||||
|
|---|---------|------|--------|
|
||||||
|
| 1 | **Agent générique** | ⏳ À faire | `defaultServerURL` est hardcodé (`wss://studioe5.edudeploy.com/api/websocket`). L’agent doit pouvoir déterminer l’URL serveur cible à l’activation (option A : champ URL, hub de résolution, ou code structuré). |
|
||||||
|
| 2 | **Script de provisionning** | ⏳ À faire | Aucun outil automatisé pour provisionner un VPS vierge : installation Docker, déploiement de la stack, génération des secrets, création des clés Headscale, configuration DNS wildcard. |
|
||||||
|
| 3 | **Registry d’images** | ⏳ À faire | Les images `server` et `resolver` sont buildées sur le serveur cible. Il faut un registry privé pour builder une fois et déployer partout. |
|
||||||
|
| 4 | **Hub central** | ⏳ À faire | Dashboard superadmin listant les clients, état des serveurs, versions déployées, logs distants et mises à jour. |
|
||||||
|
| 5 | **Mises à jour à distance** | ⏳ À faire | Mécanisme pour pousser une nouvelle version du serveur et de l’agent sur tous les déploiements clients. |
|
||||||
|
| 6 | **Monitoring / support** | ⏳ À faire | Collecte centralisée de logs, alertes (serveur down, certificat expiré, agent hors ligne). |
|
||||||
|
| 7 | **Branding / personnalisation** | ⏳ À faire | Logo, nom de l’établissement, couleurs configurables par client. |
|
||||||
|
| 8 | **Tests automatisés** | ⏳ À faire | Tests du flux activation → VPN → instance → HTTPS public pour valider chaque nouveau déploiement. |
|
||||||
|
| 9 | **Documentation procédure prod** | ⏳ À faire | Procédure complète de mise en production pour un nouveau client. |
|
||||||
|
|
||||||
|
### Statut
|
||||||
|
|
||||||
|
- ⏳ À étudier et planifier plus tard. L’architecture actuelle (un serveur par client + agent zero-config) est déjà compatible avec cette vision, mais le code n’est pas encore industrialisé pour un déploiement à grande échelle.
|
||||||
|
|
||||||
|
## 🔒 Étude certificat wildcard `*.studioe5.edudeploy.com`
|
||||||
|
|
||||||
|
### Pourquoi passer en wildcard ?
|
||||||
|
|
||||||
|
Avec `tls { on_demand }`, Caddy émet **un certificat Let’s Encrypt par sous-domaine d’instance**. Cela expose au rate limit de 50 certificats par domaine principal (`edudeploy.com`) sur 7 jours. Un certificat wildcard unique (`*.studioe5.edudeploy.com`) couvre tous les sous-domaines d’instances et évite ce problème.
|
||||||
|
|
||||||
|
### Contrainte technique
|
||||||
|
|
||||||
|
Un certificat wildcard nécessite le **challenge DNS-01** (le challenge HTTP-01 ne permet pas de valider `*.domain.tld`). Caddy doit donc pouvoir créer un enregistrement TXT automatiquement chez le registrar DNS.
|
||||||
|
|
||||||
|
### Infomaniak (registrar actuel)
|
||||||
|
|
||||||
|
Le DNS de `edudeploy.com` est chez **Infomaniak** :
|
||||||
|
```bash
|
||||||
|
dig NS edudeploy.com +short
|
||||||
|
# nsany1.infomaniak.com.
|
||||||
|
# nsany2.infomaniak.com.
|
||||||
|
```
|
||||||
|
|
||||||
|
Il existe un module Caddy DNS pour Infomaniak :
|
||||||
|
- Repository : `github.com/caddy-dns/infomaniak`
|
||||||
|
- Nécessite un **token API Infomaniak** avec droits DNS.
|
||||||
|
|
||||||
|
### Implémentation à envisager
|
||||||
|
|
||||||
|
1. **Générer un token API Infomaniak** (compte client A ou compte dédié avec accès au domaine).
|
||||||
|
2. **Builder une image Caddy custom** avec le module :
|
||||||
|
```dockerfile
|
||||||
|
FROM caddy:2-builder AS builder
|
||||||
|
RUN xcaddy build --with github.com/caddy-dns/infomaniak
|
||||||
|
|
||||||
|
FROM caddy:2-alpine
|
||||||
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
|
```
|
||||||
|
3. **Modifier le `Caddyfile`** pour gérer le wildcard :
|
||||||
|
```caddy
|
||||||
|
*.studioe5.edudeploy.com {
|
||||||
|
tls {
|
||||||
|
dns infomaniak {env.INFOMANIAK_API_TOKEN}
|
||||||
|
}
|
||||||
|
reverse_proxy resolver:2020 {
|
||||||
|
header_up Host {host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. **Ajouter le token dans `.env`** et le passer au conteneur Caddy.
|
||||||
|
5. Supprimer ou ajuster le bloc `:443` actuel qui utilise `on_demand` pour les instances.
|
||||||
|
|
||||||
|
### Alternative sans module DNS
|
||||||
|
|
||||||
|
Obtenir le certificat wildcard manuellement (Certbot DNS-01, acheté, etc.) et le charger dans Caddy :
|
||||||
|
```caddy
|
||||||
|
*.studioe5.edudeploy.com {
|
||||||
|
tls /data/certs/wildcard.crt /data/certs/wildcard.key
|
||||||
|
reverse_proxy resolver:2020 {
|
||||||
|
header_up Host {host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Inconvénient : renouvellement manuel.
|
||||||
|
|
||||||
|
## 🔧 Notes techniques
|
||||||
|
|
||||||
|
- Le conteneur `resolver-vpn` utilise `network_mode: service:resolver` pour partager le netns avec le resolver.
|
||||||
|
- L’agent utilise `tailscaled --tun=userspace-networking` ; le resolver-vpn utilise un vrai TUN (`tailscale0`).
|
||||||
|
- Le `Caddyfile` actuel utilise `tls { on_demand }` pour les instances. En cas de nouvelle rate limit, on peut temporairement remettre `tls internal` dans le bloc `:443` pour valider le flux sans certificat public.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0.3.18
|
||||||
Executable
+89
@@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
VERSION="$(cat "$(dirname "$0")/VERSION")"
|
||||||
|
APP_NAME="studioE5"
|
||||||
|
BIN_NAME="studioE5-agent"
|
||||||
|
LDFLAGS="-X main.version=${VERSION}"
|
||||||
|
|
||||||
|
# On Windows, build a GUI binary so no console window opens on double-click.
|
||||||
|
WIN_LDFLAGS="${LDFLAGS} -H windowsgui"
|
||||||
|
|
||||||
|
echo "Building ${APP_NAME} Agent v${VERSION}..."
|
||||||
|
|
||||||
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
|
||||||
|
GOOS=windows GOARCH=amd64 go build -ldflags "${WIN_LDFLAGS}" -o ${BIN_NAME}.exe .
|
||||||
|
echo " ${BIN_NAME}.exe (Windows amd64)"
|
||||||
|
cp ${BIN_NAME}.exe "${BIN_NAME}-v${VERSION}.exe"
|
||||||
|
echo " ${BIN_NAME}-v${VERSION}.exe (Windows amd64)"
|
||||||
|
|
||||||
|
GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o ${BIN_NAME} .
|
||||||
|
echo " ${BIN_NAME} (Linux amd64)"
|
||||||
|
cp ${BIN_NAME} "${BIN_NAME}-v${VERSION}"
|
||||||
|
echo " ${BIN_NAME}-v${VERSION} (Linux amd64)"
|
||||||
|
|
||||||
|
# macOS build requires CGO for the systray menu; skip gracefully if unavailable.
|
||||||
|
if GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -ldflags "${LDFLAGS}" -o ${BIN_NAME}-mac . 2>/dev/null; then
|
||||||
|
echo " ${BIN_NAME}-mac (macOS amd64)"
|
||||||
|
cp ${BIN_NAME}-mac "${BIN_NAME}-v${VERSION}-mac"
|
||||||
|
echo " ${BIN_NAME}-v${VERSION}-mac (macOS amd64)"
|
||||||
|
MAC_BUILT=1
|
||||||
|
else
|
||||||
|
echo " ${BIN_NAME}-mac (macOS amd64) - skipped, CGO required for systray"
|
||||||
|
MAC_BUILT=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build Windows distribution zip (agent + Tailscale binaries)
|
||||||
|
ZIP_NAME="${BIN_NAME}-v${VERSION}-windows.zip"
|
||||||
|
if [ -d "tailscale-bin/windows" ]; then
|
||||||
|
python3 - <<PY
|
||||||
|
import zipfile, os
|
||||||
|
with zipfile.ZipFile("${ZIP_NAME}", 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.write("${BIN_NAME}.exe", "${BIN_NAME}.exe")
|
||||||
|
for f in ["tailscale.exe", "tailscaled.exe", "wintun.dll"]:
|
||||||
|
zf.write(f"tailscale-bin/windows/{f}", f"tailscale-bin/windows/{f}")
|
||||||
|
readme = r"""${APP_NAME} Agent - Windows
|
||||||
|
=======================
|
||||||
|
1. Extract this archive to a folder (e.g. C:\${APP_NAME}-agent).
|
||||||
|
2. Create a data folder (e.g. C:\${APP_NAME}-agent\data).
|
||||||
|
3. Create the config file data\${BIN_NAME}-config.json:
|
||||||
|
{
|
||||||
|
"server": "wss://studioe5.edudeploy.com/api/websocket",
|
||||||
|
"headscale_url": "https://headscale.studioe5.edudeploy.com",
|
||||||
|
"headscale_auth_key": "YOUR_PREAUTH_KEY",
|
||||||
|
"node_id": "YOUR_NODE_ID",
|
||||||
|
"data_dir": "C:\\${APP_NAME}-agent\\data"
|
||||||
|
}
|
||||||
|
4. Run the agent:
|
||||||
|
${BIN_NAME}.exe -no-tray -data-dir C:\${APP_NAME}-agent\data
|
||||||
|
|
||||||
|
Tailscale binaries (tailscale.exe, tailscaled.exe, wintun.dll) are bundled
|
||||||
|
in tailscale-bin\windows\ and used automatically by the agent.
|
||||||
|
"""
|
||||||
|
zf.writestr("README-Windows.txt", readme)
|
||||||
|
print(f" ${ZIP_NAME}")
|
||||||
|
PY
|
||||||
|
else
|
||||||
|
echo " Warning: tailscale-bin/windows not found, run ./download-tailscale-bins.sh first"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy versioned binaries to server/public so the dashboard can serve them
|
||||||
|
SERVER_PUBLIC="../server/public"
|
||||||
|
if [ -d "${SERVER_PUBLIC}" ]; then
|
||||||
|
cp "${BIN_NAME}-v${VERSION}" "${SERVER_PUBLIC}/${BIN_NAME}-v${VERSION}"
|
||||||
|
cp "${BIN_NAME}-v${VERSION}.exe" "${SERVER_PUBLIC}/${BIN_NAME}-v${VERSION}.exe"
|
||||||
|
if [ "$MAC_BUILT" = "1" ]; then
|
||||||
|
cp "${BIN_NAME}-v${VERSION}-mac" "${SERVER_PUBLIC}/${BIN_NAME}-v${VERSION}-mac"
|
||||||
|
fi
|
||||||
|
if [ -f "${ZIP_NAME}" ]; then
|
||||||
|
cp "${ZIP_NAME}" "${SERVER_PUBLIC}/${ZIP_NAME}"
|
||||||
|
fi
|
||||||
|
echo " Copied versioned binaries to ${SERVER_PUBLIC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Download URLs (once served by Caddy):"
|
||||||
|
echo " https://studioe5.edudeploy.com/${BIN_NAME}-v${VERSION}.exe"
|
||||||
|
echo " https://studioe5.edudeploy.com/${ZIP_NAME}"
|
||||||
|
echo "Done."
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentConfig holds user-editable settings for the agent.
|
||||||
|
type AgentConfig struct {
|
||||||
|
Server string `json:"server"`
|
||||||
|
HeadscaleURL string `json:"headscale_url"`
|
||||||
|
HeadscaleAuthKey string `json:"headscale_auth_key"`
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
DataDir string `json:"data_dir"`
|
||||||
|
// ProxyURL is an optional HTTP(S) proxy used for all outbound agent traffic
|
||||||
|
// (WebSocket, update checks, downloads).
|
||||||
|
ProxyURL string `json:"proxy_url,omitempty"`
|
||||||
|
// ProxyMode controls how the proxy is used:
|
||||||
|
// - "disabled" : never use the proxy.
|
||||||
|
// - "auto" : the agent tries direct connections first and falls back to
|
||||||
|
// the proxy after a few failures (useful when moving between
|
||||||
|
// home network and school network).
|
||||||
|
// - "enabled" : always use the proxy.
|
||||||
|
ProxyMode string `json:"proxy_mode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFileName = "studioE5-config.json"
|
||||||
|
|
||||||
|
// defaultServerURL is the production WebSocket endpoint baked into the agent.
|
||||||
|
// It can be overridden by the config file for self-hosted or test setups.
|
||||||
|
const defaultServerURL = "wss://studioe5.edudeploy.com/api/websocket"
|
||||||
|
|
||||||
|
// uniqueNodeID returns a stable-ish unique identifier for this machine.
|
||||||
|
// It combines the hostname with a short random suffix so every install is distinct.
|
||||||
|
func uniqueNodeID() string {
|
||||||
|
h, err := os.Hostname()
|
||||||
|
if err != nil || h == "" {
|
||||||
|
h = "node"
|
||||||
|
}
|
||||||
|
b := make([]byte, 4)
|
||||||
|
if _, err := rand.Read(b); err == nil {
|
||||||
|
return fmt.Sprintf("%s-%s", h, hex.EncodeToString(b))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%d", h, os.Getpid())
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultConfig returns sensible defaults for a first run.
|
||||||
|
// The user only needs to provide an activation code; Headscale credentials are
|
||||||
|
// delivered by the server during activation.
|
||||||
|
func defaultConfig(dataDir string) *AgentConfig {
|
||||||
|
return &AgentConfig{
|
||||||
|
Server: defaultServerURL,
|
||||||
|
HeadscaleURL: "",
|
||||||
|
HeadscaleAuthKey: "",
|
||||||
|
NodeID: uniqueNodeID(),
|
||||||
|
DataDir: dataDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeWithDefaults fills missing fields from disk with sensible defaults.
|
||||||
|
func mergeWithDefaults(cfg *AgentConfig, dataDir string) *AgentConfig {
|
||||||
|
defaults := defaultConfig(dataDir)
|
||||||
|
if cfg == nil {
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
if cfg.Server == "" {
|
||||||
|
cfg.Server = defaults.Server
|
||||||
|
}
|
||||||
|
if cfg.NodeID == "" {
|
||||||
|
cfg.NodeID = defaults.NodeID
|
||||||
|
}
|
||||||
|
if cfg.DataDir == "" {
|
||||||
|
cfg.DataDir = defaults.DataDir
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// configPath returns the absolute path to the config file.
|
||||||
|
func configPath(dataDir string) string {
|
||||||
|
return filepath.Join(dataDir, configFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadOrCreateConfig loads the config file. If it does not exist, it creates
|
||||||
|
// one with default values and returns it (the caller can then open the settings UI).
|
||||||
|
func loadOrCreateConfig(dataDir string) (*AgentConfig, bool, error) {
|
||||||
|
cp := configPath(dataDir)
|
||||||
|
|
||||||
|
if _, err := os.Stat(cp); err == nil {
|
||||||
|
data, err := os.ReadFile(cp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
cfg := &AgentConfig{}
|
||||||
|
if err := json.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
cfg = mergeWithDefaults(cfg, dataDir)
|
||||||
|
return cfg, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := defaultConfig(dataDir)
|
||||||
|
if err := saveConfig(dataDir, cfg); err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
return cfg, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveConfig writes the config file to disk.
|
||||||
|
func saveConfig(dataDir string, cfg *AgentConfig) error {
|
||||||
|
cp := configPath(dataDir)
|
||||||
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(cp, data, 0600)
|
||||||
|
}
|
||||||
+138
-8
@@ -1,9 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func instanceDir(dataDir, instanceID string) string {
|
func instanceDir(dataDir, instanceID string) string {
|
||||||
@@ -17,38 +21,164 @@ func getContainerEngine() string {
|
|||||||
return "docker"
|
return "docker"
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeCompose(dataDir, instanceID, compose string) error {
|
func writeCompose(dataDir, instanceID, compose string, port int) error {
|
||||||
dir := instanceDir(dataDir, instanceID)
|
dir := instanceDir(dataDir, instanceID)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure the studioE5 mu-plugin is available and substitute its path
|
||||||
|
muDir, err := writeMUPlugin(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
compose = strings.ReplaceAll(compose, "{MU_PLUGINS_DIR}", filepath.Dir(muDir))
|
||||||
|
compose = strings.ReplaceAll(compose, "{INSTANCE_ID}", instanceID)
|
||||||
|
compose = strings.ReplaceAll(compose, "{PORT}", fmt.Sprintf("%d", port))
|
||||||
|
|
||||||
f := filepath.Join(dir, "docker-compose.yml")
|
f := filepath.Join(dir, "docker-compose.yml")
|
||||||
return os.WriteFile(f, []byte(compose), 0644)
|
return os.WriteFile(f, []byte(compose), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeInitScript(dataDir, instanceID, script string) error {
|
||||||
|
dir := instanceDir(dataDir, instanceID)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f := filepath.Join(dir, "wp-init.sh")
|
||||||
|
return os.WriteFile(f, []byte(script), 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureEngineCmd(cmd *exec.Cmd, dir string) {
|
||||||
|
hideWindow(cmd)
|
||||||
|
logPath := filepath.Join(dir, "compose.log")
|
||||||
|
if f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||||
|
cmd.Stdout = f
|
||||||
|
cmd.Stderr = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func dockerComposeUp(dataDir, instanceID string) error {
|
func dockerComposeUp(dataDir, instanceID string) error {
|
||||||
dir := instanceDir(dataDir, instanceID)
|
dir := instanceDir(dataDir, instanceID)
|
||||||
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "up", "-d")
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "up", "-d")
|
||||||
cmd.Stdout = os.Stdout
|
configureEngineCmd(cmd, dir)
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func dockerComposeDown(dataDir, instanceID string) error {
|
func dockerComposeDown(dataDir, instanceID string) error {
|
||||||
dir := instanceDir(dataDir, instanceID)
|
dir := instanceDir(dataDir, instanceID)
|
||||||
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down")
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down")
|
||||||
cmd.Stdout = os.Stdout
|
configureEngineCmd(cmd, dir)
|
||||||
cmd.Stderr = os.Stderr
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerComposeStop(dataDir, instanceID string) error {
|
||||||
|
dir := instanceDir(dataDir, instanceID)
|
||||||
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "stop")
|
||||||
|
configureEngineCmd(cmd, dir)
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerComposeStart(dataDir, instanceID string) error {
|
||||||
|
dir := instanceDir(dataDir, instanceID)
|
||||||
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "start")
|
||||||
|
configureEngineCmd(cmd, dir)
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func dockerComposeRm(dataDir, instanceID string) error {
|
func dockerComposeRm(dataDir, instanceID string) error {
|
||||||
dir := instanceDir(dataDir, instanceID)
|
dir := instanceDir(dataDir, instanceID)
|
||||||
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v")
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v")
|
||||||
cmd.Stdout = os.Stdout
|
configureEngineCmd(cmd, dir)
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.RemoveAll(dir)
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
log.Printf("dockerComposeRm: failed to remove %s: %v (will retry on next startup)", dir, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPublicURL tries to find the public URL from a WordPress compose config.
|
||||||
|
func extractPublicURL(composeConfig string) string {
|
||||||
|
re := regexp.MustCompile(`define\('WP_HOME',\s*'([^']+)'\);`)
|
||||||
|
m := re.FindStringSubmatch(composeConfig)
|
||||||
|
if len(m) > 1 {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateWordPressURLs patches wp-config.php inside the WordPress container
|
||||||
|
// so that WP_HOME and WP_SITEURL point to the public URL.
|
||||||
|
func updateWordPressURLs(dataDir, instanceID, publicURL string) error {
|
||||||
|
if publicURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dir := instanceDir(dataDir, instanceID)
|
||||||
|
composeFile := filepath.Join(dir, "docker-compose.yml")
|
||||||
|
engine := getContainerEngine()
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`#!/bin/sh
|
||||||
|
CONFIG=/var/www/html/wp-config.php
|
||||||
|
if [ -f "$CONFIG" ]; then
|
||||||
|
sed -i "s|define('WP_HOME',[^;]*);|define('WP_HOME', '%s');|" "$CONFIG"
|
||||||
|
sed -i "s|define('WP_SITEURL',[^;]*);|define('WP_SITEURL', '%s');|" "$CONFIG"
|
||||||
|
if ! grep -q "define('WP_HOME'" "$CONFIG"; then
|
||||||
|
sed -i "/That's all, stop editing/i define('WP_HOME', '%s');\ndefine('WP_SITEURL', '%s');" "$CONFIG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
`, publicURL, publicURL, publicURL, publicURL)
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(dir, "update-wp-urls.sh")
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(scriptPath)
|
||||||
|
|
||||||
|
cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/update-wp-urls.sh")
|
||||||
|
configureEngineCmd(cpCmd, dir)
|
||||||
|
if err := cpCmd.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/update-wp-urls.sh")
|
||||||
|
configureEngineCmd(execCmd, dir)
|
||||||
|
return execCmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripWordPressHardcodedURLs removes hardcoded WP_HOME/WP_SITEURL defines
|
||||||
|
// from wp-config.php so the studioE5 mu-plugin can compute them from the Host
|
||||||
|
// header. This is useful when repairing older instances created before the
|
||||||
|
// mu-plugin existed.
|
||||||
|
func stripWordPressHardcodedURLs(dataDir, instanceID string) error {
|
||||||
|
dir := instanceDir(dataDir, instanceID)
|
||||||
|
composeFile := filepath.Join(dir, "docker-compose.yml")
|
||||||
|
engine := getContainerEngine()
|
||||||
|
|
||||||
|
script := `#!/bin/sh
|
||||||
|
CONFIG=/var/www/html/wp-config.php
|
||||||
|
if [ -f "$CONFIG" ]; then
|
||||||
|
# Remove hardcoded WP_HOME / WP_SITEURL defines so the mu-plugin controls them
|
||||||
|
sed -i "/define('WP_HOME',/d" "$CONFIG"
|
||||||
|
sed -i "/define('WP_SITEURL',/d" "$CONFIG"
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(dir, "strip-wp-urls.sh")
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(scriptPath)
|
||||||
|
|
||||||
|
cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/strip-wp-urls.sh")
|
||||||
|
configureEngineCmd(cpCmd, dir)
|
||||||
|
if err := cpCmd.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/strip-wp-urls.sh")
|
||||||
|
configureEngineCmd(execCmd, dir)
|
||||||
|
return execCmd.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Télécharge les binaires Tailscale Windows depuis l'installateur MSI officiel.
|
||||||
|
# Nécessite: curl, msitools (msiextract)
|
||||||
|
|
||||||
|
VERSION="${1:-1.98.4}"
|
||||||
|
ARCH="amd64"
|
||||||
|
OUTDIR="$(dirname "$0")/tailscale-bin/windows"
|
||||||
|
MSI_URL="https://pkgs.tailscale.com/stable/tailscale-setup-${VERSION}-${ARCH}.msi"
|
||||||
|
TMPDIR="$(mktemp -d)"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "Downloading Tailscale ${VERSION} Windows installer..."
|
||||||
|
curl -L -o "$TMPDIR/tailscale-setup.msi" "$MSI_URL"
|
||||||
|
|
||||||
|
echo "Extracting binaries..."
|
||||||
|
mkdir -p "$TMPDIR/extract"
|
||||||
|
msiextract -C "$TMPDIR/extract" "$TMPDIR/tailscale-setup.msi" >/dev/null
|
||||||
|
|
||||||
|
echo "Installing to ${OUTDIR}..."
|
||||||
|
mkdir -p "$OUTDIR"
|
||||||
|
cp "$TMPDIR/extract/PFiles64/Tailscale/tailscale.exe" "$OUTDIR/"
|
||||||
|
cp "$TMPDIR/extract/PFiles64/Tailscale/tailscaled.exe" "$OUTDIR/"
|
||||||
|
cp "$TMPDIR/extract/PFiles64/Tailscale/wintun.dll" "$OUTDIR/"
|
||||||
|
|
||||||
|
echo "Done. Installed:"
|
||||||
|
ls -lh "$OUTDIR"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
// hideWindow is a no-op on non-Windows platforms.
|
||||||
|
func hideWindow(cmd *exec.Cmd) {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hideWindow configures a command so that it does not open a console window
|
||||||
|
// when it starts. This is essential for the agent running on student Windows
|
||||||
|
// machines, otherwise every docker/podman/tailscale command flashes a window.
|
||||||
|
func hideWindow(cmd *exec.Cmd) {
|
||||||
|
if cmd.SysProcAttr == nil {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||||
|
}
|
||||||
|
cmd.SysProcAttr.HideWindow = true
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import zlib
|
||||||
|
import struct
|
||||||
|
|
||||||
|
|
||||||
|
def png_chunk(chunk_type: bytes, data: bytes) -> bytes:
|
||||||
|
return (
|
||||||
|
struct.pack(">I", len(data))
|
||||||
|
+ chunk_type
|
||||||
|
+ data
|
||||||
|
+ struct.pack(">I", zlib.crc32(chunk_type + data) & 0xFFFFFFFF)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_png(width: int, height: int, pixels: list) -> bytes:
|
||||||
|
"""Create a PNG from a 2D list of (R, G, B) tuples."""
|
||||||
|
raw = bytearray()
|
||||||
|
for row in pixels:
|
||||||
|
raw.append(0) # no filter
|
||||||
|
for r, g, b in row:
|
||||||
|
raw.extend((r, g, b))
|
||||||
|
compressed = zlib.compress(bytes(raw), 9)
|
||||||
|
|
||||||
|
ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||||
|
ihdr = png_chunk(b"IHDR", ihdr_data)
|
||||||
|
idat = png_chunk(b"IDAT", compressed)
|
||||||
|
iend = png_chunk(b"IEND", b"")
|
||||||
|
|
||||||
|
return b"\x89PNG\r\n\x1a\n" + ihdr + idat + iend
|
||||||
|
|
||||||
|
|
||||||
|
BLUE = (37, 99, 235)
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
SIZE = 64
|
||||||
|
|
||||||
|
pixels = []
|
||||||
|
for y in range(SIZE):
|
||||||
|
row = []
|
||||||
|
for x in range(SIZE):
|
||||||
|
dx = x - SIZE // 2
|
||||||
|
dy = y - SIZE // 2
|
||||||
|
dist = (dx * dx + dy * dy) ** 0.5
|
||||||
|
if dist < SIZE * 0.25:
|
||||||
|
row.append(WHITE)
|
||||||
|
else:
|
||||||
|
row.append(BLUE)
|
||||||
|
pixels.append(row)
|
||||||
|
|
||||||
|
with open("icon.png", "wb") as f:
|
||||||
|
f.write(create_png(SIZE, SIZE, pixels))
|
||||||
|
|
||||||
|
print("Generated icon.png")
|
||||||
+1
-41
@@ -3,52 +3,12 @@ module edubox-agent
|
|||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/systray v1.12.2
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||||
tailscale.com v1.100.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
|
||||||
github.com/akutz/memconn v0.1.0 // indirect
|
|
||||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
|
|
||||||
github.com/coder/websocket v1.8.12 // indirect
|
|
||||||
github.com/creachadair/msync v0.7.1 // indirect
|
|
||||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
|
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
|
||||||
github.com/gaissmai/bart v0.26.1 // indirect
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
|
||||||
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
|
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
|
||||||
github.com/google/btree v1.1.3 // indirect
|
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
|
||||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
|
||||||
github.com/huin/goupnp v1.3.0 // indirect
|
|
||||||
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
|
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
|
||||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
|
||||||
github.com/mdlayher/socket v0.5.0 // indirect
|
|
||||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
|
||||||
github.com/pires/go-proxyproto v0.8.1 // indirect
|
|
||||||
github.com/safchain/ethtool v0.3.0 // indirect
|
|
||||||
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect
|
|
||||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
|
||||||
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect
|
|
||||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
|
|
||||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
|
|
||||||
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad // indirect
|
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
|
||||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
|
||||||
golang.org/x/crypto v0.52.0 // indirect
|
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.55.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/term v0.43.0 // indirect
|
|
||||||
golang.org/x/text v0.37.0 // indirect
|
|
||||||
golang.org/x/time v0.12.0 // indirect
|
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
|
||||||
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-224
@@ -1,233 +1,11 @@
|
|||||||
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
|
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||||
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
|
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
|
||||||
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
|
|
||||||
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
|
|
||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
|
||||||
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
|
||||||
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
|
||||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
|
||||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg=
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y=
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A=
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8=
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
|
|
||||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
|
||||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
|
||||||
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ=
|
|
||||||
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c=
|
|
||||||
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
|
|
||||||
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
|
|
||||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
|
||||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
|
||||||
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
|
|
||||||
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
|
|
||||||
github.com/creachadair/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA=
|
|
||||||
github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs=
|
|
||||||
github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho=
|
|
||||||
github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008=
|
|
||||||
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
|
||||||
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
|
||||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
|
|
||||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
|
|
||||||
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8=
|
|
||||||
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
|
|
||||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
|
|
||||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
|
|
||||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
|
||||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
|
||||||
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
|
||||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
|
||||||
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
|
|
||||||
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
|
||||||
github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8=
|
|
||||||
github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI=
|
|
||||||
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
|
|
||||||
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
|
|
||||||
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
|
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
|
||||||
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
|
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
|
||||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
|
||||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I=
|
|
||||||
github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
|
||||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
|
|
||||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
|
||||||
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
|
||||||
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
|
||||||
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
|
||||||
github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk=
|
|
||||||
github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U=
|
|
||||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
|
|
||||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
|
|
||||||
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
|
|
||||||
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
|
|
||||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
|
||||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
|
||||||
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
|
|
||||||
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
|
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
|
||||||
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
|
|
||||||
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
|
|
||||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
|
||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
|
||||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
|
||||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
|
|
||||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
|
|
||||||
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
|
|
||||||
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
|
|
||||||
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
|
|
||||||
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
|
|
||||||
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
|
|
||||||
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
|
|
||||||
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
|
||||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
|
||||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
|
||||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
|
||||||
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
|
|
||||||
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
|
||||||
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
|
|
||||||
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
|
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
|
||||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
|
||||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
|
||||||
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
|
|
||||||
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
|
|
||||||
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
|
|
||||||
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
|
|
||||||
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 h1:glgVc1ZYMjwN1Q/ITWeuSQyl029uayagaR2sjsifehc=
|
|
||||||
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw=
|
|
||||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
|
||||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
|
||||||
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
|
|
||||||
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
|
|
||||||
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA=
|
|
||||||
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
|
|
||||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
|
|
||||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
|
|
||||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
|
|
||||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
|
|
||||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
|
|
||||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
|
||||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
|
|
||||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
|
|
||||||
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad h1:Ky26FR5yZ5IKEB0xtm5A8xSTb06ImY7kxBFrvgOmJSg=
|
|
||||||
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
|
|
||||||
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
|
|
||||||
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
|
|
||||||
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
|
|
||||||
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
|
||||||
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
|
|
||||||
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
|
|
||||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
|
|
||||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
|
|
||||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
|
||||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
|
||||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
|
||||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
|
||||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
|
|
||||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
|
||||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
|
||||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA=
|
|
||||||
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
|
|
||||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
|
||||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
|
||||||
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
|
||||||
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
|
||||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
|
||||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
|
||||||
tailscale.com v1.100.0 h1:nm/M/dEaW9RaRsGUjW2HsSDpsZ60Jwd9k4gNW9tTFiE=
|
|
||||||
tailscale.com v1.100.0/go.mod h1:DQ9YBy85DpNlSyeU2XRIWzbAu3RsGp/frv+Khg57meE=
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 193 B |
@@ -0,0 +1,127 @@
|
|||||||
|
# Feuille de route — Installateur studioE5 Agent
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Fournir un **installateur professionnel Windows** pour studioE5 Agent, guidé pas à pas, qui gère les prérequis (WSL2 / Podman) et propose une désinstallation complète.
|
||||||
|
|
||||||
|
## Architecture choisie
|
||||||
|
|
||||||
|
- **Wizard C# Windows Forms (.NET 8)** : `setup-wizard/`
|
||||||
|
- Détecte les prérequis.
|
||||||
|
- Installe WSL2 si besoin (avec reprise après redémarrage via `RunOnce`).
|
||||||
|
- Installe Podman depuis le MSI officiel.
|
||||||
|
- Initialise et démarre la machine Podman.
|
||||||
|
- Lance le package Inno Setup de studioE5 Agent.
|
||||||
|
- Mode désinstallation via `/uninstall`.
|
||||||
|
- **Package agent (Inno Setup)** : `studioE5-agent.iss`
|
||||||
|
- Installe `studioE5-agent.exe` + binaires Tailscale.
|
||||||
|
- Crée les raccourcis.
|
||||||
|
- Gère la désinstallation.
|
||||||
|
|
||||||
|
## État actuel
|
||||||
|
|
||||||
|
### ✅ Réalisé
|
||||||
|
|
||||||
|
- Wizard C# avec 7 étapes guidées.
|
||||||
|
- Détection des prérequis : Windows, RAM, disque, WSL2, Podman.
|
||||||
|
- Installation WSL2 en plusieurs étapes contrôlées avec suivi visuel :
|
||||||
|
1. Activation des fonctionnalités Windows (`Microsoft-Windows-Subsystem-Linux` et `VirtualMachinePlatform`) avec gestion du code 3010 (redémarrage nécessaire).
|
||||||
|
2. Installation du package WSL2 complet depuis le bundle Microsoft Store offline (`Microsoft.WSL_*.msixbundle`) via `Add-AppxPackage`.
|
||||||
|
3. Mise à jour du noyau WSL2 depuis le MSI bundlé (`wsl_update_x64.msi`) ou, en dernier recours, via `wsl --update`.
|
||||||
|
4. Définition de WSL2 comme version par défaut (`wsl --set-default-version 2`).
|
||||||
|
5. L’étape `wsl --install --no-distribution` n’est plus utilisée : l’installation est entièrement offline grâce au bundle.
|
||||||
|
6. Conversion des distributions WSL1 existantes vers WSL2 (`wsl --set-version <nom> 2`) si nécessaire.
|
||||||
|
- Reprise automatique après redémarrage grâce à `RunOnce` (y compris redémarrage post-installation WSL2).
|
||||||
|
- Versioning indépendant du wizard (fichier `setup-wizard/VERSION`, affiché dans la fenêtre et la page d’accueil).
|
||||||
|
- Amélioration de l’interface : fenêtre agrandie à 800×600, `AutoScaleMode = Dpi`, meilleur rendu sur petits écrans (11 pouces).
|
||||||
|
- `RunCommand` capture désormais stdout + stderr pour des diagnostics et fallbacks plus fiables.
|
||||||
|
- Détection des prérequis vérifie que WSL2 (et non WSL1) est installé via `wsl --status`.
|
||||||
|
- Installation Podman via MSI bundlé.
|
||||||
|
- Configuration Podman (`machine init` + `machine start`), avec initialisation offline possible depuis une image locale (`podman-machine.*.wsl.tar.zst`).
|
||||||
|
- Lancement du package Inno Setup agent.
|
||||||
|
- Mode désinstallation complet.
|
||||||
|
- Script Inno Setup de base pour l’agent.
|
||||||
|
|
||||||
|
### 🔄 En cours / À tester
|
||||||
|
|
||||||
|
- Compilation et test du wizard sur Windows.
|
||||||
|
- Packaging final (wizard + MSI Podman + setup agent) en un seul dossier distribuable.
|
||||||
|
|
||||||
|
### ⏳ À venir
|
||||||
|
|
||||||
|
- Signature de l’exécutable pour éviter les alertes SmartScreen.
|
||||||
|
- Support macOS et Linux.
|
||||||
|
- Installateur silencieux possible pour déploiement GPO.
|
||||||
|
|
||||||
|
## Build du wizard
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
- Windows 10/11
|
||||||
|
- .NET 8 SDK
|
||||||
|
- Inno Setup 6 (pour générer `studioE5-agent-setup.exe`)
|
||||||
|
|
||||||
|
### Fichiers à placer
|
||||||
|
|
||||||
|
Dans `setup-wizard/Resources/` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
podman-installer-windows-amd64.msi
|
||||||
|
studioE5-agent-setup.exe
|
||||||
|
Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle # package WSL2 complet (offline)
|
||||||
|
podman-machine.x86_64.wsl.tar.zst # image Podman machine pour WSL (offline)
|
||||||
|
docker-compose-windows-x86_64.exe # Docker Compose standalone (offline)
|
||||||
|
wsl_update_x64.msi # optionnel, fallback noyau WSL2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commande
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd setup-wizard
|
||||||
|
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sortie
|
||||||
|
|
||||||
|
```text
|
||||||
|
setup-wizard\bin\Release\net8.0-windows\win-x64\publish\StudioE5-SetupWizard.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build du package agent (Inno Setup)
|
||||||
|
|
||||||
|
Structure attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent/
|
||||||
|
├── studioE5-agent.exe
|
||||||
|
├── tailscale-bin/
|
||||||
|
│ └── windows/
|
||||||
|
│ ├── tailscale.exe
|
||||||
|
│ ├── tailscaled.exe
|
||||||
|
│ └── wintun.dll
|
||||||
|
└── installer/
|
||||||
|
└── studioE5-agent.iss
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrir `studioE5-agent.iss` avec Inno Setup Compiler et compiler (`Ctrl+F9`).
|
||||||
|
|
||||||
|
Le fichier généré se trouve dans `installer-output/`.
|
||||||
|
|
||||||
|
## Notes importantes
|
||||||
|
|
||||||
|
- Le wizard doit être exécuté **en administrateur**.
|
||||||
|
- L’installation de WSL2 nécessite un **redémarrage** de l’ordinateur après l’activation des fonctionnalités Windows.
|
||||||
|
- L’installation de WSL2 est découpée en étapes et chaque étape est enregistrée pour permettre la reprise après redémarrage.
|
||||||
|
- Podman nécessite WSL2 : le wizard vérifie donc que la version par défaut de WSL est 2.
|
||||||
|
- Le MSI Podman officiel pour Windows est `podman-installer-windows-amd64.msi`.
|
||||||
|
- Le bundle WSL2 offline est disponible sur <https://github.com/microsoft/WSL/releases> : `Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle`.
|
||||||
|
- L’image Podman machine offline est disponible sur <https://github.com/containers/podman-machine-os/releases> : `podman-machine.x86_64.wsl.tar.zst`.
|
||||||
|
- Docker Compose standalone est disponible sur <https://github.com/docker/compose/releases> : `docker-compose-windows-x86_64.exe`.
|
||||||
|
- Le wizard crée automatiquement un fichier `.wslconfig` allouant **8 Go de RAM et 4 CPU** à WSL2, ce qui est nécessaire pour des applications lourdes comme PrestaShop.
|
||||||
|
- Pour la désinstallation, le MSI Podman doit être présent dans `Resources/`.
|
||||||
|
|
||||||
|
## Liens utiles
|
||||||
|
|
||||||
|
- Releases Podman : <https://github.com/containers/podman/releases>
|
||||||
|
- Inno Setup : <https://jrsoftware.org/isdl.php>
|
||||||
|
- .NET 8 SDK : <https://dotnet.microsoft.com/download/dotnet/8.0>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StudioE5.SetupWizard;
|
||||||
|
|
||||||
|
public enum WizardStep
|
||||||
|
{
|
||||||
|
Welcome,
|
||||||
|
Prerequisites,
|
||||||
|
InstallVirtualEnvironment,
|
||||||
|
RestartRequired,
|
||||||
|
InstallPodman,
|
||||||
|
ConfigurePodman,
|
||||||
|
InstallAgent,
|
||||||
|
Finished,
|
||||||
|
Uninstall
|
||||||
|
}
|
||||||
|
|
||||||
|
public class InstallerState
|
||||||
|
{
|
||||||
|
[JsonPropertyName("step")]
|
||||||
|
public WizardStep Step { get; set; } = WizardStep.Welcome;
|
||||||
|
|
||||||
|
[JsonPropertyName("virtualEnvironmentInstalled")]
|
||||||
|
public bool VirtualEnvironmentInstalled { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("wslFeaturesEnabled")]
|
||||||
|
public bool WslFeaturesEnabled { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("wslPackageInstalled")]
|
||||||
|
public bool WslPackageInstalled { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("wslDefaultVersionSet")]
|
||||||
|
public bool WslDefaultVersionSet { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("wslKernelUpdated")]
|
||||||
|
public bool WslKernelUpdated { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("podmanInstalled")]
|
||||||
|
public bool PodmanInstalled { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("podmanConfigured")]
|
||||||
|
public bool PodmanConfigured { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("agentInstalled")]
|
||||||
|
public bool AgentInstalled { get; set; }
|
||||||
|
|
||||||
|
private static string StateFilePath
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"studioE5",
|
||||||
|
"installer");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return Path.Combine(dir, "installer-state.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static InstallerState Load()
|
||||||
|
{
|
||||||
|
var path = StateFilePath;
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return new InstallerState();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
return JsonSerializer.Deserialize<InstallerState>(json) ?? new InstallerState();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new InstallerState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
File.WriteAllText(StateFilePath, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Delete()
|
||||||
|
{
|
||||||
|
var path = StateFilePath;
|
||||||
|
if (File.Exists(path))
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Management;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StudioE5.SetupWizard;
|
||||||
|
|
||||||
|
public record PrerequisiteResult(
|
||||||
|
bool WindowsCompatible,
|
||||||
|
ulong RamMB,
|
||||||
|
ulong FreeDiskMB,
|
||||||
|
bool VirtualEnvironmentInstalled,
|
||||||
|
bool PodmanInstalled,
|
||||||
|
bool PodmanMachineReady)
|
||||||
|
{
|
||||||
|
public bool AllReady => WindowsCompatible && RamMB >= 4096 && FreeDiskMB >= 5120 && VirtualEnvironmentInstalled && PodmanInstalled && PodmanMachineReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PrerequisiteChecker
|
||||||
|
{
|
||||||
|
public static PrerequisiteResult Check()
|
||||||
|
{
|
||||||
|
var wsl2Ready = IsWSL2Ready();
|
||||||
|
var podmanMachineReady = IsPodmanMachineReady();
|
||||||
|
|
||||||
|
// Fallback : si la machine Podman est prête, WSL2 est nécessairement fonctionnel.
|
||||||
|
// Cela contourne les problèmes de détection WSL liés à l'encodage ou au PATH.
|
||||||
|
var virtualEnvironmentInstalled = wsl2Ready || podmanMachineReady;
|
||||||
|
|
||||||
|
return new PrerequisiteResult(
|
||||||
|
WindowsCompatible: IsWindowsCompatible(),
|
||||||
|
RamMB: GetTotalPhysicalMemoryMB(),
|
||||||
|
FreeDiskMB: GetFreeDiskSpaceMB("C:\\"),
|
||||||
|
VirtualEnvironmentInstalled: virtualEnvironmentInstalled,
|
||||||
|
PodmanInstalled: IsPodmanInstalled(),
|
||||||
|
PodmanMachineReady: podmanMachineReady
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWindowsCompatible()
|
||||||
|
{
|
||||||
|
var os = Environment.OSVersion;
|
||||||
|
if (os.Platform != PlatformID.Win32NT)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Windows 10 version 2004 (build 19041) or Windows 11.
|
||||||
|
return Environment.OSVersion.Version.Build >= 19041;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong GetTotalPhysicalMemoryMB()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var searcher = new ManagementObjectSearcher("SELECT TotalVisibleMemorySize FROM Win32_OperatingSystem");
|
||||||
|
foreach (ManagementObject obj in searcher.Get())
|
||||||
|
{
|
||||||
|
var kb = Convert.ToUInt64(obj["TotalVisibleMemorySize"]);
|
||||||
|
return kb / 1024;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong GetFreeDiskSpaceMB(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var drive = new DriveInfo(Path.GetPathRoot(path) ?? path);
|
||||||
|
return (ulong)(drive.AvailableFreeSpace / (1024 * 1024));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsWSL2Ready()
|
||||||
|
{
|
||||||
|
// PowerShell gère mieux l'encodage de la sortie WSL que Process.Start en C#.
|
||||||
|
if (IsWSL2ReadyViaPowerShell())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Fallback natif si PowerShell n'est pas disponible.
|
||||||
|
return IsWSL2ReadyNative();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWSL2ReadyViaPowerShell()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tempFile = Path.GetTempFileName();
|
||||||
|
var script =
|
||||||
|
"$status = & wsl.exe --status 2>&1; " +
|
||||||
|
"$ready = ($status -match 'Version par d\\u00E9faut\\s*:\\s*2') -or " +
|
||||||
|
"($status -match 'Default Version\\s*:\\s*2'); " +
|
||||||
|
"$ready | Out-File -FilePath '" + tempFile + "' -Encoding utf8 -NoNewline";
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo("powershell.exe", $"-ExecutionPolicy Bypass -Command \"{script}\"")
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
StandardErrorEncoding = Encoding.UTF8
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = Process.Start(psi);
|
||||||
|
if (process == null) return false;
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
|
if (!File.Exists(tempFile))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var result = File.ReadAllText(tempFile).Trim();
|
||||||
|
File.Delete(tempFile);
|
||||||
|
|
||||||
|
return result.Equals("True", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWSL2ReadyNative()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// wsl --status est plus fiable que --version pour savoir si WSL2 est prêt.
|
||||||
|
var psi = new ProcessStartInfo("wsl.exe", "--status")
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
StandardErrorEncoding = Encoding.UTF8
|
||||||
|
};
|
||||||
|
using var process = Process.Start(psi);
|
||||||
|
if (process == null) return false;
|
||||||
|
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
var error = process.StandardError.ReadToEnd();
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
|
// wsl --status peut retourner un code non nul même quand l’info utile est affichée
|
||||||
|
// (par exemple si aucune distribution n’est installée). On parse quand même.
|
||||||
|
var combined = output + "\n" + error;
|
||||||
|
var normalized = combined
|
||||||
|
.Replace('\u00A0', ' ')
|
||||||
|
.Replace('\u202F', ' ');
|
||||||
|
|
||||||
|
if (normalized.Contains("Version par défaut : 2", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Contains("Default Version: 2", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Contains("Version défaut : 2", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultVersion = ParseWslDefaultVersion(combined);
|
||||||
|
if (defaultVersion == 2)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Si aucune version par défaut n'est trouvée, on tente les autres méthodes.
|
||||||
|
return (defaultVersion == 0 && WslVersionIndicatesWsl2()) ||
|
||||||
|
WslListIndicatesWsl2();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWSLInstalled()
|
||||||
|
{
|
||||||
|
return IsWSL2Ready();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseWslDefaultVersion(string text)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var rawLine in text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
// Normalise les espaces insécables et les espaces multiples.
|
||||||
|
var trimmed = rawLine
|
||||||
|
.Replace('\u00A0', ' ')
|
||||||
|
.Replace('\u202F', ' ')
|
||||||
|
.Trim();
|
||||||
|
|
||||||
|
// Regex souple pour matcher :
|
||||||
|
// - Default Version: 2
|
||||||
|
// - Version par défaut : 2
|
||||||
|
// - Version défaut:2
|
||||||
|
// etc.
|
||||||
|
var match = Regex.Match(
|
||||||
|
trimmed,
|
||||||
|
@"(?i)(?:default\s+version|version\s+(?:par\s+)?d[eé]faut)\s*[:\-]?\s*(\d+)",
|
||||||
|
RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
if (match.Success && int.TryParse(match.Groups[1].Value, out var version))
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool WslVersionIndicatesWsl2()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo("wsl.exe", "--version")
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
StandardErrorEncoding = Encoding.UTF8
|
||||||
|
};
|
||||||
|
using var process = Process.Start(psi);
|
||||||
|
if (process == null) return false;
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
var error = process.StandardError.ReadToEnd();
|
||||||
|
process.WaitForExit();
|
||||||
|
if (process.ExitCode != 0) return false;
|
||||||
|
|
||||||
|
var combined = output + "\n" + error;
|
||||||
|
|
||||||
|
// Si la sortie mentionne explicitement WSL 2 ou un noyau 5.10+, on considère WSL2 prêt.
|
||||||
|
return combined.Contains("WSL version: 2", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
combined.Contains("WSL version: 2.0", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
combined.Contains("Kernel version: 5.10", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool WslListIndicatesWsl2()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo("wsl.exe", "--list --verbose")
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
StandardErrorEncoding = Encoding.UTF8
|
||||||
|
};
|
||||||
|
using var process = Process.Start(psi);
|
||||||
|
if (process == null) return false;
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
var error = process.StandardError.ReadToEnd();
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
|
var combined = output + "\n" + error;
|
||||||
|
|
||||||
|
// Si au moins une distribution est en version 2, WSL2 est fonctionnel.
|
||||||
|
foreach (var line in combined.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length >= 2 && parts[^1] == "2")
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? GetPodmanExePath()
|
||||||
|
{
|
||||||
|
// 1. Chercher dans le PATH actuel du processus.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo("where.exe", "podman.exe")
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
StandardErrorEncoding = Encoding.UTF8
|
||||||
|
};
|
||||||
|
using var process = Process.Start(psi);
|
||||||
|
if (process != null)
|
||||||
|
{
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
process.WaitForExit();
|
||||||
|
if (process.ExitCode == 0)
|
||||||
|
{
|
||||||
|
var firstLine = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(firstLine) && File.Exists(firstLine))
|
||||||
|
return firstLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Chercher dans les emplacements d'installation connus.
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "podman", "podman.exe"),
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "RedHat", "Podman", "podman.exe"),
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Podman", "podman.exe"),
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "RedHat", "Podman", "podman.exe"),
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Podman", "podman.exe"),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPodmanInstalled()
|
||||||
|
{
|
||||||
|
var podmanPath = GetPodmanExePath();
|
||||||
|
if (string.IsNullOrEmpty(podmanPath))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo(podmanPath, "--version")
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
StandardErrorEncoding = Encoding.UTF8
|
||||||
|
};
|
||||||
|
using var process = Process.Start(psi);
|
||||||
|
if (process == null) return false;
|
||||||
|
process.WaitForExit();
|
||||||
|
return process.ExitCode == 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPodmanMachineReady()
|
||||||
|
{
|
||||||
|
var podmanPath = GetPodmanExePath();
|
||||||
|
if (string.IsNullOrEmpty(podmanPath))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo(podmanPath, "machine list --format json")
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
StandardErrorEncoding = Encoding.UTF8
|
||||||
|
};
|
||||||
|
using var process = Process.Start(psi);
|
||||||
|
if (process == null) return false;
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
process.WaitForExit();
|
||||||
|
if (process.ExitCode != 0) return false;
|
||||||
|
|
||||||
|
// Very permissive check: if podman machine list returns any JSON, we consider it ready.
|
||||||
|
return output.TrimStart().StartsWith("[") || output.TrimStart().StartsWith("{");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using StudioE5.SetupWizard;
|
||||||
|
|
||||||
|
static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
static void Main(string[] args)
|
||||||
|
{
|
||||||
|
ApplicationConfiguration.Initialize();
|
||||||
|
|
||||||
|
if (args.Contains("/uninstall", StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Application.Run(new MainForm(startInUninstallMode: true));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Application.Run(new MainForm(startInUninstallMode: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# StudioE5 Setup Wizard
|
||||||
|
|
||||||
|
Assistant d’installation graphique Windows pour studioE5 Agent.
|
||||||
|
|
||||||
|
## Rôle
|
||||||
|
|
||||||
|
Ce wizard guide l’utilisateur pas à pas pour :
|
||||||
|
|
||||||
|
1. Vérifier les prérequis (RAM, disque, Windows, environnement virtuel, Podman).
|
||||||
|
2. Installer l’**environnement virtuel** (WSL2) si nécessaire, avec reprise après redémarrage.
|
||||||
|
3. Installer **Podman** depuis le MSI bundlé.
|
||||||
|
4. Initialiser et démarrer la **machine Podman**.
|
||||||
|
5. Lancer le package **Inno Setup** de studioE5 Agent.
|
||||||
|
|
||||||
|
Il propose aussi un mode **désinstallation** complet (`/uninstall`).
|
||||||
|
|
||||||
|
## Prérequis de build
|
||||||
|
|
||||||
|
- Windows 10/11
|
||||||
|
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
|
||||||
|
- Visual Studio 2022 ou Visual Studio Code (optionnel)
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
setup-wizard/
|
||||||
|
├── SetupWizard.csproj
|
||||||
|
├── Program.cs
|
||||||
|
├── MainForm.cs
|
||||||
|
├── InstallerState.cs
|
||||||
|
├── PrerequisiteChecker.cs
|
||||||
|
├── app.manifest
|
||||||
|
└── Resources/
|
||||||
|
├── podman-installer-windows-amd64.msi # MSI officiel Podman pour Windows
|
||||||
|
├── studioE5-agent-setup.exe # Package Inno Setup de l'agent
|
||||||
|
├── Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle # Package WSL2 complet (offline)
|
||||||
|
├── podman-machine.x86_64.wsl.tar.zst # Image Podman machine pour WSL (offline)
|
||||||
|
├── docker-compose-windows-x86_64.exe # Docker Compose standalone (offline)
|
||||||
|
└── wsl_update_x64.msi # Noyau WSL2 (optionnel, fallback)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Ouvrir un terminal PowerShell dans ce dossier et exécuter :
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour publier un exécutable autonome (pas besoin du runtime .NET sur le poste cible) :
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true
|
||||||
|
```
|
||||||
|
|
||||||
|
L’exécutable se trouve dans :
|
||||||
|
|
||||||
|
```text
|
||||||
|
bin\Release\net8.0-windows\win-x64\publish\StudioE5-SetupWizard.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Préparation du package
|
||||||
|
|
||||||
|
1. Télécharger le MSI Podman Windows :
|
||||||
|
<https://github.com/containers/podman/releases>
|
||||||
|
Le renommer en `podman-installer-windows-amd64.msi` et le placer dans `Resources/`.
|
||||||
|
2. Générer le package Inno Setup de l’agent (`studioE5-agent-setup.exe`) et le placer dans `Resources/`.
|
||||||
|
3. Télécharger le package WSL2 complet (offline) :
|
||||||
|
<https://github.com/microsoft/WSL/releases>
|
||||||
|
Par exemple : `Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle`.
|
||||||
|
Le placer dans `Resources/`.
|
||||||
|
4. Télécharger l’image Podman machine pour WSL (offline) :
|
||||||
|
<https://github.com/containers/podman-machine-os/releases>
|
||||||
|
Par exemple : `podman-machine.x86_64.wsl.tar.zst`.
|
||||||
|
Le placer dans `Resources/`.
|
||||||
|
5. Télécharger Docker Compose standalone (offline) :
|
||||||
|
<https://github.com/docker/compose/releases>
|
||||||
|
Par exemple : `docker-compose-windows-x86_64.exe`.
|
||||||
|
Le placer dans `Resources/`.
|
||||||
|
6. *(Optionnel, fallback)* Télécharger le noyau WSL2 :
|
||||||
|
<https://github.com/microsoft/WSL/releases>
|
||||||
|
Par exemple : `wsl.2.7.10.0.x64.msi`, à renommer en `wsl_update_x64.msi`.
|
||||||
|
Le placer dans `Resources/`.
|
||||||
|
6. Builder et publier le wizard.
|
||||||
|
|
||||||
|
## Lancement
|
||||||
|
|
||||||
|
### Mode installation
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\StudioE5-SetupWizard.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mode désinstallation
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\StudioE5-SetupWizard.exe /uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Le wizard doit être exécuté **en administrateur**.
|
||||||
|
- L’installation de WSL2 nécessite un **redémarrage** de l’ordinateur. Le wizard s’enregistre dans `RunOnce` pour se relancer automatiquement.
|
||||||
|
- Le wizard configure WSL2 avec **8 Go de RAM et 4 CPU** via le fichier `.wslconfig` de l’utilisateur.
|
||||||
|
- Le MSI Podman doit correspondre à l’architecture `x64`.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Feuille de route — Installateur studioE5 Agent
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Fournir un **installateur professionnel Windows** pour studioE5 Agent, guidé pas à pas, qui gère les prérequis (WSL2 / Podman) et propose une désinstallation complète.
|
||||||
|
|
||||||
|
## Architecture choisie
|
||||||
|
|
||||||
|
- **Wizard C# Windows Forms (.NET 8)** : `setup-wizard/`
|
||||||
|
- Détecte les prérequis.
|
||||||
|
- Installe WSL2 si besoin (avec reprise après redémarrage via `RunOnce`).
|
||||||
|
- Installe Podman depuis le MSI officiel.
|
||||||
|
- Initialise et démarre la machine Podman.
|
||||||
|
- Lance le package Inno Setup de studioE5 Agent.
|
||||||
|
- Mode désinstallation via `/uninstall`.
|
||||||
|
- **Package agent (Inno Setup)** : `studioE5-agent.iss`
|
||||||
|
- Installe `studioE5-agent.exe` + binaires Tailscale.
|
||||||
|
- Crée les raccourcis.
|
||||||
|
- Gère la désinstallation.
|
||||||
|
|
||||||
|
## État actuel
|
||||||
|
|
||||||
|
### ✅ Réalisé
|
||||||
|
|
||||||
|
- Wizard C# avec 7 étapes guidées.
|
||||||
|
- Détection des prérequis : Windows, RAM, disque, WSL2, Podman.
|
||||||
|
- Installation WSL2 en plusieurs étapes contrôlées avec suivi visuel :
|
||||||
|
1. Activation des fonctionnalités Windows (`Microsoft-Windows-Subsystem-Linux` et `VirtualMachinePlatform`) avec gestion du code 3010 (redémarrage nécessaire).
|
||||||
|
2. Installation du package WSL2 complet depuis le bundle Microsoft Store offline (`Microsoft.WSL_*.msixbundle`) via `Add-AppxPackage`.
|
||||||
|
3. Mise à jour du noyau WSL2 depuis le MSI bundlé (`wsl_update_x64.msi`) ou, en dernier recours, via `wsl --update`.
|
||||||
|
4. Définition de WSL2 comme version par défaut (`wsl --set-default-version 2`).
|
||||||
|
5. L’étape `wsl --install --no-distribution` n’est plus utilisée : l’installation est entièrement offline grâce au bundle.
|
||||||
|
6. Conversion des distributions WSL1 existantes vers WSL2 (`wsl --set-version <nom> 2`) si nécessaire.
|
||||||
|
- Reprise automatique après redémarrage grâce à `RunOnce` (y compris redémarrage post-installation WSL2).
|
||||||
|
- Versioning indépendant du wizard (fichier `setup-wizard/VERSION`, affiché dans la fenêtre et la page d’accueil).
|
||||||
|
- Amélioration de l’interface : fenêtre agrandie à 800×600, `AutoScaleMode = Dpi`, meilleur rendu sur petits écrans (11 pouces).
|
||||||
|
- `RunCommand` capture désormais stdout + stderr pour des diagnostics et fallbacks plus fiables.
|
||||||
|
- Détection des prérequis vérifie que WSL2 (et non WSL1) est installé via `wsl --status`.
|
||||||
|
- Installation Podman via MSI bundlé.
|
||||||
|
- Configuration Podman (`machine init` + `machine start`), avec initialisation offline possible depuis une image locale (`podman-machine.*.wsl.tar.zst`).
|
||||||
|
- Lancement du package Inno Setup agent.
|
||||||
|
- Mode désinstallation complet.
|
||||||
|
- Script Inno Setup de base pour l’agent.
|
||||||
|
|
||||||
|
### 🔄 En cours / À tester
|
||||||
|
|
||||||
|
- Compilation et test du wizard sur Windows.
|
||||||
|
- Packaging final (wizard + MSI Podman + setup agent) en un seul dossier distribuable.
|
||||||
|
|
||||||
|
### ⏳ À venir
|
||||||
|
|
||||||
|
- Signature de l’exécutable pour éviter les alertes SmartScreen.
|
||||||
|
- Support macOS et Linux.
|
||||||
|
- Installateur silencieux possible pour déploiement GPO.
|
||||||
|
|
||||||
|
## Build du wizard
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
- Windows 10/11
|
||||||
|
- .NET 8 SDK
|
||||||
|
- Inno Setup 6 (pour générer `studioE5-agent-setup.exe`)
|
||||||
|
|
||||||
|
### Fichiers à placer
|
||||||
|
|
||||||
|
Dans `setup-wizard/Resources/` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
podman-installer-windows-amd64.msi
|
||||||
|
studioE5-agent-setup.exe
|
||||||
|
Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle # package WSL2 complet (offline)
|
||||||
|
podman-machine.x86_64.wsl.tar.zst # image Podman machine pour WSL (offline)
|
||||||
|
docker-compose-windows-x86_64.exe # Docker Compose standalone (offline)
|
||||||
|
wsl_update_x64.msi # optionnel, fallback noyau WSL2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commande
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd setup-wizard
|
||||||
|
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sortie
|
||||||
|
|
||||||
|
```text
|
||||||
|
setup-wizard\bin\Release\net8.0-windows\win-x64\publish\StudioE5-SetupWizard.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build du package agent (Inno Setup)
|
||||||
|
|
||||||
|
Structure attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent/
|
||||||
|
├── studioE5-agent.exe
|
||||||
|
├── tailscale-bin/
|
||||||
|
│ └── windows/
|
||||||
|
│ ├── tailscale.exe
|
||||||
|
│ ├── tailscaled.exe
|
||||||
|
│ └── wintun.dll
|
||||||
|
└── installer/
|
||||||
|
└── studioE5-agent.iss
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrir `studioE5-agent.iss` avec Inno Setup Compiler et compiler (`Ctrl+F9`).
|
||||||
|
|
||||||
|
Le fichier généré se trouve dans `installer-output/`.
|
||||||
|
|
||||||
|
## Notes importantes
|
||||||
|
|
||||||
|
- Le wizard doit être exécuté **en administrateur**.
|
||||||
|
- L’installation de WSL2 nécessite un **redémarrage** de l’ordinateur après l’activation des fonctionnalités Windows.
|
||||||
|
- L’installation de WSL2 est découpée en étapes et chaque étape est enregistrée pour permettre la reprise après redémarrage.
|
||||||
|
- Podman nécessite WSL2 : le wizard vérifie donc que la version par défaut de WSL est 2.
|
||||||
|
- Le MSI Podman officiel pour Windows est `podman-installer-windows-amd64.msi`.
|
||||||
|
- Le bundle WSL2 offline est disponible sur <https://github.com/microsoft/WSL/releases> : `Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle`.
|
||||||
|
- L’image Podman machine offline est disponible sur <https://github.com/containers/podman-machine-os/releases> : `podman-machine.x86_64.wsl.tar.zst`.
|
||||||
|
- Docker Compose standalone est disponible sur <https://github.com/docker/compose/releases> : `docker-compose-windows-x86_64.exe`.
|
||||||
|
- Le wizard crée automatiquement un fichier `.wslconfig` allouant **8 Go de RAM et 4 CPU** à WSL2, ce qui est nécessaire pour des applications lourdes comme PrestaShop.
|
||||||
|
- Pour la désinstallation, le MSI Podman doit être présent dans `Resources/`.
|
||||||
|
|
||||||
|
## Liens utiles
|
||||||
|
|
||||||
|
- Releases Podman : <https://github.com/containers/podman/releases>
|
||||||
|
- Inno Setup : <https://jrsoftware.org/isdl.php>
|
||||||
|
- .NET 8 SDK : <https://dotnet.microsoft.com/download/dotnet/8.0>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<SatelliteResourceLanguages>fr</SatelliteResourceLanguages>
|
||||||
|
<RootNamespace>StudioE5.SetupWizard</RootNamespace>
|
||||||
|
<AssemblyName>StudioE5-SetupWizard</AssemblyName>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Fichier de version affiché dans le wizard. -->
|
||||||
|
<Content Include="VERSION">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Tous les fichiers placés dans Resources/ sont copiés dans le répertoire de sortie. -->
|
||||||
|
<!-- Attendus : MSI Podman, setup agent, bundle WSL, image Podman machine, MSI noyau WSL (optionnel). -->
|
||||||
|
<Content Include="Resources\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0.1.1
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="StudioE5.SetupWizard.app"/>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<!-- Force l'exécution en tant qu'administrateur -->
|
||||||
|
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Installe ou répare WSL2 de manière fiable.
|
||||||
|
.DESCRIPTION
|
||||||
|
Ce script :
|
||||||
|
1. Vérifie si WSL2 est déjà prêt.
|
||||||
|
2. Active les fonctionnalités Windows nécessaires.
|
||||||
|
3. Définit WSL2 comme version par défaut.
|
||||||
|
4. Met à jour le noyau WSL2.
|
||||||
|
5. Installe WSL sans distribution si possible.
|
||||||
|
Un redémarrage peut être nécessaire après l’activation des fonctionnalités.
|
||||||
|
#>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Test-Wsl2Ready {
|
||||||
|
try {
|
||||||
|
$output = & wsl.exe --status 2>&1
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
Write-Host "[Test] wsl --status exit code: $exitCode" -ForegroundColor Cyan
|
||||||
|
if ($output) {
|
||||||
|
Write-Host "[Test] wsl --status output:" -ForegroundColor Cyan
|
||||||
|
$output | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($exitCode -eq 0 -or ($output -match "Version par défaut\s*:\s*2") -or ($output -match "Default Version\s*:\s*2")) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "[Test] wsl --status a échoué : $_" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Enable-WindowsFeatureIfNeeded {
|
||||||
|
param([string]$FeatureName)
|
||||||
|
|
||||||
|
Write-Host "[Feature] Activation de $FeatureName..." -ForegroundColor Cyan
|
||||||
|
$result = & dism.exe /online /enable-feature /featurename:$FeatureName /all /norestart 2>&1
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
$result | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||||
|
|
||||||
|
if ($exitCode -eq 0) {
|
||||||
|
Write-Host "[Feature] $FeatureName activé (pas de redémarrage nécessaire)." -ForegroundColor Green
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
elseif ($exitCode -eq 3010) {
|
||||||
|
Write-Host "[Feature] $FeatureName activé, mais un redémarrage est nécessaire (code 3010)." -ForegroundColor Yellow
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw "Échec de l'activation de $FeatureName (code $exitCode)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Install-Wsl2 {
|
||||||
|
Write-Host "[WSL] Tentative d'installation sans distribution..." -ForegroundColor Cyan
|
||||||
|
try {
|
||||||
|
& wsl.exe --install --no-distribution 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "wsl --install --no-distribution a retourné le code $LASTEXITCODE" }
|
||||||
|
Write-Host "[WSL] Installation sans distribution réussie." -ForegroundColor Green
|
||||||
|
return
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "[WSL] Option --no-distribution non supportée ou échec : $_" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[WSL] Fallback : installation classique de WSL..." -ForegroundColor Cyan
|
||||||
|
& wsl.exe --install 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "wsl --install a retourné le code $LASTEXITCODE" }
|
||||||
|
Write-Host "[WSL] Installation classique réussie." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# === Début du script ===
|
||||||
|
|
||||||
|
Write-Host "=== Installation / réparation WSL2 ===" -ForegroundColor Green
|
||||||
|
|
||||||
|
if (Test-Wsl2Ready) {
|
||||||
|
Write-Host "WSL2 est déjà prêt. Rien à faire." -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "WSL2 n'est pas détecté. Lancement de l'installation..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$rebootNeeded = $false
|
||||||
|
$rebootNeeded = (Enable-WindowsFeatureIfNeeded -FeatureName "Microsoft-Windows-Subsystem-Linux") -or $rebootNeeded
|
||||||
|
$rebootNeeded = (Enable-WindowsFeatureIfNeeded -FeatureName "VirtualMachinePlatform") -or $rebootNeeded
|
||||||
|
|
||||||
|
if ($rebootNeeded) {
|
||||||
|
Write-Host "`nUn redémarrage est nécessaire pour activer les fonctionnalités Windows." -ForegroundColor Yellow
|
||||||
|
Write-Host "Après le redémarrage, relance ce script pour terminer l'installation de WSL2." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$response = Read-Host "Redémarrer maintenant ? (O/N)"
|
||||||
|
if ($response -eq "O" -or $response -eq "o") {
|
||||||
|
Restart-Computer -Force
|
||||||
|
}
|
||||||
|
exit 3010
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[WSL] Définition de WSL2 comme version par défaut..." -ForegroundColor Cyan
|
||||||
|
& wsl.exe --set-default-version 2 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "wsl --set-default-version 2 a échoué (code $LASTEXITCODE)." }
|
||||||
|
|
||||||
|
Write-Host "[WSL] Mise à jour du noyau WSL2..." -ForegroundColor Cyan
|
||||||
|
& wsl.exe --update 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||||
|
# 3010 = succès mais redémarrage possible
|
||||||
|
if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 3010) { throw "wsl --update a échoué (code $LASTEXITCODE)." }
|
||||||
|
|
||||||
|
Install-Wsl2
|
||||||
|
|
||||||
|
if (Test-Wsl2Ready) {
|
||||||
|
Write-Host "`nWSL2 est maintenant prêt." -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "`nWSL2 ne semble toujours pas prêt. Essayez de redémarrer et de relancer le script." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
; studioE5 Agent Installer (Inno Setup)
|
||||||
|
; Build with Inno Setup Compiler (ISCC) on Windows.
|
||||||
|
; This installer bundles the agent and Tailscale binaries. It checks
|
||||||
|
; prerequisites and guides the user through installing missing system
|
||||||
|
; components (WSL2 + Podman) before installing studioE5.
|
||||||
|
|
||||||
|
#define MyAppName "studioE5 Agent"
|
||||||
|
#define MyAppVersion "0.3.17"
|
||||||
|
#define MyAppPublisher "studioE5"
|
||||||
|
#define MyAppURL "https://studioe5.edudeploy.com"
|
||||||
|
#define MyAppExeName "studioE5-agent.exe"
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
AppId={{studioE5-agent-ondemand}
|
||||||
|
AppName={#MyAppName}
|
||||||
|
AppVersion={#MyAppVersion}
|
||||||
|
AppPublisher={#MyAppPublisher}
|
||||||
|
AppPublisherURL={#MyAppURL}
|
||||||
|
AppSupportURL={#MyAppURL}
|
||||||
|
AppUpdatesURL={#MyAppURL}
|
||||||
|
DefaultDirName={autopf}\studioE5-agent
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
OutputDir=..\..\installer-output
|
||||||
|
OutputBaseFilename=studioE5-agent-{#MyAppVersion}-setup
|
||||||
|
Compression=lzma
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
PrivilegesRequired=admin
|
||||||
|
ArchitecturesAllowed=x64
|
||||||
|
ArchitecturesInstallIn64BitMode=x64
|
||||||
|
[Languages]
|
||||||
|
Name: "french"; MessagesFile: "compiler:Languages\French.isl"
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "..\..\agent\studioE5-agent.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "..\..\agent\tailscale-bin\windows\tailscale.exe"; DestDir: "{app}\tailscale-bin\windows"; Flags: ignoreversion
|
||||||
|
Source: "..\..\agent\tailscale-bin\windows\tailscaled.exe"; DestDir: "{app}\tailscale-bin\windows"; Flags: ignoreversion
|
||||||
|
Source: "..\..\agent\tailscale-bin\windows\wintun.dll"; DestDir: "{app}\tailscale-bin\windows"; Flags: ignoreversion
|
||||||
|
Source: "..\..\README.md"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{autoprograms}\studioE5 Agent"; Filename: "{app}\{#MyAppExeName}"
|
||||||
|
Name: "{autodesktop}\studioE5 Agent"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "{app}\{#MyAppExeName}"; Description: "Lancer studioE5 Agent"; Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
|
[UninstallRun]
|
||||||
|
Filename: "{cmd}"; Parameters: "/c taskkill /f /im studioE5-agent.exe"; Flags: runhidden waituntilterminated
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
var
|
||||||
|
PrereqPage: TWizardPage;
|
||||||
|
lblStatus: TLabel;
|
||||||
|
btnCheck: TButton;
|
||||||
|
|
||||||
|
function GetPhysicallyInstalledSystemMemoryKB(var TotalMemoryInKilobytes: Int64): Boolean;
|
||||||
|
external 'GetPhysicallyInstalledSystemMemory@kernel32.dll stdcall';
|
||||||
|
|
||||||
|
function GetTotalPhysicalMemoryMB(): Cardinal;
|
||||||
|
var
|
||||||
|
MemKB: Int64;
|
||||||
|
begin
|
||||||
|
if GetPhysicallyInstalledSystemMemoryKB(MemKB) then
|
||||||
|
Result := Cardinal(MemKB div 1024)
|
||||||
|
else
|
||||||
|
Result := 0;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function IsWSL2Installed(): Boolean;
|
||||||
|
var
|
||||||
|
ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
Result := Exec('wsl.exe', '--version', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0);
|
||||||
|
end;
|
||||||
|
|
||||||
|
function IsPodmanReady(): Boolean;
|
||||||
|
var
|
||||||
|
ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
Result := Exec('podman.exe', 'machine list', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0);
|
||||||
|
end;
|
||||||
|
|
||||||
|
function GetDiskFreeSpaceEx(
|
||||||
|
lpDirectoryName: string;
|
||||||
|
var lpFreeBytesAvailableToCaller: Int64;
|
||||||
|
var lpTotalNumberOfBytes: Int64;
|
||||||
|
var lpTotalNumberOfFreeBytes: Int64
|
||||||
|
): Boolean;
|
||||||
|
external 'GetDiskFreeSpaceExW@kernel32.dll stdcall';
|
||||||
|
|
||||||
|
function GetFreeDiskSpaceMB(const Path: string): Cardinal;
|
||||||
|
var
|
||||||
|
FreeBytes, TotalBytes: Int64;
|
||||||
|
Dummy: Int64;
|
||||||
|
begin
|
||||||
|
if GetDiskFreeSpaceEx(Path, FreeBytes, TotalBytes, Dummy) then
|
||||||
|
Result := Cardinal(FreeBytes div (1024 * 1024))
|
||||||
|
else
|
||||||
|
Result := 0;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure UpdatePrereqStatus();
|
||||||
|
var
|
||||||
|
Msg: string;
|
||||||
|
RamMB, FreeMB: Cardinal;
|
||||||
|
WSLReady, PodmanReady: Boolean;
|
||||||
|
begin
|
||||||
|
RamMB := GetTotalPhysicalMemoryMB();
|
||||||
|
FreeMB := GetFreeDiskSpaceMB('C:\');
|
||||||
|
WSLReady := IsWSL2Installed();
|
||||||
|
PodmanReady := IsPodmanReady();
|
||||||
|
|
||||||
|
Msg := 'Vérification des prérequis :' + #13#10#13#10;
|
||||||
|
|
||||||
|
if RamMB >= 8192 then
|
||||||
|
Msg := Msg + '✅ Mémoire vive (RAM) : ' + IntToStr(RamMB div 1024) + ' Go' + #13#10
|
||||||
|
else
|
||||||
|
Msg := Msg + '⚠️ Mémoire vive (RAM) : ' + IntToStr(RamMB div 1024) + ' Go (8 Go recommandés)' + #13#10;
|
||||||
|
|
||||||
|
if FreeMB >= 10240 then
|
||||||
|
Msg := Msg + '✅ Espace disque disponible : ' + IntToStr(FreeMB div 1024) + ' Go' + #13#10
|
||||||
|
else
|
||||||
|
Msg := Msg + '⚠️ Espace disque disponible : ' + IntToStr(FreeMB div 1024) + ' Go (10 Go recommandés)' + #13#10;
|
||||||
|
|
||||||
|
if WSLReady then
|
||||||
|
Msg := Msg + '✅ Environnement de virtualisation (WSL2) installé' + #13#10
|
||||||
|
else
|
||||||
|
Msg := Msg + '❌ Environnement de virtualisation (WSL2) non installé' + #13#10;
|
||||||
|
|
||||||
|
if PodmanReady then
|
||||||
|
Msg := Msg + '✅ Service de conteneurs (Podman) prêt' + #13#10
|
||||||
|
else
|
||||||
|
Msg := Msg + '❌ Service de conteneurs (Podman) non prêt' + #13#10;
|
||||||
|
|
||||||
|
Msg := Msg + #13#10;
|
||||||
|
|
||||||
|
if WSLReady and PodmanReady and (RamMB >= 4096) and (FreeMB >= 5120) then
|
||||||
|
Msg := Msg + 'Tous les prérequis sont satisfaits. Vous pouvez installer studioE5 Agent.'
|
||||||
|
else
|
||||||
|
begin
|
||||||
|
Msg := Msg + 'Ordre d''installation recommandé :' + #13#10;
|
||||||
|
if not WSLReady then
|
||||||
|
Msg := Msg + '1. Installer WSL2 : ouvrir PowerShell en administrateur et exécuter : wsl --install --no-distribution' + #13#10;
|
||||||
|
if not PodmanReady then
|
||||||
|
Msg := Msg + '2. Installer Podman : télécharger et exécuter le MSI depuis https://github.com/containers/podman/releases' + #13#10;
|
||||||
|
if not PodmanReady then
|
||||||
|
Msg := Msg + '3. Initialiser Podman : podman machine init && podman machine start' + #13#10;
|
||||||
|
Msg := Msg + #13#10 + 'Après avoir installé les éléments manquants, relancez cet installateur.';
|
||||||
|
end;
|
||||||
|
|
||||||
|
lblStatus.Caption := Msg;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure btnCheckClick(Sender: TObject);
|
||||||
|
begin
|
||||||
|
UpdatePrereqStatus();
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure InitializeWizard();
|
||||||
|
begin
|
||||||
|
PrereqPage := CreateCustomPage(wpWelcome, 'Vérification des prérequis', 'Assurez-vous que votre poste est prêt avant d''installer studioE5 Agent.');
|
||||||
|
|
||||||
|
lblStatus := TLabel.Create(WizardForm);
|
||||||
|
lblStatus.Parent := PrereqPage.Surface;
|
||||||
|
lblStatus.Left := 0;
|
||||||
|
lblStatus.Top := 0;
|
||||||
|
lblStatus.Width := PrereqPage.SurfaceWidth;
|
||||||
|
lblStatus.Height := 220;
|
||||||
|
lblStatus.AutoSize := False;
|
||||||
|
lblStatus.WordWrap := True;
|
||||||
|
|
||||||
|
btnCheck := TButton.Create(WizardForm);
|
||||||
|
btnCheck.Parent := PrereqPage.Surface;
|
||||||
|
btnCheck.Left := 0;
|
||||||
|
btnCheck.Top := lblStatus.Top + lblStatus.Height + 12;
|
||||||
|
btnCheck.Width := 160;
|
||||||
|
btnCheck.Height := 25;
|
||||||
|
btnCheck.Caption := 'Vérifier les prérequis';
|
||||||
|
btnCheck.OnClick := @btnCheckClick;
|
||||||
|
|
||||||
|
UpdatePrereqStatus();
|
||||||
|
end;
|
||||||
|
|
||||||
|
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||||
|
begin
|
||||||
|
Result := True;
|
||||||
|
if CurPageID = PrereqPage.ID then
|
||||||
|
begin
|
||||||
|
if not (IsWSL2Installed() and IsPodmanReady()) then
|
||||||
|
begin
|
||||||
|
MsgBox('Certains prérequis sont manquants. Veuillez les installer avant de continuer.', mbError, MB_OK);
|
||||||
|
Result := False;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InstanceInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TemplateName string `json:"templateName,omitempty"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func instancesFile(dataDir string) string {
|
||||||
|
return filepath.Join(dataDir, "instances.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadInstances(dataDir string) (map[string]*InstanceInfo, error) {
|
||||||
|
f := instancesFile(dataDir)
|
||||||
|
data, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return make(map[string]*InstanceInfo), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var list []*InstanceInfo
|
||||||
|
if err := json.Unmarshal(data, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := make(map[string]*InstanceInfo)
|
||||||
|
for _, inst := range list {
|
||||||
|
m[inst.ID] = inst
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveInstances(dataDir string, instances map[string]*InstanceInfo) error {
|
||||||
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
list := make([]*InstanceInfo, 0, len(instances))
|
||||||
|
for _, inst := range instances {
|
||||||
|
list = append(list, inst)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(list, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(instancesFile(dataDir), data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertInstance(dataDir string, inst *InstanceInfo) error {
|
||||||
|
instances, err := loadInstances(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
instances[inst.ID] = inst
|
||||||
|
return saveInstances(dataDir, instances)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeInstance(dataDir, instanceID string) error {
|
||||||
|
instances, err := loadInstances(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(instances, instanceID)
|
||||||
|
return saveInstances(dataDir, instances)
|
||||||
|
}
|
||||||
|
|
||||||
|
func instanceURL(inst *InstanceInfo) string {
|
||||||
|
return fmt.Sprintf("http://localhost:%d", inst.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInstanceStatus(dataDir, instanceID string) string {
|
||||||
|
dir := instanceDir(dataDir, instanceID)
|
||||||
|
composeFile := filepath.Join(dir, "docker-compose.yml")
|
||||||
|
if _, err := os.Stat(composeFile); err != nil {
|
||||||
|
return "stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := getContainerEngine()
|
||||||
|
|
||||||
|
// Try modern JSON format first
|
||||||
|
cmd := exec.Command(engine, "compose", "-f", composeFile, "ps", "--format", "json")
|
||||||
|
hideWindow(cmd)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
outStr := strings.TrimSpace(string(out))
|
||||||
|
if outStr == "" || outStr == "[]" {
|
||||||
|
return "stopped"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(outStr, "[") {
|
||||||
|
var containers []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(out, &containers); err == nil {
|
||||||
|
for _, c := range containers {
|
||||||
|
state, _ := c["State"].(string)
|
||||||
|
if state == "running" {
|
||||||
|
return "running"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "stopped"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var c map[string]interface{}
|
||||||
|
if err := json.Unmarshal(out, &c); err == nil {
|
||||||
|
state, _ := c["State"].(string)
|
||||||
|
if state == "running" {
|
||||||
|
return "running"
|
||||||
|
}
|
||||||
|
return "stopped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use "ps -q" which is supported by all docker-compose versions
|
||||||
|
cmd = exec.Command(engine, "compose", "-f", composeFile, "ps", "-q")
|
||||||
|
hideWindow(cmd)
|
||||||
|
out, err = cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(out)) != "" {
|
||||||
|
return "running"
|
||||||
|
}
|
||||||
|
return "stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupOrphanInstanceDirs removes instance directories that have no entry in
|
||||||
|
// instances.json. This typically happens on Windows when a delete operation
|
||||||
|
// could not fully remove the directory because compose.log was locked.
|
||||||
|
func cleanupOrphanInstanceDirs(dataDir string) {
|
||||||
|
instancesDir := filepath.Join(dataDir, "instances")
|
||||||
|
inst, err := loadInstances(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cleanupOrphanInstanceDirs: loadInstances error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(instancesDir)
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
log.Printf("cleanupOrphanInstanceDirs: ReadDir error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := inst[entry.Name()]; !ok {
|
||||||
|
dir := filepath.Join(instancesDir, entry.Name())
|
||||||
|
log.Printf("cleanupOrphanInstanceDirs: removing orphan directory %s", dir)
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
log.Printf("cleanupOrphanInstanceDirs: RemoveAll error for %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
-15
@@ -2,27 +2,28 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// version is injected at build time via -ldflags "-X main.version=X.Y.Z"
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
const APP_NAME = "studioE5"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
serverAddr = flag.String("server", "ws://localhost:3001", "Adresse WebSocket du serveur")
|
dataDir = flag.String("data-dir", "./studioE5-data", "Répertoire de données")
|
||||||
nodeID = flag.String("node-id", defaultNodeID(), "ID du nœud (défaut: hostname)")
|
|
||||||
dataDir = flag.String("data-dir", "./edubox-data", "Répertoire de données")
|
|
||||||
uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX")
|
uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX")
|
||||||
|
noTray = flag.Bool("no-tray", false, "Désactiver l'icône dans la barre système (mode console)")
|
||||||
)
|
)
|
||||||
|
|
||||||
func defaultNodeID() string {
|
|
||||||
h, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -42,11 +43,142 @@ func main() {
|
|||||||
log.Fatalf("Cannot create data-dir: %v", err)
|
log.Fatalf("Cannot create data-dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("EduBox Agent - node: %s - data-dir: %s\n", *nodeID, *dataDir)
|
// Redirect agent logs to a file so the console can be hidden on Windows.
|
||||||
|
agentLogPath := filepath.Join(*dataDir, "agent.log")
|
||||||
|
if agentLogFile, err := os.OpenFile(agentLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||||
|
log.SetOutput(io.MultiWriter(agentLogFile, uiLogWriter{}))
|
||||||
|
} else {
|
||||||
|
log.Printf("Cannot open agent log file %s: %v", agentLogPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, _, err := loadOrCreateConfig(*dataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Cannot load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveConfig(*dataDir, cfg); err != nil {
|
||||||
|
log.Fatalf("Cannot save config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[%s Agent] version=%s node=%s data-dir=%s server=%s", APP_NAME, version, cfg.NodeID, *dataDir, cfg.Server)
|
||||||
|
|
||||||
|
// Clean up instance directories left behind by failed deletes (common on
|
||||||
|
// Windows when compose.log is locked during removal).
|
||||||
|
cleanupOrphanInstanceDirs(*dataDir)
|
||||||
|
|
||||||
|
// Ensure Podman machine DNS is configured on Windows/macOS so images can be
|
||||||
|
// pulled and containers can reach the internet.
|
||||||
|
ensurePodmanMachineDNS()
|
||||||
|
|
||||||
if *uiEnabled {
|
if *uiEnabled {
|
||||||
go startUI(*dataDir, *nodeID, *serverAddr)
|
go startUI(*dataDir, cfg.NodeID, cfg.Server)
|
||||||
}
|
}
|
||||||
|
|
||||||
startWebSocket(*serverAddr, *nodeID, *dataDir)
|
go startWebSocket(cfg, cfg.NodeID, *dataDir)
|
||||||
|
go updateCheckerLoop(cfg, *dataDir)
|
||||||
|
|
||||||
|
shutdownCh := make(chan struct{})
|
||||||
|
|
||||||
|
// Capture Ctrl+C / SIGTERM so a console window close or service stop
|
||||||
|
// triggers the same cleanup path as the tray "Quit" menu.
|
||||||
|
go func() {
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
log.Println("Shutdown signal received")
|
||||||
|
close(shutdownCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var cleanupWg sync.WaitGroup
|
||||||
|
cleanupWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer cleanupWg.Done()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in cleanup goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
<-shutdownCh
|
||||||
|
log.Println("Cleaning up before exit...")
|
||||||
|
|
||||||
|
// Stop Tailscale so the next agent start does not conflict on the
|
||||||
|
// same socket/state.
|
||||||
|
stopTailscale()
|
||||||
|
|
||||||
|
// Stop any running instances so containers are not left behind, but keep
|
||||||
|
// their volumes intact so data survives the next agent start.
|
||||||
|
if inst, err := loadInstances(*dataDir); err == nil {
|
||||||
|
for id, info := range inst {
|
||||||
|
if info.Status == "running" {
|
||||||
|
log.Printf("Stopping instance %s", id)
|
||||||
|
_ = dockerComposeStop(*dataDir, id)
|
||||||
|
inst[id].Status = "stopped"
|
||||||
|
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = saveInstances(*dataDir, inst)
|
||||||
|
}
|
||||||
|
log.Println("Cleanup complete")
|
||||||
|
}()
|
||||||
|
|
||||||
|
if *noTray {
|
||||||
|
log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME)
|
||||||
|
<-shutdownCh
|
||||||
|
cleanupWg.Wait()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tray on its own locked OS thread; keep main blocked so the process
|
||||||
|
// does not exit when systray is not available (e.g. headless Linux).
|
||||||
|
go func() {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
runTray(APP_NAME, shutdownCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-shutdownCh
|
||||||
|
cleanupWg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in startTailscaleAndReport: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ip, err := startTailscale(dataDir, nodeID, headscaleURL, authKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Tailscale error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Tailscale IP obtained: %s", ip)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := sendMessage(WSMessage{Action: "tailscale_ip", NodeID: nodeID, TailscaleIP: ip}); err != nil {
|
||||||
|
log.Printf("Waiting for WebSocket to send tailscale_ip...")
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconfigure tailscale serve for any instances that were left running
|
||||||
|
// (e.g. after an agent restart while containers kept running).
|
||||||
|
if inst, err := loadInstances(dataDir); err == nil {
|
||||||
|
for id, info := range inst {
|
||||||
|
if info.Status == "running" {
|
||||||
|
log.Printf("Reconfiguring tailscale serve for running instance %s on port %d", id, info.Port)
|
||||||
|
if err := setupTailscaleServe(info.Port); err != nil {
|
||||||
|
log.Printf("setupTailscaleServe error for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the local UI that the service status has changed.
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed muplugins/edubox-public-url.php
|
||||||
|
var muPluginContent []byte
|
||||||
|
|
||||||
|
func writeMUPlugin(dataDir string) (string, error) {
|
||||||
|
dir := filepath.Join(dataDir, "mu-plugins")
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "edubox-public-url.php")
|
||||||
|
if err := os.WriteFile(path, muPluginContent, 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: EduBox Public URL
|
||||||
|
* Description: Adapts WordPress to the public URL used by the visitor, especially behind a reverse proxy.
|
||||||
|
* Version: 1.0.0
|
||||||
|
* Author: EduBox
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trust forwarded headers from the EduBox reverse proxy
|
||||||
|
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
|
||||||
|
if (strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') {
|
||||||
|
$_SERVER['HTTPS'] = 'on';
|
||||||
|
if (!isset($_SERVER['SERVER_PORT']) || $_SERVER['SERVER_PORT'] == 80) {
|
||||||
|
$_SERVER['SERVER_PORT'] = 443;
|
||||||
|
}
|
||||||
|
} elseif (strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'http') {
|
||||||
|
$_SERVER['HTTPS'] = 'off';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_SERVER['HTTP_X_FORWARDED_HOST']) && !empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
|
||||||
|
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_X_FORWARDED_HOST'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the public URL from the current request
|
||||||
|
$edubox_scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
$edubox_host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
$edubox_public_url = $edubox_scheme . '://' . $edubox_host;
|
||||||
|
|
||||||
|
// Define WP_HOME/WP_SITEURL if not already hardcoded in wp-config.php
|
||||||
|
if (!defined('WP_HOME')) {
|
||||||
|
define('WP_HOME', $edubox_public_url);
|
||||||
|
}
|
||||||
|
if (!defined('WP_SITEURL')) {
|
||||||
|
define('WP_SITEURL', $edubox_public_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trust the forwarded port as well when present
|
||||||
|
if (isset($_SERVER['HTTP_X_FORWARDED_PORT']) && !empty($_SERVER['HTTP_X_FORWARDED_PORT'])) {
|
||||||
|
$_SERVER['SERVER_PORT'] = $_SERVER['HTTP_X_FORWARDED_PORT'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback filters in case options are stored with a different URL
|
||||||
|
add_filter('option_home', 'edubox_filter_public_url');
|
||||||
|
add_filter('option_siteurl', 'edubox_filter_public_url');
|
||||||
|
add_filter('home_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('site_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('admin_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('includes_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('content_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('plugins_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('wp_login_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('wp_logout_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('wp_redirect', 'edubox_filter_public_url');
|
||||||
|
add_filter('wp_redirect_location', 'edubox_filter_public_url');
|
||||||
|
|
||||||
|
function edubox_filter_public_url($url) {
|
||||||
|
if (!is_string($url) || empty($url)) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
$public = $scheme . '://' . $host;
|
||||||
|
|
||||||
|
// Replace known internal bases with the public URL. Include localhost with
|
||||||
|
// any port, as well as plain http://localhost (which WordPress sometimes
|
||||||
|
// stores without port).
|
||||||
|
if (preg_match('#^(https?)://localhost(:\d+)#i', $url, $matches)) {
|
||||||
|
return $public . substr($url, strlen($matches[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$internal_bases = [
|
||||||
|
'http://localhost',
|
||||||
|
'https://localhost',
|
||||||
|
];
|
||||||
|
foreach ($internal_bases as $base) {
|
||||||
|
if (strpos($url, $base) === 0) {
|
||||||
|
return $public . substr($url, strlen($base));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure auth/secure cookies are marked Secure when served over HTTPS.
|
||||||
|
add_filter('cookie_secure', function ($secure) {
|
||||||
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $secure;
|
||||||
|
}, 999);
|
||||||
|
|
||||||
|
// Force logged-in cookies to be secure as well.
|
||||||
|
add_filter('secure_logged_in_cookie', function ($secure) {
|
||||||
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $secure;
|
||||||
|
}, 999);
|
||||||
|
|
||||||
|
add_filter('secure_auth_cookie', function ($secure) {
|
||||||
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $secure;
|
||||||
|
}, 999);
|
||||||
|
|
||||||
|
// Help WordPress believe the request method is the real one (Next.js proxy
|
||||||
|
// preserves this, but some edge cases may benefit).
|
||||||
|
if (isset($_SERVER['HTTP_X_FORWARDED_METHOD']) && !empty($_SERVER['HTTP_X_FORWARDED_METHOD'])) {
|
||||||
|
$_SERVER['REQUEST_METHOD'] = strtoupper($_SERVER['HTTP_X_FORWARDED_METHOD']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
type podmanMachine struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
VMType string `json:"vm_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensurePodmanMachineDNS configures public DNS resolvers on running Podman
|
||||||
|
// machines on Windows and macOS. This is required because the Podman VM does
|
||||||
|
// not always inherit a working DNS from the host, which prevents pulling
|
||||||
|
// images and reaching api.wordpress.org from containers.
|
||||||
|
func ensurePodmanMachineDNS() {
|
||||||
|
if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if getContainerEngine() != "podman" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.Command("podman", "machine", "list", "--format", "json").Output()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ensurePodmanMachineDNS: cannot list machines: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var machines []podmanMachine
|
||||||
|
if err := json.Unmarshal(out, &machines); err != nil {
|
||||||
|
log.Printf("ensurePodmanMachineDNS: cannot parse machine list: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range machines {
|
||||||
|
if !m.Running {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := configurePodmanMachineDNS(m.Name); err != nil {
|
||||||
|
log.Printf("ensurePodmanMachineDNS: failed for %s: %v", m.Name, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("ensurePodmanMachineDNS: DNS configured for %s", m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configurePodmanMachineDNS(name string) error {
|
||||||
|
cmd := exec.Command(
|
||||||
|
"podman", "machine", "ssh", name,
|
||||||
|
"sudo", "sh", "-c",
|
||||||
|
"echo nameserver 8.8.8.8 > /etc/resolv.conf && echo nameserver 1.1.1.1 >> /etc/resolv.conf",
|
||||||
|
)
|
||||||
|
hideWindow(cmd)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProxyModeDisabled = "disabled"
|
||||||
|
ProxyModeAuto = "auto"
|
||||||
|
ProxyModeEnabled = "enabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// autoProxyLockDuration is the minimum time we stay in proxy mode once the
|
||||||
|
// agent automatically switched to it. This prevents flip-flopping on short
|
||||||
|
// network blips.
|
||||||
|
const autoProxyLockDuration = 5 * time.Minute
|
||||||
|
|
||||||
|
// proxyState tracks the runtime proxy decision in "auto" mode. It is guarded
|
||||||
|
// by proxyMu.
|
||||||
|
var (
|
||||||
|
proxyMu sync.RWMutex
|
||||||
|
proxyActive bool
|
||||||
|
proxyLockedUntil time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
// proxyMode normalizes the configured proxy mode.
|
||||||
|
func proxyMode(cfg *AgentConfig) string {
|
||||||
|
if cfg == nil {
|
||||||
|
return ProxyModeDisabled
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(cfg.ProxyMode)) {
|
||||||
|
case ProxyModeEnabled:
|
||||||
|
return ProxyModeEnabled
|
||||||
|
case ProxyModeAuto:
|
||||||
|
return ProxyModeAuto
|
||||||
|
default:
|
||||||
|
return ProxyModeDisabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsProxyActive reports whether outbound requests should currently go through
|
||||||
|
// the configured proxy. In "enabled" mode it always returns true; in "auto"
|
||||||
|
// mode it reflects the last automatic decision.
|
||||||
|
func IsProxyActive() bool {
|
||||||
|
proxyMu.RLock()
|
||||||
|
defer proxyMu.RUnlock()
|
||||||
|
return proxyActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// setProxyActive updates the runtime proxy decision and, in auto mode, locks
|
||||||
|
// the decision for autoProxyLockDuration to avoid flip-flopping.
|
||||||
|
func setProxyActive(active bool) bool {
|
||||||
|
proxyMu.Lock()
|
||||||
|
defer proxyMu.Unlock()
|
||||||
|
changed := proxyActive != active
|
||||||
|
proxyActive = active
|
||||||
|
if active {
|
||||||
|
proxyLockedUntil = time.Now().Add(autoProxyLockDuration)
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetProxyState disables the automatic proxy decision. Call this when the
|
||||||
|
// configuration changes.
|
||||||
|
func resetProxyState() {
|
||||||
|
proxyMu.Lock()
|
||||||
|
proxyActive = false
|
||||||
|
proxyLockedUntil = time.Time{}
|
||||||
|
proxyMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// canRetryDirect reports whether enough time has passed to try a direct
|
||||||
|
// connection again while in auto-proxy mode.
|
||||||
|
func canRetryDirect() bool {
|
||||||
|
proxyMu.RLock()
|
||||||
|
defer proxyMu.RUnlock()
|
||||||
|
return time.Now().After(proxyLockedUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyURL parses and validates the configured proxy URL.
|
||||||
|
func proxyURL(cfg *AgentConfig) *url.URL {
|
||||||
|
if cfg == nil || cfg.ProxyURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u, err := url.Parse(cfg.ProxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyFunc returns a proxy selection function for http.Transport. It returns
|
||||||
|
// nil when the proxy should not be used.
|
||||||
|
func proxyFunc(cfg *AgentConfig) func(*http.Request) (*url.URL, error) {
|
||||||
|
mode := proxyMode(cfg)
|
||||||
|
u := proxyURL(cfg)
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case ProxyModeEnabled:
|
||||||
|
if u == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(*http.Request) (*url.URL, error) { return u, nil }
|
||||||
|
case ProxyModeAuto:
|
||||||
|
if u == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !IsProxyActive() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(*http.Request) (*url.URL, error) { return u, nil }
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// websocketDialer returns a websocket.Dialer configured for the current proxy
|
||||||
|
// mode and state.
|
||||||
|
func websocketDialer(cfg *AgentConfig) *websocket.Dialer {
|
||||||
|
d := websocket.DefaultDialer
|
||||||
|
fn := proxyFunc(cfg)
|
||||||
|
if fn == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return &websocket.Dialer{
|
||||||
|
Proxy: fn,
|
||||||
|
HandshakeTimeout: d.HandshakeTimeout,
|
||||||
|
ReadBufferSize: d.ReadBufferSize,
|
||||||
|
WriteBufferSize: d.WriteBufferSize,
|
||||||
|
EnableCompression: d.EnableCompression,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpClientWithProxy returns an http.Client configured for the current proxy
|
||||||
|
// mode and state.
|
||||||
|
func httpClientWithProxy(cfg *AgentConfig) *http.Client {
|
||||||
|
fn := proxyFunc(cfg)
|
||||||
|
if fn == nil {
|
||||||
|
return http.DefaultClient
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Transport: &http.Transport{Proxy: fn},
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/systray"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed icon.png
|
||||||
|
var iconBytes []byte
|
||||||
|
|
||||||
|
const uiURL = "http://localhost:7070"
|
||||||
|
|
||||||
|
func runTray(appName string, shutdownCh chan struct{}) {
|
||||||
|
systray.Run(func() { onTrayReady(appName, shutdownCh) }, func() { onTrayExit(shutdownCh) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func onTrayReady(appName string, shutdownCh chan struct{}) {
|
||||||
|
systray.SetIcon(iconBytes)
|
||||||
|
systray.SetTitle(appName)
|
||||||
|
systray.SetTooltip(fmt.Sprintf("%s Agent - Cliquez pour ouvrir l'interface", appName))
|
||||||
|
|
||||||
|
mOpen := systray.AddMenuItem("Ouvrir l'interface", "Ouvrir l'interface web locale")
|
||||||
|
mInstances := systray.AddMenuItem("Mes instances", "Afficher les instances")
|
||||||
|
mSettings := systray.AddMenuItem("Paramètres", "Ouvrir les paramètres")
|
||||||
|
systray.AddSeparator()
|
||||||
|
mQuit := systray.AddMenuItem("Quitter", "Arrêter l'agent")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mOpen.ClickedCh:
|
||||||
|
openBrowser(uiURL)
|
||||||
|
case <-mInstances.ClickedCh:
|
||||||
|
openBrowser(uiURL + "#instances")
|
||||||
|
case <-mSettings.ClickedCh:
|
||||||
|
openBrowser(uiURL + "#settings")
|
||||||
|
case <-mQuit.ClickedCh:
|
||||||
|
close(shutdownCh)
|
||||||
|
systray.Quit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onTrayExit(shutdownCh chan struct{}) {
|
||||||
|
log.Printf("Tray exit requested")
|
||||||
|
// If the user did not already trigger shutdown via the menu, signal it now.
|
||||||
|
select {
|
||||||
|
case <-shutdownCh:
|
||||||
|
default:
|
||||||
|
close(shutdownCh)
|
||||||
|
}
|
||||||
|
// Give other goroutines a moment to clean up, then exit.
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBrowser(url string) {
|
||||||
|
var cmd string
|
||||||
|
var args []string
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
cmd = "rundll32"
|
||||||
|
args = []string{"url.dll,FileProtocolHandler", url}
|
||||||
|
case "darwin":
|
||||||
|
cmd = "open"
|
||||||
|
args = []string{url}
|
||||||
|
default:
|
||||||
|
cmd = "xdg-open"
|
||||||
|
args = []string{url}
|
||||||
|
}
|
||||||
|
|
||||||
|
openCmd := exec.Command(cmd, args...)
|
||||||
|
hideWindow(openCmd)
|
||||||
|
if err := openCmd.Start(); err != nil {
|
||||||
|
log.Printf("Failed to open browser: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeName(name string) string {
|
||||||
|
return strings.ReplaceAll(name, " ", "")
|
||||||
|
}
|
||||||
+292
-13
@@ -1,28 +1,307 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"tailscale.com/tsnet"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func startTailscale(dataDir string, nodeID string) (net.Listener, error) {
|
var (
|
||||||
s := &tsnet.Server{
|
tsCmd *exec.Cmd
|
||||||
Hostname: nodeID,
|
tsCmdMu sync.Mutex
|
||||||
Dir: dataDir,
|
tsIP string
|
||||||
Logf: log.Printf,
|
tsDataDir string
|
||||||
|
tsSocket string
|
||||||
|
)
|
||||||
|
|
||||||
|
type tailscaleStatus struct {
|
||||||
|
Self struct {
|
||||||
|
TailscaleIPs []string `json:"TailscaleIPs"`
|
||||||
|
} `json:"Self"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Start(); err != nil {
|
func tailscaleBin(name string) string {
|
||||||
return nil, fmt.Errorf("tailscale start: %w", err)
|
// Prefer bundled binaries (tailscale-bin/<os>/tailscaled etc.).
|
||||||
|
ex, err := os.Executable()
|
||||||
|
if err == nil {
|
||||||
|
bundled := filepath.Join(filepath.Dir(ex), "tailscale-bin", runtime.GOOS, name)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
bundled += ".exe"
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(bundled); err == nil {
|
||||||
|
return bundled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p, err := exec.LookPath(name); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
ln, err := s.Listen("tcp", ":0")
|
func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, error) {
|
||||||
|
tsCmdMu.Lock()
|
||||||
|
defer tsCmdMu.Unlock()
|
||||||
|
|
||||||
|
if tsCmd != nil {
|
||||||
|
return tsIP, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if dataDir == "" {
|
||||||
|
return "", fmt.Errorf("tailscale data dir is empty")
|
||||||
|
}
|
||||||
|
tsDataDir = filepath.Join(dataDir, "tailscale")
|
||||||
|
if err := os.MkdirAll(tsDataDir, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("create tailscale dir: %w", err)
|
||||||
|
}
|
||||||
|
// Make sure a previous tailscaled (e.g. left behind after a crash or
|
||||||
|
// force-kill) does not block the new daemon on the same socket/state.
|
||||||
|
killStaleTailscaled(tsDataDir)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// Windows uses named pipes for tailscaled IPC, not Unix sockets.
|
||||||
|
tsSocket = `\\.\pipe\studioe5-tailscaled`
|
||||||
|
} else {
|
||||||
|
tsSocket = filepath.Join(tsDataDir, "tailscaled.sock")
|
||||||
|
}
|
||||||
|
stateFile := filepath.Join(tsDataDir, "tailscaled.state")
|
||||||
|
|
||||||
|
log.Printf("Starting tailscaled for node %s (socket=%s)", nodeID, tsSocket)
|
||||||
|
tsCmd = exec.Command(tailscaleBin("tailscaled"),
|
||||||
|
"--state="+stateFile,
|
||||||
|
"--socket="+tsSocket,
|
||||||
|
"--tun=userspace-networking",
|
||||||
|
)
|
||||||
|
hideWindow(tsCmd)
|
||||||
|
// Redirect tailscaled output to a dedicated log file.
|
||||||
|
tsLogPath := filepath.Join(tsDataDir, "tailscaled.log")
|
||||||
|
if tsLogFile, err := os.OpenFile(tsLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||||
|
tsCmd.Stdout = tsLogFile
|
||||||
|
tsCmd.Stderr = tsLogFile
|
||||||
|
} else {
|
||||||
|
log.Printf("Cannot open tailscaled log file %s: %v", tsLogPath, err)
|
||||||
|
}
|
||||||
|
if err := tsCmd.Start(); err != nil {
|
||||||
|
tsCmd = nil
|
||||||
|
return "", fmt.Errorf("start tailscaled: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tsDataDir, "tailscaled.pid"), []byte(strconv.Itoa(tsCmd.Process.Pid)), 0644); err != nil {
|
||||||
|
log.Printf("Cannot write tailscaled pid file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give tailscaled a moment to start listening.
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
// Bring the interface up with the auth key.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
upArgs := []string{
|
||||||
|
"--socket=" + tsSocket,
|
||||||
|
"up",
|
||||||
|
"--login-server=" + headscaleURL,
|
||||||
|
"--hostname=" + nodeID,
|
||||||
|
"--accept-dns=false",
|
||||||
|
}
|
||||||
|
// The auth key is omitted on reconnect: Tailscale reuses the existing state.
|
||||||
|
if authKey != "" {
|
||||||
|
upArgs = append(upArgs, "--authkey="+authKey)
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// On Windows, keep the VPN up even after the tailscale.exe CLI client disconnects.
|
||||||
|
upArgs = append(upArgs, "--unattended")
|
||||||
|
} else {
|
||||||
|
// --operator is only meaningful on Unix systems.
|
||||||
|
upArgs = append(upArgs, "--operator=root")
|
||||||
|
}
|
||||||
|
upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"), upArgs...)
|
||||||
|
hideWindow(upCmd)
|
||||||
|
upCmd.Stdout = log.Writer()
|
||||||
|
upCmd.Stderr = log.Writer()
|
||||||
|
if err := upCmd.Run(); err != nil {
|
||||||
|
_ = tsCmd.Process.Kill()
|
||||||
|
_ = tsCmd.Wait()
|
||||||
|
tsCmd = nil
|
||||||
|
return "", fmt.Errorf("tailscale up: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for an IP address.
|
||||||
|
for {
|
||||||
|
statusCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"),
|
||||||
|
"--socket="+tsSocket,
|
||||||
|
"status", "--json",
|
||||||
|
)
|
||||||
|
hideWindow(statusCmd)
|
||||||
|
out, err := statusCmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("tailscale listen: %w", err)
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = tsCmd.Process.Kill()
|
||||||
|
_ = tsCmd.Wait()
|
||||||
|
tsCmd = nil
|
||||||
|
return "", fmt.Errorf("tailscale status: %w", err)
|
||||||
|
default:
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var st tailscaleStatus
|
||||||
|
if err := json.Unmarshal(out, &st); err == nil && len(st.Self.TailscaleIPs) > 0 {
|
||||||
|
tsIP = st.Self.TailscaleIPs[0]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = tsCmd.Process.Kill()
|
||||||
|
_ = tsCmd.Wait()
|
||||||
|
tsCmd = nil
|
||||||
|
return "", fmt.Errorf("tailscale IP timeout")
|
||||||
|
default:
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ln, nil
|
log.Printf("Tailscale started with IP: %s", tsIP)
|
||||||
|
return tsIP, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stopTailscale() {
|
||||||
|
tsCmdMu.Lock()
|
||||||
|
defer tsCmdMu.Unlock()
|
||||||
|
stopTailscaleLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopTailscaleLocked() {
|
||||||
|
if tsCmd == nil || tsCmd.Process == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tsSocket != "" {
|
||||||
|
downCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down")
|
||||||
|
hideWindow(downCmd)
|
||||||
|
_ = downCmd.Run()
|
||||||
|
}
|
||||||
|
_ = tsCmd.Process.Kill()
|
||||||
|
_ = tsCmd.Wait()
|
||||||
|
tsCmd = nil
|
||||||
|
tsIP = ""
|
||||||
|
if tsDataDir != "" {
|
||||||
|
_ = os.Remove(filepath.Join(tsDataDir, "tailscaled.pid"))
|
||||||
|
}
|
||||||
|
log.Printf("Tailscale stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTailscaleRunning() bool {
|
||||||
|
tsCmdMu.Lock()
|
||||||
|
defer tsCmdMu.Unlock()
|
||||||
|
if tsCmd == nil || tsCmd.Process == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Signal 0 checks process existence without affecting it.
|
||||||
|
return tsCmd.Process.Signal(syscall.Signal(0)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTailscaleIP() string {
|
||||||
|
tsCmdMu.Lock()
|
||||||
|
defer tsCmdMu.Unlock()
|
||||||
|
return tsIP
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTailscaleReady reports whether tailscaled is running and has successfully
|
||||||
|
// joined the tailnet (i.e. it has a Tailscale IP). It does not rely on
|
||||||
|
// isTailscaleRunning because tailscaled may have been started by a previous
|
||||||
|
// agent run or externally; the important thing is that the socket responds.
|
||||||
|
func isTailscaleReady() bool {
|
||||||
|
tsCmdMu.Lock()
|
||||||
|
socket := tsSocket
|
||||||
|
tsCmdMu.Unlock()
|
||||||
|
if socket == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+socket, "status", "--json")
|
||||||
|
hideWindow(statusCmd)
|
||||||
|
out, err := statusCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var st tailscaleStatus
|
||||||
|
if err := json.Unmarshal(out, &st); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(st.Self.TailscaleIPs) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTailscaleServe configures Tailscale to proxy inbound Tailnet traffic
|
||||||
|
// on the given TCP port to localhost:<port>. This is required on Windows
|
||||||
|
// because userspace networking does not forward incoming connections to
|
||||||
|
// loopback by default.
|
||||||
|
func setupTailscaleServe(port int) error {
|
||||||
|
tsCmdMu.Lock()
|
||||||
|
defer tsCmdMu.Unlock()
|
||||||
|
if tsSocket == "" {
|
||||||
|
return fmt.Errorf("tailscale socket not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
portStr := strconv.Itoa(port)
|
||||||
|
// Clean up any stale config for this port first.
|
||||||
|
offCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "off")
|
||||||
|
hideWindow(offCmd)
|
||||||
|
_ = offCmd.Run()
|
||||||
|
|
||||||
|
serveCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "tcp://localhost:"+portStr)
|
||||||
|
hideWindow(serveCmd)
|
||||||
|
out, err := serveCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tailscale serve: %w: %s", err, string(out))
|
||||||
|
}
|
||||||
|
log.Printf("Tailscale serve configured for port %s", portStr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeTailscaleServe removes the Tailscale serve proxy for a port when an
|
||||||
|
// instance is stopped or deleted.
|
||||||
|
func removeTailscaleServe(port int) {
|
||||||
|
tsCmdMu.Lock()
|
||||||
|
defer tsCmdMu.Unlock()
|
||||||
|
if tsSocket == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
portStr := strconv.Itoa(port)
|
||||||
|
offCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "off")
|
||||||
|
hideWindow(offCmd)
|
||||||
|
_ = offCmd.Run()
|
||||||
|
log.Printf("Tailscale serve removed for port %s", portStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// killStaleTailscaled terminates a previously started tailscaled process that
|
||||||
|
// may have been left running after the agent was force-killed.
|
||||||
|
func killStaleTailscaled(tsDataDir string) {
|
||||||
|
pidFile := filepath.Join(tsDataDir, "tailscaled.pid")
|
||||||
|
data, err := os.ReadFile(pidFile)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pid int
|
||||||
|
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proc, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := proc.Signal(syscall.Signal(0)); err == nil {
|
||||||
|
log.Printf("Killing stale tailscaled process %d", pid)
|
||||||
|
_ = proc.Kill()
|
||||||
|
_, _ = proc.Wait()
|
||||||
|
}
|
||||||
|
_ = os.Remove(pidFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const nodeTokenFileName = "node.token"
|
||||||
|
|
||||||
|
func nodeTokenPath(dataDir string) string {
|
||||||
|
return filepath.Join(dataDir, nodeTokenFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadNodeToken reads the persisted node authentication token, if any.
|
||||||
|
func loadNodeToken(dataDir string) (string, error) {
|
||||||
|
path := nodeTokenPath(dataDir)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveNodeToken persists the node authentication token with restrictive permissions.
|
||||||
|
func saveNodeToken(dataDir string, token string) error {
|
||||||
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := nodeTokenPath(dataDir)
|
||||||
|
return os.WriteFile(path, []byte(token), 0600)
|
||||||
|
}
|
||||||
+427
-14
@@ -2,11 +2,16 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
@@ -16,12 +21,123 @@ var uiHTML string
|
|||||||
|
|
||||||
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||||
|
|
||||||
|
// uiConnections holds active WebSocket connections from local UI clients.
|
||||||
|
var (
|
||||||
|
uiConnections = make(map[*websocket.Conn]bool)
|
||||||
|
uiConnectionsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// uiLogWriter intercepts log output and forwards it to connected UI clients.
|
||||||
|
type uiLogWriter struct{}
|
||||||
|
|
||||||
|
func (w uiLogWriter) Write(p []byte) (n int, err error) {
|
||||||
|
line := strings.TrimSpace(string(p))
|
||||||
|
if line != "" {
|
||||||
|
sendUILog(line)
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func startUI(dataDir, nodeID, serverAddr string) {
|
func startUI(dataDir, nodeID, serverAddr string) {
|
||||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
fmt.Fprint(w, uiHTML)
|
fmt.Fprint(w, uiHTML)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
cfg, _, err := loadOrCreateConfig(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Expose a merged view with the agent version for the UI.
|
||||||
|
serverVersion := getServerAgentVersion()
|
||||||
|
updateAvailable := serverVersion != "" && serverVersion != version
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"server": cfg.Server,
|
||||||
|
"headscale_url": cfg.HeadscaleURL,
|
||||||
|
"headscale_auth_key": cfg.HeadscaleAuthKey,
|
||||||
|
"node_id": cfg.NodeID,
|
||||||
|
"data_dir": cfg.DataDir,
|
||||||
|
"proxy_url": cfg.ProxyURL,
|
||||||
|
"proxy_mode": cfg.ProxyMode,
|
||||||
|
"version": version,
|
||||||
|
"server_version": serverVersion,
|
||||||
|
"update_available": updateAvailable,
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
case http.MethodPost:
|
||||||
|
var cfg AgentConfig
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.DataDir == "" {
|
||||||
|
cfg.DataDir = dataDir
|
||||||
|
}
|
||||||
|
if err := saveConfig(dataDir, &cfg); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/api/restart", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
go func() {
|
||||||
|
cmd := exec.Command(os.Args[0], os.Args[1:]...)
|
||||||
|
hideWindow(cmd)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
log.Printf("Restart failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/api/update", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, _, err := loadOrCreateConfig(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "update_progress",
|
||||||
|
"percent": "10",
|
||||||
|
"message": "Téléchargement de la mise à jour...",
|
||||||
|
})
|
||||||
|
if err := startAgentUpdate(cfg, dataDir); err != nil {
|
||||||
|
log.Printf("Agent update failed: %v", err)
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "update_progress",
|
||||||
|
"percent": "0",
|
||||||
|
"message": "Échec de la mise à jour : " + err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -29,23 +145,33 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
uiConnectionsMu.Lock()
|
||||||
|
uiConnections[conn] = true
|
||||||
|
uiConnectionsMu.Unlock()
|
||||||
log.Printf("UI client connected from %s", r.RemoteAddr)
|
log.Printf("UI client connected from %s", r.RemoteAddr)
|
||||||
|
|
||||||
|
// Send current status immediately.
|
||||||
|
sendUIStatus(conn, dataDir)
|
||||||
|
|
||||||
// Register notifier to forward activation results from main WS to this UI connection
|
// Register notifier to forward activation results from main WS to this UI connection
|
||||||
notifierID := registerUINotifier(func(msg map[string]interface{}) {
|
notifierID := registerUINotifier(func(msg map[string]interface{}) {
|
||||||
log.Printf("UI notifier forwarding to browser: %+v", msg)
|
|
||||||
if err := conn.WriteJSON(msg); err != nil {
|
if err := conn.WriteJSON(msg); err != nil {
|
||||||
log.Printf("UI notify error: %v", err)
|
log.Printf("UI notify error: %v", err)
|
||||||
} else {
|
|
||||||
log.Printf("UI notifier sent successfully")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
defer unregisterUINotifier(notifierID)
|
defer func() {
|
||||||
|
unregisterUINotifier(notifierID)
|
||||||
|
uiConnectionsMu.Lock()
|
||||||
|
delete(uiConnections, conn)
|
||||||
|
uiConnectionsMu.Unlock()
|
||||||
|
log.Printf("UI client disconnected")
|
||||||
|
}()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
var msg map[string]interface{}
|
var msg map[string]interface{}
|
||||||
if err := conn.ReadJSON(&msg); err != nil {
|
if err := conn.ReadJSON(&msg); err != nil {
|
||||||
log.Printf("UI client disconnected: %v", err)
|
log.Printf("UI client read error: %v", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
action, _ := msg["action"].(string)
|
action, _ := msg["action"].(string)
|
||||||
@@ -69,29 +195,316 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
|||||||
}
|
}
|
||||||
case "instances":
|
case "instances":
|
||||||
listInstances(dataDir, conn)
|
listInstances(dataDir, conn)
|
||||||
|
case "get_status":
|
||||||
|
sendUIStatus(conn, dataDir)
|
||||||
|
case "run_diagnostic":
|
||||||
|
sendUIStatus(conn, dataDir)
|
||||||
|
conn.WriteJSON(map[string]interface{}{
|
||||||
|
"action": "diagnostic_result",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
"message": "Diagnostic terminé",
|
||||||
|
})
|
||||||
|
case "get_logs":
|
||||||
|
// Logs are streamed as they are produced; no persistent buffer yet.
|
||||||
|
conn.WriteJSON(map[string]interface{}{
|
||||||
|
"action": "log",
|
||||||
|
"message": "Console prête. Les nouveaux logs apparaîtront ici.",
|
||||||
|
"level": "info",
|
||||||
|
})
|
||||||
|
case "start_instance":
|
||||||
|
instanceID, _ := msg["instanceId"].(string)
|
||||||
|
if instanceID != "" {
|
||||||
|
go uiStartInstance(dataDir, nodeID, instanceID)
|
||||||
|
}
|
||||||
|
case "stop_instance":
|
||||||
|
instanceID, _ := msg["instanceId"].(string)
|
||||||
|
if instanceID != "" {
|
||||||
|
go uiStopInstance(dataDir, instanceID)
|
||||||
|
}
|
||||||
|
case "delete_instance":
|
||||||
|
instanceID, _ := msg["instanceId"].(string)
|
||||||
|
if instanceID != "" {
|
||||||
|
go uiDeleteInstance(dataDir, instanceID)
|
||||||
|
}
|
||||||
|
case "reset_instance":
|
||||||
|
instanceID, _ := msg["instanceId"].(string)
|
||||||
|
if instanceID != "" {
|
||||||
|
go uiResetInstance(dataDir, nodeID, instanceID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
port := "7070"
|
port := "7070"
|
||||||
log.Printf("UI starting on http://localhost:%s", port)
|
log.Printf("%s UI starting on http://localhost:%s", APP_NAME, port)
|
||||||
if err := http.ListenAndServe("127.0.0.1:"+port, nil); err != nil {
|
if err := http.ListenAndServe("127.0.0.1:"+port, nil); err != nil {
|
||||||
log.Fatalf("UI server error: %v", err)
|
log.Fatalf("%s UI server error: %v", APP_NAME, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func listInstances(dataDir string, conn *websocket.Conn) {
|
func listInstances(dataDir string, conn *websocket.Conn) {
|
||||||
dir := filepath.Join(dataDir, "instances")
|
instances, err := loadInstances(dataDir)
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("loadInstances error: %v", err)
|
||||||
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": []interface{}{}})
|
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": []interface{}{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var instances []map[string]interface{}
|
|
||||||
for _, e := range entries {
|
list := []map[string]interface{}{}
|
||||||
if e.IsDir() {
|
for _, inst := range instances {
|
||||||
instances = append(instances, map[string]interface{}{"id": e.Name()})
|
status := getInstanceStatus(dataDir, inst.ID)
|
||||||
|
if status != inst.Status {
|
||||||
|
inst.Status = status
|
||||||
|
_ = upsertInstance(dataDir, inst)
|
||||||
|
}
|
||||||
|
list = append(list, map[string]interface{}{
|
||||||
|
"id": inst.ID,
|
||||||
|
"templateName": inst.TemplateName,
|
||||||
|
"type": inst.TemplateName,
|
||||||
|
"port": inst.Port,
|
||||||
|
"status": inst.Status,
|
||||||
|
"url": instanceURL(inst),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendUILog broadcasts a log line to all connected UI clients.
|
||||||
|
func sendUILog(message string) {
|
||||||
|
uiConnectionsMu.RLock()
|
||||||
|
conns := make([]*websocket.Conn, 0, len(uiConnections))
|
||||||
|
for conn := range uiConnections {
|
||||||
|
conns = append(conns, conn)
|
||||||
|
}
|
||||||
|
uiConnectionsMu.RUnlock()
|
||||||
|
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"action": "log",
|
||||||
|
"message": message,
|
||||||
|
"level": "info",
|
||||||
|
}
|
||||||
|
for _, conn := range conns {
|
||||||
|
func(c *websocket.Conn) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in sendUILog: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := c.WriteJSON(msg); err != nil {
|
||||||
|
// Client may have disconnected; ignore.
|
||||||
|
}
|
||||||
|
}(conn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": instances})
|
|
||||||
|
// sendInstanceProgress broadcasts a progress update for a specific instance.
|
||||||
|
func sendInstanceProgress(instanceID, step, percent, message string) {
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "progress",
|
||||||
|
"instanceId": instanceID,
|
||||||
|
"step": step,
|
||||||
|
"percent": percent,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcastUI sends a message to all connected UI clients.
|
||||||
|
func broadcastUI(msg map[string]interface{}) {
|
||||||
|
uiConnectionsMu.RLock()
|
||||||
|
conns := make([]*websocket.Conn, 0, len(uiConnections))
|
||||||
|
for conn := range uiConnections {
|
||||||
|
conns = append(conns, conn)
|
||||||
|
}
|
||||||
|
uiConnectionsMu.RUnlock()
|
||||||
|
|
||||||
|
for _, conn := range conns {
|
||||||
|
func(c *websocket.Conn) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in broadcastUI: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := c.WriteJSON(msg); err != nil {
|
||||||
|
// Ignore write errors for disconnected clients.
|
||||||
|
}
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendUIStatus sends the current services status to a single UI connection.
|
||||||
|
func sendUIStatus(conn *websocket.Conn, dataDir string) {
|
||||||
|
if err := conn.WriteJSON(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("sendUIStatus error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUIStatus constructs a user-friendly status snapshot.
|
||||||
|
func buildUIStatus(dataDir string) map[string]interface{} {
|
||||||
|
// Connection to the school server.
|
||||||
|
connectionState := "pending"
|
||||||
|
connectionDetail := "Connexion en cours..."
|
||||||
|
mainConnMu.Lock()
|
||||||
|
connected := mainConn != nil
|
||||||
|
mainConnMu.Unlock()
|
||||||
|
if connected {
|
||||||
|
connectionState = "ok"
|
||||||
|
connectionDetail = "Connecté au serveur de l'établissement"
|
||||||
|
} else {
|
||||||
|
connectionState = "error"
|
||||||
|
connectionDetail = "Non connecté au serveur de l'établissement"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application service (Docker/Podman + VPN).
|
||||||
|
appServiceState := "pending"
|
||||||
|
appServiceDetail := "Vérification du service d'applications..."
|
||||||
|
engine := getContainerEngine()
|
||||||
|
if engineAvailable(engine) {
|
||||||
|
if isTailscaleReady() {
|
||||||
|
appServiceState = "ok"
|
||||||
|
appServiceDetail = "Service d'applications prêt"
|
||||||
|
} else if isTailscaleRunning() {
|
||||||
|
appServiceState = "warn"
|
||||||
|
appServiceDetail = "Service d'applications disponible, connexion sécurisée en cours"
|
||||||
|
} else {
|
||||||
|
appServiceState = "warn"
|
||||||
|
appServiceDetail = "Service d'applications disponible, connexion sécurisée inactive"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
appServiceState = "error"
|
||||||
|
appServiceDetail = "Service d'applications non disponible"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applications ready.
|
||||||
|
applicationsState := "pending"
|
||||||
|
applicationsDetail := "Vérification des applications..."
|
||||||
|
if instances, err := loadInstances(dataDir); err == nil {
|
||||||
|
ready := 0
|
||||||
|
total := len(instances)
|
||||||
|
for _, inst := range instances {
|
||||||
|
if getInstanceStatus(dataDir, inst.ID) == "running" {
|
||||||
|
ready++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
applicationsState = "ok"
|
||||||
|
applicationsDetail = "Aucune application assignée"
|
||||||
|
} else if ready == total {
|
||||||
|
applicationsState = "ok"
|
||||||
|
applicationsDetail = fmt.Sprintf("%d application%s prête%s", ready, plural(ready), plural(ready))
|
||||||
|
} else if ready > 0 {
|
||||||
|
applicationsState = "warn"
|
||||||
|
applicationsDetail = fmt.Sprintf("%d / %d application%s prête%s", ready, total, plural(ready), plural(ready))
|
||||||
|
} else {
|
||||||
|
applicationsState = "pending"
|
||||||
|
applicationsDetail = fmt.Sprintf("%d application%s en cours de démarrage", total, plural(total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"connection": connectionState,
|
||||||
|
"connectionDetail": connectionDetail,
|
||||||
|
"appService": appServiceState,
|
||||||
|
"appServiceDetail": appServiceDetail,
|
||||||
|
"applications": applicationsState,
|
||||||
|
"applicationsDetail": applicationsDetail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func engineAvailable(engine string) bool {
|
||||||
|
_, err := exec.LookPath(engine)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func plural(n int) string {
|
||||||
|
if n > 1 {
|
||||||
|
return "s"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiStartInstance starts a stopped instance without recreating its containers,
|
||||||
|
// so volumes and data are preserved.
|
||||||
|
func uiStartInstance(dataDir, nodeID, instanceID string) {
|
||||||
|
inst, err := loadInstances(dataDir)
|
||||||
|
if err != nil || inst[instanceID] == nil {
|
||||||
|
log.Printf("uiStartInstance: instance %s not found", instanceID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info := inst[instanceID]
|
||||||
|
|
||||||
|
if instanceContainersExist(dataDir, instanceID) {
|
||||||
|
if err := dockerComposeStart(dataDir, instanceID); err != nil {
|
||||||
|
log.Printf("uiStartInstance: start failed for %s: %v", instanceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := dockerComposeUp(dataDir, instanceID); err != nil {
|
||||||
|
log.Printf("uiStartInstance: up failed for %s: %v", instanceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
if err := setupTailscaleServe(info.Port); err != nil {
|
||||||
|
log.Printf("uiStartInstance: setupTailscaleServe failed for %s: %v", instanceID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := getInstanceStatus(dataDir, instanceID)
|
||||||
|
info.Status = status
|
||||||
|
_ = upsertInstance(dataDir, info)
|
||||||
|
_ = sendMessage(WSMessage{Action: "instance_started", InstanceID: instanceID, Port: info.Port})
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiStopInstance stops a running instance without removing its containers or volumes.
|
||||||
|
func uiStopInstance(dataDir, instanceID string) {
|
||||||
|
_ = dockerComposeStop(dataDir, instanceID)
|
||||||
|
if inst, err := loadInstances(dataDir); err == nil && inst[instanceID] != nil {
|
||||||
|
inst[instanceID].Status = "stopped"
|
||||||
|
_ = saveInstances(dataDir, inst)
|
||||||
|
}
|
||||||
|
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: instanceID})
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiDeleteInstance removes an instance and its data (volumes included).
|
||||||
|
func uiDeleteInstance(dataDir, instanceID string) {
|
||||||
|
if inst, err := loadInstances(dataDir); err == nil && inst[instanceID] != nil {
|
||||||
|
removeTailscaleServe(inst[instanceID].Port)
|
||||||
|
}
|
||||||
|
dockerComposeRm(dataDir, instanceID)
|
||||||
|
removeInstance(dataDir, instanceID)
|
||||||
|
go sendMessage(WSMessage{Action: "instance_deleted", InstanceID: instanceID})
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiResetInstance stops, removes volumes and recreates an instance from scratch.
|
||||||
|
func uiResetInstance(dataDir, nodeID, instanceID string) {
|
||||||
|
inst, err := loadInstances(dataDir)
|
||||||
|
if err != nil || inst[instanceID] == nil {
|
||||||
|
log.Printf("uiResetInstance: instance %s not found", instanceID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info := inst[instanceID]
|
||||||
|
composePath := filepath.Join(instanceDir(dataDir, instanceID), "docker-compose.yml")
|
||||||
|
composeBytes, err := os.ReadFile(composePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("uiResetInstance: cannot read compose for %s: %v", instanceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dockerComposeRm(dataDir, instanceID)
|
||||||
|
handleStartInstance(dataDir, nodeID, instanceID, info.TemplateName, string(composeBytes), "", info.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// instanceContainersExist returns true if compose containers already exist for this instance.
|
||||||
|
func instanceContainersExist(dataDir, instanceID string) bool {
|
||||||
|
dir := instanceDir(dataDir, instanceID)
|
||||||
|
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "ps", "-q")
|
||||||
|
configureEngineCmd(cmd, dir)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
return err == nil && strings.TrimSpace(string(out)) != ""
|
||||||
}
|
}
|
||||||
|
|||||||
+1059
-45
File diff suppressed because it is too large
Load Diff
+268
@@ -0,0 +1,268 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateCheckInterval = 15 * time.Minute
|
||||||
|
|
||||||
|
// AgentVersionInfo matches the server's /api/agent/version response.
|
||||||
|
type AgentVersionInfo struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
DownloadUrls struct {
|
||||||
|
Windows string `json:"windows"`
|
||||||
|
WindowsZip string `json:"windowsZip"`
|
||||||
|
Linux string `json:"linux"`
|
||||||
|
Mac string `json:"mac"`
|
||||||
|
} `json:"downloadUrls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpBaseURL converts a WebSocket server URL into the corresponding HTTP(S)
|
||||||
|
// base URL, stripping the /api/websocket path if present.
|
||||||
|
func httpBaseURL(serverURL string) string {
|
||||||
|
u := serverURL
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(u, "wss://"):
|
||||||
|
u = "https://" + strings.TrimPrefix(u, "wss://")
|
||||||
|
case strings.HasPrefix(u, "ws://"):
|
||||||
|
u = "http://" + strings.TrimPrefix(u, "ws://")
|
||||||
|
}
|
||||||
|
u = strings.TrimSuffix(u, "/api/websocket/")
|
||||||
|
u = strings.TrimSuffix(u, "/api/websocket")
|
||||||
|
return strings.TrimSuffix(u, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkForUpdate fetches the latest agent version from the server and compares
|
||||||
|
// it with the running binary's version.
|
||||||
|
func checkForUpdate(cfg *AgentConfig) (*AgentVersionInfo, bool, error) {
|
||||||
|
if cfg == nil || cfg.Server == "" {
|
||||||
|
return nil, false, fmt.Errorf("no server URL configured")
|
||||||
|
}
|
||||||
|
url := httpBaseURL(cfg.Server) + "/api/agent/version"
|
||||||
|
client := httpClientWithProxy(cfg)
|
||||||
|
client.Timeout = 30 * time.Second
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, false, fmt.Errorf("server returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
var info AgentVersionInfo
|
||||||
|
if err := json.Unmarshal(body, &info); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if info.Version == "" {
|
||||||
|
return nil, false, fmt.Errorf("server returned empty version")
|
||||||
|
}
|
||||||
|
available := info.Version != version
|
||||||
|
return &info, available, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadUpdate downloads the new agent binary to the update directory.
|
||||||
|
func downloadUpdate(cfg *AgentConfig, dataDir, downloadURL string) (string, error) {
|
||||||
|
updateDir := filepath.Join(dataDir, "update")
|
||||||
|
if err := os.MkdirAll(updateDir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ext := ""
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
ext = ".exe"
|
||||||
|
}
|
||||||
|
dest := filepath.Join(updateDir, "studioE5-agent-new"+ext)
|
||||||
|
log.Printf("Downloading update from %s to %s", downloadURL, dest)
|
||||||
|
|
||||||
|
client := httpClientWithProxy(cfg)
|
||||||
|
resp, err := client.Get(downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("download returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
out, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := out.Close(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
if err := os.Chmod(dest, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatArgsForShell returns the given arguments as a safely quoted string
|
||||||
|
// suitable for embedding in shell/PowerShell scripts.
|
||||||
|
func formatArgsForShell(args []string) string {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
quoted := make([]string, len(args))
|
||||||
|
for i, a := range args {
|
||||||
|
quoted[i] = strconv.Quote(a)
|
||||||
|
}
|
||||||
|
return strings.Join(quoted, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUpdate replaces the running binary with the downloaded one using an
|
||||||
|
// external helper script, then exits the current process. The new process is
|
||||||
|
// started with the same arguments as the current one so that tray/console mode
|
||||||
|
// is preserved.
|
||||||
|
func applyUpdate(currentPath, newPath, dataDir string) error {
|
||||||
|
pid := os.Getpid()
|
||||||
|
restartArgs := os.Args[1:]
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return applyUpdateWindows(currentPath, newPath, dataDir, pid, restartArgs)
|
||||||
|
default:
|
||||||
|
return applyUpdateUnix(currentPath, newPath, dataDir, pid, restartArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUpdateWindows(currentPath, newPath, dataDir string, pid int, restartArgs []string) error {
|
||||||
|
scriptPath := filepath.Join(dataDir, "update", "apply-update.ps1")
|
||||||
|
argsList := formatArgsForShell(restartArgs)
|
||||||
|
if argsList == "" {
|
||||||
|
argsList = ""
|
||||||
|
} else {
|
||||||
|
argsList = "$startArgs = @(" + argsList + ")"
|
||||||
|
}
|
||||||
|
script := fmt.Sprintf(`$old = "%s"
|
||||||
|
$new = "%s"
|
||||||
|
$targetPid = %d
|
||||||
|
%s
|
||||||
|
Wait-Process -Id $targetPid -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
Move-Item -Path $new -Destination $old -Force
|
||||||
|
if ($startArgs) {
|
||||||
|
Start-Process -FilePath $old -ArgumentList $startArgs -WindowStyle Hidden
|
||||||
|
} else {
|
||||||
|
Start-Process -FilePath $old -WindowStyle Hidden
|
||||||
|
}
|
||||||
|
`, currentPath, newPath, pid, argsList)
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := exec.Command("powershell.exe", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", scriptPath)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
hideWindow(cmd)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("Update helper started, exiting current process")
|
||||||
|
os.Exit(0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUpdateUnix(currentPath, newPath, dataDir string, pid int, restartArgs []string) error {
|
||||||
|
scriptPath := filepath.Join(dataDir, "update", "apply-update.sh")
|
||||||
|
argsList := formatArgsForShell(restartArgs)
|
||||||
|
script := fmt.Sprintf(`#!/bin/bash
|
||||||
|
set -e
|
||||||
|
old="%s"
|
||||||
|
new="%s"
|
||||||
|
pid=%d
|
||||||
|
while kill -0 "$pid" 2>/dev/null; do sleep 1; done
|
||||||
|
sleep 2
|
||||||
|
mv "$new" "$old"
|
||||||
|
chmod +x "$old"
|
||||||
|
nohup "$old" %s >/dev/null 2>&1 &
|
||||||
|
`, currentPath, newPath, pid, argsList)
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := exec.Command("/bin/bash", scriptPath)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("Update helper started, exiting current process")
|
||||||
|
os.Exit(0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startAgentUpdate performs the full update flow: download + replace + restart.
|
||||||
|
func startAgentUpdate(cfg *AgentConfig, dataDir string) error {
|
||||||
|
info, available, err := checkForUpdate(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update check failed: %w", err)
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
return fmt.Errorf("no update available")
|
||||||
|
}
|
||||||
|
currentPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
currentPath, err = filepath.Abs(currentPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var downloadURL string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
downloadURL = info.DownloadUrls.Windows
|
||||||
|
case "darwin":
|
||||||
|
downloadURL = info.DownloadUrls.Mac
|
||||||
|
default:
|
||||||
|
downloadURL = info.DownloadUrls.Linux
|
||||||
|
}
|
||||||
|
if downloadURL == "" {
|
||||||
|
return fmt.Errorf("no download URL for %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
newPath, err := downloadUpdate(cfg, dataDir, downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Applying update to version %s", info.Version)
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "update_progress",
|
||||||
|
"percent": "90",
|
||||||
|
"message": "Redémarrage de l'agent...",
|
||||||
|
})
|
||||||
|
return applyUpdate(currentPath, newPath, dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCheckerLoop periodically checks for agent updates and notifies the UI.
|
||||||
|
func updateCheckerLoop(cfg *AgentConfig, dataDir string) {
|
||||||
|
for {
|
||||||
|
info, available, err := checkForUpdate(cfg)
|
||||||
|
if err == nil && available && info != nil {
|
||||||
|
log.Printf("Agent update available: %s (current: %s)", info.Version, version)
|
||||||
|
setServerAgentVersion(info.Version)
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "update_available",
|
||||||
|
"version": info.Version,
|
||||||
|
"update_available": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
time.Sleep(updateCheckInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHTTPBaseURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in, want string
|
||||||
|
}{
|
||||||
|
{"wss://studioe5.edudeploy.com/api/websocket", "https://studioe5.edudeploy.com"},
|
||||||
|
{"ws://localhost:3000/api/websocket", "http://localhost:3000"},
|
||||||
|
{"https://studioe5.edudeploy.com/api/websocket", "https://studioe5.edudeploy.com"},
|
||||||
|
{"https://studioe5.edudeploy.com", "https://studioe5.edudeploy.com"},
|
||||||
|
{"wss://example.com/api/websocket/", "https://example.com"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := httpBaseURL(c.in)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("httpBaseURL(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+495
-26
@@ -3,12 +3,22 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SyncInstanceInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
ComposeConfig string `json:"composeConfig,omitempty"`
|
||||||
|
InitScript string `json:"initScript,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type WSMessage struct {
|
type WSMessage struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
NodeID string `json:"nodeId,omitempty"`
|
NodeID string `json:"nodeId,omitempty"`
|
||||||
@@ -17,9 +27,19 @@ type WSMessage struct {
|
|||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Port int `json:"port,omitempty"`
|
Port int `json:"port,omitempty"`
|
||||||
ComposeConfig string `json:"composeConfig,omitempty"`
|
ComposeConfig string `json:"composeConfig,omitempty"`
|
||||||
|
InitScript string `json:"initScript,omitempty"`
|
||||||
StudentId string `json:"studentId,omitempty"`
|
StudentId string `json:"studentId,omitempty"`
|
||||||
StudentName string `json:"studentName,omitempty"`
|
StudentName string `json:"studentName,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
TailscaleIP string `json:"tailscaleIp,omitempty"`
|
||||||
|
HeadscaleURL string `json:"headscaleUrl,omitempty"`
|
||||||
|
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
ServerVersion string `json:"serverVersion,omitempty"`
|
||||||
|
Instances []InstanceInfo `json:"instances"`
|
||||||
|
ToStart []SyncInstanceInfo `json:"toStart"`
|
||||||
|
ToDelete []string `json:"toDelete"`
|
||||||
|
ToStop []string `json:"toStop"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -27,16 +47,82 @@ var (
|
|||||||
mainConnMu sync.Mutex
|
mainConnMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// headscale config received from the server during activation.
|
||||||
|
// These are mutable because activation may happen after the agent starts.
|
||||||
|
var (
|
||||||
|
currentHeadscaleURL string
|
||||||
|
currentHeadscaleAuthKey string
|
||||||
|
headscaleConfigMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func setHeadscaleConfig(url, authKey string) {
|
||||||
|
headscaleConfigMu.Lock()
|
||||||
|
currentHeadscaleURL = url
|
||||||
|
currentHeadscaleAuthKey = authKey
|
||||||
|
headscaleConfigMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHeadscaleConfig() (string, string) {
|
||||||
|
headscaleConfigMu.Lock()
|
||||||
|
defer headscaleConfigMu.Unlock()
|
||||||
|
return currentHeadscaleURL, currentHeadscaleAuthKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverAgentVersion holds the agent version expected by the server. It is used
|
||||||
|
// to notify the user when an update is available.
|
||||||
|
var (
|
||||||
|
serverAgentVersion string
|
||||||
|
serverAgentVersionMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func setServerAgentVersion(v string) {
|
||||||
|
serverAgentVersionMu.Lock()
|
||||||
|
serverAgentVersion = v
|
||||||
|
serverAgentVersionMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getServerAgentVersion() string {
|
||||||
|
serverAgentVersionMu.RLock()
|
||||||
|
defer serverAgentVersionMu.RUnlock()
|
||||||
|
return serverAgentVersion
|
||||||
|
}
|
||||||
|
|
||||||
func sendMessage(msg WSMessage) error {
|
func sendMessage(msg WSMessage) error {
|
||||||
mainConnMu.Lock()
|
mainConnMu.Lock()
|
||||||
defer mainConnMu.Unlock()
|
defer mainConnMu.Unlock()
|
||||||
if mainConn == nil {
|
if mainConn == nil {
|
||||||
return fmt.Errorf("not connected to server")
|
return fmt.Errorf("not connected to server")
|
||||||
}
|
}
|
||||||
|
if msg.Action != "heartbeat" {
|
||||||
log.Printf("sendMessage: sending %+v", msg)
|
log.Printf("sendMessage: sending %+v", msg)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in sendMessage: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
return mainConn.WriteJSON(msg)
|
return mainConn.WriteJSON(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendSyncMessage sends the local instance list to the server so it can
|
||||||
|
// reconcile any differences (instances created/deleted while offline).
|
||||||
|
func sendSyncMessage(dataDir, nodeID string) {
|
||||||
|
inst, err := loadInstances(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sendSyncMessage: loadInstances error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list := make([]InstanceInfo, 0, len(inst))
|
||||||
|
for _, info := range inst {
|
||||||
|
list = append(list, *info)
|
||||||
|
}
|
||||||
|
if err := sendMessage(WSMessage{Action: "sync", NodeID: nodeID, Instances: list}); err != nil {
|
||||||
|
log.Printf("sendSyncMessage error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("sendSyncMessage: sent %d local instances", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// UI notifier system: broadcast activation results to all connected UI clients
|
// UI notifier system: broadcast activation results to all connected UI clients
|
||||||
type uiNotifier func(msg map[string]interface{})
|
type uiNotifier func(msg map[string]interface{})
|
||||||
|
|
||||||
@@ -73,20 +159,134 @@ func notifyUI(msg map[string]interface{}) {
|
|||||||
|
|
||||||
log.Printf("notifyUI: broadcasting to %d UI clients", len(notifiers))
|
log.Printf("notifyUI: broadcasting to %d UI clients", len(notifiers))
|
||||||
for _, fn := range notifiers {
|
for _, fn := range notifiers {
|
||||||
go fn(msg)
|
go func(notify uiNotifier) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in notifyUI goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
notify(msg)
|
||||||
|
}(fn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startWebSocket(serverAddr, nodeID, dataDir string) {
|
// directDialer returns a websocket.Dialer that never uses a proxy.
|
||||||
|
func directDialer() *websocket.Dialer {
|
||||||
|
d := websocket.DefaultDialer
|
||||||
|
return &websocket.Dialer{
|
||||||
|
Proxy: nil,
|
||||||
|
HandshakeTimeout: d.HandshakeTimeout,
|
||||||
|
ReadBufferSize: d.ReadBufferSize,
|
||||||
|
WriteBufferSize: d.WriteBufferSize,
|
||||||
|
EnableCompression: d.EnableCompression,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyOnlyDialer returns a websocket.Dialer that always uses the configured
|
||||||
|
// proxy URL, ignoring the current auto-proxy state.
|
||||||
|
func proxyOnlyDialer(cfg *AgentConfig) *websocket.Dialer {
|
||||||
|
d := websocket.DefaultDialer
|
||||||
|
u := proxyURL(cfg)
|
||||||
|
if u == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return &websocket.Dialer{
|
||||||
|
Proxy: func(*http.Request) (*url.URL, error) { return u, nil },
|
||||||
|
HandshakeTimeout: d.HandshakeTimeout,
|
||||||
|
ReadBufferSize: d.ReadBufferSize,
|
||||||
|
WriteBufferSize: d.WriteBufferSize,
|
||||||
|
EnableCompression: d.EnableCompression,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialServerWithFallback attempts to connect to the WebSocket server according
|
||||||
|
// to the configured proxy mode. In auto mode it tries direct connections first
|
||||||
|
// and falls back to the proxy after a few failures.
|
||||||
|
func dialServerWithFallback(cfg *AgentConfig, serverAddr string, headers http.Header) (*websocket.Conn, error) {
|
||||||
|
mode := proxyMode(cfg)
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case ProxyModeDisabled:
|
||||||
|
conn, _, err := directDialer().Dial(serverAddr, headers)
|
||||||
|
return conn, err
|
||||||
|
case ProxyModeEnabled:
|
||||||
|
conn, _, err := websocketDialer(cfg).Dial(serverAddr, headers)
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto mode.
|
||||||
|
u := proxyURL(cfg)
|
||||||
|
if u == nil {
|
||||||
|
conn, _, err := directDialer().Dial(serverAddr, headers)
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are currently in auto-proxy mode, try direct again only after the
|
||||||
|
// lock duration has expired. Otherwise stay on the proxy.
|
||||||
|
if IsProxyActive() {
|
||||||
|
if canRetryDirect() {
|
||||||
|
log.Println("Auto proxy: retrying direct connection after lock period")
|
||||||
|
conn, _, err := directDialer().Dial(serverAddr, headers)
|
||||||
|
if err == nil {
|
||||||
|
if setProxyActive(false) {
|
||||||
|
log.Println("Auto proxy: switched back to direct connection")
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
log.Printf("Auto proxy: direct retry failed (%v), keeping proxy", err)
|
||||||
|
}
|
||||||
|
conn, _, err := proxyOnlyDialer(cfg).Dial(serverAddr, headers)
|
||||||
|
if err != nil {
|
||||||
|
// Proxy failed too: clear the active flag so next round restarts the
|
||||||
|
// direct-first fallback sequence.
|
||||||
|
setProxyActive(false)
|
||||||
|
}
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not currently in proxy mode: try direct up to 3 times, then proxy.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
conn, _, err := directDialer().Dial(serverAddr, headers)
|
||||||
|
if err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
log.Printf("Auto proxy: direct attempt %d/%d failed: %v", i+1, 3, err)
|
||||||
|
if i < 2 {
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Auto proxy: falling back to proxy")
|
||||||
|
conn, _, err := proxyOnlyDialer(cfg).Dial(serverAddr, headers)
|
||||||
|
if err == nil {
|
||||||
|
if setProxyActive(true) {
|
||||||
|
log.Println("Auto proxy: switched to proxy")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("Auto proxy: proxy fallback failed: %v", err)
|
||||||
|
}
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func startWebSocket(cfg *AgentConfig, nodeID, dataDir string) {
|
||||||
|
setHeadscaleConfig(cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
|
||||||
|
serverAddr := cfg.Server
|
||||||
|
|
||||||
for {
|
for {
|
||||||
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
|
token, _ := loadNodeToken(dataDir)
|
||||||
|
headers := http.Header{}
|
||||||
|
if token != "" {
|
||||||
|
headers.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := dialServerWithFallback(cfg, serverAddr, headers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("WS connect error: %v, retrying in 5s...", err)
|
log.Printf("WS connect error: %v, retrying in 5s...", err)
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("WS connected to %s", serverAddr)
|
log.Printf("WS connected to %s (token=%v, proxy=%v)", serverAddr, token != "", IsProxyActive())
|
||||||
|
|
||||||
mainConnMu.Lock()
|
mainConnMu.Lock()
|
||||||
mainConn = conn
|
mainConn = conn
|
||||||
@@ -108,6 +308,13 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
|
|||||||
log.Println("Waiting for activation...")
|
log.Println("Waiting for activation...")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Already activated as %s", act.StudentName)
|
log.Printf("Already activated as %s", act.StudentName)
|
||||||
|
// If already activated, ensure VPN is up. The pre-auth key is
|
||||||
|
// one-time only, so on restart we rely on the persisted tailscaled
|
||||||
|
// state; tailscale up without an authkey reuses existing state.
|
||||||
|
hsURL, hsKey := getHeadscaleConfig()
|
||||||
|
if hsURL != "" {
|
||||||
|
go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heartbeat goroutine
|
// Heartbeat goroutine
|
||||||
@@ -149,21 +356,123 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
|
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in handleMessage (action=%s): %v", msg.Action, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
switch msg.Action {
|
switch msg.Action {
|
||||||
|
case "set_token":
|
||||||
|
if msg.Token != "" {
|
||||||
|
if err := saveNodeToken(dataDir, msg.Token); err != nil {
|
||||||
|
log.Printf("saveNodeToken error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Node token saved")
|
||||||
|
}
|
||||||
|
}
|
||||||
case "activated":
|
case "activated":
|
||||||
log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
|
log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
|
||||||
|
if msg.Token != "" {
|
||||||
|
if err := saveNodeToken(dataDir, msg.Token); err != nil {
|
||||||
|
log.Printf("saveNodeToken error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Node token saved on activation")
|
||||||
|
}
|
||||||
|
}
|
||||||
if msg.StudentName != "" {
|
if msg.StudentName != "" {
|
||||||
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
|
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server sends Headscale credentials on activation.
|
||||||
|
// The pre-auth key is ephemeral and must be used immediately;
|
||||||
|
// it is intentionally NOT persisted to the config file.
|
||||||
|
if msg.HeadscaleURL != "" && msg.HeadscaleAuthKey != "" {
|
||||||
|
setHeadscaleConfig(msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
||||||
|
cfg, _, err := loadOrCreateConfig(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("loadOrCreateConfig error: %v", err)
|
||||||
|
} else {
|
||||||
|
cfg.HeadscaleURL = msg.HeadscaleURL
|
||||||
|
// Intentionally do not save HeadscaleAuthKey: it is one-time only.
|
||||||
|
if err := saveConfig(dataDir, cfg); err != nil {
|
||||||
|
log.Printf("saveConfig error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Saved Headscale URL received from server (auth key not persisted)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go startTailscaleAndReport(dataDir, nodeID, msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
||||||
|
}
|
||||||
|
|
||||||
notifyUI(map[string]interface{}{
|
notifyUI(map[string]interface{}{
|
||||||
"action": "activated",
|
"action": "activated",
|
||||||
"studentName": msg.StudentName,
|
"studentName": msg.StudentName,
|
||||||
})
|
})
|
||||||
|
case "registered":
|
||||||
|
if msg.ServerVersion != "" {
|
||||||
|
setServerAgentVersion(msg.ServerVersion)
|
||||||
|
log.Printf("Server agent version: %s", msg.ServerVersion)
|
||||||
|
}
|
||||||
|
// After registration, send a sync request with our local instances so
|
||||||
|
// the server can reconcile any changes that happened while offline.
|
||||||
|
if act, err := loadActivation(dataDir); err == nil && act.Activated {
|
||||||
|
go sendSyncMessage(dataDir, nodeID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "start_vpn":
|
||||||
|
log.Printf("Server requested VPN start")
|
||||||
|
hsURL, hsKey := getHeadscaleConfig()
|
||||||
|
if hsURL == "" || hsKey == "" {
|
||||||
|
log.Printf("Cannot start VPN: headscale config missing")
|
||||||
|
go sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in start_vpn goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("start_vpn error: %v", err)
|
||||||
|
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if err := sendMessage(WSMessage{Action: "tailscale_ip", NodeID: nodeID, TailscaleIP: ip}); err != nil {
|
||||||
|
log.Printf("Waiting for WebSocket to send tailscale_ip...")
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
case "stop_vpn":
|
||||||
|
log.Printf("Server requested VPN stop")
|
||||||
|
stopTailscale()
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in stop_vpn goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
sendMessage(WSMessage{Action: "vpn_stopped", NodeID: nodeID})
|
||||||
|
}()
|
||||||
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{}{
|
||||||
@@ -172,36 +481,196 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
})
|
})
|
||||||
case "start":
|
case "start":
|
||||||
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
|
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
|
||||||
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil {
|
go func() {
|
||||||
log.Printf("writeCompose error: %v", err)
|
defer func() {
|
||||||
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
if r := recover(); r != nil {
|
||||||
return
|
log.Printf("PANIC in start goroutine instance=%s: %v", msg.InstanceID, r)
|
||||||
}
|
}
|
||||||
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
|
}()
|
||||||
log.Printf("dockerComposeUp error: %v", err)
|
handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port)
|
||||||
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
}()
|
||||||
return
|
|
||||||
}
|
|
||||||
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
|
|
||||||
case "stop":
|
case "stop":
|
||||||
log.Printf("Stop instance %s", msg.InstanceID)
|
log.Printf("Stop instance %s", msg.InstanceID)
|
||||||
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
|
go func() {
|
||||||
log.Printf("dockerComposeDown error: %v", err)
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in stop goroutine instance=%s: %v", msg.InstanceID, r)
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
||||||
|
removeTailscaleServe(inst[msg.InstanceID].Port)
|
||||||
|
}
|
||||||
|
if err := dockerComposeStop(dataDir, msg.InstanceID); err != nil {
|
||||||
|
log.Printf("dockerComposeStop error: %v", err)
|
||||||
|
}
|
||||||
|
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
||||||
|
inst[msg.InstanceID].Status = "stopped"
|
||||||
|
_ = saveInstances(dataDir, inst)
|
||||||
|
}
|
||||||
|
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: msg.InstanceID})
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}()
|
||||||
|
case "delete":
|
||||||
|
log.Printf("Delete instance %s", msg.InstanceID)
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in delete goroutine instance=%s: %v", msg.InstanceID, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
||||||
|
removeTailscaleServe(inst[msg.InstanceID].Port)
|
||||||
|
}
|
||||||
|
dockerComposeRm(dataDir, msg.InstanceID)
|
||||||
|
removeInstance(dataDir, msg.InstanceID)
|
||||||
|
go sendMessage(WSMessage{Action: "instance_deleted", InstanceID: msg.InstanceID})
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}()
|
||||||
|
case "sync_response":
|
||||||
|
log.Printf("Sync response received: start=%d delete=%d stop=%d", len(msg.ToStart), len(msg.ToDelete), len(msg.ToStop))
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in sync_response goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for _, id := range msg.ToDelete {
|
||||||
|
handleMessage(mainConn, WSMessage{Action: "delete", InstanceID: id}, dataDir, nodeID)
|
||||||
|
}
|
||||||
|
for _, id := range msg.ToStop {
|
||||||
|
handleMessage(mainConn, WSMessage{Action: "stop", InstanceID: id}, dataDir, nodeID)
|
||||||
|
}
|
||||||
|
for _, info := range msg.ToStart {
|
||||||
|
handleMessage(mainConn, WSMessage{
|
||||||
|
Action: "start",
|
||||||
|
InstanceID: info.ID,
|
||||||
|
Type: info.Type,
|
||||||
|
Port: info.Port,
|
||||||
|
ComposeConfig: info.ComposeConfig,
|
||||||
|
InitScript: info.InitScript,
|
||||||
|
}, dataDir, nodeID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
case "reset":
|
case "reset":
|
||||||
log.Printf("Reset instance %s", msg.InstanceID)
|
log.Printf("Reset instance %s", msg.InstanceID)
|
||||||
dockerComposeRm(dataDir, msg.InstanceID)
|
dockerComposeRm(dataDir, msg.InstanceID)
|
||||||
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil {
|
go func() {
|
||||||
log.Printf("writeCompose error: %v", err)
|
defer func() {
|
||||||
return
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in reset goroutine instance=%s: %v", msg.InstanceID, r)
|
||||||
}
|
}
|
||||||
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
|
}()
|
||||||
log.Printf("dockerComposeUp error: %v", err)
|
handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port)
|
||||||
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
}()
|
||||||
return
|
|
||||||
}
|
|
||||||
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
|
|
||||||
default:
|
default:
|
||||||
log.Printf("Unknown action: %s", msg.Action)
|
log.Printf("Unknown action: %s", msg.Action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleStartInstance(dataDir, nodeID, instanceID, instanceType, composeConfig, initScript string, port int) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in handleStartInstance instance=%s: %v", instanceID, r)
|
||||||
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
|
||||||
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: fmt.Sprintf("internal panic: %v", r)})
|
||||||
|
sendInstanceProgress(instanceID, "start", "0", "Erreur interne")
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("handleStartInstance begin: instance=%s type=%s port=%d dataDir=%s initScriptLen=%d", instanceID, instanceType, port, dataDir, len(initScript))
|
||||||
|
|
||||||
|
notifyInstanceProgress := func(percent, message string) {
|
||||||
|
sendInstanceProgress(instanceID, "start", percent, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = upsertInstance(dataDir, &InstanceInfo{
|
||||||
|
ID: instanceID,
|
||||||
|
TemplateName: instanceType,
|
||||||
|
Port: port,
|
||||||
|
Status: "starting",
|
||||||
|
})
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
notifyInstanceProgress("10", "Préparation de l'application...")
|
||||||
|
|
||||||
|
if err := writeCompose(dataDir, instanceID, composeConfig, port); err != nil {
|
||||||
|
log.Printf("writeCompose error: %v", err)
|
||||||
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
|
||||||
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: err.Error()})
|
||||||
|
notifyInstanceProgress("0", "Erreur de préparation")
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if initScript != "" {
|
||||||
|
if err := writeInitScript(dataDir, instanceID, initScript); err != nil {
|
||||||
|
log.Printf("writeInitScript error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyInstanceProgress("30", "Configuration de l'application...")
|
||||||
|
|
||||||
|
if err := dockerComposeUp(dataDir, instanceID); err != nil {
|
||||||
|
log.Printf("dockerComposeUp error: %v", err)
|
||||||
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
|
||||||
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: err.Error()})
|
||||||
|
notifyInstanceProgress("0", "Erreur de démarrage")
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notifyInstanceProgress("60", "Application en cours de démarrage...")
|
||||||
|
|
||||||
|
ensureTailscale(dataDir, nodeID, port)
|
||||||
|
if err := setupTailscaleServe(port); err != nil {
|
||||||
|
log.Printf("setupTailscaleServe error: %v", err)
|
||||||
|
// Non-fatal: the instance may still work on Linux or if Windows
|
||||||
|
// userspace forwarding happens to function.
|
||||||
|
}
|
||||||
|
notifyInstanceProgress("80", "Connexion sécurisée active...")
|
||||||
|
|
||||||
|
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
|
||||||
|
// so the studioE5 mu-plugin can compute the public URL from the Host header.
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
if err := stripWordPressHardcodedURLs(dataDir, instanceID); err != nil {
|
||||||
|
log.Printf("stripWordPressHardcodedURLs error: %v", err)
|
||||||
|
}
|
||||||
|
notifyInstanceProgress("90", "Finalisation de l'installation...")
|
||||||
|
|
||||||
|
status := getInstanceStatus(dataDir, instanceID)
|
||||||
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: status})
|
||||||
|
sendMessage(WSMessage{Action: "instance_started", InstanceID: instanceID, Port: port})
|
||||||
|
notifyInstanceProgress("100", "Application prête")
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureTailscale(dataDir, nodeID string, port int) {
|
||||||
|
hsURL, hsKey := getHeadscaleConfig()
|
||||||
|
if hsURL == "" || hsKey == "" {
|
||||||
|
log.Printf("Cannot ensure Tailscale: headscale config missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isTailscaleRunning() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Tailscale not running, starting it for instance port %d", port)
|
||||||
|
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ensureTailscale start error: %v", err)
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if err := sendMessage(WSMessage{Action: "tailscale_ip", NodeID: nodeID, TailscaleIP: ip}); err != nil {
|
||||||
|
log.Printf("Waiting for WebSocket to send tailscale_ip...")
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
broadcastUI(map[string]interface{}{
|
||||||
|
"action": "status",
|
||||||
|
"status": buildUIStatus(dataDir),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+61
-33
@@ -1,18 +1,18 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18-alpine
|
image: postgres:18-alpine
|
||||||
container_name: edubox-postgres
|
container_name: studioe5-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: edubox
|
POSTGRES_USER: studioe5
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: edubox
|
POSTGRES_DB: studioe5
|
||||||
volumes:
|
volumes:
|
||||||
- pg_data:/var/lib/postgresql
|
- pg_data:/var/lib/postgresql
|
||||||
networks:
|
networks:
|
||||||
- edubox
|
- studioe5
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U edubox -d edubox"]
|
test: ["CMD-SHELL", "pg_isready -U studioe5 -d studioe5"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -21,7 +21,10 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./server
|
context: ./server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: edubox-server
|
container_name: studioe5-server
|
||||||
|
volumes:
|
||||||
|
- ./server/public:/app/public:ro
|
||||||
|
- ./agent/VERSION:/app/agent-version:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
@@ -29,65 +32,90 @@ services:
|
|||||||
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
||||||
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL}
|
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL}
|
||||||
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD}
|
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD}
|
||||||
|
MAIN_DOMAIN: ${MAIN_DOMAIN}
|
||||||
HEADSCALE_URL: ${HEADSCALE_URL}
|
HEADSCALE_URL: ${HEADSCALE_URL}
|
||||||
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
|
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
|
||||||
GITEA_URL: ${GITEA_URL}
|
HEADSCALE_API_KEY: ${HEADSCALE_API_KEY}
|
||||||
GITEA_TOKEN: ${GITEA_TOKEN}
|
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- edubox
|
- studioe5
|
||||||
|
|
||||||
|
resolver:
|
||||||
|
build:
|
||||||
|
context: ./resolver
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: studioe5-resolver
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
MAIN_DOMAIN: ${MAIN_DOMAIN}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- studioe5
|
||||||
|
|
||||||
|
resolver-vpn:
|
||||||
|
image: tailscale/tailscale:latest
|
||||||
|
container_name: studioe5-resolver-vpn
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: service:resolver
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- SYS_MODULE
|
||||||
|
devices:
|
||||||
|
- /dev/net/tun:/dev/net/tun
|
||||||
|
environment:
|
||||||
|
TS_AUTHKEY: ${HEADSCALE_RESOLVER_AUTH_KEY}
|
||||||
|
TS_LOGIN_SERVER: ${HEADSCALE_URL}
|
||||||
|
TS_EXTRA_ARGS: --login-server=${HEADSCALE_URL}
|
||||||
|
TS_STATE_DIR: /var/lib/tailscale
|
||||||
|
TS_HOSTNAME: studioe5-resolver
|
||||||
|
TS_USERSPACE: "false"
|
||||||
|
TS_ACCEPT_DNS: "false"
|
||||||
|
volumes:
|
||||||
|
- resolver_ts_state:/var/lib/tailscale
|
||||||
|
depends_on:
|
||||||
|
- resolver
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
container_name: edubox-caddy
|
container_name: studioe5-caddy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- ./agent:/usr/share/caddy/agent:ro
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
networks:
|
networks:
|
||||||
- edubox
|
- studioe5
|
||||||
|
|
||||||
headscale:
|
headscale:
|
||||||
image: headscale/headscale:latest
|
image: headscale/headscale:latest
|
||||||
container_name: edubox-headscale
|
container_name: studioe5-headscale
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: serve
|
command: serve
|
||||||
ports:
|
ports:
|
||||||
- "41641:41641/udp"
|
- "8080:8080"
|
||||||
|
- "3478:3478/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- headscale_data:/etc/headscale
|
- ./headscale:/etc/headscale
|
||||||
networks:
|
networks:
|
||||||
- edubox
|
- studioe5
|
||||||
|
|
||||||
gitea:
|
|
||||||
image: gitea/gitea:latest
|
|
||||||
container_name: edubox-gitea
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "3001:3000"
|
|
||||||
environment:
|
|
||||||
- USER_UID=1000
|
|
||||||
- USER_GID=1000
|
|
||||||
- GITEA__database__DB_TYPE=sqlite3
|
|
||||||
- GITEA__database__PATH=/data/gitea/gitea.db
|
|
||||||
volumes:
|
|
||||||
- gitea_data:/data
|
|
||||||
networks:
|
|
||||||
- edubox
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pg_data:
|
pg_data:
|
||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
caddy_config:
|
||||||
headscale_data:
|
headscale_data:
|
||||||
gitea_data:
|
resolver_ts_state:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
edubox:
|
studioe5:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
# Deployeur studioE5 — Onboarding d’un nouvel établissement
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Ce document décrit le fonctionnement du **deployeur studioE5**, c’est-à-dire l’application / l’outil qui provisionne et configure un nouvel environnement client (un établissement) prêt à accueillir l’application studioE5.
|
||||||
|
|
||||||
|
L’application studioE5 elle-même (agent, VPN on-demand, resolver, Caddy, Headscale, etc.) est documentée dans `SUIVI_VPN_ONDEMAND.md`. Le deployeur est l’outil qui **déploie** cette application sur un VPS dédié au client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Public cible
|
||||||
|
|
||||||
|
- Équipe produit / développement du deployeur
|
||||||
|
- Équipe ops / déploiement
|
||||||
|
- Référents techniques du client A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Glossaire
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **Deployeur** | Application qui provisionne un VPS, déploie la stack studioE5 et configure le DNS/certificats pour un nouvel établissement. |
|
||||||
|
| **Hub central** | Dashboard superadmin studioE5 qui orchestre les déploiements et la gestion multi-clients. |
|
||||||
|
| **Établissement** | Entité client (école, lycée, université, entreprise). |
|
||||||
|
| **Tag établissement** | Slug unique et court identifiant l’établissement dans les URLs et le DNS. |
|
||||||
|
| **Domaine géré** | Sous-domaine fourni par studioE5 : `*.tag.edudeploy.com`. |
|
||||||
|
| **Domaine propre** | Domaine détenu par l’établissement : `*.tag.monetablissement.fr`. |
|
||||||
|
| **Application studioE5** | Stack complète déployée sur le VPS : `server`, `resolver`, `resolver-vpn`, `caddy`, `headscale`, `postgres`, etc. |
|
||||||
|
| **Agent générique** | Binaire agent unique, capable de se connecter à n’importe quel serveur studioE5 via résolution d’URL à l’activation. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture : deployeur vs application studioE5
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Hub central studioE5 │
|
||||||
|
│ (superadmin, gestion des établissements, monitoring) │
|
||||||
|
└───────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Deployeur studioE5 │
|
||||||
|
│ (provisionning VPS, DNS, certificats, déploiement stack) │
|
||||||
|
└───────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Application studioE5 (un par client) │
|
||||||
|
│ Caddy — Resolver — Resolver-VPN — Headscale — Server — DB │
|
||||||
|
│ ▲ │
|
||||||
|
│ │ WebSocket / VPN on-demand │
|
||||||
|
│ ▼ │
|
||||||
|
│ Agent élève (Windows/Linux) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flux d’onboarding par le deployeur (vue d’ensemble)
|
||||||
|
|
||||||
|
```
|
||||||
|
Création de l’établissement dans le hub
|
||||||
|
↓
|
||||||
|
Choix du domaine (géré ou propre)
|
||||||
|
↓
|
||||||
|
Génération du tag établissement
|
||||||
|
↓
|
||||||
|
Provisionning du VPS
|
||||||
|
↓
|
||||||
|
Configuration DNS wildcard
|
||||||
|
↓
|
||||||
|
Génération du certificat wildcard
|
||||||
|
↓
|
||||||
|
Déploiement de la stack studioE5 (Docker Compose)
|
||||||
|
↓
|
||||||
|
Initialisation de Headscale et création des clés
|
||||||
|
↓
|
||||||
|
Création du compte administrateur de l’établissement
|
||||||
|
↓
|
||||||
|
Génération des codes d’activation
|
||||||
|
↓
|
||||||
|
Build et mise à disposition de l’agent dédié
|
||||||
|
↓
|
||||||
|
Activation de l’agent par un élève
|
||||||
|
↓
|
||||||
|
Création d’une première instance (validation du déploiement)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Création de l’établissement dans le hub
|
||||||
|
|
||||||
|
Le superadmin crée un nouvel établissement dans le hub central.
|
||||||
|
|
||||||
|
Données minimales :
|
||||||
|
|
||||||
|
- Nom officiel
|
||||||
|
- Type d’établissement (école, lycée, université, entreprise)
|
||||||
|
- Pays / fuseau horaire
|
||||||
|
- Contact administrateur
|
||||||
|
- Choix du mode de domaine (`managed` ou `custom`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Choix du domaine
|
||||||
|
|
||||||
|
### Option A — Domaine géré par studioE5 (MVP)
|
||||||
|
|
||||||
|
Le deployeur crée automatiquement un sous-domaine du domaine maître :
|
||||||
|
|
||||||
|
```
|
||||||
|
*.tag.edudeploy.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Le hub gère le DNS chez le registrar studioE5 (actuellement Infomaniak).
|
||||||
|
|
||||||
|
### Option B — Domaine propre de l’établissement (évolution)
|
||||||
|
|
||||||
|
L’établissement fournit son propre domaine :
|
||||||
|
|
||||||
|
```
|
||||||
|
*.tag.monetablissement.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
Prérequis :
|
||||||
|
|
||||||
|
- Le client pointe son DNS wildcard vers l’IP du VPS provisionné.
|
||||||
|
- Le deployeur dispose d’un token API du registrar du client pour le challenge DNS-01.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Génération du tag établissement
|
||||||
|
|
||||||
|
Le tag est un slug court, unique au niveau du hub, utilisé dans les URLs et le DNS.
|
||||||
|
|
||||||
|
### Règles
|
||||||
|
|
||||||
|
- Uniquement `[a-z0-9-]`
|
||||||
|
- Pas de tiret au début ni à la fin
|
||||||
|
- Longueur conseillée : 2 à 20 caractères
|
||||||
|
- Vérification d’unicité en base
|
||||||
|
|
||||||
|
### Exemples
|
||||||
|
|
||||||
|
| Nom d’établissement | Tag |
|
||||||
|
|---------------------|-----|
|
||||||
|
| Lycée Jules Ferry | `ljf` |
|
||||||
|
| Institut Supérieur du Digital | `isd` |
|
||||||
|
| École Notre-Dame | `end` |
|
||||||
|
|
||||||
|
### Gestion des collisions
|
||||||
|
|
||||||
|
- `ljf` → `ljf-2`, `ljf-3`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Provisionning du VPS
|
||||||
|
|
||||||
|
Le deployeur provisionne un VPS dédié pour l’établissement.
|
||||||
|
|
||||||
|
### Prérequis sur le VPS vierge
|
||||||
|
|
||||||
|
- OS Linux (Ubuntu LTS recommandé)
|
||||||
|
- Docker + Docker Compose installés
|
||||||
|
- Accès SSH avec clé
|
||||||
|
- Ports ouverts : 22, 80, 443
|
||||||
|
|
||||||
|
### Actions automatisées par le deployeur
|
||||||
|
|
||||||
|
1. Installation de Docker et Docker Compose si absent.
|
||||||
|
2. Création de la structure de dossiers (`/opt/studioe5-<tag>/`).
|
||||||
|
3. Génération des secrets (`.env`) :
|
||||||
|
- `INTERNAL_API_KEY`
|
||||||
|
- `HEADSCALE_API_KEY`
|
||||||
|
- `HEADSCALE_AUTH_KEY` (réutilisable, taguée `tag:student-agent`)
|
||||||
|
- `HEADSCALE_RESOLVER_AUTH_KEY`
|
||||||
|
- `INFOMANIAK_API_TOKEN` (si domaine géré)
|
||||||
|
- `NEXTAUTH_SECRET`, `DATABASE_URL`, etc.
|
||||||
|
4. Récupération des images Docker depuis le registry privé (ou build sur place en attendant).
|
||||||
|
5. Génération des fichiers de configuration (`Caddyfile`, `docker-compose.yml`, `headscale/config.yaml`, `headscale/acl_policy.hujson`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Configuration DNS wildcard
|
||||||
|
|
||||||
|
### Domaine géré
|
||||||
|
|
||||||
|
Le deployeur appelle l’API du registrar pour créer :
|
||||||
|
|
||||||
|
```dns
|
||||||
|
*.tag.edudeploy.com A <IP_DU_VPS>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domaine propre
|
||||||
|
|
||||||
|
Le deployeur vérifie que l’enregistrement existe :
|
||||||
|
|
||||||
|
```dns
|
||||||
|
*.tag.monetablissement.fr A <IP_DU_VPS>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Certificat wildcard
|
||||||
|
|
||||||
|
### Principe
|
||||||
|
|
||||||
|
Un seul certificat wildcard couvre toutes les instances futures de l’établissement.
|
||||||
|
|
||||||
|
### Mise en œuvre avec Caddy
|
||||||
|
|
||||||
|
Le deployeur génère le `Caddyfile` :
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
*.tag.edudeploy.com {
|
||||||
|
tls {
|
||||||
|
dns infomaniak {env.INFOMANIAK_API_TOKEN}
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy resolver:2020 {
|
||||||
|
header_up Host {host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour un domaine propre, le provider DNS est celui du client.
|
||||||
|
|
||||||
|
### Renouvellement
|
||||||
|
|
||||||
|
Géré automatiquement par Caddy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Déploiement de la stack studioE5
|
||||||
|
|
||||||
|
Le deployeur lance la stack Docker Compose complète :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/studioe5-<tag>
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Services déployés :
|
||||||
|
|
||||||
|
- `server` : API + WebSocket + UI Next.js
|
||||||
|
- `resolver` : reverse proxy interne vers les instances
|
||||||
|
- `resolver-vpn` : sidecar Tailscale dans le netns du resolver
|
||||||
|
- `caddy` : reverse proxy public + TLS
|
||||||
|
- `headscale` : contrôleur Tailscale
|
||||||
|
- `postgres` : base de données
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Initialisation de Headscale
|
||||||
|
|
||||||
|
Le deployeur initialise Headscale et crée les clés nécessaires :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Création de l’utilisateur dédié au resolver
|
||||||
|
docker compose exec headscale headscale users create resolver
|
||||||
|
|
||||||
|
# Création de la clé pré-auth réutilisable pour les agents
|
||||||
|
docker compose exec headscale headscale preauthkeys create \
|
||||||
|
--user studioe5 \
|
||||||
|
--reusable \
|
||||||
|
--tags tag:student-agent \
|
||||||
|
-e 87600h
|
||||||
|
|
||||||
|
# Création de la clé pré-auth pour le resolver
|
||||||
|
docker compose exec headscale headscale preauthkeys create \
|
||||||
|
--user resolver \
|
||||||
|
--tags tag:resolver \
|
||||||
|
-e 87600h
|
||||||
|
|
||||||
|
# Création d’une clé API Headscale valable 10 ans
|
||||||
|
docker compose exec headscale headscale apikeys create -e 87600h
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces secrets sont stockés dans le `.env` du serveur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Création du compte administrateur de l’établissement
|
||||||
|
|
||||||
|
Une fois la stack déployée, le deployeur (ou le hub) crée le premier compte administrateur de l’établissement via l’API du serveur nouvellement déployé.
|
||||||
|
|
||||||
|
Rôles :
|
||||||
|
|
||||||
|
- `admin` : gestion des élèves, instances, agents.
|
||||||
|
- `teacher` : gestion limitée à certaines classes/groupes.
|
||||||
|
- `superadmin` (studioE5) : accès transverse.
|
||||||
|
|
||||||
|
L’administrateur reçoit un lien d’activation sécurisé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Génération des codes d’activation
|
||||||
|
|
||||||
|
Le deployeur configure le serveur pour permettre la génération de codes d’activation.
|
||||||
|
|
||||||
|
### Règles de sécurité (implémentées côté application studioE5)
|
||||||
|
|
||||||
|
- Génération avec `crypto.randomBytes`
|
||||||
|
- Alphabet sans ambiguïté : `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`
|
||||||
|
- 6 caractères
|
||||||
|
- Expiration après 60 minutes
|
||||||
|
- Invalidation après usage
|
||||||
|
- Rate-limiting : 5 tentatives par code / 5 tentatives par `nodeId` sur 15 minutes
|
||||||
|
|
||||||
|
### Flux
|
||||||
|
|
||||||
|
1. L’administrateur génère un code pour un élève.
|
||||||
|
2. L’élève saisit le code dans l’agent.
|
||||||
|
3. Le serveur valide et renvoie :
|
||||||
|
- l’identité de l’élève
|
||||||
|
- l’URL Headscale
|
||||||
|
- une clé pré-auth Headscale éphémère
|
||||||
|
4. L’agent démarre automatiquement le VPN.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Build et mise à disposition de l’agent
|
||||||
|
|
||||||
|
### Principe
|
||||||
|
|
||||||
|
L’agent est un binaire générique, mais il doit être capable de se connecter au bon serveur. Le deployeur génère un agent pré-configuré ou un installeur qui embarque l’URL du serveur de l’établissement.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/studioe5-<tag>/agent
|
||||||
|
./download-tailscale-bins.sh 1.98.4
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifacts générés :
|
||||||
|
|
||||||
|
- `studioE5-agent-vX.Y.Z-windows.zip`
|
||||||
|
- `studioE5-agent-vX.Y.Z.exe`
|
||||||
|
- `studioE5-agent-vX.Y.Z` (Linux)
|
||||||
|
|
||||||
|
### Mise à disposition
|
||||||
|
|
||||||
|
Les fichiers sont servis par Caddy depuis `server/public/agent/` :
|
||||||
|
|
||||||
|
```
|
||||||
|
https://tag.edudeploy.com/studioE5-agent-vX.Y.Z-windows.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Activation de l’agent
|
||||||
|
|
||||||
|
### Activation zéro-config
|
||||||
|
|
||||||
|
1. L’élève télécharge l’agent depuis l’URL de l’établissement.
|
||||||
|
2. Il extrait l’archive et lance `studioE5-agent.exe`.
|
||||||
|
3. Il ouvre `http://localhost:7070`.
|
||||||
|
4. Il saisit le code d’activation à 6 caractères.
|
||||||
|
5. L’agent contacte le serveur, récupère la configuration et démarre le VPN.
|
||||||
|
|
||||||
|
> Les détails techniques du VPN on-demand (named pipes Windows, logs, ACL, tokens, clés éphémères) sont documentés dans `SUIVI_VPN_ONDEMAND.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Création d’une instance et construction de l’URL (validation)
|
||||||
|
|
||||||
|
Le deployeur ou l’administrateur crée une première instance pour valider le déploiement.
|
||||||
|
|
||||||
|
### Format d’URL
|
||||||
|
|
||||||
|
```
|
||||||
|
<appli>-<initiales><id-court>.<tag>.<domaine>
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
```
|
||||||
|
wp-jd47.ljf.edudeploy.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Avec :
|
||||||
|
|
||||||
|
- `wp` : type d’application
|
||||||
|
- `jd` : initiales de l’élève
|
||||||
|
- `47` : identifiant court unique
|
||||||
|
- `ljf` : tag de l’établissement
|
||||||
|
- `edudeploy.com` : domaine de base
|
||||||
|
|
||||||
|
### Mapping type d’application → préfixe
|
||||||
|
|
||||||
|
| Application | Préfixe |
|
||||||
|
|-------------|---------|
|
||||||
|
| WordPress | `wp` |
|
||||||
|
| PrestaShop | `ps` |
|
||||||
|
| Moodle | `mdl` |
|
||||||
|
| Nextcloud | `nc` |
|
||||||
|
|
||||||
|
### Protection de l’identité
|
||||||
|
|
||||||
|
- L’URL ne contient pas le nom complet de l’élève.
|
||||||
|
- Seules les initiales + un identifiant court opaque sont exposées.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Modèles de données du deployeur
|
||||||
|
|
||||||
|
### Table / modèle `Organization` (établissement dans le hub)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "Lycée Jules Ferry",
|
||||||
|
"tag": "ljf",
|
||||||
|
"domainMode": "managed",
|
||||||
|
"baseDomain": "edudeploy.com",
|
||||||
|
"adminEmail": "admin@ljf.fr",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-06-25T17:28:07Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table / modèle `Deployment` (déploiement sur un VPS)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"organizationId": "uuid",
|
||||||
|
"serverIp": "203.0.113.10",
|
||||||
|
"serverHostname": "ljf.studioe5.edudeploy.com",
|
||||||
|
"wildcardDnsConfigured": true,
|
||||||
|
"wildcardCertificateReady": true,
|
||||||
|
"dnsProvider": "infomaniak",
|
||||||
|
"dnsProviderTokenRef": "env:INFOMANIAK_TOKEN_LJF",
|
||||||
|
"headscaleApiKeyRef": "env:HEADSCALE_API_KEY_LJF",
|
||||||
|
"status": "ready",
|
||||||
|
"deployedAt": "2026-06-25T17:28:07Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table / modèle `Student` (dans l’application studioE5 déployée)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"organizationId": "uuid",
|
||||||
|
"firstName": "Jean",
|
||||||
|
"lastName": "Dupont",
|
||||||
|
"initials": "jd",
|
||||||
|
"activationCode": "AB3D9F",
|
||||||
|
"activationCodeExpiresAt": "2026-06-25T18:28:07Z",
|
||||||
|
"nodeId": "vps-8fc665eb",
|
||||||
|
"nodeToken": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table / modèle `Instance` (dans l’application studioE5 déployée)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "cmqqgrur20001lw67t2bdgzkg",
|
||||||
|
"organizationId": "uuid",
|
||||||
|
"studentId": "uuid",
|
||||||
|
"nodeId": "vps-8fc665eb",
|
||||||
|
"templateId": "wordpress-wordpress-latest",
|
||||||
|
"applicationPrefix": "wp",
|
||||||
|
"shortId": "47",
|
||||||
|
"subdomain": "wp-jd47",
|
||||||
|
"fqdn": "wp-jd47.ljf.edudeploy.com",
|
||||||
|
"port": 8001,
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Sécurité et RGPD
|
||||||
|
|
||||||
|
### Protection de l’identité de l’élève
|
||||||
|
|
||||||
|
- L’URL publique ne contient pas le nom complet de l’élève.
|
||||||
|
- Seules les initiales + un identifiant court opaque sont exposées.
|
||||||
|
|
||||||
|
### Isolation réseau
|
||||||
|
|
||||||
|
- Les agents élèves ne peuvent pas communiquer entre eux (ACL Headscale).
|
||||||
|
- Le resolver est le seul service autorisé à joindre les agents sur leurs ports d’instance.
|
||||||
|
|
||||||
|
### Authentification
|
||||||
|
|
||||||
|
- Token unique par agent (`node.token`).
|
||||||
|
- Clé API interne pour les endpoints serveur → agent.
|
||||||
|
- Sessions NextAuth sur les routes API métier.
|
||||||
|
|
||||||
|
### Clés pré-auth Headscale
|
||||||
|
|
||||||
|
- Éphémères, à usage unique, 15 minutes d’expiration.
|
||||||
|
- Non persistées côté agent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Checklist de validation du deployeur
|
||||||
|
|
||||||
|
À l’issue d’un onboarding, les points suivants doivent être validés :
|
||||||
|
|
||||||
|
- [ ] L’établissement est créé dans le hub avec un tag unique.
|
||||||
|
- [ ] Le VPS est provisionné et accessible en SSH.
|
||||||
|
- [ ] Docker et Docker Compose sont installés.
|
||||||
|
- [ ] Le DNS wildcard est résolu (`*.tag.edudeploy.com` → IP du VPS).
|
||||||
|
- [ ] Le certificat wildcard est obtenu et valide.
|
||||||
|
- [ ] La stack studioE5 est démarrée (`docker compose ps`).
|
||||||
|
- [ ] Headscale est initialisé avec les utilisateurs et clés nécessaires.
|
||||||
|
- [ ] Le compte administrateur de l’établissement est créé.
|
||||||
|
- [ ] Un code d’activation peut être généré pour un élève.
|
||||||
|
- [ ] L’agent est buildé et téléchargeable depuis le serveur de l’établissement.
|
||||||
|
- [ ] L’agent s’active avec le code zéro-config.
|
||||||
|
- [ ] Une instance peut être créée et son URL est accessible en HTTPS.
|
||||||
|
- [ ] Deux instances différentes reçoivent des URL uniques.
|
||||||
|
- [ ] Le flux HTTPS complet retourne bien HTTP 200.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Roadmap du deployeur
|
||||||
|
|
||||||
|
### Court terme (MVP)
|
||||||
|
|
||||||
|
- Déploiement manuel ou semi-automatisé d’un nouvel établissement sur un VPS.
|
||||||
|
- Domaine géré par studioE5 uniquement.
|
||||||
|
- Build des images sur le VPS cible.
|
||||||
|
- Agent avec URL serveur hardcodée ou fournie à l’activation.
|
||||||
|
|
||||||
|
### Moyen terme
|
||||||
|
|
||||||
|
- **Agent générique** : déterminer l’URL serveur cible à l’activation (code structuré, hub de résolution, ou champ URL).
|
||||||
|
- **Script de provisionning** : installation Docker, déploiement stack, génération secrets, DNS wildcard.
|
||||||
|
- **Registry d’images privé** : builder une fois, déployer partout.
|
||||||
|
- Support de domaines propres à l’établissement.
|
||||||
|
- Support multi-registrar DNS.
|
||||||
|
|
||||||
|
### Long terme
|
||||||
|
|
||||||
|
- **Hub central multi-clients** : dashboard superadmin, gestion des versions, logs distants.
|
||||||
|
- **Mises à jour à distance** : pousser une nouvelle version du serveur et de l’agent sur tous les déploiements.
|
||||||
|
- **Monitoring / support** : alertes serveur down, certificat expiré, agent hors ligne.
|
||||||
|
- **Tests automatisés** : validation du flux activation → VPN → instance → HTTPS public à chaque déploiement.
|
||||||
|
- **Console/log intégré et barre de progression** dans l’agent.
|
||||||
|
- Génération automatique de codes d’activation par import CSV.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"tagOwners": {
|
||||||
|
"tag:student-agent": ["studioe5@studioe5.local"],
|
||||||
|
"tag:resolver": ["resolver@studioe5.local"]
|
||||||
|
},
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["tag:resolver"],
|
||||||
|
"dst": ["tag:student-agent:*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["tag:student-agent"],
|
||||||
|
"dst": ["tag:resolver:2020"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Headscale configuration for studioE5 client A
|
||||||
|
server_url: https://headscale.studioe5.edudeploy.com
|
||||||
|
listen_addr: 0.0.0.0:8080
|
||||||
|
metrics_listen_addr: 0.0.0.0:9090
|
||||||
|
grpc_listen_addr: 127.0.0.1:50443
|
||||||
|
|
||||||
|
noise:
|
||||||
|
private_key_path: /etc/headscale/noise_private.key
|
||||||
|
|
||||||
|
prefixes:
|
||||||
|
v4: 100.64.0.0/10
|
||||||
|
v6: fd7a:115c:a1e0::/48
|
||||||
|
allocation: sequential
|
||||||
|
|
||||||
|
dns:
|
||||||
|
magic_dns: true
|
||||||
|
base_domain: studioe5.local
|
||||||
|
nameservers:
|
||||||
|
global:
|
||||||
|
- 1.1.1.1
|
||||||
|
- 8.8.8.8
|
||||||
|
override_local_dns: true
|
||||||
|
|
||||||
|
derp:
|
||||||
|
server:
|
||||||
|
enabled: true
|
||||||
|
region_id: 999
|
||||||
|
region_code: headscale
|
||||||
|
region_name: Headscale Embedded DERP
|
||||||
|
stun_listen_addr: 0.0.0.0:3478
|
||||||
|
private_key_path: /etc/headscale/derp_server_private.key
|
||||||
|
urls:
|
||||||
|
- https://controlplane.tailscale.com/derpmap/default
|
||||||
|
paths: []
|
||||||
|
|
||||||
|
database:
|
||||||
|
type: sqlite3
|
||||||
|
sqlite:
|
||||||
|
path: /etc/headscale/db.sqlite
|
||||||
|
|
||||||
|
policy:
|
||||||
|
path: /etc/headscale/acl_policy.hujson
|
||||||
|
mode: file
|
||||||
|
|
||||||
|
log:
|
||||||
|
format: text
|
||||||
|
level: info
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
FROM prestashop/prestashop:9
|
||||||
|
|
||||||
|
# Apply EduBox patches so PrestaShop 9 works behind the dynamic-domain reverse proxy.
|
||||||
|
COPY edubox-tools.patch \
|
||||||
|
edubox-link.patch \
|
||||||
|
edubox-frontcontroller.patch \
|
||||||
|
edubox-shop.patch \
|
||||||
|
edubox-shopurl.patch \
|
||||||
|
edubox-shop-getbaseurl.patch \
|
||||||
|
edubox-shopcontext.patch \
|
||||||
|
edubox-asseturl.patch \
|
||||||
|
edubox-install.patch \
|
||||||
|
edubox-install-language.patch \
|
||||||
|
edubox-language.patch \
|
||||||
|
edubox-configuration.patch \
|
||||||
|
edubox-dashboard-warning.patch \
|
||||||
|
edubox-docker-run.patch \
|
||||||
|
/tmp/
|
||||||
|
RUN patch -p1 -d /var/www/html < /tmp/edubox-tools.patch && \
|
||||||
|
patch -p1 -d /var/www/html < /tmp/edubox-link.patch && \
|
||||||
|
patch -p1 -d /var/www/html < /tmp/edubox-frontcontroller.patch && \
|
||||||
|
patch -p1 -d /var/www/html < /tmp/edubox-shop.patch && \
|
||||||
|
patch -p1 -d /var/www/html < /tmp/edubox-shopurl.patch && \
|
||||||
|
patch -p1 -d /var/www/html < /tmp/edubox-shop-getbaseurl.patch && \
|
||||||
|
patch -p1 -d /var/www/html < /tmp/edubox-shopcontext.patch && \
|
||||||
|
patch -p1 -d /var/www/html < /tmp/edubox-asseturl.patch && \
|
||||||
|
patch -p1 -d /var/www/html < /tmp/edubox-install.patch && \
|
||||||
|
patch -p1 -d /var/www/html < /tmp/edubox-install-language.patch && \
|
||||||
|
patch -p1 -d /var/www/html < /tmp/edubox-language.patch && \
|
||||||
|
patch -p1 -d /var/www/html < /tmp/edubox-configuration.patch && \
|
||||||
|
patch -p1 -d /var/www/html < /tmp/edubox-dashboard-warning.patch && \
|
||||||
|
patch -p1 -d / < /tmp/edubox-docker-run.patch && \
|
||||||
|
rm /tmp/edubox-*.patch
|
||||||
|
|
||||||
|
# Apache proxy configuration
|
||||||
|
COPY proxy.conf /etc/apache2/conf-enabled/edubox-proxy.conf
|
||||||
|
|
||||||
|
# Pre-download French translation pack so the installer works offline.
|
||||||
|
# Agents may not have outbound internet access during installation.
|
||||||
|
# The official image copies /tmp/data-ps/prestashop/ into /var/www/html on first
|
||||||
|
# boot, so we place the pack there as well.
|
||||||
|
COPY translations-symfony-fr-FR.zip /tmp/data-ps/prestashop/translations/sf-fr-FR.zip
|
||||||
|
RUN chown -R www-data:www-data /tmp/data-ps/prestashop/translations
|
||||||
|
|
||||||
|
# Early bootstrap normalisation for X-Forwarded-* headers
|
||||||
|
COPY defines_custom.inc.php /var/www/html/config/defines_custom.inc.php
|
||||||
|
|
||||||
|
# Clear caches on every start so dynamic domains/ports are picked up
|
||||||
|
COPY edubox-clear-cache-init.sh /tmp/init-scripts/edubox-clear-cache.sh
|
||||||
|
RUN chmod +x /tmp/init-scripts/edubox-clear-cache.sh
|
||||||
|
|
||||||
|
RUN chown -R www-data:www-data /var/www/html
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# EduBox PrestaShop 9 Image
|
||||||
|
|
||||||
|
Image Docker patchée basée sur `prestashop/prestashop:9`, conçue pour fonctionner
|
||||||
|
avec le reverse proxy dynamique d'EduBox.
|
||||||
|
|
||||||
|
## Pourquoi une image patchée ?
|
||||||
|
|
||||||
|
PrestaShop 9 (Apache 2.4 + PHP 8.5) a plusieurs problèmes majeurs derrière EduBox :
|
||||||
|
|
||||||
|
1. Les headers `X-Forwarded-*` sont corrompus par Apache/PHP : `$_SERVER` les
|
||||||
|
reçoit sous forme d'arrays au lieu de strings. On contourne ce bug via
|
||||||
|
`getenv()` dans `config/defines_custom.inc.php`.
|
||||||
|
2. PrestaShop utilise partout le domaine stocké en base (`ps_shop_url`) et la
|
||||||
|
configuration `PS_SSL_ENABLED`. Derrière EduBox, le domaine public change à
|
||||||
|
chaque instance (`<id>.alfrednobel.edudeploy.com`) et toutes les requêtes
|
||||||
|
publiques arrivent en HTTPS. Les patches forcent l'utilisation de l'hôte et
|
||||||
|
du protocole de la requête courante.
|
||||||
|
3. Les agents étudiants peuvent être hors ligne. Le pack de langue français est
|
||||||
|
donc embarqué dans l'image pour éviter tout téléchargement pendant
|
||||||
|
l'installation.
|
||||||
|
|
||||||
|
## Build local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/edubox/prestashop-image
|
||||||
|
docker build -t edubox-prestashop:9 .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Push sur le registry Gitea
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker tag edubox-prestashop:9 \
|
||||||
|
gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-9
|
||||||
|
docker push \
|
||||||
|
gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-9
|
||||||
|
```
|
||||||
|
|
||||||
|
## Patches appliqués
|
||||||
|
|
||||||
|
| Patch | Fichier modifié | Objectif |
|
||||||
|
|-------|-----------------|----------|
|
||||||
|
| `edubox-tools.patch` | `classes/Tools.php` | `getShopDomain()` / `getShopDomainSsl()` utilisent `getHttpHost()` dynamiquement en conservant les ports non standards (ex. `localhost:8088`) ; `.htaccess` généré sans condition `HTTP_HOST` (images/catégories). |
|
||||||
|
| `edubox-link.patch` | `classes/Link.php` | `getBaseLink()` et `getAdminBaseLink()` utilisent `usingSecureMode()` et `getHttpHost()`. |
|
||||||
|
| `edubox-frontcontroller.patch` | `classes/controller/FrontController.php` | Désactive `sslRedirection()` pour éviter les boucles HTTP/HTTPS. |
|
||||||
|
| `edubox-shop.patch` | `classes/shop/Shop.php` | `Shop::initialize()` utilise le shop par défaut sans redirection forcée. |
|
||||||
|
| `edubox-shopurl.patch` | `classes/shop/ShopUrl.php` | `getMainShopDomain()` / `getMainShopDomainSSL()` retournent le domaine de la requête en conservant les ports non standards. |
|
||||||
|
| `edubox-shop-getbaseurl.patch` | `classes/shop/Shop.php` | `Shop::getBaseURL()` utilise le host/port de la requête courante. |
|
||||||
|
| `edubox-shopcontext.patch` | `src/Core/Context/ShopContext.php` | `getBaseURL()` du BO est reconstruit à partir de la requête courante. |
|
||||||
|
| `edubox-configuration.patch` | `classes/Configuration.php` | `PS_SHOP_DOMAIN`, `PS_SHOP_DOMAIN_SSL`, `PS_SSL_ENABLED`, `_PS_BASE_URL_`, `_PS_BASE_URL_SSL_` sont résolus dynamiquement depuis la requête, pas depuis le cache DB. |
|
||||||
|
| `edubox-asseturl.patch` | `src/Adapter/Assets/AssetUrlGeneratorTrait.php` | Les assets CCC utilisent le protocole de la requête, pas `PS_SSL_ENABLED`. |
|
||||||
|
| `edubox-install.patch` | `src/PrestaShopBundle/Install/Install.php` | `finalize()` respecte `PS_FOLDER_ADMIN` (évite le bug overlayfs `admin` → `admin-edubox`). |
|
||||||
|
| `edubox-install-language.patch` | `src/PrestaShopBundle/Install/Install.php` | Évite le téléchargement du pack legacy `fr.gzip` quand le pack Symfony est embarqué. |
|
||||||
|
| `edubox-language.patch` | `classes/Language.php` | Utilise `_PS_TRANSLATIONS_DIR_` au runtime pour le cache langue ; évite le téléchargement réseau si le pack est présent. |
|
||||||
|
| `edubox-dashboard-warning.patch` | `controllers/admin/AdminDashboardController.php` | Désactive le bandeau d’avertissement "domaine différent de SEO & URL". |
|
||||||
|
| `edubox-docker-run.patch` | `/tmp/docker_run.sh` | Supprime un `install.lock` résiduel si une installation précédente a échoué. |
|
||||||
|
|
||||||
|
## Fichiers injectés
|
||||||
|
|
||||||
|
- `proxy.conf` : Apache truste `X-Forwarded-Proto: https` pour positionner
|
||||||
|
`HTTPS=on` dans l'environnement PHP. Active aussi `AllowOverride All` pour
|
||||||
|
que le `.htaccess` de PrestaShop fonctionne.
|
||||||
|
- `config/defines_custom.inc.php` : normalise `HTTP_X_FORWARDED_HOST`,
|
||||||
|
`HTTP_X_FORWARDED_PROTO` et `HTTP_HOST` corrompus ; définit
|
||||||
|
`PS_TRUSTED_PROXIES` pour Symfony.
|
||||||
|
- `translations-symfony-fr-FR.zip` → copié sous `sf-fr-FR.zip` dans
|
||||||
|
`/var/www/html/translations/` : pack de langue Symfony français embarqué
|
||||||
|
( PrestaShop attend le préfixe `sf-` ).
|
||||||
|
- `edubox-clear-cache-init.sh` → `/tmp/init-scripts/edubox-clear-cache.sh` :
|
||||||
|
vidage des caches Smarty/Symfony et des assets CCC à chaque démarrage du
|
||||||
|
conteneur, afin que les changements de domaine/port soient pris en compte.
|
||||||
|
|
||||||
|
## Utilisation dans EduBox
|
||||||
|
|
||||||
|
Le template PrestaShop 9 dans `server/prisma/seed.ts` utilise cette image :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
app:
|
||||||
|
image: gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mise à jour vers une nouvelle version de PrestaShop
|
||||||
|
|
||||||
|
Si PrestaShop sort une version `9.x.y` :
|
||||||
|
|
||||||
|
1. Modifier le `FROM` du Dockerfile : `FROM prestashop/prestashop:9.x.y`
|
||||||
|
2. Relancer le build. Les patches qui échouent doivent être adaptés aux
|
||||||
|
nouvelles lignes/code de PrestaShop.
|
||||||
|
3. Re-tagger et pousser : `9.x.y-edubox-1`.
|
||||||
|
4. Mettre à jour `server/prisma/seed.ts` avec le nouveau tag.
|
||||||
|
|
||||||
|
## Déploiement sur les agents
|
||||||
|
|
||||||
|
L'image doit être accessible depuis chaque agent étudiant. Deux options :
|
||||||
|
|
||||||
|
1. **Registry privé** (recommandé) : tagger et pousser l'image sur un registry
|
||||||
|
(Docker Hub, registry Gitea, GHCR, etc.) puis mettre à jour
|
||||||
|
`server/prisma/seed.ts` avec le nom complet.
|
||||||
|
2. **Build manuel sur chaque agent** : copier ce dossier sur l'agent et lancer
|
||||||
|
`docker build` avant le premier déploiement.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* EduBox reverse proxy normalisation for PrestaShop 9 running behind the
|
||||||
|
* EduBox dynamic-public-domain resolver.
|
||||||
|
*
|
||||||
|
* The official PrestaShop 9 + PHP 8.5 + Apache image has a bug where
|
||||||
|
* X-Forwarded-* headers are exposed to PHP as arrays whose value is the
|
||||||
|
* header name. getenv() returns the correct string, so we use it to
|
||||||
|
* reconstruct $_SERVER entries used by Tools::getHttpHost/ShopDomainSSL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ($val = getenv('HTTP_X_FORWARDED_HOST')) {
|
||||||
|
$_SERVER['HTTP_X_FORWARDED_HOST'] = $val;
|
||||||
|
}
|
||||||
|
if ($val = getenv('HTTP_X_FORWARDED_PROTO')) {
|
||||||
|
$_SERVER['HTTP_X_FORWARDED_PROTO'] = $val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apache/PHP 8.5 sometimes corrupts HTTP_HOST into an array; fall back safely.
|
||||||
|
if (!empty($_SERVER['HTTP_HOST']) && is_array($_SERVER['HTTP_HOST'])) {
|
||||||
|
$_SERVER['HTTP_HOST'] = !empty($_SERVER['SERVER_NAME']) && !is_array($_SERVER['SERVER_NAME'])
|
||||||
|
? $_SERVER['SERVER_NAME']
|
||||||
|
: (getenv('HTTP_X_FORWARDED_HOST') ?: 'localhost');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($_SERVER['HTTPS']) && is_array($_SERVER['HTTPS'])) {
|
||||||
|
$_SERVER['HTTPS'] = 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell Symfony to trust the EduBox resolver so $request->isSecure() and
|
||||||
|
// $request->getHost() honour X-Forwarded-* headers.
|
||||||
|
putenv('PS_TRUSTED_PROXIES=127.0.0.1,REMOTE_ADDR');
|
||||||
|
$_SERVER['PS_TRUSTED_PROXIES'] = '127.0.0.1,REMOTE_ADDR';
|
||||||
|
$_ENV['PS_TRUSTED_PROXIES'] = '127.0.0.1,REMOTE_ADDR';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
--- a/src/Adapter/Assets/AssetUrlGeneratorTrait.php
|
||||||
|
+++ b/src/Adapter/Assets/AssetUrlGeneratorTrait.php
|
||||||
|
@@ -49,12 +49,14 @@ trait AssetUrlGeneratorTrait
|
||||||
|
protected function getFQDN()
|
||||||
|
{
|
||||||
|
if (null === $this->fqdn) {
|
||||||
|
- if ($this->configuration->get('PS_SSL_ENABLED') && ToolsLegacy::usingSecureMode()) {
|
||||||
|
- $this->fqdn = $this->configuration->get('_PS_BASE_URL_SSL_');
|
||||||
|
- } else {
|
||||||
|
+ // EduBox: rely on the current request security, not on PS_SSL_ENABLED.
|
||||||
|
+ // Behind the reverse proxy every public request is HTTPS.
|
||||||
|
+ if (ToolsLegacy::usingSecureMode()) {
|
||||||
|
+ $this->fqdn = $this->configuration->get('_PS_BASE_URL_SSL_') ?: $this->configuration->get('_PS_BASE_URL_');
|
||||||
|
+ } else {
|
||||||
|
$this->fqdn = $this->configuration->get('_PS_BASE_URL_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fqdn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# EduBox: clear PrestaShop caches at every container start so that dynamic
|
||||||
|
# domains/ports (localhost:PORT or reverse-proxy public URL) are picked up.
|
||||||
|
echo "* EduBox: clearing PrestaShop caches for dynamic domain..."
|
||||||
|
rm -rf /var/www/html/var/cache/*
|
||||||
|
rm -rf /var/www/html/app/cache/*
|
||||||
|
rm -rf /var/www/html/cache/smarty/cache/*
|
||||||
|
rm -rf /var/www/html/cache/smarty/compile/*
|
||||||
|
rm -rf /var/www/html/themes/*/assets/cache/*
|
||||||
|
rm -rf /var/www/html/img/tmp/*
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
--- a/classes/Configuration.php 2026-06-04 14:48:44.000000000 +0000
|
||||||
|
+++ b/classes/Configuration.php 2026-06-23 16:27:03.944472677 +0000
|
||||||
|
@@ -210,6 +210,33 @@
|
||||||
|
Configuration::loadConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // EduBox: dynamic public domains and ports (local access + reverse proxy).
|
||||||
|
+ // These keys must be resolved from the current request, not from the DB cache.
|
||||||
|
+ if ($key === 'PS_SHOP_DOMAIN' || $key === 'PS_SHOP_DOMAIN_SSL') {
|
||||||
|
+ $host = Tools::getHttpHost(false, false, false);
|
||||||
|
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
|
||||||
|
+ $host = substr($host, 0, strrpos($host, ':'));
|
||||||
|
+ }
|
||||||
|
+ return $host;
|
||||||
|
+ }
|
||||||
|
+ if ($key === 'PS_SSL_ENABLED' || $key === 'PS_SSL_ENABLED_EVERYWHERE') {
|
||||||
|
+ return Tools::usingSecureMode() ? '1' : '0';
|
||||||
|
+ }
|
||||||
|
+ if ($key === '_PS_BASE_URL_') {
|
||||||
|
+ $host = Tools::getHttpHost(false, false, false);
|
||||||
|
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
|
||||||
|
+ $host = substr($host, 0, strrpos($host, ':'));
|
||||||
|
+ }
|
||||||
|
+ return 'http://' . $host;
|
||||||
|
+ }
|
||||||
|
+ if ($key === '_PS_BASE_URL_SSL_') {
|
||||||
|
+ $host = Tools::getHttpHost(false, false, false);
|
||||||
|
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
|
||||||
|
+ $host = substr($host, 0, strrpos($host, ':'));
|
||||||
|
+ }
|
||||||
|
+ return 'https://' . $host;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
$idLang = self::isLangKey($key) ? (int) $idLang : 0;
|
||||||
|
|
||||||
|
if (self::$_new_cache_shop === null) {
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
--- a/controllers/admin/AdminDashboardController.php
|
||||||
|
+++ b/controllers/admin/AdminDashboardController.php
|
||||||
|
@@ -330,43 +330,9 @@
|
||||||
|
|
||||||
|
protected function getWarningDomainName()
|
||||||
|
{
|
||||||
|
- $warning = false;
|
||||||
|
- if (Shop::isFeatureActive()) {
|
||||||
|
- return;
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- $shop = Context::getContext()->shop;
|
||||||
|
- if ($_SERVER['HTTP_HOST'] != $shop->domain && $_SERVER['HTTP_HOST'] != $shop->domain_ssl && Tools::getValue('ajax') == false) {
|
||||||
|
- $warning = $this->trans('You are currently connected under the following domain name:', [], 'Admin.Dashboard.Notification') . ' <span style="color: #CC0000;">' . $_SERVER['HTTP_HOST'] . '</span><br />';
|
||||||
|
- if (Configuration::get('PS_MULTISHOP_FEATURE_ACTIVE')) {
|
||||||
|
- $warning .= $this->trans(
|
||||||
|
- 'This is different from the shop domain name set in the Multistore settings: "%s".',
|
||||||
|
- [
|
||||||
|
- '%s' => $shop->domain,
|
||||||
|
- ],
|
||||||
|
- 'Admin.Dashboard.Notification'
|
||||||
|
- ) . $this->trans(
|
||||||
|
- 'If this is your main domain, please {link}change it now{/link}.',
|
||||||
|
- [
|
||||||
|
- '{link}' => '<a href="' . $this->context->link->getAdminLink('AdminShopUrl', true, [], ['id_shop_url' => (int) $shop->id, 'updateshop_url' => 1]) . '">',
|
||||||
|
- '{/link}' => '</a>',
|
||||||
|
- ],
|
||||||
|
- 'Admin.Dashboard.Notification'
|
||||||
|
- );
|
||||||
|
- } else {
|
||||||
|
- $warning .= $this->trans('This is different from the domain name set in the "SEO & URLs" tab.', [], 'Admin.Dashboard.Notification') . '
|
||||||
|
- ' . $this->trans(
|
||||||
|
- 'If this is your main domain, please {link}change it now{/link}.',
|
||||||
|
- [
|
||||||
|
- '{link}' => '<a href="' . $this->context->link->getAdminLink('AdminMeta') . '#meta_fieldset_shop_url">',
|
||||||
|
- '{/link}' => '</a>',
|
||||||
|
- ],
|
||||||
|
- 'Admin.Dashboard.Notification'
|
||||||
|
- );
|
||||||
|
- }
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- return $warning;
|
||||||
|
+ // EduBox: instances use dynamic public domains behind a reverse proxy.
|
||||||
|
+ // The domain stored during installation never matches the request host.
|
||||||
|
+ return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ajaxProcessRefreshDashboard()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
--- a/tmp/docker_run.sh 2026-06-20 17:57:12.682339048 +0000
|
||||||
|
+++ b/tmp/docker_run.sh 2026-06-20 17:57:12.852338398 +0000
|
||||||
|
@@ -21,6 +21,13 @@
|
||||||
|
|
||||||
|
# From now, stop at error
|
||||||
|
set -e
|
||||||
|
+# EduBox: if a previous installation failed, install.lock remains but PrestaShop is not configured.
|
||||||
|
+# Remove the stale lock so the installer can run again on the next start.
|
||||||
|
+if [ -f ./install.lock ] && [ ! -f ./config/settings.inc.php ] && [ ! -f ./app/config/parameters.php ]; then
|
||||||
|
+ echo "\n* Stale install.lock detected, removing it to allow reinstallation ..."
|
||||||
|
+ rm -f ./install.lock
|
||||||
|
+fi
|
||||||
|
+
|
||||||
|
|
||||||
|
if [ ! -f ./config/settings.inc.php ] && [ ! -f ./app/config/parameters.php ] && [ ! -f ./install.lock ]; then
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
--- a/classes/controller/FrontController.php
|
||||||
|
+++ b/classes/controller/FrontController.php
|
||||||
|
@@ -849,18 +849,9 @@
|
||||||
|
*/
|
||||||
|
protected function sslRedirection()
|
||||||
|
{
|
||||||
|
- // If we call a SSL controller without SSL or a non SSL controller with SSL, we redirect with the right protocol
|
||||||
|
- if (Configuration::get('PS_SSL_ENABLED') && $_SERVER['REQUEST_METHOD'] != 'POST' && $this->ssl != Tools::usingSecureMode()) {
|
||||||
|
- $this->context->cookie->disallowWriting();
|
||||||
|
- header('HTTP/1.1 301 Moved Permanently');
|
||||||
|
- header('Cache-Control: no-cache');
|
||||||
|
- if ($this->ssl) {
|
||||||
|
- header('Location: ' . Tools::getShopDomainSsl(true) . $_SERVER['REQUEST_URI']);
|
||||||
|
- } else {
|
||||||
|
- header('Location: ' . Tools::getShopDomain(true) . $_SERVER['REQUEST_URI']);
|
||||||
|
- }
|
||||||
|
- exit;
|
||||||
|
- }
|
||||||
|
+ // EduBox: disabled. Behind the EduBox reverse proxy every request is
|
||||||
|
+ // served over HTTPS publicly, so PrestaShop must never redirect to HTTP.
|
||||||
|
+ return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
--- a/src/PrestaShopBundle/Install/Install.php 2026-06-20 18:07:13.506985399 +0000
|
||||||
|
+++ b/src/PrestaShopBundle/Install/Install.php 2026-06-20 18:07:22.294363061 +0000
|
||||||
|
@@ -622,17 +622,20 @@
|
||||||
|
'locale' => (string) $xml->locale,
|
||||||
|
];
|
||||||
|
|
||||||
|
- if (file_exists(_PS_TRANSLATIONS_DIR_ . (string) $iso . '.gzip') == false) {
|
||||||
|
- $language = EntityLanguage::downloadLanguagePack($iso, _PS_INSTALL_VERSION_);
|
||||||
|
+ // EduBox: skip legacy language pack download if Symfony pack is bundled
|
||||||
|
+ $errors = [];
|
||||||
|
+ $locale = $params_lang['locale'];
|
||||||
|
+
|
||||||
|
+ if (!EntityLanguage::translationPackIsInCache($locale)) {
|
||||||
|
+ if (file_exists(_PS_TRANSLATIONS_DIR_ . (string) $iso . '.gzip') == false) {
|
||||||
|
+ $language = EntityLanguage::downloadLanguagePack($iso, _PS_INSTALL_VERSION_);
|
||||||
|
|
||||||
|
- if ($language == false) {
|
||||||
|
- throw new PrestashopInstallerException($this->translator->trans('Cannot download language pack "%iso%"', ['%iso%' => $iso], 'Install'));
|
||||||
|
+ if ($language == false) {
|
||||||
|
+ throw new PrestashopInstallerException($this->translator->trans('Cannot download language pack "%iso%"', ['%iso%' => $iso], 'Install'));
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- $errors = [];
|
||||||
|
- $locale = $params_lang['locale'];
|
||||||
|
-
|
||||||
|
/* @todo check if a newer pack is available */
|
||||||
|
if (!EntityLanguage::translationPackIsInCache($locale)) {
|
||||||
|
EntityLanguage::downloadXLFLanguagePack($locale, $errors);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
--- a/src/PrestaShopBundle/Install/Install.php
|
||||||
|
+++ b/src/PrestaShopBundle/Install/Install.php
|
||||||
|
@@ -1202,7 +1202,7 @@ class Install extends AbstractInstall
|
||||||
|
{
|
||||||
|
- $adminFolder = 'admin-dev';
|
||||||
|
+ $adminFolder = getenv('PS_FOLDER_ADMIN') ?: 'admin-dev';
|
||||||
|
|
||||||
|
// If we need, we generate a random name for admin folder (for security purpose!)
|
||||||
|
if (file_exists(_PS_ROOT_DIR_ . '/admin/')) {
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
--- a/classes/Language.php
|
||||||
|
+++ b/classes/Language.php
|
||||||
|
@@ -1235,6 +1235,12 @@
|
||||||
|
*/
|
||||||
|
public static function downloadXLFLanguagePack($locale, &$errors = [], $type = self::PACK_TYPE_SYMFONY)
|
||||||
|
{
|
||||||
|
+ // EduBox: if the translation pack is already present in the image,
|
||||||
|
+ // do not try to download it (agents may be offline).
|
||||||
|
+ if (static::translationPackIsInCache($locale, $type)) {
|
||||||
|
+ return true;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
$file = self::getPathToCachedTranslationPack($locale, $type);
|
||||||
|
$url = (self::PACK_TYPE_EMAILS === $type) ? self::EMAILS_LANGUAGE_PACK_URL : self::SF_LANGUAGE_PACK_URL;
|
||||||
|
$url = str_replace(
|
||||||
|
@@ -1697,7 +1703,9 @@
|
||||||
|
*/
|
||||||
|
public static function translationPackIsInCache(string $locale, string $type = self::PACK_TYPE_SYMFONY): bool
|
||||||
|
{
|
||||||
|
- return file_exists(self::getPathToCachedTranslationPack($locale, $type));
|
||||||
|
+ // EduBox: use runtime constant instead of class constant, because
|
||||||
|
+ // _PS_TRANSLATIONS_DIR_ may not be defined when this file is compiled.
|
||||||
|
+ return file_exists(_PS_TRANSLATIONS_DIR_ . $type . '-' . $locale . '.zip');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
@@ -1710,7 +1718,8 @@
|
||||||
|
*/
|
||||||
|
private static function getPathToCachedTranslationPack(string $locale, string $type = self::PACK_TYPE_SYMFONY): string
|
||||||
|
{
|
||||||
|
- return self::TRANSLATION_PACK_CACHE_DIR . $type . '-' . $locale . '.zip';
|
||||||
|
+ // EduBox: use runtime constant instead of class constant.
|
||||||
|
+ return _PS_TRANSLATIONS_DIR_ . $type . '-' . $locale . '.zip';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
--- a/classes/Link.php 2026-06-20 20:05:45.983104609 +0000
|
||||||
|
+++ b/classes/Link.php 2026-06-20 20:05:46.195748630 +0000
|
||||||
|
@@ -862,7 +862,7 @@
|
||||||
|
public function getAdminBaseLink($idShop = null, $ssl = null, $relativeProtocol = false)
|
||||||
|
{
|
||||||
|
if (null === $ssl) {
|
||||||
|
- $ssl = Configuration::get('PS_SSL_ENABLED');
|
||||||
|
+ $ssl = Tools::usingSecureMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Configuration::get('PS_MULTISHOP_FEATURE_ACTIVE')) {
|
||||||
|
@@ -881,9 +881,10 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($relativeProtocol) {
|
||||||
|
- $base = '//' . ($ssl && $this->ssl_enable ? $shop->domain_ssl : $shop->domain);
|
||||||
|
+ $base = '//' . Tools::getHttpHost(false, false, false);
|
||||||
|
} else {
|
||||||
|
- $base = (($ssl && $this->ssl_enable) ? 'https://' . $shop->domain_ssl : 'http://' . $shop->domain);
|
||||||
|
+ $protocol = Tools::usingSecureMode() ? 'https://' : 'http://';
|
||||||
|
+ $base = $protocol . Tools::getHttpHost(false, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base . $shop->getBaseURI();
|
||||||
|
@@ -1391,7 +1392,7 @@
|
||||||
|
public function getBaseLink($idShop = null, $ssl = null, $relativeProtocol = false)
|
||||||
|
{
|
||||||
|
if (null === $ssl) {
|
||||||
|
- $ssl = Configuration::get('PS_SSL_ENABLED');
|
||||||
|
+ $ssl = Tools::usingSecureMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Configuration::get('PS_MULTISHOP_FEATURE_ACTIVE') && $idShop !== null) {
|
||||||
|
@@ -1401,9 +1402,10 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($relativeProtocol) {
|
||||||
|
- $base = '//' . ($ssl && $this->ssl_enable ? $shop->domain_ssl : $shop->domain);
|
||||||
|
+ $base = '//' . Tools::getHttpHost(false, false, false);
|
||||||
|
} else {
|
||||||
|
- $base = (($ssl && $this->ssl_enable) ? 'https://' . $shop->domain_ssl : 'http://' . $shop->domain);
|
||||||
|
+ $protocol = Tools::usingSecureMode() ? 'https://' : 'http://';
|
||||||
|
+ $base = $protocol . Tools::getHttpHost(false, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base . $shop->getBaseURI();
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
--- a/classes/shop/Shop.php
|
||||||
|
+++ b/classes/shop/Shop.php
|
||||||
|
@@ -489,15 +489,16 @@ class ShopCore extends ObjectModel
|
||||||
|
*/
|
||||||
|
public function getBaseURL($auto_secure_mode = true, $add_base_uri = true)
|
||||||
|
{
|
||||||
|
- if ($auto_secure_mode && Tools::usingSecureMode()) {
|
||||||
|
- if (!$this->domain_ssl) {
|
||||||
|
- return false;
|
||||||
|
- }
|
||||||
|
- $url = 'https://' . $this->domain_ssl;
|
||||||
|
+ // EduBox: use the current request host so local access on non-standard
|
||||||
|
+ // ports (e.g. localhost:8088) and reverse-proxy domains both work.
|
||||||
|
+ $host = Tools::getHttpHost(false, false, false);
|
||||||
|
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
|
||||||
|
+ $host = substr($host, 0, strrpos($host, ':'));
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if ($auto_secure_mode && Tools::usingSecureMode()) {
|
||||||
|
+ $url = 'https://' . $host;
|
||||||
|
} else {
|
||||||
|
- if (!$this->domain) {
|
||||||
|
- return false;
|
||||||
|
- }
|
||||||
|
- $url = 'http://' . $this->domain;
|
||||||
|
+ $url = 'http://' . $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($add_base_uri) {
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
--- a/classes/shop/Shop.php
|
||||||
|
+++ b/classes/shop/Shop.php
|
||||||
|
@@ -411,38 +411,14 @@
|
||||||
|
} else {
|
||||||
|
$shop = new Shop($id_shop);
|
||||||
|
if (!Validate::isLoadedObject($shop) || !$shop->active) {
|
||||||
|
- // No shop found ... too bad, let's redirect to default shop
|
||||||
|
- $default_shop = new Shop((int) Configuration::get('PS_SHOP_DEFAULT'));
|
||||||
|
+ // EduBox: behind a reverse proxy with dynamic public domains,
|
||||||
|
+ // the requested host never matches ps_shop_url. Always use the
|
||||||
|
+ // default shop instead of redirecting to a fixed canonical URL.
|
||||||
|
+ $shop = new Shop((int) Configuration::get('PS_SHOP_DEFAULT'));
|
||||||
|
|
||||||
|
- // Hmm there is something really bad in your Prestashop !
|
||||||
|
- if (!Validate::isLoadedObject($default_shop)) {
|
||||||
|
+ if (!Validate::isLoadedObject($shop)) {
|
||||||
|
throw new PrestaShopException('Shop not found');
|
||||||
|
}
|
||||||
|
-
|
||||||
|
- $params = $_GET;
|
||||||
|
- unset($params['id_shop']);
|
||||||
|
- $url = $default_shop->domain;
|
||||||
|
- if (!Configuration::get('PS_REWRITING_SETTINGS')) {
|
||||||
|
- $url .= $default_shop->getBaseURI() . 'index.php?' . http_build_query($params);
|
||||||
|
- } else {
|
||||||
|
- // Catch url with subdomain "www"
|
||||||
|
- if (strpos($url, 'www.') === 0 && 'www.' . $_SERVER['HTTP_HOST'] === $url || $_SERVER['HTTP_HOST'] === 'www.' . $url) {
|
||||||
|
- $url .= $_SERVER['REQUEST_URI'];
|
||||||
|
- } else {
|
||||||
|
- $url .= $default_shop->getBaseURI();
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- if (count($params)) {
|
||||||
|
- $url .= '?' . http_build_query($params);
|
||||||
|
- }
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- $redirect_type = Configuration::get('PS_CANONICAL_REDIRECT');
|
||||||
|
- $redirect_code = ($redirect_type == 1 ? '302' : '301');
|
||||||
|
- $redirect_header = ($redirect_type == 1 ? 'Found' : 'Moved Permanently');
|
||||||
|
- header('HTTP/1.0 ' . $redirect_code . ' ' . $redirect_header);
|
||||||
|
- header('Location: ' . Tools::getShopProtocol() . $url);
|
||||||
|
- exit;
|
||||||
|
} elseif (defined('_PS_ADMIN_DIR_') && empty($shop->physical_uri)) {
|
||||||
|
$shop_default = new Shop((int) Configuration::get('PS_SHOP_DEFAULT'));
|
||||||
|
$shop->physical_uri = $shop_default->physical_uri;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
--- a/src/Core/Context/ShopContext.php
|
||||||
|
+++ b/src/Core/Context/ShopContext.php
|
||||||
|
@@ -9,6 +9,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PrestaShop\PrestaShop\Core\Context;
|
||||||
|
|
||||||
|
+use Tools;
|
||||||
|
use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
@@ -121,11 +122,12 @@ class ShopContext
|
||||||
|
|
||||||
|
public function getBaseURL(): string
|
||||||
|
{
|
||||||
|
- if ($this->secured) {
|
||||||
|
- $url = 'https://' . $this->domainSSL;
|
||||||
|
- } else {
|
||||||
|
- $url = 'http://' . $this->domain;
|
||||||
|
- }
|
||||||
|
+ // EduBox: behind a reverse proxy with dynamic public domains the shop
|
||||||
|
+ // URL stored in the database is never the real public URL. Rebuild the
|
||||||
|
+ // base URL from the current request instead.
|
||||||
|
+ $secure = Tools::usingSecureMode();
|
||||||
|
+ $domain = $secure ? Tools::getShopDomainSsl(false, false) : Tools::getShopDomain(false, false);
|
||||||
|
+ $url = ($secure ? 'https://' : 'http://') . $domain;
|
||||||
|
|
||||||
|
return $url . $this->getBaseURI();
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
--- a/classes/shop/ShopUrl.php
|
||||||
|
+++ b/classes/shop/ShopUrl.php
|
||||||
|
@@ -175,15 +175,23 @@
|
||||||
|
|
||||||
|
public static function getMainShopDomain($id_shop = null)
|
||||||
|
{
|
||||||
|
- ShopUrl::cacheMainDomainForShop($id_shop);
|
||||||
|
-
|
||||||
|
- return self::$main_domain[(int) $id_shop] ?? null;
|
||||||
|
+ // EduBox: dynamic public domain behind reverse proxy or direct local access.
|
||||||
|
+ // Always use the request host instead of the domain stored in database.
|
||||||
|
+ // Keep non-standard ports (e.g. localhost:8088) so local access works.
|
||||||
|
+ $host = Tools::getHttpHost(false, false, false);
|
||||||
|
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
|
||||||
|
+ $host = substr($host, 0, strrpos($host, ':'));
|
||||||
|
+ }
|
||||||
|
+ return $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getMainShopDomainSSL($id_shop = null)
|
||||||
|
{
|
||||||
|
- ShopUrl::cacheMainDomainForShop($id_shop);
|
||||||
|
-
|
||||||
|
- return self::$main_domain_ssl[(int) $id_shop] ?? null;
|
||||||
|
+ // EduBox: dynamic public domain behind reverse proxy or direct local access.
|
||||||
|
+ $host = Tools::getHttpHost(false, false, false);
|
||||||
|
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
|
||||||
|
+ $host = substr($host, 0, strrpos($host, ':'));
|
||||||
|
+ }
|
||||||
|
+ return $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
--- a/classes/Tools.php 2026-06-04 14:48:44.000000000 +0000
|
||||||
|
+++ b/classes/Tools.php 2026-06-23 16:34:13.226899992 +0000
|
||||||
|
@@ -269,8 +269,10 @@
|
||||||
|
*/
|
||||||
|
public static function getShopDomain($http = false, $entities = false)
|
||||||
|
{
|
||||||
|
- if (!$domain = ShopUrl::getMainShopDomain()) {
|
||||||
|
- $domain = Tools::getHttpHost();
|
||||||
|
+ // EduBox: dynamic domain + keep non-standard ports (e.g. localhost:8088).
|
||||||
|
+ $domain = Tools::getHttpHost(false, false, false);
|
||||||
|
+ if (substr($domain, -3) === ':80' || substr($domain, -4) === ':443') {
|
||||||
|
+ $domain = substr($domain, 0, strrpos($domain, ':'));
|
||||||
|
}
|
||||||
|
if ($entities) {
|
||||||
|
$domain = htmlspecialchars($domain, ENT_COMPAT, 'UTF-8');
|
||||||
|
@@ -292,14 +294,16 @@
|
||||||
|
*/
|
||||||
|
public static function getShopDomainSsl($http = false, $entities = false)
|
||||||
|
{
|
||||||
|
- if (!$domain = ShopUrl::getMainShopDomainSSL()) {
|
||||||
|
- $domain = Tools::getHttpHost();
|
||||||
|
+ // EduBox: dynamic domain + keep non-standard ports.
|
||||||
|
+ $domain = Tools::getHttpHost(false, false, false);
|
||||||
|
+ if (substr($domain, -3) === ':80' || substr($domain, -4) === ':443') {
|
||||||
|
+ $domain = substr($domain, 0, strrpos($domain, ':'));
|
||||||
|
}
|
||||||
|
if ($entities) {
|
||||||
|
$domain = htmlspecialchars($domain, ENT_COMPAT, 'UTF-8');
|
||||||
|
}
|
||||||
|
if ($http) {
|
||||||
|
- $domain = static::getProtocol((bool) Configuration::get('PS_SSL_ENABLED')) . $domain;
|
||||||
|
+ $domain = static::getProtocol(Tools::usingSecureMode()) . $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $domain;
|
||||||
|
@@ -2246,7 +2250,7 @@
|
||||||
|
$rewrite_settings = (int) Configuration::get('PS_REWRITING_SETTINGS', null, null, (int) $uri['id_shop']);
|
||||||
|
}
|
||||||
|
|
||||||
|
- $domain_rewrite_cond = 'RewriteCond %{HTTP_HOST} ^' . $domain . '$' . PHP_EOL;
|
||||||
|
+ $domain_rewrite_cond = ''; // EduBox: removed HTTP_HOST condition for dynamic domains
|
||||||
|
// Rewrite virtual multishop uri
|
||||||
|
if ($uri['virtual']) {
|
||||||
|
if (!$rewrite_settings) {
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# EduBox reverse proxy handling
|
||||||
|
# Apache sees HTTP requests from the EduBox resolver. The public request is HTTPS.
|
||||||
|
SetEnvIf X-Forwarded-Proto ^https$ HTTPS=on
|
||||||
|
SetEnvIf X-Forwarded-Proto ^https$ SERVER_PORT=443
|
||||||
|
|
||||||
|
# Enable .htaccess overrides for PrestaShop URL rewriting (images, products, etc.)
|
||||||
|
<Directory /var/www/html>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
Binary file not shown.
@@ -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)
|
||||||
|
}
|
||||||
@@ -5,5 +5,6 @@ COPY package.json package-lock.json* ./
|
|||||||
COPY prisma ./prisma
|
COPY prisma ./prisma
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
CMD ["node_modules/.bin/next", "start"]
|
CMD ["node_modules/.bin/next", "start"]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||||
<div className="w-full max-w-md p-8 space-y-6 bg-white rounded-lg shadow-md">
|
<div className="w-full max-w-md p-8 space-y-6 bg-white rounded-lg shadow-md">
|
||||||
<h1 className="text-2xl font-bold text-center text-gray-900">EduBox V2</h1>
|
<h1 className="text-2xl font-bold text-center text-gray-900">studioE5</h1>
|
||||||
<p className="text-center text-muted-foreground">Connexion à la plateforme</p>
|
<p className="text-center text-muted-foreground">Connexion à la plateforme</p>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const baseUrl = getBaseUrlFromRequest(request);
|
||||||
|
return NextResponse.json(getAgentVersionInfo(baseUrl));
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const MAIN_DOMAIN = process.env.MAIN_DOMAIN || "alfrednobel.edudeploy.com";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const domain = searchParams.get("domain");
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return NextResponse.json({ ok: false }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
domain === MAIN_DOMAIN ||
|
||||||
|
domain === `headscale.${MAIN_DOMAIN}`
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!domain.endsWith(`.${MAIN_DOMAIN}`)) {
|
||||||
|
return NextResponse.json({ ok: false }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subdomain = domain.replace(`.${MAIN_DOMAIN}`, "");
|
||||||
|
const instance = await prisma.instance.findUnique({
|
||||||
|
where: { id: subdomain },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: !!instance });
|
||||||
|
}
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth, requireRole, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const requestedId = searchParams.get("establishmentId");
|
||||||
if (!establishmentId) return NextResponse.json({ error: "Missing establishmentId" }, { status: 400 });
|
const establishmentId = getScopedEstablishmentId(user, requestedId);
|
||||||
|
if (establishmentId instanceof NextResponse) return establishmentId;
|
||||||
|
|
||||||
|
const where = establishmentId ? { establishmentId } : {};
|
||||||
|
|
||||||
const classes = await prisma.class.findMany({
|
const classes = await prisma.class.findMany({
|
||||||
where: { establishmentId },
|
where,
|
||||||
include: { _count: { select: { students: true } } },
|
include: { _count: { select: { students: true } } },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
@@ -15,8 +22,19 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { establishmentId, name, level } = body;
|
const requestedId = body.establishmentId;
|
||||||
|
const establishmentId = getScopedEstablishmentId(user, requestedId);
|
||||||
|
if (establishmentId instanceof NextResponse) return establishmentId;
|
||||||
|
if (!establishmentId) return forbidden();
|
||||||
|
|
||||||
|
const { name, level } = body;
|
||||||
const cls = await prisma.class.create({
|
const cls = await prisma.class.create({
|
||||||
data: { establishmentId, name, level },
|
data: { establishmentId, name, level },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
|
const baseUrl = getBaseUrlFromRequest(request);
|
||||||
|
const info = getAgentVersionInfo(baseUrl);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
windows: "/agent/edubox-agent.exe",
|
version: info.version,
|
||||||
linux: "/agent/edubox-agent",
|
windows: info.downloadUrls.windows,
|
||||||
mac: "/agent/edubox-agent-mac",
|
linux: info.downloadUrls.linux,
|
||||||
|
mac: info.downloadUrls.mac,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { hashPassword } from "@/lib/auth";
|
import { hashPassword } from "@/lib/auth";
|
||||||
|
import { requireAuth, requireRole } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const where = user.role === "superadmin" ? {} : { id: user.establishmentId };
|
||||||
const establishments = await prisma.establishment.findMany({
|
const establishments = await prisma.establishment.findMany({
|
||||||
|
where,
|
||||||
include: { subscription: true, _count: { select: { users: true, classes: true } } },
|
include: { subscription: true, _count: { select: { users: true, classes: true } } },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
@@ -11,6 +17,12 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { name, slug, adminEmail, adminPassword } = body;
|
const { name, slug, adminEmail, adminPassword } = body;
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,54 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendToNode } from "@/lib/websocket";
|
import { sendToNode } from "@/lib/websocket";
|
||||||
|
import { authOptions } from "@/lib/auth-config";
|
||||||
|
|
||||||
|
async function requireAuth() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) return null;
|
||||||
|
return session.user as { id: string; email: string; role: string; establishmentId?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function userCanAccessNode(user: { role: string; establishmentId?: string }, node: any) {
|
||||||
|
if (user.role === "superadmin") return true;
|
||||||
|
const establishmentId = node?.student?.class?.establishmentId;
|
||||||
|
return establishmentId && establishmentId === user.establishmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userCanAccessInstance(user: { role: string; establishmentId?: string }, instance: any) {
|
||||||
|
if (user.role === "superadmin") return true;
|
||||||
|
const establishmentId = instance?.node?.student?.class?.establishmentId;
|
||||||
|
return establishmentId && establishmentId === user.establishmentId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const nodeId = searchParams.get("nodeId");
|
const nodeId = searchParams.get("nodeId");
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const establishmentIdParam = searchParams.get("establishmentId");
|
||||||
|
|
||||||
let where: any = {};
|
let where: any = {};
|
||||||
if (nodeId) where.nodeId = nodeId;
|
if (nodeId) where.nodeId = nodeId;
|
||||||
if (establishmentId) {
|
|
||||||
const classes = await prisma.class.findMany({ where: { establishmentId }, select: { id: true } });
|
if (user.role !== "superadmin") {
|
||||||
|
const classes = await prisma.class.findMany({
|
||||||
|
where: { establishmentId: user.establishmentId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const students = await prisma.student.findMany({
|
||||||
|
where: { classId: { in: classes.map((c) => c.id) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const nodes = await prisma.node.findMany({
|
||||||
|
where: { studentId: { in: students.map((s) => s.id) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
where.nodeId = { in: nodes.map((n) => n.id) };
|
||||||
|
} else if (establishmentIdParam) {
|
||||||
|
const classes = await prisma.class.findMany({ where: { establishmentId: establishmentIdParam }, select: { id: true } });
|
||||||
const students = await prisma.student.findMany({ where: { classId: { in: classes.map((c) => c.id) } }, select: { id: true } });
|
const students = await prisma.student.findMany({ where: { classId: { in: classes.map((c) => c.id) } }, select: { id: true } });
|
||||||
const nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } });
|
const nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } });
|
||||||
where.nodeId = { in: nodes.map((n) => n.id) };
|
where.nodeId = { in: nodes.map((n) => n.id) };
|
||||||
@@ -18,29 +56,85 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const instances = await prisma.instance.findMany({
|
const instances = await prisma.instance.findMany({
|
||||||
where,
|
where,
|
||||||
include: { node: { include: { student: { include: { class: true } } } }, template: true },
|
include: {
|
||||||
|
node: {
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
include: {
|
||||||
|
class: {
|
||||||
|
include: {
|
||||||
|
establishment: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: true,
|
||||||
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return NextResponse.json(instances);
|
|
||||||
|
const enriched = instances.map((inst) => {
|
||||||
|
const domain = inst.node.student?.class.establishment?.domain;
|
||||||
|
const publicUrl = domain ? `https://${inst.id}.${domain}` : null;
|
||||||
|
const localUrl = inst.node.tailscaleIp ? `http://${inst.node.tailscaleIp}:${inst.port}` : null;
|
||||||
|
return {
|
||||||
|
...inst,
|
||||||
|
publicUrl,
|
||||||
|
localUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(enriched);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { nodeId, templateId, port } = body;
|
const { nodeId, templateId, port } = body;
|
||||||
|
if (!nodeId || !templateId) {
|
||||||
|
return NextResponse.json({ error: "Missing nodeId or templateId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const template = await prisma.template.findUnique({ where: { id: templateId } });
|
const template = await prisma.template.findUnique({ where: { id: templateId } });
|
||||||
if (!template) return NextResponse.json({ error: "Template not found" }, { status: 404 });
|
if (!template) return NextResponse.json({ error: "Template not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const node = await prisma.node.findUnique({
|
||||||
|
where: { id: nodeId },
|
||||||
|
include: { student: { include: { class: { include: { establishment: true } } } } },
|
||||||
|
});
|
||||||
|
if (!node) return NextResponse.json({ error: "Node not found" }, { status: 404 });
|
||||||
|
if (!userCanAccessNode(user, node)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const instance = await prisma.instance.create({
|
const instance = await prisma.instance.create({
|
||||||
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
|
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const domain = node.student?.class.establishment?.domain;
|
||||||
|
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
||||||
|
const publicUrl = domain ? `https://${publicDomain}` : null;
|
||||||
const sent = sendToNode(nodeId, {
|
const sent = sendToNode(nodeId, {
|
||||||
action: "start",
|
action: "start",
|
||||||
instanceId: instance.id,
|
instanceId: instance.id,
|
||||||
type: template.type,
|
type: template.type,
|
||||||
port: instance.port,
|
port: instance.port,
|
||||||
composeConfig: template.composeConfig.replace(/{PORT}/g, String(instance.port)).replace(/{INSTANCE_ID}/g, instance.id),
|
composeConfig: template.composeConfig
|
||||||
|
.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, "localhost"),
|
||||||
|
initScript: template.initScript
|
||||||
|
? template.initScript
|
||||||
|
.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, "localhost")
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
@@ -51,36 +145,96 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { id, action } = body;
|
const { id, action } = body;
|
||||||
const instance = await prisma.instance.findUnique({ where: { id }, include: { template: true } });
|
if (!id || !action) {
|
||||||
|
return NextResponse.json({ error: "Missing id or action" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = await prisma.instance.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { template: true, node: { include: { student: { include: { class: { include: { establishment: true } } } } } } },
|
||||||
|
});
|
||||||
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (!userCanAccessInstance(user, instance)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = instance.node.student?.class.establishment?.domain;
|
||||||
|
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
|
||||||
|
const publicUrl = domain ? `https://${publicDomain}` : null;
|
||||||
|
|
||||||
if (action === "stop") {
|
if (action === "stop") {
|
||||||
sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id });
|
const sent = sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id });
|
||||||
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
|
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||||
} else if (action === "start") {
|
} else if (action === "start") {
|
||||||
const sent = sendToNode(instance.nodeId, {
|
const sent = sendToNode(instance.nodeId, {
|
||||||
action: "start",
|
action: "start",
|
||||||
instanceId: instance.id,
|
instanceId: instance.id,
|
||||||
type: instance.template.type,
|
type: instance.template.type,
|
||||||
port: instance.port,
|
port: instance.port,
|
||||||
composeConfig: instance.template.composeConfig.replace(/{PORT}/g, String(instance.port)).replace(/{INSTANCE_ID}/g, instance.id),
|
composeConfig: instance.template.composeConfig
|
||||||
|
.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, "localhost"),
|
||||||
|
initScript: instance.template.initScript
|
||||||
|
? instance.template.initScript
|
||||||
|
.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, "localhost")
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
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") {
|
||||||
sendToNode(instance.nodeId, { action: "reset", instanceId: instance.id });
|
const sent = sendToNode(instance.nodeId, {
|
||||||
|
action: "reset",
|
||||||
|
instanceId: instance.id,
|
||||||
|
type: instance.template.type,
|
||||||
|
port: instance.port,
|
||||||
|
composeConfig: instance.template.composeConfig
|
||||||
|
.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, "localhost"),
|
||||||
|
initScript: instance.template.initScript
|
||||||
|
? instance.template.initScript
|
||||||
|
.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, "localhost")
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
|
||||||
if (instance) sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id });
|
const instance = await prisma.instance.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { node: { include: { student: { include: { class: { include: { establishment: true } } } } } } },
|
||||||
|
});
|
||||||
|
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (!userCanAccessInstance(user, instance)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instance) sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
|
||||||
await prisma.instance.delete({ where: { id } });
|
await prisma.instance.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { sendToNode } from "@/lib/websocket";
|
||||||
|
|
||||||
|
function getBearerToken(req: NextRequest): string | null {
|
||||||
|
const auth = req.headers.get("authorization") || "";
|
||||||
|
const match = auth.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const apiKey = process.env.INTERNAL_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json({ error: "Internal API key not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
if (!token || token !== apiKey) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { nodeId, message } = body;
|
||||||
|
if (!nodeId || !message) {
|
||||||
|
return NextResponse.json({ error: "Missing nodeId or message" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const sent = sendToNode(nodeId, message);
|
||||||
|
return NextResponse.json({ sent });
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const requestedId = searchParams.get("establishmentId");
|
||||||
|
const establishmentId = getScopedEstablishmentId(user, requestedId);
|
||||||
|
if (establishmentId instanceof NextResponse) return establishmentId;
|
||||||
|
|
||||||
let where: any = {};
|
let where: any = {};
|
||||||
if (establishmentId) {
|
if (establishmentId) {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
function getBearerToken(req: NextRequest): string | null {
|
||||||
|
const auth = req.headers.get("authorization") || "";
|
||||||
|
const match = auth.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const apiKey = process.env.INTERNAL_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json({ error: "Internal API key not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
if (!token || token !== apiKey) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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,12 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||||
function generateCode(length = 6) {
|
|
||||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
let code = "";
|
|
||||||
for (let i = 0; i < length; i++) code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
@@ -31,13 +25,15 @@ export async function GET(req: NextRequest) {
|
|||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { classId, firstName, lastName, email } = body;
|
const { classId, firstName, lastName, email } = body;
|
||||||
|
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||||
const student = await prisma.student.create({
|
const student = await prisma.student.create({
|
||||||
data: {
|
data: {
|
||||||
classId,
|
classId,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email,
|
email,
|
||||||
activationCode: generateCode(),
|
activationCode: code,
|
||||||
|
activationCodeExpiresAt: expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(student, { status: 201 });
|
return NextResponse.json(student, { status: 201 });
|
||||||
|
|||||||
@@ -1,25 +1,57 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
function templateAccessWhere(user: { role: string; establishmentId?: string }, establishmentId?: string | null) {
|
||||||
|
if (user.role === "superadmin" && establishmentId) {
|
||||||
|
return { OR: [{ isPublic: true }, { establishmentId }] };
|
||||||
|
}
|
||||||
|
if (user.establishmentId) {
|
||||||
|
return { OR: [{ isPublic: true }, { establishmentId: user.establishmentId }] };
|
||||||
|
}
|
||||||
|
return { isPublic: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canManageTemplate(user: { role: string; establishmentId?: string }, id: string) {
|
||||||
|
if (user.role === "superadmin") return true;
|
||||||
|
const template = await prisma.template.findUnique({ where: { id } });
|
||||||
|
if (!template) return false;
|
||||||
|
return template.establishmentId === user.establishmentId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const requestedEst = searchParams.get("establishmentId");
|
||||||
|
|
||||||
|
const where = user.role === "superadmin" && !requestedEst ? {} : templateAccessWhere(user, requestedEst);
|
||||||
|
|
||||||
const templates = await prisma.template.findMany({
|
const templates = await prisma.template.findMany({
|
||||||
where: {
|
where,
|
||||||
OR: [
|
|
||||||
{ isPublic: true },
|
|
||||||
...(establishmentId ? [{ establishmentId }] : []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return NextResponse.json(templates);
|
return NextResponse.json(templates);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body;
|
let { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body;
|
||||||
|
|
||||||
|
if (user.role !== "superadmin") {
|
||||||
|
if (establishmentId && establishmentId !== user.establishmentId) {
|
||||||
|
return forbidden();
|
||||||
|
}
|
||||||
|
establishmentId = user.establishmentId;
|
||||||
|
}
|
||||||
|
|
||||||
const template = await prisma.template.create({
|
const template = await prisma.template.create({
|
||||||
data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy },
|
data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy },
|
||||||
});
|
});
|
||||||
@@ -27,16 +59,39 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(req: NextRequest) {
|
export async function PUT(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { id, ...data } = body;
|
const { id, ...data } = body;
|
||||||
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
|
||||||
|
if (!(await canManageTemplate(user, id))) return forbidden();
|
||||||
|
|
||||||
|
if (user.role !== "superadmin" && data.establishmentId && data.establishmentId !== user.establishmentId) {
|
||||||
|
return forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
const template = await prisma.template.update({ where: { id }, data });
|
const template = await prisma.template.update({ where: { id }, data });
|
||||||
return NextResponse.json(template);
|
return NextResponse.json(template);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
|
||||||
|
if (!(await canManageTemplate(user, id))) return forbidden();
|
||||||
|
|
||||||
await prisma.template.delete({ where: { id } });
|
await prisma.template.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { hashPassword } from "@/lib/auth";
|
import { hashPassword } from "@/lib/auth";
|
||||||
|
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const establishmentId = searchParams.get("establishmentId");
|
const establishmentId = searchParams.get("establishmentId");
|
||||||
const role = searchParams.get("role");
|
const role = searchParams.get("role");
|
||||||
|
|
||||||
|
if (user.role !== "superadmin") {
|
||||||
|
if (establishmentId && establishmentId !== user.establishmentId) {
|
||||||
|
return forbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (establishmentId) where.establishmentId = establishmentId;
|
if (establishmentId) where.establishmentId = establishmentId;
|
||||||
|
else if (user.role !== "superadmin") where.establishmentId = user.establishmentId;
|
||||||
if (role) where.role = role;
|
if (role) where.role = role;
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
@@ -19,23 +30,56 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { email, password, role, establishmentId } = body;
|
const { email, password, role, establishmentId } = body;
|
||||||
const user = await prisma.user.create({
|
|
||||||
|
if (!email || !password || !role) {
|
||||||
|
return NextResponse.json({ error: "Missing email, password or role" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === "admin") {
|
||||||
|
if (role === "superadmin") return forbidden();
|
||||||
|
if (establishmentId && establishmentId !== user.establishmentId) return forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalEstablishmentId = user.role === "superadmin" ? establishmentId : user.establishmentId;
|
||||||
|
|
||||||
|
const newUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
password: await hashPassword(password),
|
password: await hashPassword(password),
|
||||||
role,
|
role,
|
||||||
establishmentId,
|
establishmentId: finalEstablishmentId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(user, { status: 201 });
|
return NextResponse.json(newUser, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (user instanceof NextResponse) return user;
|
||||||
|
|
||||||
|
const denied = requireRole(user, "superadmin", "admin");
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (user.role === "admin") {
|
||||||
|
if (target.role === "superadmin") return forbidden();
|
||||||
|
if (target.establishmentId !== user.establishmentId) return forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.user.delete({ where: { id } });
|
await prisma.user.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ const globalWss = globalThis as typeof globalThis & { __eduboxWss?: WebSocketSer
|
|||||||
|
|
||||||
if (!globalWss.__eduboxWss) {
|
if (!globalWss.__eduboxWss) {
|
||||||
try {
|
try {
|
||||||
globalWss.__eduboxWss = new WebSocketServer({ port: 3001 });
|
const wss = new WebSocketServer({ port: 3001 });
|
||||||
initWebSocketServer(globalWss.__eduboxWss);
|
wss.on("error", (err) => {
|
||||||
|
// Silently ignore EADDRINUSE during build/hot reload; the existing
|
||||||
|
// server will keep handling agent connections.
|
||||||
|
console.warn("WebSocket server error:", (err as Error).message);
|
||||||
|
});
|
||||||
|
globalWss.__eduboxWss = wss;
|
||||||
|
initWebSocketServer(wss);
|
||||||
} catch {
|
} catch {
|
||||||
// Port may be in use during build or hot reload
|
// Port may be in use during build or hot reload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function DashboardNav({ role }: { role: string }) {
|
|||||||
return (
|
return (
|
||||||
<nav className="w-64 bg-white border-r flex flex-col">
|
<nav className="w-64 bg-white border-r flex flex-col">
|
||||||
<div className="p-6 border-b">
|
<div className="p-6 border-b">
|
||||||
<h2 className="text-xl font-bold text-primary">EduBox</h2>
|
<h2 className="text-xl font-bold text-primary">studioE5</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 p-4 space-y-1">
|
<div className="flex-1 p-4 space-y-1">
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
|
|||||||
@@ -1,38 +1,49 @@
|
|||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function DownloadPage() {
|
export default async function DownloadPage() {
|
||||||
|
const h = await headers();
|
||||||
|
const proto = h.get("x-forwarded-proto") ?? "https";
|
||||||
|
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
|
||||||
|
const baseUrl = host ? `${proto}://${host}` : undefined;
|
||||||
|
const info = getAgentVersionInfo(baseUrl);
|
||||||
|
const { version, downloadUrls } = info;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-3xl font-bold">Téléchargements Agent</h1>
|
<h1 className="text-3xl font-bold">Téléchargements Agent</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Version actuelle : <strong>{version}</strong></p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Windows</CardTitle>
|
<CardTitle>Windows (.exe)</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour Windows (64 bits)</p>
|
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Windows (64 bits). Nécessite Tailscale installé séparément ou les binaires dans <code>tailscale-bin/windows/</code>.</p>
|
||||||
<a href="/edubox-agent.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={downloadUrls.windows} 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>Windows (archive)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Archive complète incluant l'agent, Tailscale et le README Windows.</p>
|
||||||
|
<a href={downloadUrls.windowsZip} 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 (.zip)</a>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Linux</CardTitle>
|
<CardTitle>Linux</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour Linux (64 bits)</p>
|
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Linux (64 bits).</p>
|
||||||
<a href="/edubox-agent" 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>
|
<a href={downloadUrls.linux} 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 (Linux)</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-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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ export default function InstanceActions({ instanceId, status }: { instanceId: st
|
|||||||
|
|
||||||
async function action(type: string) {
|
async function action(type: string) {
|
||||||
setLoading(type);
|
setLoading(type);
|
||||||
|
if (type === "delete") {
|
||||||
|
if (!confirm("Voulez-vous vraiment supprimer cette instance ?")) {
|
||||||
|
setLoading(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetch(`/api/instances?id=${instanceId}`, { method: "DELETE" });
|
||||||
|
} else {
|
||||||
await fetch("/api/instances", {
|
await fetch("/api/instances", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ id: instanceId, action: type }),
|
body: JSON.stringify({ id: instanceId, action: type }),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
@@ -32,6 +40,9 @@ export default function InstanceActions({ instanceId, status }: { instanceId: st
|
|||||||
<Button size="sm" variant="outline" onClick={() => action("reset")} disabled={!!loading}>
|
<Button size="sm" variant="outline" onClick={() => action("reset")} disabled={!!loading}>
|
||||||
{loading === "reset" ? "..." : "Réinitialiser"}
|
{loading === "reset" ? "..." : "Réinitialiser"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" onClick={() => action("delete")} disabled={!!loading}>
|
||||||
|
{loading === "delete" ? "..." : "Supprimer"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,19 @@ export default async function InstancesPage() {
|
|||||||
where: establishmentId
|
where: establishmentId
|
||||||
? { node: { student: { class: { establishmentId } } } }
|
? { node: { student: { class: { establishmentId } } } }
|
||||||
: {},
|
: {},
|
||||||
include: { node: { include: { student: { include: { class: true } } } }, template: true },
|
include: { node: { include: { student: { include: { class: { include: { establishment: true } } } } } }, template: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const enrichedInstances = instances.map((inst) => {
|
||||||
|
const domain = inst.node.student?.class.establishment?.domain;
|
||||||
|
return {
|
||||||
|
...inst,
|
||||||
|
publicUrl: domain ? `https://${inst.id}.${domain}` : null,
|
||||||
|
localUrl: inst.node.tailscaleIp ? `http://${inst.node.tailscaleIp}:${inst.port}` : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -44,11 +53,13 @@ export default async function InstancesPage() {
|
|||||||
<TableHead>Étudiant</TableHead>
|
<TableHead>Étudiant</TableHead>
|
||||||
<TableHead>Port</TableHead>
|
<TableHead>Port</TableHead>
|
||||||
<TableHead>Statut</TableHead>
|
<TableHead>Statut</TableHead>
|
||||||
|
<TableHead>URL publique</TableHead>
|
||||||
|
<TableHead>Accès prof</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{instances.map((inst) => (
|
{enrichedInstances.map((inst: any) => (
|
||||||
<TableRow key={inst.id}>
|
<TableRow key={inst.id}>
|
||||||
<TableCell className="font-medium">{inst.id.slice(0, 8)}...</TableCell>
|
<TableCell className="font-medium">{inst.id.slice(0, 8)}...</TableCell>
|
||||||
<TableCell>{inst.template.name}</TableCell>
|
<TableCell>{inst.template.name}</TableCell>
|
||||||
@@ -58,6 +69,12 @@ export default async function InstancesPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={inst.status === "running" ? "success" : inst.status === "error" ? "destructive" : "secondary"}>{inst.status}</Badge>
|
<Badge variant={inst.status === "running" ? "success" : inst.status === "error" ? "destructive" : "secondary"}>{inst.status}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{inst.publicUrl ? <a href={inst.publicUrl} target="_blank" rel="noopener" className="text-blue-600 hover:underline">{inst.publicUrl}</a> : "-"}
|
||||||
|
</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>
|
||||||
@@ -65,7 +82,7 @@ export default async function InstancesPage() {
|
|||||||
))}
|
))}
|
||||||
{instances.length === 0 && (
|
{instances.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} 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,17 +3,9 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { authOptions } from "@/lib/auth-config";
|
import { authOptions } from "@/lib/auth-config";
|
||||||
|
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
function generateCode(): string {
|
|
||||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
let code = "";
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteStudent(formData: FormData) {
|
export async function deleteStudent(formData: FormData) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
@@ -54,9 +46,10 @@ export async function generateActivationCodeAction(formData: FormData) {
|
|||||||
|
|
||||||
if (!student) return;
|
if (!student) return;
|
||||||
|
|
||||||
|
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||||
await prisma.student.update({
|
await prisma.student.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { activationCode: generateCode() },
|
data: { activationCode: code, activationCodeExpiresAt: expiresAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
redirect(`/dashboard/students/${id}`);
|
redirect(`/dashboard/students/${id}`);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { authOptions } from "@/lib/auth-config";
|
import { authOptions } from "@/lib/auth-config";
|
||||||
|
import { generateUniqueActivationCode } from "@/lib/activation";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -17,15 +18,6 @@ const schema = z.object({
|
|||||||
classId: z.string().min(1),
|
classId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
function generateActivationCode(): string {
|
|
||||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
let code = "";
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createStudent(formData: FormData) {
|
async function createStudent(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@@ -35,13 +27,15 @@ async function createStudent(formData: FormData) {
|
|||||||
const parsed = schema.safeParse(Object.fromEntries(formData));
|
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||||
if (!parsed.success) return;
|
if (!parsed.success) return;
|
||||||
|
|
||||||
|
const { code, expiresAt } = await generateUniqueActivationCode();
|
||||||
await prisma.student.create({
|
await prisma.student.create({
|
||||||
data: {
|
data: {
|
||||||
firstName: parsed.data.firstName,
|
firstName: parsed.data.firstName,
|
||||||
lastName: parsed.data.lastName,
|
lastName: parsed.data.lastName,
|
||||||
email: parsed.data.email,
|
email: parsed.data.email,
|
||||||
classId: parsed.data.classId,
|
classId: parsed.data.classId,
|
||||||
activationCode: generateActivationCode(),
|
activationCode: code,
|
||||||
|
activationCodeExpiresAt: expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import "./globals.css";
|
|||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "EduBox V2",
|
title: "studioE5",
|
||||||
description: "Plateforme de gestion d'instances pour l'enseignement BTS",
|
description: "Plateforme de gestion d'instances pour l'enseignement BTS",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
const CODE_LENGTH = 6;
|
||||||
|
const CODE_TTL_MINUTES = 60;
|
||||||
|
|
||||||
|
export function generateActivationCode(): { code: string; expiresAt: Date } {
|
||||||
|
let code = "";
|
||||||
|
const bytes = randomBytes(CODE_LENGTH);
|
||||||
|
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||||
|
code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
|
||||||
|
}
|
||||||
|
const expiresAt = new Date(Date.now() + CODE_TTL_MINUTES * 60 * 1000);
|
||||||
|
return { code, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateUniqueActivationCode(retries = 5): Promise<{ code: string; expiresAt: Date }> {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
const { code, expiresAt } = generateActivationCode();
|
||||||
|
const existing = await prisma.student.findUnique({ where: { activationCode: code } });
|
||||||
|
if (!existing) return { code, expiresAt };
|
||||||
|
}
|
||||||
|
throw new Error("Failed to generate a unique activation code");
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const BIN_NAME = "studioE5-agent";
|
||||||
|
|
||||||
|
// Build the public base URL from an incoming request, respecting common
|
||||||
|
// reverse-proxy headers (X-Forwarded-Proto / X-Forwarded-Host).
|
||||||
|
export function getBaseUrlFromRequest(req: Request): string {
|
||||||
|
const headers = req.headers;
|
||||||
|
const forwardedProto = headers.get("x-forwarded-proto");
|
||||||
|
const forwardedHost = headers.get("x-forwarded-host");
|
||||||
|
|
||||||
|
if (forwardedProto && forwardedHost) {
|
||||||
|
return `${forwardedProto}://${forwardedHost}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
return `${url.protocol}//${url.host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVersionFile(): string | null {
|
||||||
|
// Try a few common paths relative to the server workspace and Next.js build output.
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.cwd(), "..", "agent", "VERSION"),
|
||||||
|
path.join(process.cwd(), "..", "..", "agent", "VERSION"),
|
||||||
|
path.join(process.cwd(), "agent", "VERSION"),
|
||||||
|
path.join(__dirname, "..", "..", "..", "agent", "VERSION"),
|
||||||
|
path.join(__dirname, "..", "..", "agent", "VERSION"),
|
||||||
|
"/app/agent-version",
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentVersion(): string {
|
||||||
|
const versionFile = findVersionFile();
|
||||||
|
if (versionFile) {
|
||||||
|
return fs.readFileSync(versionFile, "utf-8").trim();
|
||||||
|
}
|
||||||
|
// Fallback used when the agent workspace is not mounted (should not happen).
|
||||||
|
return "0.3.9";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentDownloadUrls {
|
||||||
|
windows: string;
|
||||||
|
windowsZip: string;
|
||||||
|
linux: string;
|
||||||
|
mac: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentDownloadUrls(
|
||||||
|
version: string,
|
||||||
|
baseUrl?: string
|
||||||
|
): AgentDownloadUrls {
|
||||||
|
const prefix = baseUrl ? baseUrl.replace(/\/$/, "") : "";
|
||||||
|
return {
|
||||||
|
windows: `${prefix}/${BIN_NAME}-v${version}.exe`,
|
||||||
|
windowsZip: `${prefix}/${BIN_NAME}-v${version}-windows.zip`,
|
||||||
|
linux: `${prefix}/${BIN_NAME}-v${version}`,
|
||||||
|
mac: `${prefix}/${BIN_NAME}-v${version}-mac`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentVersionInfo(baseUrl?: string) {
|
||||||
|
const version = getAgentVersion();
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
downloadUrls: getAgentDownloadUrls(version, baseUrl),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "./auth-config";
|
||||||
|
|
||||||
|
export type ApiUser = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: "superadmin" | "admin" | "teacher";
|
||||||
|
establishmentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function requireAuth(): Promise<ApiUser | NextResponse> {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
return session.user as ApiUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireRole(user: ApiUser, ...allowed: string[]): NextResponse | null {
|
||||||
|
if (!allowed.includes(user.role)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forbidden(): NextResponse {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScopedEstablishmentId(user: ApiUser, requested?: string | null): string | undefined | NextResponse {
|
||||||
|
if (user.role === "superadmin") {
|
||||||
|
return requested ?? undefined;
|
||||||
|
}
|
||||||
|
if (requested && requested !== user.establishmentId) {
|
||||||
|
return forbidden();
|
||||||
|
}
|
||||||
|
return user.establishmentId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
interface HeadscaleUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeadscalePreAuthKey {
|
||||||
|
key: string;
|
||||||
|
expiration: string;
|
||||||
|
aclTags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHeadscaleUserId(
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
userName: string
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${baseUrl}/api/v1/user?name=${encodeURIComponent(userName)}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Headscale list users failed: ${res.status} ${await res.text()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { users: HeadscaleUser[] };
|
||||||
|
const user = data.users.find((u) => u.name === userName);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(`Headscale user not found: ${userName}`);
|
||||||
|
}
|
||||||
|
return user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEphemeralPreAuthKey(
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
userId: string,
|
||||||
|
options: {
|
||||||
|
expirationMinutes?: number;
|
||||||
|
aclTags?: string[];
|
||||||
|
} = {}
|
||||||
|
): Promise<string> {
|
||||||
|
const expirationMinutes = options.expirationMinutes ?? 15;
|
||||||
|
const aclTags = options.aclTags ?? [];
|
||||||
|
|
||||||
|
const expiration = new Date(
|
||||||
|
Date.now() + expirationMinutes * 60 * 1000
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/v1/preauthkey`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
user: userId,
|
||||||
|
reusable: false,
|
||||||
|
ephemeral: false,
|
||||||
|
expiration,
|
||||||
|
aclTags,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Headscale create preauthkey failed: ${res.status} ${await res.text()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { preAuthKey: HeadscalePreAuthKey };
|
||||||
|
return data.preAuthKey.key;
|
||||||
|
}
|
||||||
+295
-20
@@ -1,5 +1,9 @@
|
|||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import type { IncomingMessage } from "http";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
import { createEphemeralPreAuthKey, getHeadscaleUserId } from "./headscale";
|
||||||
|
import { getAgentVersion } from "./agent-version";
|
||||||
|
|
||||||
interface NodeMessage {
|
interface NodeMessage {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -9,16 +13,77 @@ interface NodeMessage {
|
|||||||
type?: string;
|
type?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
composeConfig?: string;
|
composeConfig?: string;
|
||||||
|
initScript?: string;
|
||||||
studentName?: string;
|
studentName?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
tailscaleIp?: string;
|
||||||
|
token?: string;
|
||||||
|
serverVersion?: string;
|
||||||
|
instances?: Array<{ id: string; status: string; port: number; templateName?: string }>;
|
||||||
|
toStart?: Array<{ id: string; type: string; port: number; composeConfig?: string; initScript?: string }>;
|
||||||
|
toDelete?: string[];
|
||||||
|
toStop?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = new Map<string, WebSocket>();
|
const nodes = new Map<string, WebSocket>();
|
||||||
|
|
||||||
|
interface AttemptWindow {
|
||||||
|
count: number;
|
||||||
|
firstAttempt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activationAttemptsByCode = new Map<string, AttemptWindow>();
|
||||||
|
const activationAttemptsByNode = new Map<string, AttemptWindow>();
|
||||||
|
const MAX_ACTIVATION_ATTEMPTS = 5;
|
||||||
|
const ACTIVATION_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
const HEADSCALE_USER = "studioe5";
|
||||||
|
const HEADSCALE_AGENT_TAG = "tag:student-agent";
|
||||||
|
const HEADSCALE_KEY_EXPIRATION_MINUTES = 15;
|
||||||
|
|
||||||
|
let headscaleUserIdCache: string | null = null;
|
||||||
|
|
||||||
|
function recordActivationAttempt(map: Map<string, AttemptWindow>, key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const win = map.get(key);
|
||||||
|
if (!win || now - win.firstAttempt > ACTIVATION_WINDOW_MS) {
|
||||||
|
map.set(key, { count: 1, firstAttempt: now });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
win.count++;
|
||||||
|
return win.count <= MAX_ACTIVATION_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActivationAttempts(code: string, nodeId: string) {
|
||||||
|
activationAttemptsByCode.delete(code);
|
||||||
|
activationAttemptsByNode.delete(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNodeToken(): string {
|
||||||
|
return randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBearerToken(req: IncomingMessage): string | null {
|
||||||
|
const auth = req.headers.authorization || "";
|
||||||
|
const match = auth.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(ws: WebSocket, code: number, reason: string) {
|
||||||
|
try {
|
||||||
|
ws.close(code, reason);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function initWebSocketServer(wss: WebSocketServer) {
|
export function initWebSocketServer(wss: WebSocketServer) {
|
||||||
wss.on("connection", (ws: WebSocket) => {
|
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
||||||
let nodeId: string | null = null;
|
let nodeId: string | null = null;
|
||||||
console.log("[WS] New connection");
|
let authenticated = false;
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
|
||||||
|
console.log("[WS] New connection", token ? "(token provided)" : "(no token)");
|
||||||
|
|
||||||
ws.on("message", async (raw) => {
|
ws.on("message", async (raw) => {
|
||||||
try {
|
try {
|
||||||
@@ -26,19 +91,69 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
console.log("[WS] Received:", msg.action, "from", msg.nodeId || nodeId);
|
console.log("[WS] Received:", msg.action, "from", msg.nodeId || nodeId);
|
||||||
|
|
||||||
if (msg.action === "register" && msg.nodeId) {
|
if (msg.action === "register" && msg.nodeId) {
|
||||||
nodeId = msg.nodeId;
|
const id = msg.nodeId;
|
||||||
nodes.set(nodeId, ws);
|
const existing = await prisma.node.findUnique({ where: { id } });
|
||||||
await prisma.node.upsert({
|
|
||||||
where: { id: nodeId },
|
if (token) {
|
||||||
update: { status: "online", lastSeen: new Date() },
|
// Token supplied: it must match the stored token for this node.
|
||||||
create: { id: nodeId, status: "online", lastSeen: new Date() },
|
if (!existing || existing.token !== token) {
|
||||||
|
console.log("[WS] Invalid token for node", id);
|
||||||
|
close(ws, 1008, "invalid token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authenticated = true;
|
||||||
|
} else if (existing && existing.token) {
|
||||||
|
// Existing node has a token but none was supplied.
|
||||||
|
console.log("[WS] Missing token for node", id);
|
||||||
|
close(ws, 1008, "missing token");
|
||||||
|
return;
|
||||||
|
} else if (existing) {
|
||||||
|
// Migration path: existing node without a token gets one on first register.
|
||||||
|
const newToken = generateNodeToken();
|
||||||
|
await prisma.node.update({
|
||||||
|
where: { id },
|
||||||
|
data: { token: newToken, status: "online", lastSeen: new Date() },
|
||||||
});
|
});
|
||||||
ws.send(JSON.stringify({ action: "registered" }));
|
ws.send(JSON.stringify({ action: "set_token", token: newToken }));
|
||||||
|
authenticated = true;
|
||||||
|
}
|
||||||
|
// If the node does not exist yet, we stay unauthenticated until activation.
|
||||||
|
|
||||||
|
nodeId = id;
|
||||||
|
if (authenticated) {
|
||||||
|
const existing = nodes.get(id);
|
||||||
|
if (existing && existing !== ws && existing.readyState === WebSocket.OPEN) {
|
||||||
|
console.log("[WS] Superseding previous connection for", id);
|
||||||
|
existing.close(1008, "superseded");
|
||||||
|
}
|
||||||
|
nodes.set(id, ws);
|
||||||
|
await prisma.node.upsert({
|
||||||
|
where: { id },
|
||||||
|
update: { status: "online", lastSeen: new Date() },
|
||||||
|
create: { id, status: "online", lastSeen: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ws.send(JSON.stringify({ action: "registered", serverVersion: getAgentVersion() }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "activate" && msg.code && msg.nodeId) {
|
if (msg.action === "activate" && msg.code && msg.nodeId) {
|
||||||
nodeId = msg.nodeId;
|
const id = msg.nodeId;
|
||||||
|
nodeId = id;
|
||||||
|
|
||||||
|
if (!recordActivationAttempt(activationAttemptsByCode, msg.code) ||
|
||||||
|
!recordActivationAttempt(activationAttemptsByNode, id)) {
|
||||||
|
console.log("[WS] Too many activation attempts for code/node", msg.code, id);
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Too many attempts" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.node.findUnique({ where: { id } });
|
||||||
|
if (existing && existing.token && (!authenticated || nodeId !== id)) {
|
||||||
|
console.log("[WS] Node already activated and not authenticated:", id);
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Node already activated" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const student = await prisma.student.findUnique({
|
const student = await prisma.student.findUnique({
|
||||||
where: { activationCode: msg.code },
|
where: { activationCode: msg.code },
|
||||||
});
|
});
|
||||||
@@ -47,17 +162,97 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" }));
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await prisma.node.upsert({
|
if (!student.activationCodeExpiresAt || student.activationCodeExpiresAt < new Date()) {
|
||||||
where: { id: nodeId },
|
console.log("[WS] Expired code:", msg.code);
|
||||||
update: { studentId: student.id, status: "online", lastSeen: new Date() },
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Code expired" }));
|
||||||
create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() },
|
|
||||||
});
|
|
||||||
console.log("[WS] Activated:", student.firstName, student.lastName, "on", nodeId);
|
|
||||||
ws.send(JSON.stringify({ action: "activated", studentId: student.id, studentName: `${student.firstName} ${student.lastName}` }));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "heartbeat" && nodeId) {
|
const newToken = generateNodeToken();
|
||||||
|
await prisma.node.upsert({
|
||||||
|
where: { id },
|
||||||
|
update: {
|
||||||
|
studentId: student.id,
|
||||||
|
status: "online",
|
||||||
|
lastSeen: new Date(),
|
||||||
|
token: newToken,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id,
|
||||||
|
studentId: student.id,
|
||||||
|
status: "online",
|
||||||
|
lastSeen: new Date(),
|
||||||
|
token: newToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate the activation code so it cannot be reused.
|
||||||
|
await prisma.student.update({
|
||||||
|
where: { id: student.id },
|
||||||
|
data: { activationCode: null, activationCodeExpiresAt: null },
|
||||||
|
});
|
||||||
|
clearActivationAttempts(msg.code, id);
|
||||||
|
|
||||||
|
authenticated = true;
|
||||||
|
const previous = nodes.get(id);
|
||||||
|
if (previous && previous !== ws && previous.readyState === WebSocket.OPEN) {
|
||||||
|
console.log("[WS] Superseding previous connection for", id);
|
||||||
|
previous.close(1008, "superseded");
|
||||||
|
}
|
||||||
|
nodes.set(id, ws);
|
||||||
|
const headscaleUrl = process.env.HEADSCALE_URL;
|
||||||
|
const headscaleApiKey = process.env.HEADSCALE_API_KEY;
|
||||||
|
const reusableAuthKey = process.env.HEADSCALE_AUTH_KEY;
|
||||||
|
|
||||||
|
if (!headscaleUrl) {
|
||||||
|
console.log("[WS] HEADSCALE_URL missing");
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Server misconfiguration" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let headscaleAuthKey: string;
|
||||||
|
try {
|
||||||
|
if (headscaleApiKey) {
|
||||||
|
if (!headscaleUserIdCache) {
|
||||||
|
headscaleUserIdCache = await getHeadscaleUserId(headscaleUrl, headscaleApiKey, HEADSCALE_USER);
|
||||||
|
}
|
||||||
|
headscaleAuthKey = await createEphemeralPreAuthKey(headscaleUrl, headscaleApiKey, headscaleUserIdCache, {
|
||||||
|
expirationMinutes: HEADSCALE_KEY_EXPIRATION_MINUTES,
|
||||||
|
aclTags: [HEADSCALE_AGENT_TAG],
|
||||||
|
});
|
||||||
|
console.log("[WS] Generated ephemeral Headscale key for", id);
|
||||||
|
} else if (reusableAuthKey) {
|
||||||
|
console.log("[WS] HEADSCALE_API_KEY not set, falling back to reusable HEADSCALE_AUTH_KEY");
|
||||||
|
headscaleAuthKey = reusableAuthKey;
|
||||||
|
} else {
|
||||||
|
console.log("[WS] No Headscale key available");
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Server misconfiguration" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[WS] Failed to create ephemeral Headscale key:", err);
|
||||||
|
ws.send(JSON.stringify({ action: "activation_failed", error: "Failed to create VPN key" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[WS] Activated:", student.firstName, student.lastName, "on", id);
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
action: "activated",
|
||||||
|
studentId: student.id,
|
||||||
|
studentName: `${student.firstName} ${student.lastName}`,
|
||||||
|
headscaleUrl,
|
||||||
|
headscaleAuthKey,
|
||||||
|
token: newToken,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authenticated || !nodeId) {
|
||||||
|
console.log("[WS] Unauthenticated message", msg.action, "ignored");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.action === "heartbeat") {
|
||||||
await prisma.node.upsert({
|
await prisma.node.upsert({
|
||||||
where: { id: nodeId },
|
where: { id: nodeId },
|
||||||
update: { lastSeen: new Date() },
|
update: { lastSeen: new Date() },
|
||||||
@@ -66,19 +261,99 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.action === "tailscale_ip" && msg.tailscaleIp) {
|
||||||
|
await prisma.node.update({
|
||||||
|
where: { id: nodeId },
|
||||||
|
data: { tailscaleIp: msg.tailscaleIp },
|
||||||
|
});
|
||||||
|
console.log("[WS] Tailscale IP updated for", nodeId, ":", msg.tailscaleIp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.action === "sync" && msg.instances) {
|
||||||
|
const serverInstances = await prisma.instance.findMany({
|
||||||
|
where: { nodeId },
|
||||||
|
include: { template: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const localIds = new Set(msg.instances.map((i) => i.id));
|
||||||
|
const serverIds = new Set(serverInstances.map((i) => i.id));
|
||||||
|
|
||||||
|
const toDelete = msg.instances
|
||||||
|
.filter((i) => !serverIds.has(i.id))
|
||||||
|
.map((i) => i.id);
|
||||||
|
|
||||||
|
const toStop = msg.instances
|
||||||
|
.filter((i) => {
|
||||||
|
const server = serverInstances.find((s) => s.id === i.id);
|
||||||
|
return server && server.status === "stopped" && i.status === "running";
|
||||||
|
})
|
||||||
|
.map((i) => i.id);
|
||||||
|
|
||||||
|
const toStart = serverInstances
|
||||||
|
.filter((s) => !localIds.has(s.id))
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
type: s.template.type,
|
||||||
|
port: s.port,
|
||||||
|
composeConfig: s.template.composeConfig,
|
||||||
|
initScript: s.template.initScript ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[WS] Sync for",
|
||||||
|
nodeId,
|
||||||
|
"- toStart:",
|
||||||
|
toStart.length,
|
||||||
|
"toDelete:",
|
||||||
|
toDelete.length,
|
||||||
|
"toStop:",
|
||||||
|
toStop.length
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
action: "sync_response",
|
||||||
|
toStart,
|
||||||
|
toDelete,
|
||||||
|
toStop,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.action === "instance_started" && msg.instanceId) {
|
if (msg.action === "instance_started" && msg.instanceId) {
|
||||||
await prisma.instance.update({
|
const { count } = await prisma.instance.updateMany({
|
||||||
where: { id: msg.instanceId },
|
where: { id: msg.instanceId },
|
||||||
data: { status: "running" },
|
data: { status: "running" },
|
||||||
});
|
});
|
||||||
|
if (count) console.log("[WS] Instance started:", msg.instanceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.action === "instance_stopped" && msg.instanceId) {
|
||||||
|
const { count } = await prisma.instance.updateMany({
|
||||||
|
where: { id: msg.instanceId },
|
||||||
|
data: { status: "stopped" },
|
||||||
|
});
|
||||||
|
if (count) console.log("[WS] Instance stopped:", msg.instanceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.action === "instance_deleted" && msg.instanceId) {
|
||||||
|
const { count } = await prisma.instance.deleteMany({
|
||||||
|
where: { id: msg.instanceId },
|
||||||
|
});
|
||||||
|
if (count) console.log("[WS] Instance deleted:", msg.instanceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "instance_error" && msg.instanceId) {
|
if (msg.action === "instance_error" && msg.instanceId) {
|
||||||
await prisma.instance.update({
|
const { count } = await prisma.instance.updateMany({
|
||||||
where: { id: msg.instanceId },
|
where: { id: msg.instanceId },
|
||||||
data: { status: "error" },
|
data: { status: "error" },
|
||||||
});
|
});
|
||||||
|
if (count) console.log("[WS] Instance error:", msg.instanceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+3
-28
@@ -1,35 +1,10 @@
|
|||||||
import { withAuth } from "next-auth/middleware";
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
export default withAuth(
|
export function middleware(_req: NextRequest) {
|
||||||
function middleware(req) {
|
|
||||||
const { pathname } = req.nextUrl;
|
|
||||||
const role = req.nextauth.token?.role as string;
|
|
||||||
|
|
||||||
if (pathname.startsWith("/superadmin")) {
|
|
||||||
if (role !== "superadmin") {
|
|
||||||
return NextResponse.redirect(new URL("/dashboard", req.url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.startsWith("/dashboard")) {
|
|
||||||
if (!role || (role !== "admin" && role !== "teacher" && role !== "superadmin")) {
|
|
||||||
return NextResponse.redirect(new URL("/login", req.url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
},
|
|
||||||
{
|
|
||||||
callbacks: {
|
|
||||||
authorized({ req, token }) {
|
|
||||||
if (req.nextUrl.pathname.startsWith("/login")) return true;
|
|
||||||
return !!token;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/dashboard/:path*", "/superadmin/:path*", "/api/protected/:path*"],
|
matcher: ["/((?!_next|static|favicon.ico).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {}
|
const nextConfig = {
|
||||||
|
trailingSlash: true,
|
||||||
|
skipTrailingSlashRedirect: true,
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user