Compare commits

..

20 Commits

Author SHA1 Message Date
EduBox Dev fc61404271 feat: installation offline complete, HTTPS registry, 8Go WSL, v0.3.18
- Wizard: installation 100% offline (WSL bundle, Podman MSI, machine image, docker-compose)
- Wizard: suppression de wsl --install --no-distribution
- Wizard: .wslconfig avec 8Go RAM / 4 CPU
- Wizard: operations asynchrones pour eviter le freeze UI
- Wizard: detection automatique de podman.exe
- Wizard: version 0.1.1
- Agent: passage en v0.3.18
- Serveur: registry PrestaShop en HTTPS via gitea.alfrednobel.edudeploy.com
- Caddy: config gitea.alfrednobel.edudeploy.com
- Docs: mise a jour SUIVI_INSTALLER.md, README.md, seed.ts
2026-07-02 22:52:28 +00:00
EduBox Dev 3c519629d2 installer: corrections wizard C# (System.Management, AppContext.BaseDirectory), fix script Inno Setup GetDiskFreeSpaceEx, ajout SUIVI_INSTALLER 2026-06-28 22:52:45 +00:00
EduBox Dev 0f07a2d2a3 installer: wizard C# Windows d'installation guidee (WSL2, Podman, agent, desinstallation) 2026-06-28 20:49:57 +00:00
EduBox Dev d2c3edea2f agent v0.3.17: correction statut Tailscale + overlay de reconnexion pendant l'update 2026-06-28 20:03:49 +00:00
EduBox Dev 41929be34c agent v0.3.16: statut service d'applications basé sur la connexion Tailscale effective 2026-06-28 19:57:06 +00:00
EduBox Dev adab165274 agent v0.3.15: mode proxy auto/manuel, correction auto-update et conservation systray, animation UI update 2026-06-28 19:53:19 +00:00
EduBox Dev 33d89c66c0 fix(agent): v0.3.10 cleanup orphan instance dirs on startup
- Add cleanupOrphanInstanceDirs() to remove leftover instance directories
  after failed deletes (common on Windows when compose.log is locked)
- Log RemoveAll errors in dockerComposeRm for better visibility
- Bump version to 0.3.10 and rebuild binaries
2026-06-27 21:36:02 +00:00
EduBox Dev e946b22a42 feat(agent): v0.3.9 sync, UI details, self-update, centralized version
- Add agent/server startup sync (sync/sync_response)
- Centralize agent version in agent/VERSION + expose /api/agent/version
- Display agent version, nodeId and server version in local UI
- Add agent self-update detection/download/restart via helper scripts
- Run start/stop/delete/reset handlers in goroutines to avoid WebSocket blocking
- Update dashboard download links and SUIVI_VPN_ONDEMAND.md
- Document Podman stays installer-managed, not agent-updated
2026-06-27 21:11:20 +00:00
EduBox Dev cf8b66340a agent v0.3.8: fix crash UI notifications, auto Podman machine DNS, WordPress 7.0.0 ready template 2026-06-26 15:24:21 +00:00
EduBox Dev a414f03a59 feat(agent): v0.3.5 Windows inbound forwarding, UI actions, lifecycle
- Configure tailscale serve automatically for each instance on Windows userspace networking.
- Add local UI buttons: start/stop/reset/delete instances (stop/start preserve volumes).
- Clean shutdown: stop tailscaled and instances, notify server with instance_stopped.
- Restart tailscaled on agent boot using persisted state when pre-auth key is absent.
- Sync instance stopped/deleted status to dashboard (server/lib/websocket.ts).
- Security: include prior authz/scoping changes across API routes, ephemeral pre-auth keys, ACL policy, internal API key.
- Update SUIVI_VPN_ONDEMAND.md and docs/ONBOARDING_CLIENT.md.
- Bump agent version to 0.3.5.
2026-06-25 22:59:09 +00:00
EduBox Dev 331187e9b5 docs: update SUIVI_VPN_ONDEMAND.md with v0.3.3 links and --unattended fix 2026-06-23 18:36:58 +00:00
EduBox Dev 281c7c9a19 fix(agent/windows): add --unattended to tailscale up
On Windows tailscaled stops the backend when the CLI client disconnects.
--unattended keeps the VPN session alive so the agent can report its IP
and the server can reach the node. Bump to v0.3.3.
2026-06-23 18:36:14 +00:00
EduBox Dev 3a3e3ed202 fix(agent): correct version log, bump to v0.3.2, increase tailscale timeout
- Replace hardcoded AGENT_VERSION constant with injected version variable
- Bump agent version to 0.3.2
- Increase tailscale up/status timeout from 60s to 120s
2026-06-23 18:29:21 +00:00
EduBox Dev d090f67bff fix(agent/windows): named pipe Tailscale + hideWindow + logs
- Use Windows named pipe \.\pipe\studioe5-tailscaled instead of Unix socket
- Apply hideWindow to all child processes (tailscale, podman, docker, browser)
- Redirect agent logs to <data-dir>/agent.log and tailscaled logs to tailscaled.log
- Fix double tailscale/ tailscale dir path in startTailscaleAndReport
- Remove --operator=root on Windows
- Bump agent version to 0.3.1
2026-06-23 18:18:26 +00:00
EduBox Dev 03b2f1267d docs(vpn): ajout des améliorations UI (console intégrée + barre de progression) 2026-06-23 17:08:36 +00:00
EduBox Dev 73b561ed33 fix(prestashop): image edubox-prestashop fonctionne en local et derrière proxy
- edubox-tools.patch : getShopDomain/Ssl conservent les ports non standards
- edubox-configuration.patch : PS_SHOP_DOMAIN, _PS_BASE_URL_, PS_SSL_ENABLED… résolus dynamiquement
- edubox-shop-getbaseurl.patch : Shop::getBaseURL() utilise le host de la requête
- edubox-shopurl.patch : getMainShopDomain conserve les ports non standards
- edubox-clear-cache-init.sh : vidage des caches à chaque démarrage
- seed.ts : passage au tag 9-edubox-9
- README mis à jour avec les nouveaux patches
2026-06-23 16:39:12 +00:00
EduBox Dev 8a9deb8ebc feat(agent): activation zéro-config – config Headscale envoyée par le serveur
- Agent: URL serveur par défaut, node_id auto-généré, config Headscale vide par défaut
- Serveur: lors de l’activation, renvoie headscaleUrl + headscaleAuthKey
- Agent: sauvegarde la config reçue et démarre Tailscale automatiquement
- docker-compose.yml: passe HEADSCALE_URL et HEADSCALE_AUTH_KEY au service server
- Mise à jour du suivi avec le flow zéro-config
2026-06-23 10:30:19 +00:00
EduBox Dev df77caf64a feat(agent): packaging Windows + téléchargement HTTPS
- Téléchargement des binaires Tailscale Windows via download-tailscale-bins.sh
- Build Windows/Linux via build.sh avec génération d’archive zip
- Caddyfile : serve les agents en HTTPS sous /studioE5-agent*
- .gitignore : ignore agent/tailscale-bin/
- Documentation du téléchargement dans SUIVI_VPN_ONDEMAND.md
2026-06-23 10:00:52 +00:00
EduBox Dev b4344e9d66 docs(vpn): mise à jour du suivi – flux web OK et étude wildcard Infomaniak 2026-06-23 09:53:02 +00:00
EduBox Dev 124543d658 feat(vpn): VPN on-demand Tailscale + agent studioE5 standalone
- Agent studioE5 standalone en Go (console + systray)
- VPN on-demand via tailscaled + tailscale up (authkey Headscale)
- Resolver/serveur dans le tailnet studioe5
- Caddy on-demand TLS pour les instances
- Nouveaux endpoints serveur /api/internal/send-to-node
- Suppression des anciens binaires edubox-agent
- Suivi dans SUIVI_VPN_ONDEMAND.md
2026-06-23 09:48:00 +00:00
106 changed files with 9377 additions and 780 deletions
+5
View File
@@ -5,6 +5,11 @@ NEXTAUTH_URL=http://localhost
SUPERADMIN_EMAIL=admin@edudeploy.fr
SUPERADMIN_PASSWORD=CHANGE_ME
HEADSCALE_URL=http://headscale:8080
# Legacy reusable pre-auth key (kept for manual/debug setups).
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_TOKEN=CHANGE_ME
+12
View File
@@ -2,6 +2,7 @@
node_modules/
.next/
*.log
studioE5-data/
edubox-data/
dist/
coverage/
@@ -9,11 +10,22 @@ coverage/
*.dll
*.so
*.dylib
agent/studioE5-agent
agent/studioE5-agent.exe
agent/studioE5-agent-mac
agent/studioE5-agent-v*
agent/edubox-agent
agent/edubox-agent.exe
agent/edubox-agent-mac
agent/edubox-agent-v*
server/public/studioE5-agent*
server/public/edubox-agent*
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
+387
View File
@@ -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 |
+21 -6
View File
@@ -6,7 +6,7 @@
}
:80 {
route /edubox-agent* {
route /studioE5-agent* {
file_server {
root /usr/share/caddy/agent
}
@@ -22,11 +22,21 @@
}
}
headscale.alfrednobel.edudeploy.com {
headscale.studioe5.edudeploy.com:443 {
reverse_proxy headscale:8080
}
alfrednobel.edudeploy.com {
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
}
@@ -35,10 +45,15 @@ alfrednobel.edudeploy.com {
tls {
on_demand
}
route /studioE5-agent* {
file_server {
root /usr/share/caddy/agent
}
}
@instance {
not host alfrednobel.edudeploy.com
not host headscale.alfrednobel.edudeploy.com
host *.alfrednobel.edudeploy.com
not host studioe5.edudeploy.com
not host headscale.studioe5.edudeploy.com
host *.studioe5.edudeploy.com
}
handle @instance {
reverse_proxy resolver:2020 {
+961
View File
@@ -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**
- Lagent ne démarre plus Tailscale au boot.
- Le VPN se lance automatiquement à la création/démarrage dune 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 lagent (modèle commercialisable)**
- Lagent démarre sans `headscale_url` ni `headscale_auth_key`.
- Lutilisateur entre seulement un code dactivation.
- Le serveur envoie la config Headscale, lagent la sauvegarde et démarre le VPN automatiquement.
## ✅ Blocage levé
**Rate limit Lets Encrypt pour `edudeploy.com` est levé.**
Le 2026-06-23 vers 09:35 UTC, Caddy a pu obtenir un certificat Lets 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 dinstance.
## 🎯 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 Lets 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 à lemploi
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 à lusage 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 laccès à `api.wordpress.org` |
### Architecture technique
- Le modèle `Template` de Prisma dispose dun 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 dinitialisation.
- Lagent écrit le script `wp-init.sh` dans le dossier de linstance 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 linstance à 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 dinitialisation WP-CLI.
- `server/app/api/instances/route.ts` envoi de `initScript` à lagent 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 lAPI (`cmqv03a6v0001vg8zrpe8zqfy`) :
```bash
$ curl -sS -I -L https://cmqv03a6v0001vg8zrpe8zqfy.studioe5.edudeploy.com/
HTTP/2 200
```
- Page daccueil 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 lagent 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 :
- Lagent 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 lagent 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
Lagent 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 dinstance | `serve --bg --tcp=<port> tcp://localhost:<port>` |
| Arrêt dinstance | `serve --bg --tcp=<port> off` |
| Suppression dinstance | `serve --bg --tcp=<port> off` |
| Redémarrage de lagent | 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 daction 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 lagent, les instances en cours sont arrêtées proprement (`stop`) et le serveur est notifié (`instance_stopped`).
### Démarrage du VPN après activation
Lagent 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 dinstance
### Problème
Lors de la création dune instance depuis le dashboard vers certains agents (notamment Windows), lagent sarrê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 dun `defer recover()` dans `handleStartInstance` ; en cas de panic, linstance passe en statut `error` et un message `instance_error` est envoyé au serveur.
- Ajout dun `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
Lagent continuait de sarrêter brutalement lors de la création dune 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 dun `defer recover()` dans `notifyUI` pour chaque goroutine de notification.
- Ajout dun `defer recover()` dans `sendUILog` (logs diffusés aux clients UI).
- Ajout dun `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, lagent Windows avec Podman échouait au `docker compose up` avec :
```text
lookup registry-1.docker.io: Temporary failure in name resolution
```
La VM Podman machine navait 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 à lintérieur des conteneurs, mais pas pour le pull dimages par Podman machine.
### Solution
Lagent configure automatiquement le DNS des machines Podman en cours dexé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 lagent :
- Après un **Arrêter** lancé depuis le dashboard, linstance restait affichée comme elle l’était avant, ou disparaissait avec perte des données.
- Après une **Suppression**, linstance n’était pas retirée de la liste.
### Causes racines
1. **Action `stop` du dashboard envoyée comme `delete` à lagent** (`server/app/api/instances/route.ts`).
Lagent exécutait alors `docker compose down -v` + suppression des fichiers, cest-à-dire une suppression réelle, tout en marquant linstance `stopped` en base.
2. **Lagent 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 lUI locale le faisait.
3. **Le handler `stop` de lagent 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` | Laction dashboard `stop` envoie désormais `action: "stop"` à lagent (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 dinstances 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 darrêt brutal lors dactions 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 dun `recover` dans `handleMessage` pour capturer d’éventuels panics sans tuer lagent.
- 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 lagent sur chaque poste, ou télécharger à nouveau le binaire v0.3.5 depuis le dashboard pour Windows.
## 🛠️ Commandes utiles pour reprendre
### Voir lagent de test
```bash
pgrep -a studioe5-agent
```
### Relancer lagent 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 lAPI web
Test réalisé le 2026-06-23 en utilisant le compte superadmin :
1. **Authentification NextAuth** sur `/api/auth/callback/credentials`.
2. **Création dinstance** via `POST /api/instances` :
```json
{
"nodeId": "vps-8fc665eb",
"templateId": "wordpress-wordpress-latest",
"port": 8002
}
```
→ Instance créée : `cmqqgrur20001lw67t2bdgzkg`.
3. Le serveur a automatiquement envoyé laction `start` au node via WebSocket.
4. Lagent a démarré le VPN (si besoin), écrit le compose et a lancé les conteneurs WordPress.
5. Caddy a obtenu un certificat Lets 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 lagent
Lagent 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 davoir installé Tailscale Windows séparément ou davoir 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 lagent 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 dactivation zéro-config (modèle commercialisable)
L’élève/employé na **aucune configuration technique** à saisir :
1. **Télécharger** lagent Windows (`studioE5-agent-v0.3.10-windows.zip`).
2. **Extraire** et **lancer** `studioE5-agent.exe`.
3. **Entrer le code dactivation** à 6 caractères fourni par l’établissement (affiché dans lUI locale `http://localhost:7070`).
4. Lagent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** :
- lidentité de l’élève (`studentName`)
- lURL Headscale
- la clé pré-auth Headscale
5. Lagent sauvegarde ces informations localement et **démarre automatiquement le VPN**.
6. Lagent 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 dactivation
### 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 dactivation 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 dinstance ;
- 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 lutilisateur `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 dauthentification par nœud
- Le modèle `Node` dispose dun champ `token` unique.
- Lagent envoie son token dans len-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 lactivation, le serveur génère un token aléatoire (32 octets hex) et le renvoie dans le message `activated` ; lagent 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 lenvoie via `set_token`.
### Endpoint `/api/internal/send-to-node`
- Protégé par la variable denvironnement `INTERNAL_API_KEY`.
- Requiert len-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 len-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
- Le resolver (`resolver:2020`) ne lutilise 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
À lactivation zero-config, le serveur génère désormais une **clé pré-auth unique et à usage unique** pour chaque agent, au lieu denvoyer la clé réutilisable `HEADSCALE_AUTH_KEY`.
Avantages :
- une clé compromise ne permet pas denregistrer dautres nœuds ;
- traçabilité directe entre une activation et une clé Headscale ;
- expiration courte (15 min) ;
- la clé nest **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 lutilisateur `studioe5`. Fallback sur `HEADSCALE_AUTH_KEY` si `HEADSCALE_API_KEY` nest pas configurée. |
| `agent/websocket.go` | La clé reçue est utilisée immédiatement mais **nest 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 lajouter 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 lancienne 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 lapplication 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 lOS des VPS et des postes agents.
- Mécanisme de mise à jour automatique ou notification de lagent.
### Logs daudit
- Tracer la création / suppression dinstances.
- Tracer la génération et lusage des codes dactivation.
- Tracer les actions admin (connexion, création d’élève, démarrage/arrêt VPN).
- Conservation et consultation des logs daudit.
### Backups et reprise dactivité
- 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 lagent
- Vérifier lintégrité des binaires Tailscale téléchargés (checksum / signature).
- Signer lexécutable Windows `studioE5-agent.exe` pour éviter les alertes Defender.
- Fournir un hash SHA256 des archives dagent.
### RGPD et données personnelles
- Justifier la conservation des noms/prénoms des élèves.
- Gérer les droits daccès, la suppression de compte et lexport de données.
- Définir la durée de conservation des logs et historiques.
### Sécurité réseau complémentaire
- Restreindre laccès à `/api/internal/send-to-node` par IP source si possible.
- Vérifier lexposition 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 dinstance).
- Limitation du nombre dinstances par élève et par établissement.
- Protection contre les abus sur la génération de codes dactivation.
### Tests de sécurité
- Tests dintrusion légers (agent → agent, accès aux endpoints internes sans clé, accès à une instance dun autre élève).
- Tests automatisés du flux complet avant chaque release.
---
## 🖥️ Installateur agent professionnel
### Objectif
Créer un package dinstallation unique et professionnel par OS, incluant lagent studioE5, Tailscale et **Podman CLI**, afin de ne plus dépendre dinstallations manuelles préalables par lutilisateur.
### 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 lagent 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 lagent dans `/Applications/studioE5-agent/`.
- Installer Podman CLI.
- Exécuter `podman machine init` puis `podman machine start`.
- Optionnellement créer un LaunchAgent pour démarrer lagent au login.
- **Linux** (script shell) :
- Détecter le package manager (`apt`, `dnf`, `pacman`, etc.).
- Installer Podman et Podman Compose.
- Copier lagent dans `/opt/studioe5-agent/`.
- Créer le service systemd `studioe5-agent.service`.
- Activer et démarrer le service.
### Adaptations nécessaires dans lagent
- 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 larrêt de la machine à la fermeture de lagent (optionnel).
### Mise à jour de lagent vs dépendances système
- **Lagent peut se mettre à jour lui-même** (binaire + fichiers) depuis la v0.3.10.
- **Podman / Docker / Tailscale restent gérés par linstallateur** : lagent vérifie leur présence et alertera lutilisateur 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 Lets 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 dactivation** (`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 dinstance).
- [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 à lemploi** (`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 lUI locale** : version de lagent, nodeId, version attendue par le serveur, notification de mise à jour.
- [x] **Agent v0.3.10 mise à jour automatique de lagent** : 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 dinstallation unique incluant lagent 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 à lemploi (usage examen/classe)** :
- Forcer le DNS (`8.8.8.8`, `1.1.1.1`) dans le `docker-compose.yml` pour permettre laccè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 lenvironnement.
- Installer et activer le **thème Astra**.
- Installer **Yoast SEO** (inactif) et **Spectra** (actif).
- [ ] **Barre de progression basée sur les logs dinstallation** : enrichir la barre de progression agent/dashboard en lisant les logs Podman/Docker (`podman logs -f`) pendant le premier démarrage dune 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 dun nouveau serveur client + agent générique (option A : URL serveur déterminée à lactivation).
- [ ] **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 daudit** (instances, codes dactivation, actions admin).
- [ ] **Sécurité backups et reprise dactivité** (DB, state Headscale, states agents).
- [ ] **Sécurité intégrité et signature de lagent** (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 lagent (v0.3.5)
Les logs de lagent sont redirigés vers `<data-dir>/agent.log` et diffusés en temps réel dans lUI locale (`http://localhost:7070`) via le WebSocket existant.
### ✅ Barre de progression (v0.3.5)
Lagent envoie des messages `progress` au frontend pendant le démarrage dune instance :
| Étape | Poids |
|-------|-------|
| Préparation de lapplication | 10 % |
| Configuration de lapplication | 30 % |
| Application en cours de démarrage | 60 % |
| Connexion sécurisée active | 80 % |
| Finalisation de linstallation | 90 % |
| Application prête | 100 % |
### Boutons daction par instance (v0.3.5)
LUI 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. LURL du serveur cible est déterminée au moment de lactivation, pas hardcodée dans lagent.
- Pistes : code dactivation résolu par un hub central, code structuré contenant lidentifiant du serveur, ou champ URL serveur saisi dans lUI 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`). Lagent doit pouvoir déterminer lURL serveur cible à lactivation (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 dimages** | ⏳ À 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 lagent 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. Larchitecture actuelle (un serveur par client + agent zero-config) est déjà compatible avec cette vision, mais le code nest 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 Lets Encrypt par sous-domaine dinstance**. 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 dinstances 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.
- Lagent 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.
+1
View File
@@ -0,0 +1 @@
0.3.18
+71 -17
View File
@@ -1,35 +1,89 @@
#!/bin/bash
set -e
VERSION="0.3.0"
VERSION="$(cat "$(dirname "$0")/VERSION")"
APP_NAME="studioE5"
BIN_NAME="studioE5-agent"
LDFLAGS="-X main.version=${VERSION}"
echo "Building EduBox Agent v${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 "${LDFLAGS}" -o edubox-agent.exe .
echo " edubox-agent.exe (Windows amd64)"
cp edubox-agent.exe "edubox-agent-v${VERSION}.exe"
echo " edubox-agent-v${VERSION}.exe (Windows amd64)"
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 edubox-agent .
echo " edubox-agent (Linux amd64)"
cp edubox-agent "edubox-agent-v${VERSION}"
echo " edubox-agent-v${VERSION} (Linux 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)"
GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o edubox-agent-mac .
echo " edubox-agent-mac (macOS amd64)"
cp edubox-agent-mac "edubox-agent-v${VERSION}-mac"
echo " edubox-agent-v${VERSION}-mac (macOS 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 "edubox-agent-v${VERSION}" "${SERVER_PUBLIC}/edubox-agent-v${VERSION}"
cp "edubox-agent-v${VERSION}-mac" "${SERVER_PUBLIC}/edubox-agent-v${VERSION}-mac"
cp "edubox-agent-v${VERSION}.exe" "${SERVER_PUBLIC}/edubox-agent-v${VERSION}.exe"
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
View File
@@ -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)
}
+50 -18
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
@@ -20,48 +21,83 @@ func getContainerEngine() string {
return "docker"
}
func writeCompose(dataDir, instanceID, compose string) error {
func writeCompose(dataDir, instanceID, compose string, port int) error {
dir := instanceDir(dataDir, instanceID)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
// Ensure the EduBox mu-plugin is available and substitute its path
// 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")
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 {
dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "up", "-d")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
configureEngineCmd(cmd, dir)
return cmd.Run()
}
func dockerComposeDown(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
configureEngineCmd(cmd, dir)
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()
}
func dockerComposeRm(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID)
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
configureEngineCmd(cmd, dir)
if err := cmd.Run(); err != nil {
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.
@@ -102,20 +138,18 @@ fi
defer os.Remove(scriptPath)
cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/update-wp-urls.sh")
cpCmd.Stdout = os.Stdout
cpCmd.Stderr = os.Stderr
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")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
configureEngineCmd(execCmd, dir)
return execCmd.Run()
}
// stripWordPressHardcodedURLs removes hardcoded WP_HOME/WP_SITEURL defines
// from wp-config.php so the EduBox mu-plugin can compute them from the Host
// 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 {
@@ -139,14 +173,12 @@ fi
defer os.Remove(scriptPath)
cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/strip-wp-urls.sh")
cpCmd.Stdout = os.Stdout
cpCmd.Stderr = os.Stderr
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")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
configureEngineCmd(execCmd, dir)
return execCmd.Run()
}
+32
View File
@@ -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"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -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) {}
+18
View File
@@ -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
}
+51
View File
@@ -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
View File
@@ -3,52 +3,12 @@ module edubox-agent
go 1.26.4
require (
fyne.io/systray v1.12.2
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
tailscale.com v1.100.0
)
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/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/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.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
View File
@@ -1,233 +1,11 @@
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
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=
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
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/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/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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/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=
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

+127
View File
@@ -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` nest plus utilisée : linstallation 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 daccueil).
- Amélioration de linterface : 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 lagent.
### 🔄 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 lexé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**.
- Linstallation de WSL2 nécessite un **redémarrage** de lordinateur après lactivation des fonctionnalités Windows.
- Linstallation 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`.
- Limage 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 linfo utile est affichée
// (par exemple si aucune distribution nest 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;
}
}
}
+19
View File
@@ -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));
}
}
}
+105
View File
@@ -0,0 +1,105 @@
# StudioE5 Setup Wizard
Assistant dinstallation graphique Windows pour studioE5 Agent.
## Rôle
Ce wizard guide lutilisateur 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
```
Lexé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 lagent (`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 limage 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**.
- Linstallation de WSL2 nécessite un **redémarrage** de lordinateur. Le wizard senregistre dans `RunOnce` pour se relancer automatiquement.
- Le wizard configure WSL2 avec **8 Go de RAM et 4 CPU** via le fichier `.wslconfig` de lutilisateur.
- Le MSI Podman doit correspondre à larchitecture `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` nest plus utilisée : linstallation 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 daccueil).
- Amélioration de linterface : 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 lagent.
### 🔄 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 lexé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**.
- Linstallation de WSL2 nécessite un **redémarrage** de lordinateur après lactivation des fonctionnalités Windows.
- Linstallation 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`.
- Limage 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>
+1
View File
@@ -0,0 +1 @@
0.1.1
+12
View File
@@ -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 lactivation 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
}
+199
View File
@@ -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;
+34
View File
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
@@ -88,6 +89,7 @@ func getInstanceStatus(dataDir, instanceID string) string {
// 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))
@@ -119,6 +121,7 @@ func getInstanceStatus(dataDir, instanceID string) string {
// 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"
@@ -128,3 +131,34 @@ func getInstanceStatus(dataDir, instanceID string) string {
}
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)
}
}
}
}
+121 -22
View File
@@ -2,34 +2,28 @@ package main
import (
"flag"
"io"
"log"
"os"
"os/signal"
"path/filepath"
"runtime"
"sync"
"syscall"
"time"
)
// version is injected at build time via -ldflags "-X main.version=X.Y.Z"
var version = "dev"
const AGENT_VERSION = "0.3.0"
const APP_NAME = "studioE5"
var (
serverAddr = flag.String("server", "ws://localhost:3001", "Adresse WebSocket du serveur")
nodeID = flag.String("node-id", defaultNodeID(), "ID du nœud (défaut: hostname)")
dataDir = flag.String("data-dir", "./edubox-data", "Répertoire de données")
dataDir = flag.String("data-dir", "./studioE5-data", "Répertoire de données")
uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX")
headscaleURL = flag.String("headscale-url", "", "URL du serveur Headscale (ex: http://151.80.60.98:8080)")
headscaleAuthKey = flag.String("headscale-auth-key", "", "Clé d'authentification Headscale")
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() {
flag.Parse()
@@ -49,24 +43,110 @@ func main() {
log.Fatalf("Cannot create data-dir: %v", err)
}
log.Printf("[EduBox Agent] version=%s node=%s data-dir=%s", AGENT_VERSION, *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 {
go startUI(*dataDir, *nodeID, *serverAddr)
go startUI(*dataDir, cfg.NodeID, cfg.Server)
}
go startWebSocket(*serverAddr, *nodeID, *dataDir)
go startWebSocket(cfg, cfg.NodeID, *dataDir)
go updateCheckerLoop(cfg, *dataDir)
if *headscaleURL != "" && *headscaleAuthKey != "" {
go startTailscaleAndReport(*dataDir, *nodeID, *headscaleURL, *headscaleAuthKey)
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
}
select {}
// 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) {
tsDir := filepath.Join(dataDir, "tailscale")
ip, err := startTailscale(tsDir, nodeID, headscaleURL, authKey)
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
@@ -82,4 +162,23 @@ func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
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),
})
}
+65
View File
@@ -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
View File
@@ -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.
+92
View File
@@ -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, " ", "")
}
+261 -59
View File
@@ -2,104 +2,306 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"os"
"io"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"sync"
"syscall"
"time"
"tailscale.com/tsnet"
)
var globalTSServer *tsnet.Server
var (
tsCmd *exec.Cmd
tsCmdMu sync.Mutex
tsIP string
tsDataDir string
tsSocket string
)
type tailscaleStatus struct {
Self struct {
TailscaleIPs []string `json:"TailscaleIPs"`
} `json:"Self"`
}
func tailscaleBin(name string) string {
// 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
}
func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, error) {
// Configure tsnet to use our Headscale server
os.Setenv("TS_AUTHKEY", authKey)
os.Setenv("TS_CONTROL_URL", headscaleURL)
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
s := &tsnet.Server{
Hostname: nodeID,
Dir: dataDir,
Logf: log.Printf,
if tsCmd != nil {
return tsIP, nil
}
if err := s.Start(); err != nil {
return "", fmt.Errorf("tailscale start: %w", err)
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)
}
globalTSServer = s
// Give tailscaled a moment to start listening.
time.Sleep(1 * time.Second)
// Wait for Tailscale to come up and retrieve IP
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
// Bring the interface up with the auth key.
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
lc, err := s.LocalClient()
if err != nil {
return "", fmt.Errorf("tailscale local client: %w", err)
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)
}
var tailscaleIP string
// Wait for an IP address.
for {
status, err := lc.Status(ctx)
statusCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"),
"--socket="+tsSocket,
"status", "--json",
)
hideWindow(statusCmd)
out, err := statusCmd.Output()
if err != nil {
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
}
if status.Self != nil && len(status.Self.TailscaleIPs) > 0 {
tailscaleIP = status.Self.TailscaleIPs[0].String()
}
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")
case <-time.After(1 * time.Second):
default:
time.Sleep(1 * time.Second)
}
}
log.Printf("Tailscale started with IP: %s", tailscaleIP)
return tailscaleIP, nil
log.Printf("Tailscale started with IP: %s", tsIP)
return tsIP, nil
}
func startTailscaleProxy(port int) (net.Listener, error) {
if globalTSServer == nil {
return nil, fmt.Errorf("tailscale server not started")
func stopTailscale() {
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
stopTailscaleLocked()
}
ln, err := globalTSServer.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return nil, fmt.Errorf("tailscale listen on port %d: %w", port, err)
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
log.Printf("tailscale proxy accept error on port %d: %v", port, err)
func stopTailscaleLocked() {
if tsCmd == nil || tsCmd.Process == nil {
return
}
go handleProxyConn(conn, port)
if tsSocket != "" {
downCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down")
hideWindow(downCmd)
_ = downCmd.Run()
}
}()
log.Printf("Tailscale proxy started on port %d", port)
return ln, nil
_ = tsCmd.Process.Kill()
_ = tsCmd.Wait()
tsCmd = nil
tsIP = ""
if tsDataDir != "" {
_ = os.Remove(filepath.Join(tsDataDir, "tailscaled.pid"))
}
log.Printf("Tailscale stopped")
}
func handleProxyConn(src net.Conn, port int) {
defer src.Close()
dst, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port))
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 {
log.Printf("tailscale proxy dial localhost:%d error: %v", port, err)
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
}
defer dst.Close()
done := make(chan struct{}, 2)
go func() {
_, _ = io.Copy(dst, src)
done <- struct{}{}
}()
go func() {
_, _ = io.Copy(src, dst)
done <- struct{}{}
}()
<-done
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)
}
+31
View File
@@ -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)
}
+412 -8
View File
@@ -2,9 +2,16 @@ package main
import (
_ "embed"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
@@ -14,12 +21,123 @@ var uiHTML string
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) {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
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) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
@@ -27,23 +145,33 @@ func startUI(dataDir, nodeID, serverAddr string) {
return
}
defer conn.Close()
uiConnectionsMu.Lock()
uiConnections[conn] = true
uiConnectionsMu.Unlock()
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
notifierID := registerUINotifier(func(msg map[string]interface{}) {
log.Printf("UI notifier forwarding to browser: %+v", msg)
if err := conn.WriteJSON(msg); err != nil {
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 {
var msg map[string]interface{}
if err := conn.ReadJSON(&msg); err != nil {
log.Printf("UI client disconnected: %v", err)
log.Printf("UI client read error: %v", err)
break
}
action, _ := msg["action"].(string)
@@ -67,14 +195,50 @@ func startUI(dataDir, nodeID, serverAddr string) {
}
case "instances":
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"
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 {
log.Fatalf("UI server error: %v", err)
log.Fatalf("%s UI server error: %v", APP_NAME, err)
}
}
@@ -86,7 +250,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
return
}
var list []map[string]interface{}
list := []map[string]interface{}{}
for _, inst := range instances {
status := getInstanceStatus(dataDir, inst.ID)
if status != inst.Status {
@@ -96,6 +260,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
list = append(list, map[string]interface{}{
"id": inst.ID,
"templateName": inst.TemplateName,
"type": inst.TemplateName,
"port": inst.Port,
"status": inst.Status,
"url": instanceURL(inst),
@@ -104,3 +269,242 @@ func listInstances(dataDir string, conn *websocket.Conn) {
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)
}
}
// 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)) != ""
}
+1026 -79
View File
File diff suppressed because it is too large Load Diff
+268
View File
@@ -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)
}
}
+21
View File
@@ -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)
}
}
}
+468 -97
View File
@@ -3,13 +3,22 @@ package main
import (
"fmt"
"log"
"net"
"net/http"
"net/url"
"sync"
"time"
"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 {
Action string `json:"action"`
NodeID string `json:"nodeId,omitempty"`
@@ -18,10 +27,19 @@ type WSMessage struct {
Type string `json:"type,omitempty"`
Port int `json:"port,omitempty"`
ComposeConfig string `json:"composeConfig,omitempty"`
InitScript string `json:"initScript,omitempty"`
StudentId string `json:"studentId,omitempty"`
StudentName string `json:"studentName,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 (
@@ -29,11 +47,46 @@ var (
mainConnMu sync.Mutex
)
// headscale config received from the server during activation.
// These are mutable because activation may happen after the agent starts.
var (
tsProxies = make(map[int]net.Listener)
tsProxiesMu sync.Mutex
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 {
mainConnMu.Lock()
defer mainConnMu.Unlock()
@@ -43,9 +96,33 @@ func sendMessage(msg WSMessage) error {
if msg.Action != "heartbeat" {
log.Printf("sendMessage: sending %+v", msg)
}
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in sendMessage: %v", r)
}
}()
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
type uiNotifier func(msg map[string]interface{})
@@ -82,20 +159,134 @@ func notifyUI(msg map[string]interface{}) {
log.Printf("notifyUI: broadcasting to %d UI clients", len(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 {
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 {
log.Printf("WS connect error: %v, retrying in 5s...", err)
time.Sleep(5 * time.Second)
continue
}
log.Printf("WS connected to %s", serverAddr)
log.Printf("WS connected to %s (token=%v, proxy=%v)", serverAddr, token != "", IsProxyActive())
mainConnMu.Lock()
mainConn = conn
@@ -117,6 +308,13 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
log.Println("Waiting for activation...")
} else {
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
@@ -158,9 +356,30 @@ func startWebSocket(serverAddr, nodeID, dataDir 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 {
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":
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 != "" {
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
if err := saveActivation(dataDir, act); err != nil {
@@ -169,13 +388,91 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
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{}{
"action": "activated",
"studentName": msg.StudentName,
})
case "registered":
// Server acknowledged our register message; nothing to do.
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":
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
notifyUI(map[string]interface{}{
@@ -184,122 +481,196 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
})
case "start":
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
if err := upsertInstance(dataDir, &InstanceInfo{
ID: msg.InstanceID,
TemplateName: msg.Type,
Port: msg.Port,
Status: "starting",
}); err != nil {
log.Printf("upsertInstance error: %v", err)
}
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return
}
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeUp error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return
}
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
// so the EduBox mu-plugin can compute the public URL from the Host header.
go func() {
// Give the container a moment to be ready before touching wp-config.php
time.Sleep(2 * time.Second)
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil {
log.Printf("stripWordPressHardcodedURLs error: %v", err)
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in start goroutine instance=%s: %v", msg.InstanceID, r)
}
}()
// Start Tailscale proxy so the server can reach localhost via Tailscale IP
tsProxiesMu.Lock()
if _, exists := tsProxies[msg.Port]; !exists {
if ln, err := startTailscaleProxy(msg.Port); err == nil {
tsProxies[msg.Port] = ln
} else {
log.Printf("startTailscaleProxy error: %v", err)
}
}
tsProxiesMu.Unlock()
status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
notifyUI(map[string]interface{}{"action": "instances_updated"})
handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port)
}()
case "stop":
log.Printf("Stop instance %s", msg.InstanceID)
// Stop Tailscale proxy for this instance port
go func() {
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 {
tsProxiesMu.Lock()
if ln, exists := tsProxies[inst[msg.InstanceID].Port]; exists {
_ = ln.Close()
delete(tsProxies, inst[msg.InstanceID].Port)
removeTailscaleServe(inst[msg.InstanceID].Port)
}
tsProxiesMu.Unlock()
}
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeDown error: %v", err)
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)
tsProxiesMu.Lock()
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 {
if ln, exists := tsProxies[inst[msg.InstanceID].Port]; exists {
_ = ln.Close()
delete(tsProxies, inst[msg.InstanceID].Port)
removeTailscaleServe(inst[msg.InstanceID].Port)
}
}
tsProxiesMu.Unlock()
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":
log.Printf("Reset instance %s", msg.InstanceID)
dockerComposeRm(dataDir, msg.InstanceID)
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return
}
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeUp error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return
}
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
// so the EduBox mu-plugin can compute the public URL from the Host header.
go func() {
// Give the container a moment to be ready before touching wp-config.php
time.Sleep(2 * time.Second)
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil {
log.Printf("stripWordPressHardcodedURLs error: %v", err)
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in reset goroutine instance=%s: %v", msg.InstanceID, r)
}
}()
// Start Tailscale proxy so the server can reach localhost via Tailscale IP
tsProxiesMu.Lock()
if _, exists := tsProxies[msg.Port]; !exists {
if ln, err := startTailscaleProxy(msg.Port); err == nil {
tsProxies[msg.Port] = ln
} else {
log.Printf("startTailscaleProxy error: %v", err)
}
}
tsProxiesMu.Unlock()
status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
notifyUI(map[string]interface{}{"action": "instances_updated"})
handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port)
}()
default:
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),
})
}
+42 -43
View File
@@ -1,18 +1,18 @@
services:
postgres:
image: postgres:18-alpine
container_name: edubox-postgres
container_name: studioe5-postgres
restart: unless-stopped
environment:
POSTGRES_USER: edubox
POSTGRES_USER: studioe5
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: edubox
POSTGRES_DB: studioe5
volumes:
- pg_data:/var/lib/postgresql
networks:
- edubox
- studioe5
healthcheck:
test: ["CMD-SHELL", "pg_isready -U edubox -d edubox"]
test: ["CMD-SHELL", "pg_isready -U studioe5 -d studioe5"]
interval: 5s
timeout: 5s
retries: 5
@@ -21,13 +21,10 @@ services:
build:
context: ./server
dockerfile: Dockerfile
container_name: edubox-server
container_name: studioe5-server
volumes:
- ./server/public:/app/public:ro
cap_add:
- NET_ADMIN
command: >
sh -c "ip route add 100.64.0.0/10 via $$(ip route | awk '/default/ {{print $$3}}') || true && exec node_modules/.bin/next start"
- ./agent/VERSION:/app/agent-version:ro
restart: unless-stopped
environment:
DATABASE_URL: ${DATABASE_URL}
@@ -35,28 +32,23 @@ services:
NEXTAUTH_URL: ${NEXTAUTH_URL}
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL}
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD}
MAIN_DOMAIN: ${MAIN_DOMAIN}
HEADSCALE_URL: ${HEADSCALE_URL}
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
MAIN_DOMAIN: ${MAIN_DOMAIN}
GITEA_URL: ${GITEA_URL}
GITEA_TOKEN: ${GITEA_TOKEN}
HEADSCALE_API_KEY: ${HEADSCALE_API_KEY}
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
depends_on:
postgres:
condition: service_healthy
networks:
- edubox
- studioe5
resolver:
build:
context: ./resolver
dockerfile: Dockerfile
container_name: edubox-resolver
container_name: studioe5-resolver
restart: unless-stopped
cap_add:
- NET_ADMIN
command: >
sh -c "ip route add 100.64.0.0/10 via \$$(ip route | awk '/default/ {print \$$3}') || true && exec ./resolver"
environment:
DATABASE_URL: ${DATABASE_URL}
MAIN_DOMAIN: ${MAIN_DOMAIN}
@@ -64,11 +56,34 @@ services:
postgres:
condition: service_healthy
networks:
- edubox
- 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:
image: caddy:2-alpine
container_name: edubox-caddy
container_name: studioe5-caddy
restart: unless-stopped
ports:
- "80:80"
@@ -79,11 +94,11 @@ services:
- caddy_data:/data
- caddy_config:/config
networks:
- edubox
- studioe5
headscale:
image: headscale/headscale:latest
container_name: edubox-headscale
container_name: studioe5-headscale
restart: unless-stopped
command: serve
ports:
@@ -92,31 +107,15 @@ services:
volumes:
- ./headscale:/etc/headscale
networks:
- edubox
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
- studioe5
volumes:
pg_data:
caddy_data:
caddy_config:
headscale_data:
gitea_data:
resolver_ts_state:
networks:
edubox:
studioe5:
driver: bridge
+549
View File
@@ -0,0 +1,549 @@
# Deployeur studioE5 — Onboarding dun nouvel établissement
## Objectif
Ce document décrit le fonctionnement du **deployeur studioE5**, cest-à-dire lapplication / loutil qui provisionne et configure un nouvel environnement client (un établissement) prêt à accueillir lapplication studioE5.
Lapplication studioE5 elle-même (agent, VPN on-demand, resolver, Caddy, Headscale, etc.) est documentée dans `SUIVI_VPN_ONDEMAND.md`. Le deployeur est loutil 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 à nimporte quel serveur studioE5 via résolution dURL à lactivation. |
---
## 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 donboarding par le deployeur (vue densemble)
```
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 dactivation
Build et mise à disposition de lagent dédié
Activation de lagent par un élève
Création dune 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 lIP du VPS provisionné.
- Le deployeur dispose dun 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 dunicité 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 lAPI du registrar pour créer :
```dns
*.tag.edudeploy.com A <IP_DU_VPS>
```
### Domaine propre
Le deployeur vérifie que lenregistrement 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 lutilisateur 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 dune 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 lAPI 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.
Ladministrateur reçoit un lien dactivation sécurisé.
---
## 10. Génération des codes dactivation
Le deployeur configure le serveur pour permettre la génération de codes dactivation.
### 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. Ladministrateur génère un code pour un élève.
2. L’élève saisit le code dans lagent.
3. Le serveur valide et renvoie :
- lidentité de l’élève
- lURL Headscale
- une clé pré-auth Headscale éphémère
4. Lagent démarre automatiquement le VPN.
---
## 11. Build et mise à disposition de lagent
### Principe
Lagent 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 lURL 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 lagent
### Activation zéro-config
1. L’élève télécharge lagent depuis lURL de l’établissement.
2. Il extrait larchive et lance `studioE5-agent.exe`.
3. Il ouvre `http://localhost:7070`.
4. Il saisit le code dactivation à 6 caractères.
5. Lagent 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 dune instance et construction de lURL (validation)
Le deployeur ou ladministrateur crée une première instance pour valider le déploiement.
### Format dURL
```
<appli>-<initiales><id-court>.<tag>.<domaine>
```
Exemple :
```
wp-jd47.ljf.edudeploy.com
```
Avec :
- `wp` : type dapplication
- `jd` : initiales de l’élève
- `47` : identifiant court unique
- `ljf` : tag de l’établissement
- `edudeploy.com` : domaine de base
### Mapping type dapplication → préfixe
| Application | Préfixe |
|-------------|---------|
| WordPress | `wp` |
| PrestaShop | `ps` |
| Moodle | `mdl` |
| Nextcloud | `nc` |
### Protection de lidentité
- LURL 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 lapplication 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 lapplication 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 lidentité de l’élève
- LURL 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 dinstance.
### 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 dexpiration.
- Non persistées côté agent.
---
## 16. Checklist de validation du deployeur
À lissue dun 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 dactivation peut être généré pour un élève.
- [ ] Lagent est buildé et téléchargeable depuis le serveur de l’établissement.
- [ ] Lagent sactive 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é dun 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 à lactivation.
### Moyen terme
- **Agent générique** : déterminer lURL serveur cible à lactivation (code structuré, hub de résolution, ou champ URL).
- **Script de provisionning** : installation Docker, déploiement stack, génération secrets, DNS wildcard.
- **Registry dimages 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 lagent 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 lagent.
- Génération automatique de codes dactivation par import CSV.
+18
View File
@@ -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"]
}
]
}
+7 -3
View File
@@ -1,5 +1,5 @@
# Headscale configuration for EduBox
server_url: https://headscale.alfrednobel.edudeploy.com
# 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
@@ -14,7 +14,7 @@ prefixes:
dns:
magic_dns: true
base_domain: edubox.local
base_domain: studioe5.local
nameservers:
global:
- 1.1.1.1
@@ -38,6 +38,10 @@ database:
sqlite:
path: /etc/headscale/db.sqlite
policy:
path: /etc/headscale/acl_policy.hujson
mode: file
log:
format: text
level: info
+52
View File
@@ -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
+99
View File
@@ -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 davertissement "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.
+34
View File
@@ -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';
+20
View File
@@ -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()
+16
View File
@@ -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);
+9
View File
@@ -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/')) {
+36
View File
@@ -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';
}
/**
+46
View File
@@ -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) {
+46
View File
@@ -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;
+28
View File
@@ -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();
}
+32
View File
@@ -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;
}
}
+44
View File
@@ -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) {
+10
View File
@@ -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.
+1 -1
View File
@@ -6,7 +6,7 @@ export default function LoginPage() {
return (
<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">
<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>
<LoginForm />
</div>
+7
View File
@@ -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));
}
+4 -1
View File
@@ -11,7 +11,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ ok: false }, { status: 400 });
}
if (domain === MAIN_DOMAIN || domain === `headscale.${MAIN_DOMAIN}`) {
if (
domain === MAIN_DOMAIN ||
domain === `headscale.${MAIN_DOMAIN}`
) {
return NextResponse.json({ ok: true });
}
+22 -4
View File
@@ -1,13 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth, requireRole, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
export async function GET(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const { searchParams } = new URL(req.url);
const establishmentId = searchParams.get("establishmentId");
if (!establishmentId) return NextResponse.json({ error: "Missing establishmentId" }, { status: 400 });
const requestedId = searchParams.get("establishmentId");
const establishmentId = getScopedEstablishmentId(user, requestedId);
if (establishmentId instanceof NextResponse) return establishmentId;
const where = establishmentId ? { establishmentId } : {};
const classes = await prisma.class.findMany({
where: { establishmentId },
where,
include: { _count: { select: { students: true } } },
orderBy: { createdAt: "desc" },
});
@@ -15,8 +22,19 @@ export async function GET(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 { 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({
data: { establishmentId, name, level },
});
+8 -7
View File
@@ -1,12 +1,13 @@
import { NextResponse } from "next/server";
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
const AGENT_VERSION = "0.3.0";
export async function GET() {
export async function GET(request: Request) {
const baseUrl = getBaseUrlFromRequest(request);
const info = getAgentVersionInfo(baseUrl);
return NextResponse.json({
version: AGENT_VERSION,
windows: `/edubox-agent-v${AGENT_VERSION}.exe`,
linux: `/edubox-agent-v${AGENT_VERSION}`,
mac: `/edubox-agent-v${AGENT_VERSION}-mac`,
version: info.version,
windows: info.downloadUrls.windows,
linux: info.downloadUrls.linux,
mac: info.downloadUrls.mac,
});
}
+12
View File
@@ -1,9 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/auth";
import { requireAuth, requireRole } from "@/lib/api-auth";
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({
where,
include: { subscription: true, _count: { select: { users: true, classes: true } } },
orderBy: { createdAt: "desc" },
});
@@ -11,6 +17,12 @@ export async function GET() {
}
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 { name, slug, adminEmail, adminPassword } = body;
+111 -18
View File
@@ -1,16 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { prisma } from "@/lib/prisma";
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) {
const user = await requireAuth();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url);
const nodeId = searchParams.get("nodeId");
const establishmentId = searchParams.get("establishmentId");
const establishmentIdParam = searchParams.get("establishmentId");
let where: any = {};
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 nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } });
where.nodeId = { in: nodes.map((n) => n.id) };
@@ -39,12 +77,8 @@ export async function GET(req: NextRequest) {
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;
const publicUrl = domain ? `https://${inst.id}.${domain}` : null;
const localUrl = inst.node.tailscaleIp ? `http://${inst.node.tailscaleIp}:${inst.port}` : null;
return {
...inst,
publicUrl,
@@ -56,22 +90,32 @@ export async function GET(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 { 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 } });
if (!template) return NextResponse.json({ error: "Template not found" }, { status: 404 });
const instance = await prisma.instance.create({
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
});
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 domain = node?.student?.class.establishment?.domain;
const instance = await prisma.instance.create({
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, {
@@ -84,6 +128,13 @@ export async function POST(req: NextRequest) {
.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) {
@@ -94,17 +145,31 @@ export async function POST(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 { id, action } = body;
const instance = await prisma.instance.findUnique({ where: { id }, include: { template: true, node: { include: { student: { include: { class: { include: { establishment: 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 (!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") {
sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
const sent = sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id });
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
} else if (action === "start") {
const sent = sendToNode(instance.nodeId, {
action: "start",
@@ -116,6 +181,13 @@ export async function PATCH(req: NextRequest) {
.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 if (action === "reset") {
@@ -129,18 +201,39 @@ export async function PATCH(req: NextRequest) {
.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 });
}
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 id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
const instance = await prisma.instance.findUnique({ where: { 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 } });
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 });
}
+7 -1
View File
@@ -1,9 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth, getScopedEstablishmentId, forbidden } from "@/lib/api-auth";
export async function GET(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
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 = {};
if (establishmentId) {
+15
View File
@@ -1,7 +1,22 @@
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");
+4 -8
View File
@@ -1,12 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
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;
}
import { generateUniqueActivationCode } from "@/lib/activation";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
@@ -31,13 +25,15 @@ export async function GET(req: NextRequest) {
export async function POST(req: NextRequest) {
const body = await req.json();
const { classId, firstName, lastName, email } = body;
const { code, expiresAt } = await generateUniqueActivationCode();
const student = await prisma.student.create({
data: {
classId,
firstName,
lastName,
email,
activationCode: generateCode(),
activationCode: code,
activationCodeExpiresAt: expiresAt,
},
});
return NextResponse.json(student, { status: 201 });
+63 -8
View File
@@ -1,25 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
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) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
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({
where: {
OR: [
{ isPublic: true },
...(establishmentId ? [{ establishmentId }] : []),
],
},
where,
orderBy: { createdAt: "desc" },
});
return NextResponse.json(templates);
}
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 { 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({
data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy },
});
@@ -27,16 +59,39 @@ export async function POST(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 { 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 });
return NextResponse.json(template);
}
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 id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
if (!(await canManageTemplate(user, id))) return forbidden();
await prisma.template.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+47 -3
View File
@@ -1,14 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/auth";
import { requireAuth, requireRole, forbidden } from "@/lib/api-auth";
export async function GET(req: NextRequest) {
const user = await requireAuth();
if (user instanceof NextResponse) return user;
const { searchParams } = new URL(req.url);
const establishmentId = searchParams.get("establishmentId");
const role = searchParams.get("role");
if (user.role !== "superadmin") {
if (establishmentId && establishmentId !== user.establishmentId) {
return forbidden();
}
}
const where: any = {};
if (establishmentId) where.establishmentId = establishmentId;
else if (user.role !== "superadmin") where.establishmentId = user.establishmentId;
if (role) where.role = role;
const users = await prisma.user.findMany({
@@ -19,23 +30,56 @@ export async function GET(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 { 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: {
email,
password: await hashPassword(password),
role,
establishmentId,
establishmentId: finalEstablishmentId,
},
});
return NextResponse.json(user, { status: 201 });
return NextResponse.json(newUser, { status: 201 });
}
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 id = searchParams.get("id");
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 } });
return NextResponse.json({ ok: true });
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function DashboardNav({ role }: { role: string }) {
return (
<nav className="w-64 bg-white border-r flex flex-col">
<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 className="flex-1 p-4 space-y-1">
{links.map((link) => (
+34 -7
View File
@@ -1,22 +1,49 @@
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
const AGENT_VERSION = "0.3.0";
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
import { headers } from "next/headers";
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 (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Téléchargements Agent</h1>
<p className="text-sm text-muted-foreground">Version actuelle : <strong>{AGENT_VERSION}</strong></p>
<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">
<Card>
<CardHeader>
<CardTitle>Windows</CardTitle>
<CardTitle>Windows (.exe)</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour Windows (64 bits)</p>
<a href={`/edubox-agent-v${AGENT_VERSION}.exe`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a>
<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={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>
</Card>
<Card>
<CardHeader>
<CardTitle>Windows (archive)</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">Archive complète incluant l&apos;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>
<CardHeader>
<CardTitle>Linux</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Linux (64 bits).</p>
<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>
</div>
+3 -10
View File
@@ -3,17 +3,9 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth-config";
import { generateUniqueActivationCode } from "@/lib/activation";
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) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login");
@@ -54,9 +46,10 @@ export async function generateActivationCodeAction(formData: FormData) {
if (!student) return;
const { code, expiresAt } = await generateUniqueActivationCode();
await prisma.student.update({
where: { id },
data: { activationCode: generateCode() },
data: { activationCode: code, activationCodeExpiresAt: expiresAt },
});
redirect(`/dashboard/students/${id}`);
+4 -10
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth-config";
import { generateUniqueActivationCode } from "@/lib/activation";
import { redirect } from "next/navigation";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -17,15 +18,6 @@ const schema = z.object({
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) {
"use server";
const session = await getServerSession(authOptions);
@@ -35,13 +27,15 @@ async function createStudent(formData: FormData) {
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return;
const { code, expiresAt } = await generateUniqueActivationCode();
await prisma.student.create({
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
email: parsed.data.email,
classId: parsed.data.classId,
activationCode: generateActivationCode(),
activationCode: code,
activationCodeExpiresAt: expiresAt,
},
});
+1 -1
View File
@@ -5,7 +5,7 @@ import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "EduBox V2",
title: "studioE5",
description: "Plateforme de gestion d'instances pour l'enseignement BTS",
};
+25
View File
@@ -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");
}
+74
View File
@@ -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),
};
}
+39
View File
@@ -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;
}
+75
View File
@@ -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;
}
+286 -21
View File
@@ -1,5 +1,9 @@
import { WebSocketServer, WebSocket } from "ws";
import { randomBytes } from "crypto";
import type { IncomingMessage } from "http";
import { prisma } from "./prisma";
import { createEphemeralPreAuthKey, getHeadscaleUserId } from "./headscale";
import { getAgentVersion } from "./agent-version";
interface NodeMessage {
action: string;
@@ -9,17 +13,77 @@ interface NodeMessage {
type?: string;
port?: number;
composeConfig?: string;
initScript?: string;
studentName?: 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>();
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) {
wss.on("connection", (ws: WebSocket) => {
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
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) => {
try {
@@ -27,19 +91,69 @@ export function initWebSocketServer(wss: WebSocketServer) {
console.log("[WS] Received:", msg.action, "from", msg.nodeId || nodeId);
if (msg.action === "register" && msg.nodeId) {
nodeId = msg.nodeId;
nodes.set(nodeId, ws);
await prisma.node.upsert({
where: { id: nodeId },
update: { status: "online", lastSeen: new Date() },
create: { id: nodeId, status: "online", lastSeen: new Date() },
const id = msg.nodeId;
const existing = await prisma.node.findUnique({ where: { id } });
if (token) {
// Token supplied: it must match the stored token for this node.
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;
}
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({
where: { activationCode: msg.code },
});
@@ -48,17 +162,97 @@ export function initWebSocketServer(wss: WebSocketServer) {
ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" }));
return;
}
await prisma.node.upsert({
where: { id: nodeId },
update: { studentId: student.id, status: "online", lastSeen: new Date() },
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}` }));
if (!student.activationCodeExpiresAt || student.activationCodeExpiresAt < new Date()) {
console.log("[WS] Expired code:", msg.code);
ws.send(JSON.stringify({ action: "activation_failed", error: "Code expired" }));
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({
where: { id: nodeId },
update: { lastSeen: new Date() },
@@ -67,7 +261,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
return;
}
if (msg.action === "tailscale_ip" && nodeId && msg.tailscaleIp) {
if (msg.action === "tailscale_ip" && msg.tailscaleIp) {
await prisma.node.update({
where: { id: nodeId },
data: { tailscaleIp: msg.tailscaleIp },
@@ -76,19 +270,90 @@ export function initWebSocketServer(wss: WebSocketServer) {
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) {
await prisma.instance.update({
const { count } = await prisma.instance.updateMany({
where: { id: msg.instanceId },
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;
}
if (msg.action === "instance_error" && msg.instanceId) {
await prisma.instance.update({
const { count } = await prisma.instance.updateMany({
where: { id: msg.instanceId },
data: { status: "error" },
});
if (count) console.log("[WS] Instance error:", msg.instanceId);
return;
}
} catch (err) {
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "initScript" TEXT;
+3
View File
@@ -57,6 +57,7 @@ model Student {
lastName String
email String
activationCode String? @unique
activationCodeExpiresAt DateTime?
createdAt DateTime @default(now())
nodes Node[]
}
@@ -65,6 +66,7 @@ model Node {
id String @id
studentId String?
student Student? @relation(fields: [studentId], references: [id], onDelete: Cascade)
token String? @unique
tailscaleIp String?
status String @default("offline")
lastSeen DateTime?
@@ -89,6 +91,7 @@ model Template {
type String
dockerImage String
composeConfig String
initScript String?
isPublic Boolean @default(true)
establishmentId String?
establishment Establishment? @relation(fields: [establishmentId], references: [id], onDelete: Cascade)
+100 -6
View File
@@ -1,5 +1,7 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
import fs from "fs";
import path from "path";
const prisma = new PrismaClient();
@@ -19,8 +21,10 @@ async function main() {
},
});
// Remove obsolete PrestaShop templates from previous seeds
await prisma.template.deleteMany({ where: { type: "prestashop" } });
const wpReadyInitScript = fs.readFileSync(
path.join(__dirname, "../templates/wordpress-ready/wp-init.sh"),
"utf-8"
);
const templates = [
{
@@ -53,23 +57,109 @@ async function main() {
dbPassword: "wordpress",
dbRootPassword: "rootpassword",
},
{
name: "WordPress latest prêt à l'emploi",
type: "wordpress-ready",
dockerImage: "wordpress:latest",
dbImage: "mariadb:10.11",
dbName: "wordpress",
dbUser: "wordpress",
dbPassword: "wordpress",
dbRootPassword: "rootpassword",
ready: true,
initScript: wpReadyInitScript,
},
{
name: "WordPress 7.0.0 prêt à l'emploi",
type: "wordpress-ready",
dockerImage: "wordpress:7.0.0-php8.3",
dbImage: "mariadb:10.11",
dbName: "wordpress",
dbUser: "wordpress",
dbPassword: "wordpress",
dbRootPassword: "rootpassword",
ready: true,
initScript: wpReadyInitScript,
},
{
name: "PrestaShop 9 vierge (edubox)",
type: "prestashop",
dockerImage: "gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-9",
dbImage: "mariadb:10.11",
dbName: "prestashop",
dbUser: "prestashop",
dbPassword: "prestashop",
dbRootPassword: "rootpassword",
},
];
for (const t of templates) {
const dbHost = "db";
const dbPort = "3306";
const isPrestaShop = t.type === "prestashop";
const isWordPressReady = (t as any).ready === true;
const appEnv = ` WORDPRESS_DB_HOST: ${dbHost}:${dbPort}
const appEnv = isPrestaShop
? ` DB_SERVER: ${dbHost}
DB_PORT: ${dbPort}
DB_NAME: ${t.dbName}
DB_USER: ${t.dbUser}
DB_PASSWD: ${t.dbPassword}
DB_PREFIX: ps_
PS_DOMAIN: {PUBLIC_DOMAIN}
PS_SHOP_NAME: ${t.name}
PS_INSTALL_AUTO: "1"
PS_INSTALL_DB: "0"
PS_ENABLE_SSL: "0"
PS_LANGUAGE: fr
PS_COUNTRY: fr
ADMIN_MAIL: admin@edubox.local
ADMIN_PASSWD: EduboxPrestashop2024!
PS_FOLDER_ADMIN: admin-edubox
PS_FOLDER_INSTALL: install
PS_DEV_MODE: "1"`
: ` WORDPRESS_DB_HOST: ${dbHost}:${dbPort}
WORDPRESS_DB_NAME: ${t.dbName}
WORDPRESS_DB_USER: ${t.dbUser}
WORDPRESS_DB_PASSWORD: ${t.dbPassword}
WORDPRESS_DB_PREFIX: wp_
# No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`;
const appVolumes = ` volumes:
const appDNS = isWordPressReady
? ` dns:
- 8.8.8.8
- 1.1.1.1`
: "";
const appVolumes = isPrestaShop
? ` volumes:
- app_data:/var/www/html`
: ` volumes:
- app_data:/var/www/html
- {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`;
const wpInitService = isWordPressReady
? ` wp-init:
image: wordpress:cli
user: "0:0"
environment:
WORDPRESS_DB_HOST: ${dbHost}:${dbPort}
WORDPRESS_DB_NAME: ${t.dbName}
WORDPRESS_DB_USER: ${t.dbUser}
WORDPRESS_DB_PASSWORD: ${t.dbPassword}
depends_on:
db:
condition: service_healthy
app:
condition: service_started
volumes:
- app_data:/var/www/html
- ./wp-init.sh:/wp-init.sh:ro
restart: "no"
entrypoint: ["/bin/sh", "/wp-init.sh"]
`
: "";
const composeConfig = `services:
db:
image: ${t.dbImage}
@@ -93,24 +183,28 @@ async function main() {
environment:
${appEnv}
INSTANCE_ID: {INSTANCE_ID}
${appDNS}
depends_on:
db:
condition: service_healthy
${appVolumes}
restart: unless-stopped
volumes:
${wpInitService}volumes:
db_data:
app_data:
`;
const initScript = isWordPressReady ? wpReadyInitScript : null;
await prisma.template.upsert({
where: { id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}` },
update: { composeConfig },
update: { composeConfig, initScript },
create: {
id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}`,
name: t.name,
type: t.type,
dockerImage: t.dockerImage,
composeConfig,
initScript,
isPublic: true,
createdBy: "system",
},
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More