Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc61404271 | |||
| 3c519629d2 | |||
| 0f07a2d2a3 | |||
| d2c3edea2f | |||
| 41929be34c | |||
| adab165274 | |||
| 33d89c66c0 | |||
| e946b22a42 | |||
| cf8b66340a | |||
| a414f03a59 | |||
| 331187e9b5 | |||
| 281c7c9a19 | |||
| 3a3e3ed202 | |||
| d090f67bff | |||
| 03b2f1267d | |||
| 73b561ed33 | |||
| 8a9deb8ebc | |||
| df77caf64a | |||
| b4344e9d66 | |||
| 124543d658 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
# Suivi – VPN on-demand studioE5 (client A)
|
||||
|
||||
## ✅ Ce qui fonctionne
|
||||
|
||||
1. **Agent standalone (mode console / systray)**
|
||||
- Exécutable : `agent/studioE5-agent`
|
||||
- Config lu depuis `<data-dir>/studioE5-config.json`
|
||||
- Mode console : `-no-tray`
|
||||
|
||||
2. **VPN on-demand dans l'agent**
|
||||
- L’agent ne démarre plus Tailscale au boot.
|
||||
- Le VPN se lance automatiquement à la création/démarrage d’une instance, ou sur commande serveur.
|
||||
- Implémentation basée sur les binaires `tailscaled` + `tailscale up` (pas `tsnet`, car `tsnet` ne loguait pas automatiquement avec une authkey sur un state vierge).
|
||||
|
||||
3. **Commandes serveur → agent**
|
||||
- Endpoint de test : `POST /api/internal/send-to-node`
|
||||
- Actions supportées : `start_vpn`, `stop_vpn`, `start`, `stop`, `reset`, `delete`.
|
||||
|
||||
4. **Resolver/serveur dans le tailnet studioe5**
|
||||
- Service `resolver-vpn` (conteneur Tailscale) partage le netns du `resolver`.
|
||||
- Le resolver peut joindre les IPs Tailscale des nodes (`ping 100.64.0.x` OK).
|
||||
|
||||
5. **Instance WordPress démarrée avec succès**
|
||||
- Le resolver a renvoyé une 302 WordPress via `http://resolver:2020/`.
|
||||
|
||||
6. **Activation zéro-config de l’agent (modèle commercialisable)**
|
||||
- L’agent démarre sans `headscale_url` ni `headscale_auth_key`.
|
||||
- L’utilisateur entre seulement un code d’activation.
|
||||
- Le serveur envoie la config Headscale, l’agent la sauvegarde et démarre le VPN automatiquement.
|
||||
|
||||
## ✅ Blocage levé
|
||||
|
||||
**Rate limit Let’s Encrypt pour `edudeploy.com` est levé.**
|
||||
Le 2026-06-23 vers 09:35 UTC, Caddy a pu obtenir un certificat Let’s Encrypt pour `test-wp-001.studioe5.edudeploy.com` :
|
||||
```
|
||||
tls.obtain: certificate obtained successfully identifier=test-wp-001.studioe5.edudeploy.com issuer=acme-v02.api.letsencrypt.org-directory
|
||||
```
|
||||
|
||||
Le flux complet HTTPS public est désormais validé :
|
||||
```bash
|
||||
curl -sS -I -L https://test-wp-001.studioe5.edudeploy.com/
|
||||
# => HTTP/2 302 -> HTTP/2 200 (WordPress install.php)
|
||||
```
|
||||
|
||||
Le DNS wildcard `*.studioe5.edudeploy.com` est en place. Caddy utilise toujours `tls { on_demand }` et émet un certificat par sous-domaine d’instance.
|
||||
|
||||
## 🎯 Validation du flux HTTPS public
|
||||
|
||||
Le 2026-06-23 09:39 UTC, le flux complet a été validé :
|
||||
|
||||
```text
|
||||
Client (HTTPS) → Caddy (:443) → resolver (:2020) → Tailnet (100.64.0.8) → agent → WordPress (:8001)
|
||||
```
|
||||
|
||||
Résultat :
|
||||
```bash
|
||||
$ curl -sS -I -L https://test-wp-001.studioe5.edudeploy.com/
|
||||
HTTP/2 302
|
||||
location: https://test-wp-001.studioe5.edudeploy.com/wp-admin/install.php
|
||||
...
|
||||
HTTP/2 200
|
||||
```
|
||||
|
||||
- Certificat Let’s Encrypt obtenu automatiquement par Caddy (`tls { on_demand }`).
|
||||
- Le resolver réécrit les en-têtes `Location` et le contenu HTML pour passer de `http://` à `https://`.
|
||||
|
||||
## 📝 Template WordPress prêt à l’emploi
|
||||
|
||||
Un nouveau template `wordpress-ready-wordpress-latest` a été créé et validé le 2026-06-26. Il fournit un WordPress déjà initialisé en français, prêt à l’usage en classe ou en examen.
|
||||
|
||||
### Contenu du template
|
||||
|
||||
| Élément | Valeur / État |
|
||||
|---|---|
|
||||
| Langue | **Français** (`fr_FR`) |
|
||||
| Titre du site | **Mon site wordpress** |
|
||||
| Compte administrateur | **admin / admin** |
|
||||
| Thème actif | **Astra** |
|
||||
| Spectra | installé et **actif** |
|
||||
| Yoast SEO | installé mais **inactif** |
|
||||
| Mises à jour automatiques | **désactivées** (core, plugins, thèmes) |
|
||||
| DNS conteneur | `8.8.8.8` + `1.1.1.1` pour permettre l’accès à `api.wordpress.org` |
|
||||
|
||||
### Architecture technique
|
||||
|
||||
- Le modèle `Template` de Prisma dispose d’un nouveau champ `initScript` (`TEXT?`).
|
||||
- Le seed génère le template avec :
|
||||
- une section `dns` dans le service `app` du `docker-compose.yml` ;
|
||||
- un service sidecar `wp-init` (image `wordpress:cli`) exécutant le script d’initialisation.
|
||||
- L’agent écrit le script `wp-init.sh` dans le dossier de l’instance au démarrage.
|
||||
- Le conteneur `wp-init` attend que WordPress soit prêt, puis exécute WP-CLI en tant que `www-data`.
|
||||
- Un fichier flag `.studioe5-init-done` évite de réinitialiser l’instance à chaque redémarrage.
|
||||
|
||||
### Fichiers modifiés / ajoutés
|
||||
|
||||
- `server/prisma/schema.prisma` – champ `initScript` sur `Template`.
|
||||
- `server/prisma/seed.ts` – génération du template `wordpress-ready-wordpress-latest`.
|
||||
- `server/templates/wordpress-ready/wp-init.sh` – script d’initialisation WP-CLI.
|
||||
- `server/app/api/instances/route.ts` – envoi de `initScript` à l’agent avec remplacement des placeholders.
|
||||
- `agent/websocket.go` – réception et transmission de `InitScript`.
|
||||
- `agent/docker.go` – écriture du script dans le dossier instance (`writeInitScript`).
|
||||
|
||||
### Validation
|
||||
|
||||
Instance de test créée via l’API (`cmqv03a6v0001vg8zrpe8zqfy`) :
|
||||
|
||||
```bash
|
||||
$ curl -sS -I -L https://cmqv03a6v0001vg8zrpe8zqfy.studioe5.edudeploy.com/
|
||||
HTTP/2 200
|
||||
```
|
||||
|
||||
- Page d’accueil en français, titre **« Mon site wordpress »**.
|
||||
- Connexion admin `/wp-login.php` avec **admin / admin** fonctionnelle.
|
||||
- Tableau de bord en français.
|
||||
- Plugins : Spectra actif, Yoast SEO inactif.
|
||||
- `wp-config.php` contient les constantes de désactivation des mises à jour automatiques.
|
||||
|
||||
Les instances de test ont été nettoyées après validation.
|
||||
|
||||
### Template versionné WordPress 7.0.0
|
||||
|
||||
Un second template `wordpress-ready-wordpress-7.0.0-php8.3` a été ajouté pour figer WordPress sur la version **7.0.0** (PHP 8.3) au lieu de `latest`. Cela garantit que tous les postes déploient exactement la même version, sans dépendre du cache local de `wordpress:latest`.
|
||||
|
||||
| Template | Image Docker |
|
||||
|---|---|
|
||||
| `wordpress-ready-wordpress-latest` | `wordpress:latest` |
|
||||
| `wordpress-ready-wordpress-7.0.0-php8.3` | `wordpress:7.0.0-php8.3` |
|
||||
|
||||
## 📁 Fichiers modifiés (non exhaustif)
|
||||
|
||||
- `agent/tailscale.go` – lancement `tailscaled` + `tailscale up`, gestion start/stop/status.
|
||||
- `agent/websocket.go` – handlers `start_vpn` / `stop_vpn`, `ensureTailscale()`.
|
||||
- `agent/docker.go` – remplacement des placeholders `{PORT}` et `{INSTANCE_ID}` dans les compose.
|
||||
- `docker-compose.yml` – ajout du sidecar `resolver-vpn`, suppression des `cap_add`/`ip route` obsolètes sur `server`/`resolver`.
|
||||
- `Caddyfile` – configuration on-demand TLS pour les instances.
|
||||
- `.env` – clé pré-auth Headscale mise à jour (clé réutilisable).
|
||||
|
||||
## 🧪 Tests / environnement de test actuel
|
||||
|
||||
Agent de test lancé en arrière-plan :
|
||||
- data-dir : `/tmp/studioe5-test-clienta`
|
||||
- node-id : `vps-8fc665eb`
|
||||
- tailnet IP actuelle : `100.64.0.8`
|
||||
- PID : voir `/tmp/studioe5-test-clienta/agent.pid` (relancé le 2026-06-26 11:53 UTC avec l’agent v0.3.5 corrigé)
|
||||
|
||||
Instance de test créée :
|
||||
- ID : `test-wp-001`
|
||||
- Node : `vps-8fc665eb`
|
||||
- Port : `8001`
|
||||
- Template : `wordpress-wordpress-latest`
|
||||
- État : WordPress répond sur `http://127.0.0.1:8001` **et en HTTPS public sur `https://test-wp-001.studioe5.edudeploy.com/`**.
|
||||
|
||||
## 🪟 Fix agent Windows v0.3.1
|
||||
|
||||
Problème rencontré sur le PC de test (`OMEGA-GAMER-dc166b1a`) :
|
||||
- Le nœud apparaissait `online` dans le dashboard mais sans IP Tailscale.
|
||||
- `tailscale.exe ip -4` retournait une erreur de connexion au socket local.
|
||||
|
||||
Cause racine :
|
||||
- L’agent lançait `tailscaled` avec `--socket=<fichier>.sock`, mais **Tailscale sur Windows utilise des named pipes** (`\\.\pipe\...`), pas des sockets Unix.
|
||||
- De plus, les commandes `podman`/`docker`/`tailscale` ouvraient une fenêtre console à chaque exécution.
|
||||
|
||||
Corrections apportées (`agent/tailscale.go`, `agent/docker.go`, `agent/instance.go`, `agent/systray.go`, `agent/ui.go`, `agent/main.go`) :
|
||||
- Sur Windows, utilisation de la named pipe `\\.\pipe\studioe5-tailscaled`.
|
||||
- Application de `hideWindow` à tous les processus enfants (Tailscale, Podman, Docker, ouverture navigateur, redémarrage agent).
|
||||
- Redirection des logs agent vers `<data-dir>/agent.log` et des logs `tailscaled` vers `<data-dir>/tailscale/tailscaled.log`.
|
||||
- Suppression de `--operator=root` sur Windows (non pertinent).
|
||||
- Ajout de `--unattended` au `tailscale up` sur Windows pour que le daemon reste connecté après la déconnexion du client CLI.
|
||||
- Correction du chemin `dataDir` passé à `startTailscale` (évitait un double dossier `tailscale/tailscale`).
|
||||
|
||||
Validation manuelle sur Windows :
|
||||
```powershell
|
||||
.\tailscaled.exe --state="C:\...\data\tailscale.state" --socket="\\.\pipe\studioe5-tailscaled" --tun=userspace-networking
|
||||
.\tailscale.exe --socket="\\.\pipe\studioe5-tailscaled" status # => Logged out (NeedsLogin)
|
||||
```
|
||||
|
||||
## 🪟 Agent v0.3.5 – forwarding entrant Windows + UI locale + cycle de vie
|
||||
|
||||
### Problème
|
||||
|
||||
Sur Windows, Tailscale en `userspace-networking` ne forwarde pas automatiquement les connexions entrantes du Tailnet vers `localhost`. Résultat : les URLs publiques retournaient une erreur 502/timeout, bien que l’agent soit `online`.
|
||||
|
||||
Logs caractéristiques :
|
||||
```text
|
||||
client -> backend close connection: close tcp 100.64.0.12:8080->100.64.0.11:xxxxx: endpoint not connected
|
||||
```
|
||||
|
||||
### Solution : `tailscale serve` automatique
|
||||
|
||||
L’agent configure automatiquement un proxy TCP pour chaque instance démarrée :
|
||||
```powershell
|
||||
tailscale serve --bg --tcp=<port> tcp://localhost:<port>
|
||||
```
|
||||
|
||||
| Action agent | Commande Tailscale |
|
||||
|--------------|--------------------|
|
||||
| Démarrage d’instance | `serve --bg --tcp=<port> tcp://localhost:<port>` |
|
||||
| Arrêt d’instance | `serve --bg --tcp=<port> off` |
|
||||
| Suppression d’instance | `serve --bg --tcp=<port> off` |
|
||||
| Redémarrage de l’agent | reconfiguration pour les instances déjà `running` |
|
||||
|
||||
Fichiers modifiés : `agent/tailscale.go`, `agent/websocket.go`, `agent/main.go`, `agent/ui.go`.
|
||||
|
||||
### UI locale modernisée
|
||||
|
||||
- Tableau de bord avec indicateurs de service.
|
||||
- Liste des applications avec badges de statut.
|
||||
- Boutons d’action par instance : **Démarrer**, **Arrêter**, **Redémarrer**, **Supprimer**.
|
||||
- Panneau de logs et diagnostic intégré.
|
||||
- Panneau de configuration (URL serveur, Headscale, node ID).
|
||||
|
||||
### Cycle de vie des instances
|
||||
|
||||
- **Arrêter** → `docker compose stop` (volumes conservés).
|
||||
- **Démarrer** → `docker compose start` (ou `up -d` la première fois).
|
||||
- **Redémarrer** → `docker compose down -v` + recréation (données remises à zéro).
|
||||
- **Supprimer** → `docker compose down -v` + suppression des fichiers.
|
||||
- À la fermeture de l’agent, les instances en cours sont arrêtées proprement (`stop`) et le serveur est notifié (`instance_stopped`).
|
||||
|
||||
### Démarrage du VPN après activation
|
||||
|
||||
L’agent redémarre `tailscaled` automatiquement au lancement, même si la clé pré-auth a déjà été utilisée. Il se base sur l’état persistant `tailscaled.state` (`tailscale up` sans `--authkey`).
|
||||
|
||||
### Téléchargement
|
||||
|
||||
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5-windows.zip`
|
||||
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5.exe`
|
||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5`
|
||||
|
||||
## 🪟 Agent v0.3.6 – recover() dans les goroutines de démarrage d’instance
|
||||
|
||||
### Problème
|
||||
|
||||
Lors de la création d’une instance depuis le dashboard vers certains agents (notamment Windows), l’agent s’arrêtait brutalement. Le `recover()` présent dans `handleMessage` ne capturait pas le panic car celui-ci survenait dans les goroutines lancées par `go handleStartInstance(...)`.
|
||||
|
||||
### Corrections apportées
|
||||
|
||||
- Ajout d’un `defer recover()` dans `handleStartInstance` ; en cas de panic, l’instance passe en statut `error` et un message `instance_error` est envoyé au serveur.
|
||||
- Ajout d’un `defer recover()` dans toutes les goroutines critiques du WebSocket :
|
||||
- `start_vpn`
|
||||
- `stop_vpn`
|
||||
- `start`
|
||||
- `reset`
|
||||
- `startTailscaleAndReport`
|
||||
- cleanup au shutdown
|
||||
- Ajout de logs de traçage au début de `handleStartInstance` (`instance`, `type`, `port`, `dataDir`, `initScriptLen`).
|
||||
|
||||
### Téléchargement
|
||||
|
||||
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6-windows.zip`
|
||||
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6.exe`
|
||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6`
|
||||
|
||||
### Redeploiement
|
||||
|
||||
- Agent rebuildé en v0.3.6 pour Windows et Linux.
|
||||
- Binaires versionnés copiés dans `server/public/`.
|
||||
- Page `/dashboard/download` mise à jour vers la v0.3.6.
|
||||
- Serveur rebuildé et redémarré.
|
||||
|
||||
## 🪟 Agent v0.3.7 – recover() dans les notifications UI
|
||||
|
||||
### Problème
|
||||
|
||||
L’agent continuait de s’arrêter brutalement lors de la création d’une instance depuis le dashboard. Le crash survenait juste après les logs `Start instance ...` et `notifyUI: broadcasting to ...`, sans laisser de trace de panic. Cela pointait vers une panique dans les goroutines de notification UI ou dans l’écriture des logs vers les clients UI locaux.
|
||||
|
||||
### Corrections apportées
|
||||
|
||||
- Ajout d’un `defer recover()` dans `notifyUI` pour chaque goroutine de notification.
|
||||
- Ajout d’un `defer recover()` dans `sendUILog` (logs diffusés aux clients UI).
|
||||
- Ajout d’un `defer recover()` dans `broadcastUI` (messages diffusés aux clients UI).
|
||||
|
||||
### Téléchargement
|
||||
|
||||
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7-windows.zip`
|
||||
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7.exe`
|
||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7`
|
||||
|
||||
## 🪟 Agent v0.3.8 – DNS automatique pour Podman machine (Windows/macOS)
|
||||
|
||||
### Problème
|
||||
|
||||
Après correction du crash, l’agent Windows avec Podman échouait au `docker compose up` avec :
|
||||
```text
|
||||
lookup registry-1.docker.io: Temporary failure in name resolution
|
||||
```
|
||||
La VM Podman machine n’avait pas de DNS fonctionnel, ce qui empêchait le téléchargement des images Docker. Le DNS des conteneurs (`dns: 8.8.8.8` dans le compose) résout le problème à l’intérieur des conteneurs, mais pas pour le pull d’images par Podman machine.
|
||||
|
||||
### Solution
|
||||
|
||||
L’agent configure automatiquement le DNS des machines Podman en cours d’exécution au démarrage :
|
||||
- Détection de Podman sur Windows/macOS.
|
||||
- Liste des machines Podman (`podman machine list --format json`).
|
||||
- Pour chaque machine `running`, exécution de :
|
||||
```bash
|
||||
podman machine ssh <name> sudo sh -c 'echo nameserver 8.8.8.8 > /etc/resolv.conf && echo nameserver 1.1.1.1 >> /etc/resolv.conf'
|
||||
```
|
||||
|
||||
Fichier ajouté : `agent/podman.go`. Appel depuis `agent/main.go` au démarrage.
|
||||
|
||||
### Téléchargement
|
||||
|
||||
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.8-windows.zip`
|
||||
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.8.exe`
|
||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.8`
|
||||
|
||||
## 🐛 Fix synchronisation agent / dashboard
|
||||
|
||||
### Problème
|
||||
|
||||
Le statut affiché dans le dashboard pouvait diverger de l’état réel de l’agent :
|
||||
- Après un **Arrêter** lancé depuis le dashboard, l’instance restait affichée comme elle l’était avant, ou disparaissait avec perte des données.
|
||||
- Après une **Suppression**, l’instance n’était pas retirée de la liste.
|
||||
|
||||
### Causes racines
|
||||
|
||||
1. **Action `stop` du dashboard envoyée comme `delete` à l’agent** (`server/app/api/instances/route.ts`).
|
||||
L’agent exécutait alors `docker compose down -v` + suppression des fichiers, c’est-à-dire une suppression réelle, tout en marquant l’instance `stopped` en base.
|
||||
2. **L’agent ne confirmait pas les actions serveur** (`agent/websocket.go`).
|
||||
Les handlers `stop` et `delete` ne renvoyaient jamais les messages `instance_stopped` / `instance_deleted` au serveur ; seule l’UI locale le faisait.
|
||||
3. **Le handler `stop` de l’agent utilisait `dockerComposeDown`** au lieu de `dockerComposeStop`, ne respectant pas le cycle de vie documenté (arrêt = conteneurs et volumes conservés).
|
||||
|
||||
### Corrections apportées
|
||||
|
||||
| Fichier | Changement |
|
||||
|---------|------------|
|
||||
| `server/app/api/instances/route.ts` | L’action dashboard `stop` envoie désormais `action: "stop"` à l’agent (et non plus `"delete"`). |
|
||||
| `agent/websocket.go` | Le cas `stop` utilise `dockerComposeStop`, puis envoie `instance_stopped` au serveur. Le cas `delete` envoie `instance_deleted` au serveur. |
|
||||
| `server/lib/websocket.ts` | Utilisation de `updateMany`/`deleteMany` pour ignorer silencieusement les messages d’instances déjà absentes/supprimées (évite les erreurs Prisma en double suppression). |
|
||||
|
||||
### Résultat
|
||||
|
||||
Le dashboard reflète désormais l’état réel après une action serveur-initiée, dès le rechargement de la page. Le cycle de vie respecte la sémantique attendue :
|
||||
- **Arrêter** : `docker compose stop` → statut `stopped`.
|
||||
- **Démarrer** : `docker compose up -d` → statut `running`.
|
||||
- **Redémarrer** : `docker compose down -v` + recréation.
|
||||
- **Supprimer** : `docker compose down -v` + suppression fichiers.
|
||||
|
||||
### Redeploiement effectué le 2026-06-26
|
||||
|
||||
- **Agent rebuildé** en v0.3.5 (`agent/studioE5-agent`, `.exe`, `.zip` et `server/public/` mis à jour).
|
||||
- **Serveur rebuildé et redémarré** (`docker compose up -d --build server`) pour intégrer les corrections TypeScript.
|
||||
- **Page `/dashboard/download` mise à jour** : passage à la version 0.3.5 et ajout des liens Windows (.exe, .zip) et Linux.
|
||||
- **Corrections défensives agent** après signalement d’arrêt brutal lors d’actions dashboard :
|
||||
- `sendMessage` exécuté de manière asynchrone (`go`) dans les handlers `stop`, `delete`, `stop_vpn` et cleanup, pour ne pas bloquer la boucle de lecture WebSocket.
|
||||
- Ajout d’un `recover` dans `handleMessage` pour capturer d’éventuels panics sans tuer l’agent.
|
||||
- Correction du cleanup `main.go` : modification de `inst[id].Status` (et non de la copie locale `info`).
|
||||
- **Agent de test Linux relancé** (PID dans `/tmp/studioe5-test-clienta/agent.pid`).
|
||||
- **Agents clients** : il faut redémarrer l’agent sur chaque poste, ou télécharger à nouveau le binaire v0.3.5 depuis le dashboard pour Windows.
|
||||
|
||||
## 🛠️ Commandes utiles pour reprendre
|
||||
|
||||
### Voir l’agent de test
|
||||
```bash
|
||||
pgrep -a studioe5-agent
|
||||
```
|
||||
|
||||
### Relancer l’agent de test (si besoin)
|
||||
```bash
|
||||
mkdir -p /tmp/studioe5-test-clienta
|
||||
cat > /tmp/studioe5-test-clienta/studioE5-config.json <<EOF
|
||||
{
|
||||
"server": "wss://studioe5.edudeploy.com/api/websocket",
|
||||
"headscale_url": "https://headscale.studioe5.edudeploy.com",
|
||||
"headscale_auth_key": "$(grep HEADSCALE_AUTH_KEY /opt/studioe5-client-a/.env | cut -d= -f2)",
|
||||
"node_id": "vps-8fc665eb",
|
||||
"data_dir": "/tmp/studioe5-test-clienta"
|
||||
}
|
||||
EOF
|
||||
cd /opt/studioe5-client-a/agent
|
||||
./studioE5-agent -no-tray -data-dir /tmp/studioe5-test-clienta
|
||||
```
|
||||
|
||||
### Démarrer le VPN manuellement
|
||||
```bash
|
||||
curl -sS -X POST https://studioe5.edudeploy.com/api/internal/send-to-node \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"nodeId":"vps-8fc665eb","message":{"action":"start_vpn"}}'
|
||||
```
|
||||
|
||||
### Voir les nodes Headscale
|
||||
```bash
|
||||
cd /opt/studioe5-client-a
|
||||
docker compose exec -T headscale headscale nodes list studioe5
|
||||
```
|
||||
|
||||
### Tester le resolver (depuis Caddy)
|
||||
```bash
|
||||
cd /opt/studioe5-client-a
|
||||
docker exec studioe5-caddy curl -sS -I -H "Host: test-wp-001.studioe5.edudeploy.com" http://resolver:2020/
|
||||
```
|
||||
|
||||
### Tester en HTTPS public (dès que la limite sera levée)
|
||||
```bash
|
||||
curl -sS -I -L https://test-wp-001.studioe5.edudeploy.com/
|
||||
```
|
||||
|
||||
## 🌐 Flux complet testé via l’API web
|
||||
|
||||
Test réalisé le 2026-06-23 en utilisant le compte superadmin :
|
||||
|
||||
1. **Authentification NextAuth** sur `/api/auth/callback/credentials`.
|
||||
2. **Création d’instance** via `POST /api/instances` :
|
||||
```json
|
||||
{
|
||||
"nodeId": "vps-8fc665eb",
|
||||
"templateId": "wordpress-wordpress-latest",
|
||||
"port": 8002
|
||||
}
|
||||
```
|
||||
→ Instance créée : `cmqqgrur20001lw67t2bdgzkg`.
|
||||
3. Le serveur a automatiquement envoyé l’action `start` au node via WebSocket.
|
||||
4. L’agent a démarré le VPN (si besoin), écrit le compose et a lancé les conteneurs WordPress.
|
||||
5. Caddy a obtenu un certificat Let’s Encrypt pour `cmqqgrur20001lw67t2bdgzkg.studioe5.edudeploy.com`.
|
||||
6. **Validation HTTPS** :
|
||||
```bash
|
||||
curl -sS -I -L https://cmqqgrur20001lw67t2bdgzkg.studioe5.edudeploy.com/
|
||||
# => HTTP/2 302 -> HTTP/2 200 (WordPress install.php)
|
||||
```
|
||||
|
||||
Le flux `UI → API → WebSocket → agent → Docker → VPN → Caddy → HTTPS public` est fonctionnel.
|
||||
|
||||
## 💻 Téléchargement de l’agent
|
||||
|
||||
L’agent est servi par Caddy depuis le dossier `agent/` monté dans le conteneur Caddy (`./agent:/usr/share/caddy/agent`).
|
||||
|
||||
### Binaires disponibles
|
||||
|
||||
- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.10-windows.zip`
|
||||
- Contient `studioE5-agent.exe` + `tailscale-bin/windows/` (`tailscale.exe`, `tailscaled.exe`, `wintun.dll`) + `README-Windows.txt`.
|
||||
- **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.10.exe`
|
||||
- Nécessite d’avoir installé Tailscale Windows séparément ou d’avoir les binaires dans `tailscale-bin/windows/`.
|
||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.10`
|
||||
|
||||
### Builder / préparer les binaires
|
||||
|
||||
```bash
|
||||
cd /opt/studioe5-client-a/agent
|
||||
|
||||
# 1. Télécharger les binaires Tailscale Windows (nécessite msitools)
|
||||
./download-tailscale-bins.sh 1.98.4
|
||||
|
||||
# 2. Builder l’agent pour Windows et Linux (macOS nécessite CGO)
|
||||
./build.sh
|
||||
```
|
||||
|
||||
Le `build.sh` génère automatiquement `studioE5-agent-v0.3.10-windows.zip` et copie les binaires versionnés dans `server/public/`.
|
||||
|
||||
### Flow d’activation zéro-config (modèle commercialisable)
|
||||
|
||||
L’élève/employé n’a **aucune configuration technique** à saisir :
|
||||
|
||||
1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.10-windows.zip`).
|
||||
2. **Extraire** et **lancer** `studioE5-agent.exe`.
|
||||
3. **Entrer le code d’activation** à 6 caractères fourni par l’établissement (affiché dans l’UI locale `http://localhost:7070`).
|
||||
4. L’agent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** :
|
||||
- l’identité de l’élève (`studentName`)
|
||||
- l’URL Headscale
|
||||
- la clé pré-auth Headscale
|
||||
5. L’agent sauvegarde ces informations localement et **démarre automatiquement le VPN**.
|
||||
6. L’agent est alors visible dans le dashboard et peut recevoir des instances.
|
||||
|
||||
### Configuration manuelle (mode debug / admin)
|
||||
|
||||
Si besoin, on peut toujours forcer une config via `data/studioE5-config.json` :
|
||||
|
||||
```json
|
||||
{
|
||||
"server": "wss://studioe5.edudeploy.com/api/websocket",
|
||||
"headscale_url": "https://headscale.studioe5.edudeploy.com",
|
||||
"headscale_auth_key": "CLE_PREAUTH_ICI",
|
||||
"node_id": "IDENTIFIANT_DU_POSTE",
|
||||
"data_dir": "C:\\studioE5-agent\\data"
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ `headscale_auth_key` doit être une clé pré-auth réutilisable valide pour le tailnet studioe5. Ne jamais commiter cette clé.
|
||||
|
||||
Lancement :
|
||||
```powershell
|
||||
.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data
|
||||
```
|
||||
|
||||
## 🔒 Durcissement du code d’activation
|
||||
|
||||
### Génération
|
||||
|
||||
- Les codes sont générés avec `crypto.randomBytes` (au lieu de `Math.random`).
|
||||
- Longueur conservée à 6 caractères, alphabet sans ambiguïté (`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`).
|
||||
- Un champ `activationCodeExpiresAt` a été ajouté au modèle `Student` ; les codes expirent après **60 minutes**.
|
||||
|
||||
### Rate-limiting
|
||||
|
||||
- Maximum de **5 tentatives d’activation par code** sur une fenêtre de **15 minutes**.
|
||||
- Maximum de **5 tentatives par `nodeId`** sur la même fenêtre.
|
||||
- Au-delà, le serveur répond `activation_failed` avec `Too many attempts`.
|
||||
|
||||
### Cycle de vie
|
||||
|
||||
- Le code est **invalide après une activation réussie** (`activationCode` et `activationCodeExpiresAt` mis à `null`).
|
||||
- Un code expiré renvoie `Code expired`.
|
||||
- Un code déjà utilisé renvoie `Invalid code`.
|
||||
|
||||
### Tests validés
|
||||
|
||||
- Activation valide → `activated` + token node reçu.
|
||||
- Code expiré → `Code expired`.
|
||||
- Code déjà utilisé → `Invalid code`.
|
||||
- 5+ tentatives invalides → `Too many attempts`.
|
||||
|
||||
## 🔒 ACL Headscale (isolation du tailnet)
|
||||
|
||||
### Objectif
|
||||
|
||||
Par défaut, tous les nœuds du tailnet peuvent communiquer entre eux. Les ACL restreignent la connectivité au strict nécessaire :
|
||||
- les agents élèves ne peuvent pas se parler entre eux ;
|
||||
- le resolver peut atteindre les agents sur leurs ports d’instance ;
|
||||
- les agents peuvent joindre le resolver sur son port HTTP interne.
|
||||
|
||||
### Mise en œuvre
|
||||
|
||||
- Fichier de politique : `headscale/acl_policy.hujson`.
|
||||
- `headscale/config.yaml` pointe vers ce fichier via `policy.path`.
|
||||
- Le resolver a été déplacé dans un utilisateur Headscale dédié `resolver` (clé `HEADSCALE_RESOLVER_AUTH_KEY`).
|
||||
- Les agents utilisent l’utilisateur `studioe5` et sont tagués `tag:student-agent`.
|
||||
- Les nouveaux agents recevront automatiquement le tag via la nouvelle clé pré-auth `HEADSCALE_AUTH_KEY` (créée avec `--tags tag:student-agent`).
|
||||
|
||||
### Contenu de la politique
|
||||
|
||||
```json
|
||||
{
|
||||
"groups": {
|
||||
"group:agents": ["studioe5@studioe5.local"],
|
||||
"group:resolvers": ["resolver@studioe5.local"]
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:student-agent": ["studioe5@studioe5.local"],
|
||||
"tag:resolver": ["resolver@studioe5.local"]
|
||||
},
|
||||
"acls": [
|
||||
{ "action": "accept", "src": ["tag:resolver"], "dst": ["tag:student-agent:*"] },
|
||||
{ "action": "accept", "src": ["tag:student-agent"], "dst": ["tag:resolver:2020"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Tests validés
|
||||
|
||||
| Test | Résultat |
|
||||
|------|----------|
|
||||
| `resolver` ping agent | ✅ OK |
|
||||
| Agent → agent (port instance) | ❌ bloqué (timeout) |
|
||||
| Agent → resolver:2020 | ✅ OK |
|
||||
| Flux HTTPS public | ✅ HTTP 200 |
|
||||
|
||||
## 🔒 Authentification du canal serveur → agent
|
||||
|
||||
### Token d’authentification par nœud
|
||||
|
||||
- Le modèle `Node` dispose d’un champ `token` unique.
|
||||
- L’agent envoie son token dans l’en-tête `Authorization: Bearer <token>` lors de la connexion WebSocket.
|
||||
- Le serveur rejette toute connexion/register dont le token ne correspond pas au `nodeId` (fermeture `1008`).
|
||||
- Lors de l’activation, le serveur génère un token aléatoire (32 octets hex) et le renvoie dans le message `activated` ; l’agent le sauvegarde dans `<data-dir>/node.token` (permissions `0600`).
|
||||
- Pour les nœuds existants sans token, le serveur en génère un à la première connexion et l’envoie via `set_token`.
|
||||
|
||||
### Endpoint `/api/internal/send-to-node`
|
||||
|
||||
- Protégé par la variable d’environnement `INTERNAL_API_KEY`.
|
||||
- Requiert l’en-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
|
||||
- Appel sans clé → `401 Unauthorized`.
|
||||
|
||||
### Routes API métier
|
||||
|
||||
- Les routes de gestion des instances (`/api/instances`) requièrent une session NextAuth valide.
|
||||
- Un administrateur ne peut agir que sur les ressources de son établissement ; le `superadmin` peut tout voir/tout faire.
|
||||
|
||||
### Endpoint `/api/resolve`
|
||||
|
||||
- Protégé par la même clé `INTERNAL_API_KEY`.
|
||||
- Requiert l’en-tête `Authorization: Bearer <INTERNAL_API_KEY>`.
|
||||
- Le resolver (`resolver:2020`) ne l’utilise pas ; il interroge directement PostgreSQL. Cette route est donc réservée aux outils/scripts internes authentifiés.
|
||||
|
||||
### Exemples de commandes avec la clé interne
|
||||
|
||||
```bash
|
||||
KEY=$(grep INTERNAL_API_KEY /opt/studioe5-client-a/.env | cut -d= -f2)
|
||||
|
||||
curl -sS -X POST https://studioe5.edudeploy.com/api/internal/send-to-node \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $KEY" \
|
||||
-d '{"nodeId":"vps-8fc665eb","message":{"action":"start_vpn"}}'
|
||||
|
||||
curl -sS -H "Authorization: Bearer $KEY" \
|
||||
"https://studioe5.edudeploy.com/api/resolve?subdomain=test-wp-001"
|
||||
```
|
||||
|
||||
## 🔒 Clés pré-auth Headscale éphémères
|
||||
|
||||
### Principe
|
||||
|
||||
À l’activation zero-config, le serveur génère désormais une **clé pré-auth unique et à usage unique** pour chaque agent, au lieu d’envoyer la clé réutilisable `HEADSCALE_AUTH_KEY`.
|
||||
|
||||
Avantages :
|
||||
- une clé compromise ne permet pas d’enregistrer d’autres nœuds ;
|
||||
- traçabilité directe entre une activation et une clé Headscale ;
|
||||
- expiration courte (15 min) ;
|
||||
- la clé n’est **pas persistée** dans `studioE5-config.json` côté agent.
|
||||
|
||||
### Implémentation
|
||||
|
||||
| Composant | Changement |
|
||||
|-----------|------------|
|
||||
| `server/lib/headscale.ts` | Nouveau helper : `getHeadscaleUserId()` + `createEphemeralPreAuthKey()` appelant `POST /api/v1/preauthkey`. |
|
||||
| `server/lib/websocket.ts` | Sur `activate`, génère une clé éphémère taguée `tag:student-agent` pour l’utilisateur `studioe5`. Fallback sur `HEADSCALE_AUTH_KEY` si `HEADSCALE_API_KEY` n’est pas configurée. |
|
||||
| `agent/websocket.go` | La clé reçue est utilisée immédiatement mais **n’est plus écrite** dans `studioE5-config.json`. |
|
||||
| `agent/tailscale.go` | `tailscale up` fonctionne sans `--authkey` quand le state Tailscale existe déjà (reconnexion). |
|
||||
| `.env.example` / `docker-compose.yml` | Ajout de `HEADSCALE_API_KEY` pour le service `server`. |
|
||||
|
||||
### Configuration requise
|
||||
|
||||
Générer une clé API Headscale (depuis le conteneur ou la CLI) :
|
||||
|
||||
```bash
|
||||
cd /opt/studioe5-client-a
|
||||
# Clé valable 10 ans (87600h) pour éviter un renouvellement fréquent.
|
||||
docker compose exec headscale headscale apikeys create -e 87600h
|
||||
```
|
||||
|
||||
Puis l’ajouter dans `.env` :
|
||||
|
||||
```bash
|
||||
HEADSCALE_API_KEY=hskey-api-...
|
||||
```
|
||||
|
||||
> ⚠️ La clé API Headscale a une expiration par défaut de 90 jours. La clé de production a été créée avec une expiration de **10 ans** (`-e 87600h`). Les anciennes clés ont été révoquées.
|
||||
|
||||
### Rotation / renouvellement
|
||||
|
||||
Si la clé doit être changée :
|
||||
|
||||
1. Créer une nouvelle clé API :
|
||||
```bash
|
||||
docker compose exec headscale headscale apikeys create -e 87600h
|
||||
```
|
||||
2. Mettre à jour `.env` :
|
||||
```bash
|
||||
HEADSCALE_API_KEY=<nouvelle_clé>
|
||||
```
|
||||
3. Redémarrer le serveur :
|
||||
```bash
|
||||
docker compose up -d server
|
||||
```
|
||||
4. Révoquer l’ancienne clé :
|
||||
```bash
|
||||
docker compose exec headscale headscale apikeys expire --id <id_ancienne>
|
||||
```
|
||||
|
||||
### Déploiement effectué
|
||||
|
||||
- Clé API créée et ajoutée au `.env` de production.
|
||||
- Image serveur rebuildée et redémarrée.
|
||||
- Agents Linux/Windows rebuildés en v0.3.4 et copiés dans `server/public/`.
|
||||
|
||||
## 🔒 Sécurité — points restants à traiter
|
||||
|
||||
> Le certificat wildcard `*.studioe5.edudeploy.com` est désormais du ressort du **deployeur** (voir `docs/ONBOARDING_CLIENT.md`). Les points ci-dessous concernent l’application studioE5 proprement dite.
|
||||
|
||||
### Gestion et rotation des secrets
|
||||
|
||||
| Secret | Où ? | Action |
|
||||
|--------|------|--------|
|
||||
| `INTERNAL_API_KEY` | `.env` serveur | Prévoir une procédure de rotation régulière. |
|
||||
| `HEADSCALE_API_KEY` | `.env` serveur | Rotation tous les 10 ans max, stockage sécurisé. |
|
||||
| `NEXTAUTH_SECRET` | `.env` serveur | Génération robuste, rotation si suspicion de fuite. |
|
||||
| `DATABASE_URL` | `.env` serveur | Utilisateur DB dédié, mot de passe fort. |
|
||||
| `node.token` | `<data-dir>/node.token` | Vérifier permissions `0600` sur tous les OS. |
|
||||
|
||||
### Durcissement des conteneurs
|
||||
|
||||
- Limiter les `cap_add` au strict minimum.
|
||||
- Faire tourner les services avec un utilisateur non-root quand possible.
|
||||
- Mettre à jour régulièrement les images de base (Caddy, Node, Postgres, Headscale).
|
||||
- Scanner les images Docker pour les CVE.
|
||||
|
||||
### Mises à jour de sécurité
|
||||
|
||||
- Mise à jour des binaires Tailscale (Windows et Linux).
|
||||
- Mise à jour des images Docker (`server`, `resolver`, `caddy`, `postgres`, `headscale`).
|
||||
- Mise à jour de l’OS des VPS et des postes agents.
|
||||
- Mécanisme de mise à jour automatique ou notification de l’agent.
|
||||
|
||||
### Logs d’audit
|
||||
|
||||
- Tracer la création / suppression d’instances.
|
||||
- Tracer la génération et l’usage des codes d’activation.
|
||||
- Tracer les actions admin (connexion, création d’élève, démarrage/arrêt VPN).
|
||||
- Conservation et consultation des logs d’audit.
|
||||
|
||||
### Backups et reprise d’activité
|
||||
|
||||
- Backup régulier de la base PostgreSQL.
|
||||
- Backup du state Headscale.
|
||||
- Backup des states Tailscale côté agents.
|
||||
- Procédure de restauration documentée et testée.
|
||||
|
||||
### Sécurité du build et distribution de l’agent
|
||||
|
||||
- Vérifier l’intégrité des binaires Tailscale téléchargés (checksum / signature).
|
||||
- Signer l’exécutable Windows `studioE5-agent.exe` pour éviter les alertes Defender.
|
||||
- Fournir un hash SHA256 des archives d’agent.
|
||||
|
||||
### RGPD et données personnelles
|
||||
|
||||
- Justifier la conservation des noms/prénoms des élèves.
|
||||
- Gérer les droits d’accès, la suppression de compte et l’export de données.
|
||||
- Définir la durée de conservation des logs et historiques.
|
||||
|
||||
### Sécurité réseau complémentaire
|
||||
|
||||
- Restreindre l’accès à `/api/internal/send-to-node` par IP source si possible.
|
||||
- Vérifier l’exposition publique du dashboard Headscale et la durcir si nécessaire.
|
||||
- Évaluer si `headscale.studioe5.edudeploy.com` doit rester public.
|
||||
|
||||
### Rate limiting et quotas
|
||||
|
||||
- Rate-limiting global sur les routes publiques (`/api/auth/*`, activation, création d’instance).
|
||||
- Limitation du nombre d’instances par élève et par établissement.
|
||||
- Protection contre les abus sur la génération de codes d’activation.
|
||||
|
||||
### Tests de sécurité
|
||||
|
||||
- Tests d’intrusion légers (agent → agent, accès aux endpoints internes sans clé, accès à une instance d’un autre élève).
|
||||
- Tests automatisés du flux complet avant chaque release.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Installateur agent professionnel
|
||||
|
||||
### Objectif
|
||||
|
||||
Créer un package d’installation unique et professionnel par OS, incluant l’agent studioE5, Tailscale et **Podman CLI**, afin de ne plus dépendre d’installations manuelles préalables par l’utilisateur.
|
||||
|
||||
### Choix des outils
|
||||
|
||||
| OS | Outil | Format | Justification |
|
||||
|---|---|---|---|
|
||||
| **Windows** | **Inno Setup** | `.exe` | Gratuit, open source, très répandu, personnalisable, exécution de scripts PowerShell/silencieux. |
|
||||
| **macOS** | **`pkgbuild`** | `.pkg` | Outil natif Apple, gratuit, format professionnel pour la distribution macOS. |
|
||||
| **Linux** | **Script shell** (+ `.deb`/`.rpm` optionnels) | `.sh` | Universel, détecte le package manager, simple à maintenir. |
|
||||
|
||||
### Contenu du package par OS
|
||||
|
||||
- **Windows** (Inno Setup) :
|
||||
- Installer l’agent dans `C:\Program Files\studioE5-agent\`.
|
||||
- Extraire Tailscale dans `C:\Program Files\studioE5-agent\tailscale-bin\windows\`.
|
||||
- Installer Podman CLI via le MSI officiel en mode silencieux.
|
||||
- Exécuter `podman machine init` puis `podman machine start`.
|
||||
- Créer un raccourci de démarrage et/ou un service Windows.
|
||||
|
||||
- **macOS** (`pkgbuild`) :
|
||||
- Installer l’agent dans `/Applications/studioE5-agent/`.
|
||||
- Installer Podman CLI.
|
||||
- Exécuter `podman machine init` puis `podman machine start`.
|
||||
- Optionnellement créer un LaunchAgent pour démarrer l’agent au login.
|
||||
|
||||
- **Linux** (script shell) :
|
||||
- Détecter le package manager (`apt`, `dnf`, `pacman`, etc.).
|
||||
- Installer Podman et Podman Compose.
|
||||
- Copier l’agent dans `/opt/studioe5-agent/`.
|
||||
- Créer le service systemd `studioe5-agent.service`.
|
||||
- Activer et démarrer le service.
|
||||
|
||||
### Adaptations nécessaires dans l’agent
|
||||
|
||||
- Détecter si Podman est utilisé et si une machine est requise (Windows/macOS).
|
||||
- Vérifier au démarrage que la machine Podman est démarrée, et lancer `podman machine start` si besoin.
|
||||
- Gérer proprement l’arrêt de la machine à la fermeture de l’agent (optionnel).
|
||||
|
||||
### Mise à jour de l’agent vs dépendances système
|
||||
|
||||
- **L’agent peut se mettre à jour lui-même** (binaire + fichiers) depuis la v0.3.10.
|
||||
- **Podman / Docker / Tailscale restent gérés par l’installateur** : l’agent vérifie leur présence et alertera l’utilisateur si une dépendance est manquante ou trop ancienne, mais ne les met pas à jour automatiquement (droits élevés, risque de casser les machines Podman, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 📋 Prochaines étapes à faire
|
||||
|
||||
### ✅ Terminé
|
||||
|
||||
- [x] Rate limit Let’s Encrypt levé.
|
||||
- [x] Flux HTTPS public validé (`test-wp-001.studioe5.edudeploy.com`).
|
||||
- [x] Branche `feat/studioe5-vpn-ondemand` créée, commit `124543d`.
|
||||
- [x] **Pousser la branche** vers Gitea : la branche est synchronisée sur `origin/feat/studioe5-vpn-ondemand` (commit `cf8b663`).
|
||||
- [x] Flux complet UI → API → WebSocket → agent → Docker → VPN → Caddy validé.
|
||||
- [x] Packager les binaires Tailscale pour Windows (`studioE5-agent-v0.3.5-windows.zip`).
|
||||
- [x] **Sécurité – authentification du canal serveur → agent** (token par nœud, clé API interne, sessions NextAuth sur les routes API).
|
||||
- [x] **Sécurité – durcissement du code d’activation** (`crypto.randomBytes`, expiration 60 min, rate-limiting, invalidation après usage).
|
||||
- [x] **Sécurité – ACL Headscale** (isolation agent ↔ agent, resolver → agent autorisé).
|
||||
- [x] **Sécurité – clés pré-auth Headscale éphémères** (génération côté serveur via `HEADSCALE_API_KEY`, non persistées côté agent).
|
||||
- [x] **Agent v0.3.5 – forwarding entrant Windows** (`tailscale serve` automatique au démarrage de chaque instance).
|
||||
- [x] **Agent v0.3.5 – UI locale moderne** (dashboard, logs, progression, actions d’instance).
|
||||
- [x] **Agent v0.3.5 – cycle de vie des instances** (`stop`/`start` préservent les volumes, `reset`/`delete` effacent).
|
||||
- [x] **Agent v0.3.5 – cleanup au shutdown** (arrêt propre de Tailscale et des instances, notification serveur).
|
||||
- [x] **Synchronisation dashboard** (messages `instance_stopped` / `instance_deleted` traités côté serveur, et agent renvoyant correctement ces messages après un ordre serveur `stop`/`delete`).
|
||||
- [x] **Template WordPress prêt à l’emploi** (`wordpress-ready-wordpress-latest`) : WordPress en français, titre « Mon site wordpress », compte admin/admin, thème Astra, Spectra actif, Yoast SEO inactif, mises à jour automatiques désactivées, DNS `8.8.8.8`/`1.1.1.1` pour `api.wordpress.org`.
|
||||
- [x] **Nettoyer les instances/agent de test** (2026-06-27) : agent de test arrêté (`vps-8fc665eb`), `tailscaled` associé arrêté, data-dir `/tmp/studioe5-test-clienta` supprimé ; **13 instances de test supprimées de la base PostgreSQL** (`vps-8fc665eb` + `OMEGA-GAMER-60d7f87c`).
|
||||
- [x] **Nettoyer les anciens nodes/volumes Headscale de test** (2026-06-27) : nœuds `edubox`, `prof`, `invalid-*`, anciens `vps-8fc665eb`, anciens `studioe5-resolver` et `test-node-b` supprimés ; volume Docker anonyme orphelin supprimé.
|
||||
- [x] **Centralisation de la version agent** : fichier unique `agent/VERSION`, API `GET /api/agent/version`, dashboard et route `/api/download` alignés.
|
||||
- [x] **Agent v0.3.10 – synchronisation agent ↔ serveur au démarrage** : protocole `sync` / `sync_response`, suppression/lancement automatique des instances décalées pendant un offline.
|
||||
- [x] **Agent v0.3.10 – détails techniques dans l’UI locale** : version de l’agent, nodeId, version attendue par le serveur, notification de mise à jour.
|
||||
- [x] **Agent v0.3.10 – mise à jour automatique de l’agent** : détection de nouvelle version, téléchargement, remplacement du binaire via script helper et redémarrage.
|
||||
- [x] **Agent v0.3.10 – handlers asynchrones** : `start`, `stop`, `delete`, `reset` exécutés dans des goroutines pour ne plus bloquer la boucle WebSocket.
|
||||
- [x] **Agent v0.3.10 – nettoyage des dossiers instances orphelins au démarrage** : supprime les répertoires résiduels laissés par des `delete` incomplets (souvent `compose.log` verrouillé sous Windows).
|
||||
|
||||
### ⏳ Reste à faire
|
||||
|
||||
- [ ] **Certificat wildcard** : transféré au deployeur (`docs/ONBOARDING_CLIENT.md`). L’étude technique reste disponible ci-dessous pour référence.
|
||||
- [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, ACL, etc.).
|
||||
- [ ] **Installateur agent professionnel (Windows / macOS / Linux)** : créer un package d’installation unique incluant l’agent studioE5, Tailscale et **Podman CLI**. Voir la section « 🖥️ Installateur agent professionnel » ci-dessous pour le détail des outils (Inno Setup, pkgbuild, script shell) et du contenu par OS.
|
||||
- [ ] **Template WordPress prêt à l’emploi (usage examen/classe)** :
|
||||
- Forcer le DNS (`8.8.8.8`, `1.1.1.1`) dans le `docker-compose.yml` pour permettre l’accès à la bibliothèque de plugins/mises à jour depuis le conteneur.
|
||||
- Pré-installer WordPress en **français** via WP-CLI avec le titre **“Mon site wordpress”** et le compte **admin / admin**.
|
||||
- Désactiver les **mises à jour automatiques** (core, plugins, thèmes) pour figer l’environnement.
|
||||
- Installer et activer le **thème Astra**.
|
||||
- Installer **Yoast SEO** (inactif) et **Spectra** (actif).
|
||||
- [ ] **Barre de progression basée sur les logs d’installation** : enrichir la barre de progression agent/dashboard en lisant les logs Podman/Docker (`podman logs -f`) pendant le premier démarrage d’une instance. Définir des patterns de logs par template (ex. `Installation successful` pour PrestaShop) et relayer les étapes réelles au dashboard via WebSocket.
|
||||
- [ ] **Étude – interface de déploiement multi-clients** : outil de provisionning d’un nouveau serveur client + agent générique (option A : URL serveur déterminée à l’activation).
|
||||
- [ ] **Sécurité – gestion et rotation des secrets** (`INTERNAL_API_KEY`, `HEADSCALE_API_KEY`, `NEXTAUTH_SECRET`, `DATABASE_URL`).
|
||||
- [ ] **Sécurité – durcissement des conteneurs** (`cap_add`, utilisateurs non-root, scans CVE).
|
||||
- [ ] **Sécurité – mises à jour de sécurité** (Tailscale, images Docker, OS agents).
|
||||
- [ ] **Sécurité – logs d’audit** (instances, codes d’activation, actions admin).
|
||||
- [ ] **Sécurité – backups et reprise d’activité** (DB, state Headscale, states agents).
|
||||
- [ ] **Sécurité – intégrité et signature de l’agent** (checksum Tailscale, signature Windows, hash SHA256).
|
||||
- [ ] **Sécurité – conformité RGPD** (données élèves, suppression de compte, export).
|
||||
- [ ] **Sécurité – restriction réseau** (endpoint interne, dashboard Headscale).
|
||||
- [ ] **Sécurité – rate limiting et quotas** (routes publiques, instances par élève/établissement).
|
||||
- [ ] **Sécurité – tests de sécurité** (intrusion légère, tests automatisés avant release).
|
||||
|
||||
## 💡 Améliorations UI
|
||||
|
||||
### ✅ Console / log intégrée dans l’agent (v0.3.5)
|
||||
|
||||
Les logs de l’agent sont redirigés vers `<data-dir>/agent.log` et diffusés en temps réel dans l’UI locale (`http://localhost:7070`) via le WebSocket existant.
|
||||
|
||||
### ✅ Barre de progression (v0.3.5)
|
||||
|
||||
L’agent envoie des messages `progress` au frontend pendant le démarrage d’une instance :
|
||||
|
||||
| Étape | Poids |
|
||||
|-------|-------|
|
||||
| Préparation de l’application | 10 % |
|
||||
| Configuration de l’application | 30 % |
|
||||
| Application en cours de démarrage | 60 % |
|
||||
| Connexion sécurisée active | 80 % |
|
||||
| Finalisation de l’installation | 90 % |
|
||||
| Application prête | 100 % |
|
||||
|
||||
### Boutons d’action par instance (v0.3.5)
|
||||
|
||||
L’UI locale affiche désormais des boutons **Démarrer**, **Arrêter**, **Redémarrer** et **Supprimer** pour chaque instance.
|
||||
|
||||
## 🚀 Scalabilité commerciale — déploiement multi-clients
|
||||
|
||||
### Objectif
|
||||
|
||||
Permettre de déployer facilement une stack studioE5 complète pour un nouvel établissement/client sur un VPS dédié, sans intervention technique lourde.
|
||||
|
||||
### Architecture cible
|
||||
|
||||
- **Un serveur = un client** : chaque établissement a sa propre stack Docker Compose, sa base PostgreSQL, son Headscale et son Caddy.
|
||||
- **Agent générique (option A)** : un seul binaire agent pour tous les clients. L’URL du serveur cible est déterminée au moment de l’activation, pas hardcodée dans l’agent.
|
||||
- Pistes : code d’activation résolu par un hub central, code structuré contenant l’identifiant du serveur, ou champ URL serveur saisi dans l’UI locale.
|
||||
- **Interface de déploiement** : dashboard superadmin (hub) permettant de créer un client, provisionner le VPS, générer les secrets et retourner les informations de connexion.
|
||||
|
||||
### Prérequis techniques à préparer
|
||||
|
||||
Avant de pouvoir déployer un nouveau client en quelques clics, il faut encore préparer les éléments suivants :
|
||||
|
||||
| # | Élément | État | Détail |
|
||||
|---|---------|------|--------|
|
||||
| 1 | **Agent générique** | ⏳ À faire | `defaultServerURL` est hardcodé (`wss://studioe5.edudeploy.com/api/websocket`). L’agent doit pouvoir déterminer l’URL serveur cible à l’activation (option A : champ URL, hub de résolution, ou code structuré). |
|
||||
| 2 | **Script de provisionning** | ⏳ À faire | Aucun outil automatisé pour provisionner un VPS vierge : installation Docker, déploiement de la stack, génération des secrets, création des clés Headscale, configuration DNS wildcard. |
|
||||
| 3 | **Registry d’images** | ⏳ À faire | Les images `server` et `resolver` sont buildées sur le serveur cible. Il faut un registry privé pour builder une fois et déployer partout. |
|
||||
| 4 | **Hub central** | ⏳ À faire | Dashboard superadmin listant les clients, état des serveurs, versions déployées, logs distants et mises à jour. |
|
||||
| 5 | **Mises à jour à distance** | ⏳ À faire | Mécanisme pour pousser une nouvelle version du serveur et de l’agent sur tous les déploiements clients. |
|
||||
| 6 | **Monitoring / support** | ⏳ À faire | Collecte centralisée de logs, alertes (serveur down, certificat expiré, agent hors ligne). |
|
||||
| 7 | **Branding / personnalisation** | ⏳ À faire | Logo, nom de l’établissement, couleurs configurables par client. |
|
||||
| 8 | **Tests automatisés** | ⏳ À faire | Tests du flux activation → VPN → instance → HTTPS public pour valider chaque nouveau déploiement. |
|
||||
| 9 | **Documentation procédure prod** | ⏳ À faire | Procédure complète de mise en production pour un nouveau client. |
|
||||
|
||||
### Statut
|
||||
|
||||
- ⏳ À étudier et planifier plus tard. L’architecture actuelle (un serveur par client + agent zero-config) est déjà compatible avec cette vision, mais le code n’est pas encore industrialisé pour un déploiement à grande échelle.
|
||||
|
||||
## 🔒 Étude certificat wildcard `*.studioe5.edudeploy.com`
|
||||
|
||||
### Pourquoi passer en wildcard ?
|
||||
|
||||
Avec `tls { on_demand }`, Caddy émet **un certificat Let’s Encrypt par sous-domaine d’instance**. Cela expose au rate limit de 50 certificats par domaine principal (`edudeploy.com`) sur 7 jours. Un certificat wildcard unique (`*.studioe5.edudeploy.com`) couvre tous les sous-domaines d’instances et évite ce problème.
|
||||
|
||||
### Contrainte technique
|
||||
|
||||
Un certificat wildcard nécessite le **challenge DNS-01** (le challenge HTTP-01 ne permet pas de valider `*.domain.tld`). Caddy doit donc pouvoir créer un enregistrement TXT automatiquement chez le registrar DNS.
|
||||
|
||||
### Infomaniak (registrar actuel)
|
||||
|
||||
Le DNS de `edudeploy.com` est chez **Infomaniak** :
|
||||
```bash
|
||||
dig NS edudeploy.com +short
|
||||
# nsany1.infomaniak.com.
|
||||
# nsany2.infomaniak.com.
|
||||
```
|
||||
|
||||
Il existe un module Caddy DNS pour Infomaniak :
|
||||
- Repository : `github.com/caddy-dns/infomaniak`
|
||||
- Nécessite un **token API Infomaniak** avec droits DNS.
|
||||
|
||||
### Implémentation à envisager
|
||||
|
||||
1. **Générer un token API Infomaniak** (compte client A ou compte dédié avec accès au domaine).
|
||||
2. **Builder une image Caddy custom** avec le module :
|
||||
```dockerfile
|
||||
FROM caddy:2-builder AS builder
|
||||
RUN xcaddy build --with github.com/caddy-dns/infomaniak
|
||||
|
||||
FROM caddy:2-alpine
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
```
|
||||
3. **Modifier le `Caddyfile`** pour gérer le wildcard :
|
||||
```caddy
|
||||
*.studioe5.edudeploy.com {
|
||||
tls {
|
||||
dns infomaniak {env.INFOMANIAK_API_TOKEN}
|
||||
}
|
||||
reverse_proxy resolver:2020 {
|
||||
header_up Host {host}
|
||||
}
|
||||
}
|
||||
```
|
||||
4. **Ajouter le token dans `.env`** et le passer au conteneur Caddy.
|
||||
5. Supprimer ou ajuster le bloc `:443` actuel qui utilise `on_demand` pour les instances.
|
||||
|
||||
### Alternative sans module DNS
|
||||
|
||||
Obtenir le certificat wildcard manuellement (Certbot DNS-01, acheté, etc.) et le charger dans Caddy :
|
||||
```caddy
|
||||
*.studioe5.edudeploy.com {
|
||||
tls /data/certs/wildcard.crt /data/certs/wildcard.key
|
||||
reverse_proxy resolver:2020 {
|
||||
header_up Host {host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Inconvénient : renouvellement manuel.
|
||||
|
||||
## 🔧 Notes techniques
|
||||
|
||||
- Le conteneur `resolver-vpn` utilise `network_mode: service:resolver` pour partager le netns avec le resolver.
|
||||
- L’agent utilise `tailscaled --tun=userspace-networking` ; le resolver-vpn utilise un vrai TUN (`tailscale0`).
|
||||
- Le `Caddyfile` actuel utilise `tls { on_demand }` pour les instances. En cas de nouvelle rate limit, on peut temporairement remettre `tls internal` dans le bloc `:443` pour valider le flux sans certificat public.
|
||||
@@ -0,0 +1 @@
|
||||
0.3.18
|
||||
+71
-17
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Télécharge les binaires Tailscale Windows depuis l'installateur MSI officiel.
|
||||
# Nécessite: curl, msitools (msiextract)
|
||||
|
||||
VERSION="${1:-1.98.4}"
|
||||
ARCH="amd64"
|
||||
OUTDIR="$(dirname "$0")/tailscale-bin/windows"
|
||||
MSI_URL="https://pkgs.tailscale.com/stable/tailscale-setup-${VERSION}-${ARCH}.msi"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMPDIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Downloading Tailscale ${VERSION} Windows installer..."
|
||||
curl -L -o "$TMPDIR/tailscale-setup.msi" "$MSI_URL"
|
||||
|
||||
echo "Extracting binaries..."
|
||||
mkdir -p "$TMPDIR/extract"
|
||||
msiextract -C "$TMPDIR/extract" "$TMPDIR/tailscale-setup.msi" >/dev/null
|
||||
|
||||
echo "Installing to ${OUTDIR}..."
|
||||
mkdir -p "$OUTDIR"
|
||||
cp "$TMPDIR/extract/PFiles64/Tailscale/tailscale.exe" "$OUTDIR/"
|
||||
cp "$TMPDIR/extract/PFiles64/Tailscale/tailscaled.exe" "$OUTDIR/"
|
||||
cp "$TMPDIR/extract/PFiles64/Tailscale/wintun.dll" "$OUTDIR/"
|
||||
|
||||
echo "Done. Installed:"
|
||||
ls -lh "$OUTDIR"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// hideWindow is a no-op on non-Windows platforms.
|
||||
func hideWindow(cmd *exec.Cmd) {}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// hideWindow configures a command so that it does not open a console window
|
||||
// when it starts. This is essential for the agent running on student Windows
|
||||
// machines, otherwise every docker/podman/tailscale command flashes a window.
|
||||
func hideWindow(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.HideWindow = true
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import zlib
|
||||
import struct
|
||||
|
||||
|
||||
def png_chunk(chunk_type: bytes, data: bytes) -> bytes:
|
||||
return (
|
||||
struct.pack(">I", len(data))
|
||||
+ chunk_type
|
||||
+ data
|
||||
+ struct.pack(">I", zlib.crc32(chunk_type + data) & 0xFFFFFFFF)
|
||||
)
|
||||
|
||||
|
||||
def create_png(width: int, height: int, pixels: list) -> bytes:
|
||||
"""Create a PNG from a 2D list of (R, G, B) tuples."""
|
||||
raw = bytearray()
|
||||
for row in pixels:
|
||||
raw.append(0) # no filter
|
||||
for r, g, b in row:
|
||||
raw.extend((r, g, b))
|
||||
compressed = zlib.compress(bytes(raw), 9)
|
||||
|
||||
ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||
ihdr = png_chunk(b"IHDR", ihdr_data)
|
||||
idat = png_chunk(b"IDAT", compressed)
|
||||
iend = png_chunk(b"IEND", b"")
|
||||
|
||||
return b"\x89PNG\r\n\x1a\n" + ihdr + idat + iend
|
||||
|
||||
|
||||
BLUE = (37, 99, 235)
|
||||
WHITE = (255, 255, 255)
|
||||
SIZE = 64
|
||||
|
||||
pixels = []
|
||||
for y in range(SIZE):
|
||||
row = []
|
||||
for x in range(SIZE):
|
||||
dx = x - SIZE // 2
|
||||
dy = y - SIZE // 2
|
||||
dist = (dx * dx + dy * dy) ** 0.5
|
||||
if dist < SIZE * 0.25:
|
||||
row.append(WHITE)
|
||||
else:
|
||||
row.append(BLUE)
|
||||
pixels.append(row)
|
||||
|
||||
with open("icon.png", "wb") as f:
|
||||
f.write(create_png(SIZE, SIZE, pixels))
|
||||
|
||||
print("Generated icon.png")
|
||||
+1
-41
@@ -3,52 +3,12 @@ module edubox-agent
|
||||
go 1.26.4
|
||||
|
||||
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
@@ -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=
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 193 B |
@@ -0,0 +1,127 @@
|
||||
# Feuille de route — Installateur studioE5 Agent
|
||||
|
||||
## Objectif
|
||||
|
||||
Fournir un **installateur professionnel Windows** pour studioE5 Agent, guidé pas à pas, qui gère les prérequis (WSL2 / Podman) et propose une désinstallation complète.
|
||||
|
||||
## Architecture choisie
|
||||
|
||||
- **Wizard C# Windows Forms (.NET 8)** : `setup-wizard/`
|
||||
- Détecte les prérequis.
|
||||
- Installe WSL2 si besoin (avec reprise après redémarrage via `RunOnce`).
|
||||
- Installe Podman depuis le MSI officiel.
|
||||
- Initialise et démarre la machine Podman.
|
||||
- Lance le package Inno Setup de studioE5 Agent.
|
||||
- Mode désinstallation via `/uninstall`.
|
||||
- **Package agent (Inno Setup)** : `studioE5-agent.iss`
|
||||
- Installe `studioE5-agent.exe` + binaires Tailscale.
|
||||
- Crée les raccourcis.
|
||||
- Gère la désinstallation.
|
||||
|
||||
## État actuel
|
||||
|
||||
### ✅ Réalisé
|
||||
|
||||
- Wizard C# avec 7 étapes guidées.
|
||||
- Détection des prérequis : Windows, RAM, disque, WSL2, Podman.
|
||||
- Installation WSL2 en plusieurs étapes contrôlées avec suivi visuel :
|
||||
1. Activation des fonctionnalités Windows (`Microsoft-Windows-Subsystem-Linux` et `VirtualMachinePlatform`) avec gestion du code 3010 (redémarrage nécessaire).
|
||||
2. Installation du package WSL2 complet depuis le bundle Microsoft Store offline (`Microsoft.WSL_*.msixbundle`) via `Add-AppxPackage`.
|
||||
3. Mise à jour du noyau WSL2 depuis le MSI bundlé (`wsl_update_x64.msi`) ou, en dernier recours, via `wsl --update`.
|
||||
4. Définition de WSL2 comme version par défaut (`wsl --set-default-version 2`).
|
||||
5. L’étape `wsl --install --no-distribution` n’est plus utilisée : l’installation est entièrement offline grâce au bundle.
|
||||
6. Conversion des distributions WSL1 existantes vers WSL2 (`wsl --set-version <nom> 2`) si nécessaire.
|
||||
- Reprise automatique après redémarrage grâce à `RunOnce` (y compris redémarrage post-installation WSL2).
|
||||
- Versioning indépendant du wizard (fichier `setup-wizard/VERSION`, affiché dans la fenêtre et la page d’accueil).
|
||||
- Amélioration de l’interface : fenêtre agrandie à 800×600, `AutoScaleMode = Dpi`, meilleur rendu sur petits écrans (11 pouces).
|
||||
- `RunCommand` capture désormais stdout + stderr pour des diagnostics et fallbacks plus fiables.
|
||||
- Détection des prérequis vérifie que WSL2 (et non WSL1) est installé via `wsl --status`.
|
||||
- Installation Podman via MSI bundlé.
|
||||
- Configuration Podman (`machine init` + `machine start`), avec initialisation offline possible depuis une image locale (`podman-machine.*.wsl.tar.zst`).
|
||||
- Lancement du package Inno Setup agent.
|
||||
- Mode désinstallation complet.
|
||||
- Script Inno Setup de base pour l’agent.
|
||||
|
||||
### 🔄 En cours / À tester
|
||||
|
||||
- Compilation et test du wizard sur Windows.
|
||||
- Packaging final (wizard + MSI Podman + setup agent) en un seul dossier distribuable.
|
||||
|
||||
### ⏳ À venir
|
||||
|
||||
- Signature de l’exécutable pour éviter les alertes SmartScreen.
|
||||
- Support macOS et Linux.
|
||||
- Installateur silencieux possible pour déploiement GPO.
|
||||
|
||||
## Build du wizard
|
||||
|
||||
### Prérequis
|
||||
|
||||
- Windows 10/11
|
||||
- .NET 8 SDK
|
||||
- Inno Setup 6 (pour générer `studioE5-agent-setup.exe`)
|
||||
|
||||
### Fichiers à placer
|
||||
|
||||
Dans `setup-wizard/Resources/` :
|
||||
|
||||
```text
|
||||
podman-installer-windows-amd64.msi
|
||||
studioE5-agent-setup.exe
|
||||
Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle # package WSL2 complet (offline)
|
||||
podman-machine.x86_64.wsl.tar.zst # image Podman machine pour WSL (offline)
|
||||
docker-compose-windows-x86_64.exe # Docker Compose standalone (offline)
|
||||
wsl_update_x64.msi # optionnel, fallback noyau WSL2
|
||||
```
|
||||
|
||||
### Commande
|
||||
|
||||
```powershell
|
||||
cd setup-wizard
|
||||
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true
|
||||
```
|
||||
|
||||
### Sortie
|
||||
|
||||
```text
|
||||
setup-wizard\bin\Release\net8.0-windows\win-x64\publish\StudioE5-SetupWizard.exe
|
||||
```
|
||||
|
||||
## Build du package agent (Inno Setup)
|
||||
|
||||
Structure attendue :
|
||||
|
||||
```text
|
||||
agent/
|
||||
├── studioE5-agent.exe
|
||||
├── tailscale-bin/
|
||||
│ └── windows/
|
||||
│ ├── tailscale.exe
|
||||
│ ├── tailscaled.exe
|
||||
│ └── wintun.dll
|
||||
└── installer/
|
||||
└── studioE5-agent.iss
|
||||
```
|
||||
|
||||
Ouvrir `studioE5-agent.iss` avec Inno Setup Compiler et compiler (`Ctrl+F9`).
|
||||
|
||||
Le fichier généré se trouve dans `installer-output/`.
|
||||
|
||||
## Notes importantes
|
||||
|
||||
- Le wizard doit être exécuté **en administrateur**.
|
||||
- L’installation de WSL2 nécessite un **redémarrage** de l’ordinateur après l’activation des fonctionnalités Windows.
|
||||
- L’installation de WSL2 est découpée en étapes et chaque étape est enregistrée pour permettre la reprise après redémarrage.
|
||||
- Podman nécessite WSL2 : le wizard vérifie donc que la version par défaut de WSL est 2.
|
||||
- Le MSI Podman officiel pour Windows est `podman-installer-windows-amd64.msi`.
|
||||
- Le bundle WSL2 offline est disponible sur <https://github.com/microsoft/WSL/releases> : `Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle`.
|
||||
- L’image Podman machine offline est disponible sur <https://github.com/containers/podman-machine-os/releases> : `podman-machine.x86_64.wsl.tar.zst`.
|
||||
- Docker Compose standalone est disponible sur <https://github.com/docker/compose/releases> : `docker-compose-windows-x86_64.exe`.
|
||||
- Le wizard crée automatiquement un fichier `.wslconfig` allouant **8 Go de RAM et 4 CPU** à WSL2, ce qui est nécessaire pour des applications lourdes comme PrestaShop.
|
||||
- Pour la désinstallation, le MSI Podman doit être présent dans `Resources/`.
|
||||
|
||||
## Liens utiles
|
||||
|
||||
- Releases Podman : <https://github.com/containers/podman/releases>
|
||||
- Inno Setup : <https://jrsoftware.org/isdl.php>
|
||||
- .NET 8 SDK : <https://dotnet.microsoft.com/download/dotnet/8.0>
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StudioE5.SetupWizard;
|
||||
|
||||
public enum WizardStep
|
||||
{
|
||||
Welcome,
|
||||
Prerequisites,
|
||||
InstallVirtualEnvironment,
|
||||
RestartRequired,
|
||||
InstallPodman,
|
||||
ConfigurePodman,
|
||||
InstallAgent,
|
||||
Finished,
|
||||
Uninstall
|
||||
}
|
||||
|
||||
public class InstallerState
|
||||
{
|
||||
[JsonPropertyName("step")]
|
||||
public WizardStep Step { get; set; } = WizardStep.Welcome;
|
||||
|
||||
[JsonPropertyName("virtualEnvironmentInstalled")]
|
||||
public bool VirtualEnvironmentInstalled { get; set; }
|
||||
|
||||
[JsonPropertyName("wslFeaturesEnabled")]
|
||||
public bool WslFeaturesEnabled { get; set; }
|
||||
|
||||
[JsonPropertyName("wslPackageInstalled")]
|
||||
public bool WslPackageInstalled { get; set; }
|
||||
|
||||
[JsonPropertyName("wslDefaultVersionSet")]
|
||||
public bool WslDefaultVersionSet { get; set; }
|
||||
|
||||
[JsonPropertyName("wslKernelUpdated")]
|
||||
public bool WslKernelUpdated { get; set; }
|
||||
|
||||
[JsonPropertyName("podmanInstalled")]
|
||||
public bool PodmanInstalled { get; set; }
|
||||
|
||||
[JsonPropertyName("podmanConfigured")]
|
||||
public bool PodmanConfigured { get; set; }
|
||||
|
||||
[JsonPropertyName("agentInstalled")]
|
||||
public bool AgentInstalled { get; set; }
|
||||
|
||||
private static string StateFilePath
|
||||
{
|
||||
get
|
||||
{
|
||||
var dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"studioE5",
|
||||
"installer");
|
||||
Directory.CreateDirectory(dir);
|
||||
return Path.Combine(dir, "installer-state.json");
|
||||
}
|
||||
}
|
||||
|
||||
public static InstallerState Load()
|
||||
{
|
||||
var path = StateFilePath;
|
||||
if (!File.Exists(path))
|
||||
return new InstallerState();
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<InstallerState>(json) ?? new InstallerState();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new InstallerState();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(StateFilePath, json);
|
||||
}
|
||||
|
||||
public static void Delete()
|
||||
{
|
||||
var path = StateFilePath;
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StudioE5.SetupWizard;
|
||||
|
||||
public record PrerequisiteResult(
|
||||
bool WindowsCompatible,
|
||||
ulong RamMB,
|
||||
ulong FreeDiskMB,
|
||||
bool VirtualEnvironmentInstalled,
|
||||
bool PodmanInstalled,
|
||||
bool PodmanMachineReady)
|
||||
{
|
||||
public bool AllReady => WindowsCompatible && RamMB >= 4096 && FreeDiskMB >= 5120 && VirtualEnvironmentInstalled && PodmanInstalled && PodmanMachineReady;
|
||||
}
|
||||
|
||||
public static class PrerequisiteChecker
|
||||
{
|
||||
public static PrerequisiteResult Check()
|
||||
{
|
||||
var wsl2Ready = IsWSL2Ready();
|
||||
var podmanMachineReady = IsPodmanMachineReady();
|
||||
|
||||
// Fallback : si la machine Podman est prête, WSL2 est nécessairement fonctionnel.
|
||||
// Cela contourne les problèmes de détection WSL liés à l'encodage ou au PATH.
|
||||
var virtualEnvironmentInstalled = wsl2Ready || podmanMachineReady;
|
||||
|
||||
return new PrerequisiteResult(
|
||||
WindowsCompatible: IsWindowsCompatible(),
|
||||
RamMB: GetTotalPhysicalMemoryMB(),
|
||||
FreeDiskMB: GetFreeDiskSpaceMB("C:\\"),
|
||||
VirtualEnvironmentInstalled: virtualEnvironmentInstalled,
|
||||
PodmanInstalled: IsPodmanInstalled(),
|
||||
PodmanMachineReady: podmanMachineReady
|
||||
);
|
||||
}
|
||||
|
||||
private static bool IsWindowsCompatible()
|
||||
{
|
||||
var os = Environment.OSVersion;
|
||||
if (os.Platform != PlatformID.Win32NT)
|
||||
return false;
|
||||
|
||||
// Windows 10 version 2004 (build 19041) or Windows 11.
|
||||
return Environment.OSVersion.Version.Build >= 19041;
|
||||
}
|
||||
|
||||
private static ulong GetTotalPhysicalMemoryMB()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher("SELECT TotalVisibleMemorySize FROM Win32_OperatingSystem");
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var kb = Convert.ToUInt64(obj["TotalVisibleMemorySize"]);
|
||||
return kb / 1024;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static ulong GetFreeDiskSpaceMB(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var drive = new DriveInfo(Path.GetPathRoot(path) ?? path);
|
||||
return (ulong)(drive.AvailableFreeSpace / (1024 * 1024));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsWSL2Ready()
|
||||
{
|
||||
// PowerShell gère mieux l'encodage de la sortie WSL que Process.Start en C#.
|
||||
if (IsWSL2ReadyViaPowerShell())
|
||||
return true;
|
||||
|
||||
// Fallback natif si PowerShell n'est pas disponible.
|
||||
return IsWSL2ReadyNative();
|
||||
}
|
||||
|
||||
private static bool IsWSL2ReadyViaPowerShell()
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempFile = Path.GetTempFileName();
|
||||
var script =
|
||||
"$status = & wsl.exe --status 2>&1; " +
|
||||
"$ready = ($status -match 'Version par d\\u00E9faut\\s*:\\s*2') -or " +
|
||||
"($status -match 'Default Version\\s*:\\s*2'); " +
|
||||
"$ready | Out-File -FilePath '" + tempFile + "' -Encoding utf8 -NoNewline";
|
||||
|
||||
var psi = new ProcessStartInfo("powershell.exe", $"-ExecutionPolicy Bypass -Command \"{script}\"")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null) return false;
|
||||
process.WaitForExit();
|
||||
|
||||
if (!File.Exists(tempFile))
|
||||
return false;
|
||||
|
||||
var result = File.ReadAllText(tempFile).Trim();
|
||||
File.Delete(tempFile);
|
||||
|
||||
return result.Equals("True", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWSL2ReadyNative()
|
||||
{
|
||||
try
|
||||
{
|
||||
// wsl --status est plus fiable que --version pour savoir si WSL2 est prêt.
|
||||
var psi = new ProcessStartInfo("wsl.exe", "--status")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null) return false;
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
|
||||
// wsl --status peut retourner un code non nul même quand l’info utile est affichée
|
||||
// (par exemple si aucune distribution n’est installée). On parse quand même.
|
||||
var combined = output + "\n" + error;
|
||||
var normalized = combined
|
||||
.Replace('\u00A0', ' ')
|
||||
.Replace('\u202F', ' ');
|
||||
|
||||
if (normalized.Contains("Version par défaut : 2", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Contains("Default Version: 2", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Contains("Version défaut : 2", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var defaultVersion = ParseWslDefaultVersion(combined);
|
||||
if (defaultVersion == 2)
|
||||
return true;
|
||||
|
||||
// Si aucune version par défaut n'est trouvée, on tente les autres méthodes.
|
||||
return (defaultVersion == 0 && WslVersionIndicatesWsl2()) ||
|
||||
WslListIndicatesWsl2();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWSLInstalled()
|
||||
{
|
||||
return IsWSL2Ready();
|
||||
}
|
||||
|
||||
private static int ParseWslDefaultVersion(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var rawLine in text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
// Normalise les espaces insécables et les espaces multiples.
|
||||
var trimmed = rawLine
|
||||
.Replace('\u00A0', ' ')
|
||||
.Replace('\u202F', ' ')
|
||||
.Trim();
|
||||
|
||||
// Regex souple pour matcher :
|
||||
// - Default Version: 2
|
||||
// - Version par défaut : 2
|
||||
// - Version défaut:2
|
||||
// etc.
|
||||
var match = Regex.Match(
|
||||
trimmed,
|
||||
@"(?i)(?:default\s+version|version\s+(?:par\s+)?d[eé]faut)\s*[:\-]?\s*(\d+)",
|
||||
RegexOptions.CultureInvariant);
|
||||
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var version))
|
||||
return version;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool WslVersionIndicatesWsl2()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("wsl.exe", "--version")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null) return false;
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0) return false;
|
||||
|
||||
var combined = output + "\n" + error;
|
||||
|
||||
// Si la sortie mentionne explicitement WSL 2 ou un noyau 5.10+, on considère WSL2 prêt.
|
||||
return combined.Contains("WSL version: 2", StringComparison.OrdinalIgnoreCase) ||
|
||||
combined.Contains("WSL version: 2.0", StringComparison.OrdinalIgnoreCase) ||
|
||||
combined.Contains("Kernel version: 5.10", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool WslListIndicatesWsl2()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("wsl.exe", "--list --verbose")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null) return false;
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
|
||||
var combined = output + "\n" + error;
|
||||
|
||||
// Si au moins une distribution est en version 2, WSL2 est fonctionnel.
|
||||
foreach (var line in combined.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2 && parts[^1] == "2")
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string? GetPodmanExePath()
|
||||
{
|
||||
// 1. Chercher dans le PATH actuel du processus.
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("where.exe", "podman.exe")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
using var process = Process.Start(psi);
|
||||
if (process != null)
|
||||
{
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
var firstLine = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(firstLine) && File.Exists(firstLine))
|
||||
return firstLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
// 2. Chercher dans les emplacements d'installation connus.
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "podman", "podman.exe"),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "RedHat", "Podman", "podman.exe"),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Podman", "podman.exe"),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "RedHat", "Podman", "podman.exe"),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Podman", "podman.exe"),
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsPodmanInstalled()
|
||||
{
|
||||
var podmanPath = GetPodmanExePath();
|
||||
if (string.IsNullOrEmpty(podmanPath))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo(podmanPath, "--version")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null) return false;
|
||||
process.WaitForExit();
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPodmanMachineReady()
|
||||
{
|
||||
var podmanPath = GetPodmanExePath();
|
||||
if (string.IsNullOrEmpty(podmanPath))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo(podmanPath, "machine list --format json")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null) return false;
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0) return false;
|
||||
|
||||
// Very permissive check: if podman machine list returns any JSON, we consider it ready.
|
||||
return output.TrimStart().StartsWith("[") || output.TrimStart().StartsWith("{");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using StudioE5.SetupWizard;
|
||||
|
||||
static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static void Main(string[] args)
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
if (args.Contains("/uninstall", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Application.Run(new MainForm(startInUninstallMode: true));
|
||||
}
|
||||
else
|
||||
{
|
||||
Application.Run(new MainForm(startInUninstallMode: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# StudioE5 Setup Wizard
|
||||
|
||||
Assistant d’installation graphique Windows pour studioE5 Agent.
|
||||
|
||||
## Rôle
|
||||
|
||||
Ce wizard guide l’utilisateur pas à pas pour :
|
||||
|
||||
1. Vérifier les prérequis (RAM, disque, Windows, environnement virtuel, Podman).
|
||||
2. Installer l’**environnement virtuel** (WSL2) si nécessaire, avec reprise après redémarrage.
|
||||
3. Installer **Podman** depuis le MSI bundlé.
|
||||
4. Initialiser et démarrer la **machine Podman**.
|
||||
5. Lancer le package **Inno Setup** de studioE5 Agent.
|
||||
|
||||
Il propose aussi un mode **désinstallation** complet (`/uninstall`).
|
||||
|
||||
## Prérequis de build
|
||||
|
||||
- Windows 10/11
|
||||
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
|
||||
- Visual Studio 2022 ou Visual Studio Code (optionnel)
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
setup-wizard/
|
||||
├── SetupWizard.csproj
|
||||
├── Program.cs
|
||||
├── MainForm.cs
|
||||
├── InstallerState.cs
|
||||
├── PrerequisiteChecker.cs
|
||||
├── app.manifest
|
||||
└── Resources/
|
||||
├── podman-installer-windows-amd64.msi # MSI officiel Podman pour Windows
|
||||
├── studioE5-agent-setup.exe # Package Inno Setup de l'agent
|
||||
├── Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle # Package WSL2 complet (offline)
|
||||
├── podman-machine.x86_64.wsl.tar.zst # Image Podman machine pour WSL (offline)
|
||||
├── docker-compose-windows-x86_64.exe # Docker Compose standalone (offline)
|
||||
└── wsl_update_x64.msi # Noyau WSL2 (optionnel, fallback)
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Ouvrir un terminal PowerShell dans ce dossier et exécuter :
|
||||
|
||||
```powershell
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
Pour publier un exécutable autonome (pas besoin du runtime .NET sur le poste cible) :
|
||||
|
||||
```powershell
|
||||
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true
|
||||
```
|
||||
|
||||
L’exécutable se trouve dans :
|
||||
|
||||
```text
|
||||
bin\Release\net8.0-windows\win-x64\publish\StudioE5-SetupWizard.exe
|
||||
```
|
||||
|
||||
## Préparation du package
|
||||
|
||||
1. Télécharger le MSI Podman Windows :
|
||||
<https://github.com/containers/podman/releases>
|
||||
Le renommer en `podman-installer-windows-amd64.msi` et le placer dans `Resources/`.
|
||||
2. Générer le package Inno Setup de l’agent (`studioE5-agent-setup.exe`) et le placer dans `Resources/`.
|
||||
3. Télécharger le package WSL2 complet (offline) :
|
||||
<https://github.com/microsoft/WSL/releases>
|
||||
Par exemple : `Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle`.
|
||||
Le placer dans `Resources/`.
|
||||
4. Télécharger l’image Podman machine pour WSL (offline) :
|
||||
<https://github.com/containers/podman-machine-os/releases>
|
||||
Par exemple : `podman-machine.x86_64.wsl.tar.zst`.
|
||||
Le placer dans `Resources/`.
|
||||
5. Télécharger Docker Compose standalone (offline) :
|
||||
<https://github.com/docker/compose/releases>
|
||||
Par exemple : `docker-compose-windows-x86_64.exe`.
|
||||
Le placer dans `Resources/`.
|
||||
6. *(Optionnel, fallback)* Télécharger le noyau WSL2 :
|
||||
<https://github.com/microsoft/WSL/releases>
|
||||
Par exemple : `wsl.2.7.10.0.x64.msi`, à renommer en `wsl_update_x64.msi`.
|
||||
Le placer dans `Resources/`.
|
||||
6. Builder et publier le wizard.
|
||||
|
||||
## Lancement
|
||||
|
||||
### Mode installation
|
||||
|
||||
```powershell
|
||||
.\StudioE5-SetupWizard.exe
|
||||
```
|
||||
|
||||
### Mode désinstallation
|
||||
|
||||
```powershell
|
||||
.\StudioE5-SetupWizard.exe /uninstall
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Le wizard doit être exécuté **en administrateur**.
|
||||
- L’installation de WSL2 nécessite un **redémarrage** de l’ordinateur. Le wizard s’enregistre dans `RunOnce` pour se relancer automatiquement.
|
||||
- Le wizard configure WSL2 avec **8 Go de RAM et 4 CPU** via le fichier `.wslconfig` de l’utilisateur.
|
||||
- Le MSI Podman doit correspondre à l’architecture `x64`.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Feuille de route — Installateur studioE5 Agent
|
||||
|
||||
## Objectif
|
||||
|
||||
Fournir un **installateur professionnel Windows** pour studioE5 Agent, guidé pas à pas, qui gère les prérequis (WSL2 / Podman) et propose une désinstallation complète.
|
||||
|
||||
## Architecture choisie
|
||||
|
||||
- **Wizard C# Windows Forms (.NET 8)** : `setup-wizard/`
|
||||
- Détecte les prérequis.
|
||||
- Installe WSL2 si besoin (avec reprise après redémarrage via `RunOnce`).
|
||||
- Installe Podman depuis le MSI officiel.
|
||||
- Initialise et démarre la machine Podman.
|
||||
- Lance le package Inno Setup de studioE5 Agent.
|
||||
- Mode désinstallation via `/uninstall`.
|
||||
- **Package agent (Inno Setup)** : `studioE5-agent.iss`
|
||||
- Installe `studioE5-agent.exe` + binaires Tailscale.
|
||||
- Crée les raccourcis.
|
||||
- Gère la désinstallation.
|
||||
|
||||
## État actuel
|
||||
|
||||
### ✅ Réalisé
|
||||
|
||||
- Wizard C# avec 7 étapes guidées.
|
||||
- Détection des prérequis : Windows, RAM, disque, WSL2, Podman.
|
||||
- Installation WSL2 en plusieurs étapes contrôlées avec suivi visuel :
|
||||
1. Activation des fonctionnalités Windows (`Microsoft-Windows-Subsystem-Linux` et `VirtualMachinePlatform`) avec gestion du code 3010 (redémarrage nécessaire).
|
||||
2. Installation du package WSL2 complet depuis le bundle Microsoft Store offline (`Microsoft.WSL_*.msixbundle`) via `Add-AppxPackage`.
|
||||
3. Mise à jour du noyau WSL2 depuis le MSI bundlé (`wsl_update_x64.msi`) ou, en dernier recours, via `wsl --update`.
|
||||
4. Définition de WSL2 comme version par défaut (`wsl --set-default-version 2`).
|
||||
5. L’étape `wsl --install --no-distribution` n’est plus utilisée : l’installation est entièrement offline grâce au bundle.
|
||||
6. Conversion des distributions WSL1 existantes vers WSL2 (`wsl --set-version <nom> 2`) si nécessaire.
|
||||
- Reprise automatique après redémarrage grâce à `RunOnce` (y compris redémarrage post-installation WSL2).
|
||||
- Versioning indépendant du wizard (fichier `setup-wizard/VERSION`, affiché dans la fenêtre et la page d’accueil).
|
||||
- Amélioration de l’interface : fenêtre agrandie à 800×600, `AutoScaleMode = Dpi`, meilleur rendu sur petits écrans (11 pouces).
|
||||
- `RunCommand` capture désormais stdout + stderr pour des diagnostics et fallbacks plus fiables.
|
||||
- Détection des prérequis vérifie que WSL2 (et non WSL1) est installé via `wsl --status`.
|
||||
- Installation Podman via MSI bundlé.
|
||||
- Configuration Podman (`machine init` + `machine start`), avec initialisation offline possible depuis une image locale (`podman-machine.*.wsl.tar.zst`).
|
||||
- Lancement du package Inno Setup agent.
|
||||
- Mode désinstallation complet.
|
||||
- Script Inno Setup de base pour l’agent.
|
||||
|
||||
### 🔄 En cours / À tester
|
||||
|
||||
- Compilation et test du wizard sur Windows.
|
||||
- Packaging final (wizard + MSI Podman + setup agent) en un seul dossier distribuable.
|
||||
|
||||
### ⏳ À venir
|
||||
|
||||
- Signature de l’exécutable pour éviter les alertes SmartScreen.
|
||||
- Support macOS et Linux.
|
||||
- Installateur silencieux possible pour déploiement GPO.
|
||||
|
||||
## Build du wizard
|
||||
|
||||
### Prérequis
|
||||
|
||||
- Windows 10/11
|
||||
- .NET 8 SDK
|
||||
- Inno Setup 6 (pour générer `studioE5-agent-setup.exe`)
|
||||
|
||||
### Fichiers à placer
|
||||
|
||||
Dans `setup-wizard/Resources/` :
|
||||
|
||||
```text
|
||||
podman-installer-windows-amd64.msi
|
||||
studioE5-agent-setup.exe
|
||||
Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle # package WSL2 complet (offline)
|
||||
podman-machine.x86_64.wsl.tar.zst # image Podman machine pour WSL (offline)
|
||||
docker-compose-windows-x86_64.exe # Docker Compose standalone (offline)
|
||||
wsl_update_x64.msi # optionnel, fallback noyau WSL2
|
||||
```
|
||||
|
||||
### Commande
|
||||
|
||||
```powershell
|
||||
cd setup-wizard
|
||||
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true
|
||||
```
|
||||
|
||||
### Sortie
|
||||
|
||||
```text
|
||||
setup-wizard\bin\Release\net8.0-windows\win-x64\publish\StudioE5-SetupWizard.exe
|
||||
```
|
||||
|
||||
## Build du package agent (Inno Setup)
|
||||
|
||||
Structure attendue :
|
||||
|
||||
```text
|
||||
agent/
|
||||
├── studioE5-agent.exe
|
||||
├── tailscale-bin/
|
||||
│ └── windows/
|
||||
│ ├── tailscale.exe
|
||||
│ ├── tailscaled.exe
|
||||
│ └── wintun.dll
|
||||
└── installer/
|
||||
└── studioE5-agent.iss
|
||||
```
|
||||
|
||||
Ouvrir `studioE5-agent.iss` avec Inno Setup Compiler et compiler (`Ctrl+F9`).
|
||||
|
||||
Le fichier généré se trouve dans `installer-output/`.
|
||||
|
||||
## Notes importantes
|
||||
|
||||
- Le wizard doit être exécuté **en administrateur**.
|
||||
- L’installation de WSL2 nécessite un **redémarrage** de l’ordinateur après l’activation des fonctionnalités Windows.
|
||||
- L’installation de WSL2 est découpée en étapes et chaque étape est enregistrée pour permettre la reprise après redémarrage.
|
||||
- Podman nécessite WSL2 : le wizard vérifie donc que la version par défaut de WSL est 2.
|
||||
- Le MSI Podman officiel pour Windows est `podman-installer-windows-amd64.msi`.
|
||||
- Le bundle WSL2 offline est disponible sur <https://github.com/microsoft/WSL/releases> : `Microsoft.WSL_2.7.10.0_x64_ARM64.msixbundle`.
|
||||
- L’image Podman machine offline est disponible sur <https://github.com/containers/podman-machine-os/releases> : `podman-machine.x86_64.wsl.tar.zst`.
|
||||
- Docker Compose standalone est disponible sur <https://github.com/docker/compose/releases> : `docker-compose-windows-x86_64.exe`.
|
||||
- Le wizard crée automatiquement un fichier `.wslconfig` allouant **8 Go de RAM et 4 CPU** à WSL2, ce qui est nécessaire pour des applications lourdes comme PrestaShop.
|
||||
- Pour la désinstallation, le MSI Podman doit être présent dans `Resources/`.
|
||||
|
||||
## Liens utiles
|
||||
|
||||
- Releases Podman : <https://github.com/containers/podman/releases>
|
||||
- Inno Setup : <https://jrsoftware.org/isdl.php>
|
||||
- .NET 8 SDK : <https://dotnet.microsoft.com/download/dotnet/8.0>
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<SatelliteResourceLanguages>fr</SatelliteResourceLanguages>
|
||||
<RootNamespace>StudioE5.SetupWizard</RootNamespace>
|
||||
<AssemblyName>StudioE5-SetupWizard</AssemblyName>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Fichier de version affiché dans le wizard. -->
|
||||
<Content Include="VERSION">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Tous les fichiers placés dans Resources/ sont copiés dans le répertoire de sortie. -->
|
||||
<!-- Attendus : MSI Podman, setup agent, bundle WSL, image Podman machine, MSI noyau WSL (optionnel). -->
|
||||
<Content Include="Resources\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1 @@
|
||||
0.1.1
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="StudioE5.SetupWizard.app"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<!-- Force l'exécution en tant qu'administrateur -->
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
@@ -0,0 +1,120 @@
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Installe ou répare WSL2 de manière fiable.
|
||||
.DESCRIPTION
|
||||
Ce script :
|
||||
1. Vérifie si WSL2 est déjà prêt.
|
||||
2. Active les fonctionnalités Windows nécessaires.
|
||||
3. Définit WSL2 comme version par défaut.
|
||||
4. Met à jour le noyau WSL2.
|
||||
5. Installe WSL sans distribution si possible.
|
||||
Un redémarrage peut être nécessaire après l’activation des fonctionnalités.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Test-Wsl2Ready {
|
||||
try {
|
||||
$output = & wsl.exe --status 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
Write-Host "[Test] wsl --status exit code: $exitCode" -ForegroundColor Cyan
|
||||
if ($output) {
|
||||
Write-Host "[Test] wsl --status output:" -ForegroundColor Cyan
|
||||
$output | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
}
|
||||
|
||||
if ($exitCode -eq 0 -or ($output -match "Version par défaut\s*:\s*2") -or ($output -match "Default Version\s*:\s*2")) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host "[Test] wsl --status a échoué : $_" -ForegroundColor Yellow
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Enable-WindowsFeatureIfNeeded {
|
||||
param([string]$FeatureName)
|
||||
|
||||
Write-Host "[Feature] Activation de $FeatureName..." -ForegroundColor Cyan
|
||||
$result = & dism.exe /online /enable-feature /featurename:$FeatureName /all /norestart 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
$result | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "[Feature] $FeatureName activé (pas de redémarrage nécessaire)." -ForegroundColor Green
|
||||
return $false
|
||||
}
|
||||
elseif ($exitCode -eq 3010) {
|
||||
Write-Host "[Feature] $FeatureName activé, mais un redémarrage est nécessaire (code 3010)." -ForegroundColor Yellow
|
||||
return $true
|
||||
}
|
||||
else {
|
||||
throw "Échec de l'activation de $FeatureName (code $exitCode)."
|
||||
}
|
||||
}
|
||||
|
||||
function Install-Wsl2 {
|
||||
Write-Host "[WSL] Tentative d'installation sans distribution..." -ForegroundColor Cyan
|
||||
try {
|
||||
& wsl.exe --install --no-distribution 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
if ($LASTEXITCODE -ne 0) { throw "wsl --install --no-distribution a retourné le code $LASTEXITCODE" }
|
||||
Write-Host "[WSL] Installation sans distribution réussie." -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
catch {
|
||||
Write-Host "[WSL] Option --no-distribution non supportée ou échec : $_" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "[WSL] Fallback : installation classique de WSL..." -ForegroundColor Cyan
|
||||
& wsl.exe --install 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
if ($LASTEXITCODE -ne 0) { throw "wsl --install a retourné le code $LASTEXITCODE" }
|
||||
Write-Host "[WSL] Installation classique réussie." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# === Début du script ===
|
||||
|
||||
Write-Host "=== Installation / réparation WSL2 ===" -ForegroundColor Green
|
||||
|
||||
if (Test-Wsl2Ready) {
|
||||
Write-Host "WSL2 est déjà prêt. Rien à faire." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "WSL2 n'est pas détecté. Lancement de l'installation..." -ForegroundColor Yellow
|
||||
|
||||
$rebootNeeded = $false
|
||||
$rebootNeeded = (Enable-WindowsFeatureIfNeeded -FeatureName "Microsoft-Windows-Subsystem-Linux") -or $rebootNeeded
|
||||
$rebootNeeded = (Enable-WindowsFeatureIfNeeded -FeatureName "VirtualMachinePlatform") -or $rebootNeeded
|
||||
|
||||
if ($rebootNeeded) {
|
||||
Write-Host "`nUn redémarrage est nécessaire pour activer les fonctionnalités Windows." -ForegroundColor Yellow
|
||||
Write-Host "Après le redémarrage, relance ce script pour terminer l'installation de WSL2." -ForegroundColor Yellow
|
||||
|
||||
$response = Read-Host "Redémarrer maintenant ? (O/N)"
|
||||
if ($response -eq "O" -or $response -eq "o") {
|
||||
Restart-Computer -Force
|
||||
}
|
||||
exit 3010
|
||||
}
|
||||
|
||||
Write-Host "[WSL] Définition de WSL2 comme version par défaut..." -ForegroundColor Cyan
|
||||
& wsl.exe --set-default-version 2 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
if ($LASTEXITCODE -ne 0) { throw "wsl --set-default-version 2 a échoué (code $LASTEXITCODE)." }
|
||||
|
||||
Write-Host "[WSL] Mise à jour du noyau WSL2..." -ForegroundColor Cyan
|
||||
& wsl.exe --update 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
# 3010 = succès mais redémarrage possible
|
||||
if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 3010) { throw "wsl --update a échoué (code $LASTEXITCODE)." }
|
||||
|
||||
Install-Wsl2
|
||||
|
||||
if (Test-Wsl2Ready) {
|
||||
Write-Host "`nWSL2 est maintenant prêt." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
else {
|
||||
Write-Host "`nWSL2 ne semble toujours pas prêt. Essayez de redémarrer et de relancer le script." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
; studioE5 Agent Installer (Inno Setup)
|
||||
; Build with Inno Setup Compiler (ISCC) on Windows.
|
||||
; This installer bundles the agent and Tailscale binaries. It checks
|
||||
; prerequisites and guides the user through installing missing system
|
||||
; components (WSL2 + Podman) before installing studioE5.
|
||||
|
||||
#define MyAppName "studioE5 Agent"
|
||||
#define MyAppVersion "0.3.17"
|
||||
#define MyAppPublisher "studioE5"
|
||||
#define MyAppURL "https://studioe5.edudeploy.com"
|
||||
#define MyAppExeName "studioE5-agent.exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{studioE5-agent-ondemand}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL={#MyAppURL}
|
||||
AppUpdatesURL={#MyAppURL}
|
||||
DefaultDirName={autopf}\studioE5-agent
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir=..\..\installer-output
|
||||
OutputBaseFilename=studioE5-agent-{#MyAppVersion}-setup
|
||||
Compression=lzma
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=admin
|
||||
ArchitecturesAllowed=x64
|
||||
ArchitecturesInstallIn64BitMode=x64
|
||||
[Languages]
|
||||
Name: "french"; MessagesFile: "compiler:Languages\French.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "..\..\agent\studioE5-agent.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "..\..\agent\tailscale-bin\windows\tailscale.exe"; DestDir: "{app}\tailscale-bin\windows"; Flags: ignoreversion
|
||||
Source: "..\..\agent\tailscale-bin\windows\tailscaled.exe"; DestDir: "{app}\tailscale-bin\windows"; Flags: ignoreversion
|
||||
Source: "..\..\agent\tailscale-bin\windows\wintun.dll"; DestDir: "{app}\tailscale-bin\windows"; Flags: ignoreversion
|
||||
Source: "..\..\README.md"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\studioE5 Agent"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\studioE5 Agent"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "Lancer studioE5 Agent"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[UninstallRun]
|
||||
Filename: "{cmd}"; Parameters: "/c taskkill /f /im studioE5-agent.exe"; Flags: runhidden waituntilterminated
|
||||
|
||||
[Code]
|
||||
var
|
||||
PrereqPage: TWizardPage;
|
||||
lblStatus: TLabel;
|
||||
btnCheck: TButton;
|
||||
|
||||
function GetPhysicallyInstalledSystemMemoryKB(var TotalMemoryInKilobytes: Int64): Boolean;
|
||||
external 'GetPhysicallyInstalledSystemMemory@kernel32.dll stdcall';
|
||||
|
||||
function GetTotalPhysicalMemoryMB(): Cardinal;
|
||||
var
|
||||
MemKB: Int64;
|
||||
begin
|
||||
if GetPhysicallyInstalledSystemMemoryKB(MemKB) then
|
||||
Result := Cardinal(MemKB div 1024)
|
||||
else
|
||||
Result := 0;
|
||||
end;
|
||||
|
||||
function IsWSL2Installed(): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := Exec('wsl.exe', '--version', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0);
|
||||
end;
|
||||
|
||||
function IsPodmanReady(): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := Exec('podman.exe', 'machine list', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0);
|
||||
end;
|
||||
|
||||
function GetDiskFreeSpaceEx(
|
||||
lpDirectoryName: string;
|
||||
var lpFreeBytesAvailableToCaller: Int64;
|
||||
var lpTotalNumberOfBytes: Int64;
|
||||
var lpTotalNumberOfFreeBytes: Int64
|
||||
): Boolean;
|
||||
external 'GetDiskFreeSpaceExW@kernel32.dll stdcall';
|
||||
|
||||
function GetFreeDiskSpaceMB(const Path: string): Cardinal;
|
||||
var
|
||||
FreeBytes, TotalBytes: Int64;
|
||||
Dummy: Int64;
|
||||
begin
|
||||
if GetDiskFreeSpaceEx(Path, FreeBytes, TotalBytes, Dummy) then
|
||||
Result := Cardinal(FreeBytes div (1024 * 1024))
|
||||
else
|
||||
Result := 0;
|
||||
end;
|
||||
|
||||
procedure UpdatePrereqStatus();
|
||||
var
|
||||
Msg: string;
|
||||
RamMB, FreeMB: Cardinal;
|
||||
WSLReady, PodmanReady: Boolean;
|
||||
begin
|
||||
RamMB := GetTotalPhysicalMemoryMB();
|
||||
FreeMB := GetFreeDiskSpaceMB('C:\');
|
||||
WSLReady := IsWSL2Installed();
|
||||
PodmanReady := IsPodmanReady();
|
||||
|
||||
Msg := 'Vérification des prérequis :' + #13#10#13#10;
|
||||
|
||||
if RamMB >= 8192 then
|
||||
Msg := Msg + '✅ Mémoire vive (RAM) : ' + IntToStr(RamMB div 1024) + ' Go' + #13#10
|
||||
else
|
||||
Msg := Msg + '⚠️ Mémoire vive (RAM) : ' + IntToStr(RamMB div 1024) + ' Go (8 Go recommandés)' + #13#10;
|
||||
|
||||
if FreeMB >= 10240 then
|
||||
Msg := Msg + '✅ Espace disque disponible : ' + IntToStr(FreeMB div 1024) + ' Go' + #13#10
|
||||
else
|
||||
Msg := Msg + '⚠️ Espace disque disponible : ' + IntToStr(FreeMB div 1024) + ' Go (10 Go recommandés)' + #13#10;
|
||||
|
||||
if WSLReady then
|
||||
Msg := Msg + '✅ Environnement de virtualisation (WSL2) installé' + #13#10
|
||||
else
|
||||
Msg := Msg + '❌ Environnement de virtualisation (WSL2) non installé' + #13#10;
|
||||
|
||||
if PodmanReady then
|
||||
Msg := Msg + '✅ Service de conteneurs (Podman) prêt' + #13#10
|
||||
else
|
||||
Msg := Msg + '❌ Service de conteneurs (Podman) non prêt' + #13#10;
|
||||
|
||||
Msg := Msg + #13#10;
|
||||
|
||||
if WSLReady and PodmanReady and (RamMB >= 4096) and (FreeMB >= 5120) then
|
||||
Msg := Msg + 'Tous les prérequis sont satisfaits. Vous pouvez installer studioE5 Agent.'
|
||||
else
|
||||
begin
|
||||
Msg := Msg + 'Ordre d''installation recommandé :' + #13#10;
|
||||
if not WSLReady then
|
||||
Msg := Msg + '1. Installer WSL2 : ouvrir PowerShell en administrateur et exécuter : wsl --install --no-distribution' + #13#10;
|
||||
if not PodmanReady then
|
||||
Msg := Msg + '2. Installer Podman : télécharger et exécuter le MSI depuis https://github.com/containers/podman/releases' + #13#10;
|
||||
if not PodmanReady then
|
||||
Msg := Msg + '3. Initialiser Podman : podman machine init && podman machine start' + #13#10;
|
||||
Msg := Msg + #13#10 + 'Après avoir installé les éléments manquants, relancez cet installateur.';
|
||||
end;
|
||||
|
||||
lblStatus.Caption := Msg;
|
||||
end;
|
||||
|
||||
procedure btnCheckClick(Sender: TObject);
|
||||
begin
|
||||
UpdatePrereqStatus();
|
||||
end;
|
||||
|
||||
procedure InitializeWizard();
|
||||
begin
|
||||
PrereqPage := CreateCustomPage(wpWelcome, 'Vérification des prérequis', 'Assurez-vous que votre poste est prêt avant d''installer studioE5 Agent.');
|
||||
|
||||
lblStatus := TLabel.Create(WizardForm);
|
||||
lblStatus.Parent := PrereqPage.Surface;
|
||||
lblStatus.Left := 0;
|
||||
lblStatus.Top := 0;
|
||||
lblStatus.Width := PrereqPage.SurfaceWidth;
|
||||
lblStatus.Height := 220;
|
||||
lblStatus.AutoSize := False;
|
||||
lblStatus.WordWrap := True;
|
||||
|
||||
btnCheck := TButton.Create(WizardForm);
|
||||
btnCheck.Parent := PrereqPage.Surface;
|
||||
btnCheck.Left := 0;
|
||||
btnCheck.Top := lblStatus.Top + lblStatus.Height + 12;
|
||||
btnCheck.Width := 160;
|
||||
btnCheck.Height := 25;
|
||||
btnCheck.Caption := 'Vérifier les prérequis';
|
||||
btnCheck.OnClick := @btnCheckClick;
|
||||
|
||||
UpdatePrereqStatus();
|
||||
end;
|
||||
|
||||
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||
begin
|
||||
Result := True;
|
||||
if CurPageID = PrereqPage.ID then
|
||||
begin
|
||||
if not (IsWSL2Installed() and IsPodmanReady()) then
|
||||
begin
|
||||
MsgBox('Certains prérequis sont manquants. Veuillez les installer avant de continuer.', mbError, MB_OK);
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
@@ -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
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
type podmanMachine struct {
|
||||
Name string `json:"name"`
|
||||
Running bool `json:"running"`
|
||||
VMType string `json:"vm_type"`
|
||||
}
|
||||
|
||||
// ensurePodmanMachineDNS configures public DNS resolvers on running Podman
|
||||
// machines on Windows and macOS. This is required because the Podman VM does
|
||||
// not always inherit a working DNS from the host, which prevents pulling
|
||||
// images and reaching api.wordpress.org from containers.
|
||||
func ensurePodmanMachineDNS() {
|
||||
if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
|
||||
return
|
||||
}
|
||||
if getContainerEngine() != "podman" {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := exec.Command("podman", "machine", "list", "--format", "json").Output()
|
||||
if err != nil {
|
||||
log.Printf("ensurePodmanMachineDNS: cannot list machines: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var machines []podmanMachine
|
||||
if err := json.Unmarshal(out, &machines); err != nil {
|
||||
log.Printf("ensurePodmanMachineDNS: cannot parse machine list: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, m := range machines {
|
||||
if !m.Running {
|
||||
continue
|
||||
}
|
||||
if err := configurePodmanMachineDNS(m.Name); err != nil {
|
||||
log.Printf("ensurePodmanMachineDNS: failed for %s: %v", m.Name, err)
|
||||
} else {
|
||||
log.Printf("ensurePodmanMachineDNS: DNS configured for %s", m.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func configurePodmanMachineDNS(name string) error {
|
||||
cmd := exec.Command(
|
||||
"podman", "machine", "ssh", name,
|
||||
"sudo", "sh", "-c",
|
||||
"echo nameserver 8.8.8.8 > /etc/resolv.conf && echo nameserver 1.1.1.1 >> /etc/resolv.conf",
|
||||
)
|
||||
hideWindow(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
ProxyModeDisabled = "disabled"
|
||||
ProxyModeAuto = "auto"
|
||||
ProxyModeEnabled = "enabled"
|
||||
)
|
||||
|
||||
// autoProxyLockDuration is the minimum time we stay in proxy mode once the
|
||||
// agent automatically switched to it. This prevents flip-flopping on short
|
||||
// network blips.
|
||||
const autoProxyLockDuration = 5 * time.Minute
|
||||
|
||||
// proxyState tracks the runtime proxy decision in "auto" mode. It is guarded
|
||||
// by proxyMu.
|
||||
var (
|
||||
proxyMu sync.RWMutex
|
||||
proxyActive bool
|
||||
proxyLockedUntil time.Time
|
||||
)
|
||||
|
||||
// proxyMode normalizes the configured proxy mode.
|
||||
func proxyMode(cfg *AgentConfig) string {
|
||||
if cfg == nil {
|
||||
return ProxyModeDisabled
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.ProxyMode)) {
|
||||
case ProxyModeEnabled:
|
||||
return ProxyModeEnabled
|
||||
case ProxyModeAuto:
|
||||
return ProxyModeAuto
|
||||
default:
|
||||
return ProxyModeDisabled
|
||||
}
|
||||
}
|
||||
|
||||
// IsProxyActive reports whether outbound requests should currently go through
|
||||
// the configured proxy. In "enabled" mode it always returns true; in "auto"
|
||||
// mode it reflects the last automatic decision.
|
||||
func IsProxyActive() bool {
|
||||
proxyMu.RLock()
|
||||
defer proxyMu.RUnlock()
|
||||
return proxyActive
|
||||
}
|
||||
|
||||
// setProxyActive updates the runtime proxy decision and, in auto mode, locks
|
||||
// the decision for autoProxyLockDuration to avoid flip-flopping.
|
||||
func setProxyActive(active bool) bool {
|
||||
proxyMu.Lock()
|
||||
defer proxyMu.Unlock()
|
||||
changed := proxyActive != active
|
||||
proxyActive = active
|
||||
if active {
|
||||
proxyLockedUntil = time.Now().Add(autoProxyLockDuration)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// resetProxyState disables the automatic proxy decision. Call this when the
|
||||
// configuration changes.
|
||||
func resetProxyState() {
|
||||
proxyMu.Lock()
|
||||
proxyActive = false
|
||||
proxyLockedUntil = time.Time{}
|
||||
proxyMu.Unlock()
|
||||
}
|
||||
|
||||
// canRetryDirect reports whether enough time has passed to try a direct
|
||||
// connection again while in auto-proxy mode.
|
||||
func canRetryDirect() bool {
|
||||
proxyMu.RLock()
|
||||
defer proxyMu.RUnlock()
|
||||
return time.Now().After(proxyLockedUntil)
|
||||
}
|
||||
|
||||
// proxyURL parses and validates the configured proxy URL.
|
||||
func proxyURL(cfg *AgentConfig) *url.URL {
|
||||
if cfg == nil || cfg.ProxyURL == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(cfg.ProxyURL)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// proxyFunc returns a proxy selection function for http.Transport. It returns
|
||||
// nil when the proxy should not be used.
|
||||
func proxyFunc(cfg *AgentConfig) func(*http.Request) (*url.URL, error) {
|
||||
mode := proxyMode(cfg)
|
||||
u := proxyURL(cfg)
|
||||
|
||||
switch mode {
|
||||
case ProxyModeEnabled:
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return func(*http.Request) (*url.URL, error) { return u, nil }
|
||||
case ProxyModeAuto:
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
if !IsProxyActive() {
|
||||
return nil
|
||||
}
|
||||
return func(*http.Request) (*url.URL, error) { return u, nil }
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// websocketDialer returns a websocket.Dialer configured for the current proxy
|
||||
// mode and state.
|
||||
func websocketDialer(cfg *AgentConfig) *websocket.Dialer {
|
||||
d := websocket.DefaultDialer
|
||||
fn := proxyFunc(cfg)
|
||||
if fn == nil {
|
||||
return d
|
||||
}
|
||||
return &websocket.Dialer{
|
||||
Proxy: fn,
|
||||
HandshakeTimeout: d.HandshakeTimeout,
|
||||
ReadBufferSize: d.ReadBufferSize,
|
||||
WriteBufferSize: d.WriteBufferSize,
|
||||
EnableCompression: d.EnableCompression,
|
||||
}
|
||||
}
|
||||
|
||||
// httpClientWithProxy returns an http.Client configured for the current proxy
|
||||
// mode and state.
|
||||
func httpClientWithProxy(cfg *AgentConfig) *http.Client {
|
||||
fn := proxyFunc(cfg)
|
||||
if fn == nil {
|
||||
return http.DefaultClient
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{Proxy: fn},
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
)
|
||||
|
||||
//go:embed icon.png
|
||||
var iconBytes []byte
|
||||
|
||||
const uiURL = "http://localhost:7070"
|
||||
|
||||
func runTray(appName string, shutdownCh chan struct{}) {
|
||||
systray.Run(func() { onTrayReady(appName, shutdownCh) }, func() { onTrayExit(shutdownCh) })
|
||||
}
|
||||
|
||||
func onTrayReady(appName string, shutdownCh chan struct{}) {
|
||||
systray.SetIcon(iconBytes)
|
||||
systray.SetTitle(appName)
|
||||
systray.SetTooltip(fmt.Sprintf("%s Agent - Cliquez pour ouvrir l'interface", appName))
|
||||
|
||||
mOpen := systray.AddMenuItem("Ouvrir l'interface", "Ouvrir l'interface web locale")
|
||||
mInstances := systray.AddMenuItem("Mes instances", "Afficher les instances")
|
||||
mSettings := systray.AddMenuItem("Paramètres", "Ouvrir les paramètres")
|
||||
systray.AddSeparator()
|
||||
mQuit := systray.AddMenuItem("Quitter", "Arrêter l'agent")
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-mOpen.ClickedCh:
|
||||
openBrowser(uiURL)
|
||||
case <-mInstances.ClickedCh:
|
||||
openBrowser(uiURL + "#instances")
|
||||
case <-mSettings.ClickedCh:
|
||||
openBrowser(uiURL + "#settings")
|
||||
case <-mQuit.ClickedCh:
|
||||
close(shutdownCh)
|
||||
systray.Quit()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func onTrayExit(shutdownCh chan struct{}) {
|
||||
log.Printf("Tray exit requested")
|
||||
// If the user did not already trigger shutdown via the menu, signal it now.
|
||||
select {
|
||||
case <-shutdownCh:
|
||||
default:
|
||||
close(shutdownCh)
|
||||
}
|
||||
// Give other goroutines a moment to clean up, then exit.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func openBrowser(url string) {
|
||||
var cmd string
|
||||
var args []string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = "rundll32"
|
||||
args = []string{"url.dll,FileProtocolHandler", url}
|
||||
case "darwin":
|
||||
cmd = "open"
|
||||
args = []string{url}
|
||||
default:
|
||||
cmd = "xdg-open"
|
||||
args = []string{url}
|
||||
}
|
||||
|
||||
openCmd := exec.Command(cmd, args...)
|
||||
hideWindow(openCmd)
|
||||
if err := openCmd.Start(); err != nil {
|
||||
log.Printf("Failed to open browser: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeName(name string) string {
|
||||
return strings.ReplaceAll(name, " ", "")
|
||||
}
|
||||
+262
-60
@@ -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")
|
||||
}
|
||||
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 stopTailscale() {
|
||||
tsCmdMu.Lock()
|
||||
defer tsCmdMu.Unlock()
|
||||
stopTailscaleLocked()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+268
@@ -0,0 +1,268 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const updateCheckInterval = 15 * time.Minute
|
||||
|
||||
// AgentVersionInfo matches the server's /api/agent/version response.
|
||||
type AgentVersionInfo struct {
|
||||
Version string `json:"version"`
|
||||
DownloadUrls struct {
|
||||
Windows string `json:"windows"`
|
||||
WindowsZip string `json:"windowsZip"`
|
||||
Linux string `json:"linux"`
|
||||
Mac string `json:"mac"`
|
||||
} `json:"downloadUrls"`
|
||||
}
|
||||
|
||||
// httpBaseURL converts a WebSocket server URL into the corresponding HTTP(S)
|
||||
// base URL, stripping the /api/websocket path if present.
|
||||
func httpBaseURL(serverURL string) string {
|
||||
u := serverURL
|
||||
switch {
|
||||
case strings.HasPrefix(u, "wss://"):
|
||||
u = "https://" + strings.TrimPrefix(u, "wss://")
|
||||
case strings.HasPrefix(u, "ws://"):
|
||||
u = "http://" + strings.TrimPrefix(u, "ws://")
|
||||
}
|
||||
u = strings.TrimSuffix(u, "/api/websocket/")
|
||||
u = strings.TrimSuffix(u, "/api/websocket")
|
||||
return strings.TrimSuffix(u, "/")
|
||||
}
|
||||
|
||||
// checkForUpdate fetches the latest agent version from the server and compares
|
||||
// it with the running binary's version.
|
||||
func checkForUpdate(cfg *AgentConfig) (*AgentVersionInfo, bool, error) {
|
||||
if cfg == nil || cfg.Server == "" {
|
||||
return nil, false, fmt.Errorf("no server URL configured")
|
||||
}
|
||||
url := httpBaseURL(cfg.Server) + "/api/agent/version"
|
||||
client := httpClientWithProxy(cfg)
|
||||
client.Timeout = 30 * time.Second
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, false, fmt.Errorf("server returned %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var info AgentVersionInfo
|
||||
if err := json.Unmarshal(body, &info); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if info.Version == "" {
|
||||
return nil, false, fmt.Errorf("server returned empty version")
|
||||
}
|
||||
available := info.Version != version
|
||||
return &info, available, nil
|
||||
}
|
||||
|
||||
// downloadUpdate downloads the new agent binary to the update directory.
|
||||
func downloadUpdate(cfg *AgentConfig, dataDir, downloadURL string) (string, error) {
|
||||
updateDir := filepath.Join(dataDir, "update")
|
||||
if err := os.MkdirAll(updateDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ext := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
ext = ".exe"
|
||||
}
|
||||
dest := filepath.Join(updateDir, "studioE5-agent-new"+ext)
|
||||
log.Printf("Downloading update from %s to %s", downloadURL, dest)
|
||||
|
||||
client := httpClientWithProxy(cfg)
|
||||
resp, err := client.Get(downloadURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("download returned %d", resp.StatusCode)
|
||||
}
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(dest, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// formatArgsForShell returns the given arguments as a safely quoted string
|
||||
// suitable for embedding in shell/PowerShell scripts.
|
||||
func formatArgsForShell(args []string) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
quoted := make([]string, len(args))
|
||||
for i, a := range args {
|
||||
quoted[i] = strconv.Quote(a)
|
||||
}
|
||||
return strings.Join(quoted, " ")
|
||||
}
|
||||
|
||||
// applyUpdate replaces the running binary with the downloaded one using an
|
||||
// external helper script, then exits the current process. The new process is
|
||||
// started with the same arguments as the current one so that tray/console mode
|
||||
// is preserved.
|
||||
func applyUpdate(currentPath, newPath, dataDir string) error {
|
||||
pid := os.Getpid()
|
||||
restartArgs := os.Args[1:]
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return applyUpdateWindows(currentPath, newPath, dataDir, pid, restartArgs)
|
||||
default:
|
||||
return applyUpdateUnix(currentPath, newPath, dataDir, pid, restartArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func applyUpdateWindows(currentPath, newPath, dataDir string, pid int, restartArgs []string) error {
|
||||
scriptPath := filepath.Join(dataDir, "update", "apply-update.ps1")
|
||||
argsList := formatArgsForShell(restartArgs)
|
||||
if argsList == "" {
|
||||
argsList = ""
|
||||
} else {
|
||||
argsList = "$startArgs = @(" + argsList + ")"
|
||||
}
|
||||
script := fmt.Sprintf(`$old = "%s"
|
||||
$new = "%s"
|
||||
$targetPid = %d
|
||||
%s
|
||||
Wait-Process -Id $targetPid -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
Move-Item -Path $new -Destination $old -Force
|
||||
if ($startArgs) {
|
||||
Start-Process -FilePath $old -ArgumentList $startArgs -WindowStyle Hidden
|
||||
} else {
|
||||
Start-Process -FilePath $old -WindowStyle Hidden
|
||||
}
|
||||
`, currentPath, newPath, pid, argsList)
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command("powershell.exe", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", scriptPath)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
hideWindow(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Update helper started, exiting current process")
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyUpdateUnix(currentPath, newPath, dataDir string, pid int, restartArgs []string) error {
|
||||
scriptPath := filepath.Join(dataDir, "update", "apply-update.sh")
|
||||
argsList := formatArgsForShell(restartArgs)
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
set -e
|
||||
old="%s"
|
||||
new="%s"
|
||||
pid=%d
|
||||
while kill -0 "$pid" 2>/dev/null; do sleep 1; done
|
||||
sleep 2
|
||||
mv "$new" "$old"
|
||||
chmod +x "$old"
|
||||
nohup "$old" %s >/dev/null 2>&1 &
|
||||
`, currentPath, newPath, pid, argsList)
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command("/bin/bash", scriptPath)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Update helper started, exiting current process")
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// startAgentUpdate performs the full update flow: download + replace + restart.
|
||||
func startAgentUpdate(cfg *AgentConfig, dataDir string) error {
|
||||
info, available, err := checkForUpdate(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update check failed: %w", err)
|
||||
}
|
||||
if !available {
|
||||
return fmt.Errorf("no update available")
|
||||
}
|
||||
currentPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentPath, err = filepath.Abs(currentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var downloadURL string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
downloadURL = info.DownloadUrls.Windows
|
||||
case "darwin":
|
||||
downloadURL = info.DownloadUrls.Mac
|
||||
default:
|
||||
downloadURL = info.DownloadUrls.Linux
|
||||
}
|
||||
if downloadURL == "" {
|
||||
return fmt.Errorf("no download URL for %s", runtime.GOOS)
|
||||
}
|
||||
newPath, err := downloadUpdate(cfg, dataDir, downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
log.Printf("Applying update to version %s", info.Version)
|
||||
broadcastUI(map[string]interface{}{
|
||||
"action": "update_progress",
|
||||
"percent": "90",
|
||||
"message": "Redémarrage de l'agent...",
|
||||
})
|
||||
return applyUpdate(currentPath, newPath, dataDir)
|
||||
}
|
||||
|
||||
// updateCheckerLoop periodically checks for agent updates and notifies the UI.
|
||||
func updateCheckerLoop(cfg *AgentConfig, dataDir string) {
|
||||
for {
|
||||
info, available, err := checkForUpdate(cfg)
|
||||
if err == nil && available && info != nil {
|
||||
log.Printf("Agent update available: %s (current: %s)", info.Version, version)
|
||||
setServerAgentVersion(info.Version)
|
||||
broadcastUI(map[string]interface{}{
|
||||
"action": "update_available",
|
||||
"version": info.Version,
|
||||
"update_available": true,
|
||||
})
|
||||
}
|
||||
time.Sleep(updateCheckInterval)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHTTPBaseURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"wss://studioe5.edudeploy.com/api/websocket", "https://studioe5.edudeploy.com"},
|
||||
{"ws://localhost:3000/api/websocket", "http://localhost:3000"},
|
||||
{"https://studioe5.edudeploy.com/api/websocket", "https://studioe5.edudeploy.com"},
|
||||
{"https://studioe5.edudeploy.com", "https://studioe5.edudeploy.com"},
|
||||
{"wss://example.com/api/websocket/", "https://example.com"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := httpBaseURL(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("httpBaseURL(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+468
-97
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
# Deployeur studioE5 — Onboarding d’un nouvel établissement
|
||||
|
||||
## Objectif
|
||||
|
||||
Ce document décrit le fonctionnement du **deployeur studioE5**, c’est-à-dire l’application / l’outil qui provisionne et configure un nouvel environnement client (un établissement) prêt à accueillir l’application studioE5.
|
||||
|
||||
L’application studioE5 elle-même (agent, VPN on-demand, resolver, Caddy, Headscale, etc.) est documentée dans `SUIVI_VPN_ONDEMAND.md`. Le deployeur est l’outil qui **déploie** cette application sur un VPS dédié au client.
|
||||
|
||||
---
|
||||
|
||||
## Public cible
|
||||
|
||||
- Équipe produit / développement du deployeur
|
||||
- Équipe ops / déploiement
|
||||
- Référents techniques du client A
|
||||
|
||||
---
|
||||
|
||||
## Glossaire
|
||||
|
||||
| Terme | Définition |
|
||||
|-------|------------|
|
||||
| **Deployeur** | Application qui provisionne un VPS, déploie la stack studioE5 et configure le DNS/certificats pour un nouvel établissement. |
|
||||
| **Hub central** | Dashboard superadmin studioE5 qui orchestre les déploiements et la gestion multi-clients. |
|
||||
| **Établissement** | Entité client (école, lycée, université, entreprise). |
|
||||
| **Tag établissement** | Slug unique et court identifiant l’établissement dans les URLs et le DNS. |
|
||||
| **Domaine géré** | Sous-domaine fourni par studioE5 : `*.tag.edudeploy.com`. |
|
||||
| **Domaine propre** | Domaine détenu par l’établissement : `*.tag.monetablissement.fr`. |
|
||||
| **Application studioE5** | Stack complète déployée sur le VPS : `server`, `resolver`, `resolver-vpn`, `caddy`, `headscale`, `postgres`, etc. |
|
||||
| **Agent générique** | Binaire agent unique, capable de se connecter à n’importe quel serveur studioE5 via résolution d’URL à l’activation. |
|
||||
|
||||
---
|
||||
|
||||
## Architecture : deployeur vs application studioE5
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Hub central studioE5 │
|
||||
│ (superadmin, gestion des établissements, monitoring) │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Deployeur studioE5 │
|
||||
│ (provisionning VPS, DNS, certificats, déploiement stack) │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Application studioE5 (un par client) │
|
||||
│ Caddy — Resolver — Resolver-VPN — Headscale — Server — DB │
|
||||
│ ▲ │
|
||||
│ │ WebSocket / VPN on-demand │
|
||||
│ ▼ │
|
||||
│ Agent élève (Windows/Linux) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flux d’onboarding par le deployeur (vue d’ensemble)
|
||||
|
||||
```
|
||||
Création de l’établissement dans le hub
|
||||
↓
|
||||
Choix du domaine (géré ou propre)
|
||||
↓
|
||||
Génération du tag établissement
|
||||
↓
|
||||
Provisionning du VPS
|
||||
↓
|
||||
Configuration DNS wildcard
|
||||
↓
|
||||
Génération du certificat wildcard
|
||||
↓
|
||||
Déploiement de la stack studioE5 (Docker Compose)
|
||||
↓
|
||||
Initialisation de Headscale et création des clés
|
||||
↓
|
||||
Création du compte administrateur de l’établissement
|
||||
↓
|
||||
Génération des codes d’activation
|
||||
↓
|
||||
Build et mise à disposition de l’agent dédié
|
||||
↓
|
||||
Activation de l’agent par un élève
|
||||
↓
|
||||
Création d’une première instance (validation du déploiement)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Création de l’établissement dans le hub
|
||||
|
||||
Le superadmin crée un nouvel établissement dans le hub central.
|
||||
|
||||
Données minimales :
|
||||
|
||||
- Nom officiel
|
||||
- Type d’établissement (école, lycée, université, entreprise)
|
||||
- Pays / fuseau horaire
|
||||
- Contact administrateur
|
||||
- Choix du mode de domaine (`managed` ou `custom`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Choix du domaine
|
||||
|
||||
### Option A — Domaine géré par studioE5 (MVP)
|
||||
|
||||
Le deployeur crée automatiquement un sous-domaine du domaine maître :
|
||||
|
||||
```
|
||||
*.tag.edudeploy.com
|
||||
```
|
||||
|
||||
Le hub gère le DNS chez le registrar studioE5 (actuellement Infomaniak).
|
||||
|
||||
### Option B — Domaine propre de l’établissement (évolution)
|
||||
|
||||
L’établissement fournit son propre domaine :
|
||||
|
||||
```
|
||||
*.tag.monetablissement.fr
|
||||
```
|
||||
|
||||
Prérequis :
|
||||
|
||||
- Le client pointe son DNS wildcard vers l’IP du VPS provisionné.
|
||||
- Le deployeur dispose d’un token API du registrar du client pour le challenge DNS-01.
|
||||
|
||||
---
|
||||
|
||||
## 3. Génération du tag établissement
|
||||
|
||||
Le tag est un slug court, unique au niveau du hub, utilisé dans les URLs et le DNS.
|
||||
|
||||
### Règles
|
||||
|
||||
- Uniquement `[a-z0-9-]`
|
||||
- Pas de tiret au début ni à la fin
|
||||
- Longueur conseillée : 2 à 20 caractères
|
||||
- Vérification d’unicité en base
|
||||
|
||||
### Exemples
|
||||
|
||||
| Nom d’établissement | Tag |
|
||||
|---------------------|-----|
|
||||
| Lycée Jules Ferry | `ljf` |
|
||||
| Institut Supérieur du Digital | `isd` |
|
||||
| École Notre-Dame | `end` |
|
||||
|
||||
### Gestion des collisions
|
||||
|
||||
- `ljf` → `ljf-2`, `ljf-3`, etc.
|
||||
|
||||
---
|
||||
|
||||
## 4. Provisionning du VPS
|
||||
|
||||
Le deployeur provisionne un VPS dédié pour l’établissement.
|
||||
|
||||
### Prérequis sur le VPS vierge
|
||||
|
||||
- OS Linux (Ubuntu LTS recommandé)
|
||||
- Docker + Docker Compose installés
|
||||
- Accès SSH avec clé
|
||||
- Ports ouverts : 22, 80, 443
|
||||
|
||||
### Actions automatisées par le deployeur
|
||||
|
||||
1. Installation de Docker et Docker Compose si absent.
|
||||
2. Création de la structure de dossiers (`/opt/studioe5-<tag>/`).
|
||||
3. Génération des secrets (`.env`) :
|
||||
- `INTERNAL_API_KEY`
|
||||
- `HEADSCALE_API_KEY`
|
||||
- `HEADSCALE_AUTH_KEY` (réutilisable, taguée `tag:student-agent`)
|
||||
- `HEADSCALE_RESOLVER_AUTH_KEY`
|
||||
- `INFOMANIAK_API_TOKEN` (si domaine géré)
|
||||
- `NEXTAUTH_SECRET`, `DATABASE_URL`, etc.
|
||||
4. Récupération des images Docker depuis le registry privé (ou build sur place en attendant).
|
||||
5. Génération des fichiers de configuration (`Caddyfile`, `docker-compose.yml`, `headscale/config.yaml`, `headscale/acl_policy.hujson`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Configuration DNS wildcard
|
||||
|
||||
### Domaine géré
|
||||
|
||||
Le deployeur appelle l’API du registrar pour créer :
|
||||
|
||||
```dns
|
||||
*.tag.edudeploy.com A <IP_DU_VPS>
|
||||
```
|
||||
|
||||
### Domaine propre
|
||||
|
||||
Le deployeur vérifie que l’enregistrement existe :
|
||||
|
||||
```dns
|
||||
*.tag.monetablissement.fr A <IP_DU_VPS>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Certificat wildcard
|
||||
|
||||
### Principe
|
||||
|
||||
Un seul certificat wildcard couvre toutes les instances futures de l’établissement.
|
||||
|
||||
### Mise en œuvre avec Caddy
|
||||
|
||||
Le deployeur génère le `Caddyfile` :
|
||||
|
||||
```caddy
|
||||
*.tag.edudeploy.com {
|
||||
tls {
|
||||
dns infomaniak {env.INFOMANIAK_API_TOKEN}
|
||||
}
|
||||
|
||||
reverse_proxy resolver:2020 {
|
||||
header_up Host {host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pour un domaine propre, le provider DNS est celui du client.
|
||||
|
||||
### Renouvellement
|
||||
|
||||
Géré automatiquement par Caddy.
|
||||
|
||||
---
|
||||
|
||||
## 7. Déploiement de la stack studioE5
|
||||
|
||||
Le deployeur lance la stack Docker Compose complète :
|
||||
|
||||
```bash
|
||||
cd /opt/studioe5-<tag>
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Services déployés :
|
||||
|
||||
- `server` : API + WebSocket + UI Next.js
|
||||
- `resolver` : reverse proxy interne vers les instances
|
||||
- `resolver-vpn` : sidecar Tailscale dans le netns du resolver
|
||||
- `caddy` : reverse proxy public + TLS
|
||||
- `headscale` : contrôleur Tailscale
|
||||
- `postgres` : base de données
|
||||
|
||||
---
|
||||
|
||||
## 8. Initialisation de Headscale
|
||||
|
||||
Le deployeur initialise Headscale et crée les clés nécessaires :
|
||||
|
||||
```bash
|
||||
# Création de l’utilisateur dédié au resolver
|
||||
docker compose exec headscale headscale users create resolver
|
||||
|
||||
# Création de la clé pré-auth réutilisable pour les agents
|
||||
docker compose exec headscale headscale preauthkeys create \
|
||||
--user studioe5 \
|
||||
--reusable \
|
||||
--tags tag:student-agent \
|
||||
-e 87600h
|
||||
|
||||
# Création de la clé pré-auth pour le resolver
|
||||
docker compose exec headscale headscale preauthkeys create \
|
||||
--user resolver \
|
||||
--tags tag:resolver \
|
||||
-e 87600h
|
||||
|
||||
# Création d’une clé API Headscale valable 10 ans
|
||||
docker compose exec headscale headscale apikeys create -e 87600h
|
||||
```
|
||||
|
||||
Ces secrets sont stockés dans le `.env` du serveur.
|
||||
|
||||
---
|
||||
|
||||
## 9. Création du compte administrateur de l’établissement
|
||||
|
||||
Une fois la stack déployée, le deployeur (ou le hub) crée le premier compte administrateur de l’établissement via l’API du serveur nouvellement déployé.
|
||||
|
||||
Rôles :
|
||||
|
||||
- `admin` : gestion des élèves, instances, agents.
|
||||
- `teacher` : gestion limitée à certaines classes/groupes.
|
||||
- `superadmin` (studioE5) : accès transverse.
|
||||
|
||||
L’administrateur reçoit un lien d’activation sécurisé.
|
||||
|
||||
---
|
||||
|
||||
## 10. Génération des codes d’activation
|
||||
|
||||
Le deployeur configure le serveur pour permettre la génération de codes d’activation.
|
||||
|
||||
### Règles de sécurité (implémentées côté application studioE5)
|
||||
|
||||
- Génération avec `crypto.randomBytes`
|
||||
- Alphabet sans ambiguïté : `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`
|
||||
- 6 caractères
|
||||
- Expiration après 60 minutes
|
||||
- Invalidation après usage
|
||||
- Rate-limiting : 5 tentatives par code / 5 tentatives par `nodeId` sur 15 minutes
|
||||
|
||||
### Flux
|
||||
|
||||
1. L’administrateur génère un code pour un élève.
|
||||
2. L’élève saisit le code dans l’agent.
|
||||
3. Le serveur valide et renvoie :
|
||||
- l’identité de l’élève
|
||||
- l’URL Headscale
|
||||
- une clé pré-auth Headscale éphémère
|
||||
4. L’agent démarre automatiquement le VPN.
|
||||
|
||||
---
|
||||
|
||||
## 11. Build et mise à disposition de l’agent
|
||||
|
||||
### Principe
|
||||
|
||||
L’agent est un binaire générique, mais il doit être capable de se connecter au bon serveur. Le deployeur génère un agent pré-configuré ou un installeur qui embarque l’URL du serveur de l’établissement.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd /opt/studioe5-<tag>/agent
|
||||
./download-tailscale-bins.sh 1.98.4
|
||||
./build.sh
|
||||
```
|
||||
|
||||
Artifacts générés :
|
||||
|
||||
- `studioE5-agent-vX.Y.Z-windows.zip`
|
||||
- `studioE5-agent-vX.Y.Z.exe`
|
||||
- `studioE5-agent-vX.Y.Z` (Linux)
|
||||
|
||||
### Mise à disposition
|
||||
|
||||
Les fichiers sont servis par Caddy depuis `server/public/agent/` :
|
||||
|
||||
```
|
||||
https://tag.edudeploy.com/studioE5-agent-vX.Y.Z-windows.zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Activation de l’agent
|
||||
|
||||
### Activation zéro-config
|
||||
|
||||
1. L’élève télécharge l’agent depuis l’URL de l’établissement.
|
||||
2. Il extrait l’archive et lance `studioE5-agent.exe`.
|
||||
3. Il ouvre `http://localhost:7070`.
|
||||
4. Il saisit le code d’activation à 6 caractères.
|
||||
5. L’agent contacte le serveur, récupère la configuration et démarre le VPN.
|
||||
|
||||
> Les détails techniques du VPN on-demand (named pipes Windows, logs, ACL, tokens, clés éphémères) sont documentés dans `SUIVI_VPN_ONDEMAND.md`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Création d’une instance et construction de l’URL (validation)
|
||||
|
||||
Le deployeur ou l’administrateur crée une première instance pour valider le déploiement.
|
||||
|
||||
### Format d’URL
|
||||
|
||||
```
|
||||
<appli>-<initiales><id-court>.<tag>.<domaine>
|
||||
```
|
||||
|
||||
Exemple :
|
||||
|
||||
```
|
||||
wp-jd47.ljf.edudeploy.com
|
||||
```
|
||||
|
||||
Avec :
|
||||
|
||||
- `wp` : type d’application
|
||||
- `jd` : initiales de l’élève
|
||||
- `47` : identifiant court unique
|
||||
- `ljf` : tag de l’établissement
|
||||
- `edudeploy.com` : domaine de base
|
||||
|
||||
### Mapping type d’application → préfixe
|
||||
|
||||
| Application | Préfixe |
|
||||
|-------------|---------|
|
||||
| WordPress | `wp` |
|
||||
| PrestaShop | `ps` |
|
||||
| Moodle | `mdl` |
|
||||
| Nextcloud | `nc` |
|
||||
|
||||
### Protection de l’identité
|
||||
|
||||
- L’URL ne contient pas le nom complet de l’élève.
|
||||
- Seules les initiales + un identifiant court opaque sont exposées.
|
||||
|
||||
---
|
||||
|
||||
## 14. Modèles de données du deployeur
|
||||
|
||||
### Table / modèle `Organization` (établissement dans le hub)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Lycée Jules Ferry",
|
||||
"tag": "ljf",
|
||||
"domainMode": "managed",
|
||||
"baseDomain": "edudeploy.com",
|
||||
"adminEmail": "admin@ljf.fr",
|
||||
"status": "active",
|
||||
"createdAt": "2026-06-25T17:28:07Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Table / modèle `Deployment` (déploiement sur un VPS)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"organizationId": "uuid",
|
||||
"serverIp": "203.0.113.10",
|
||||
"serverHostname": "ljf.studioe5.edudeploy.com",
|
||||
"wildcardDnsConfigured": true,
|
||||
"wildcardCertificateReady": true,
|
||||
"dnsProvider": "infomaniak",
|
||||
"dnsProviderTokenRef": "env:INFOMANIAK_TOKEN_LJF",
|
||||
"headscaleApiKeyRef": "env:HEADSCALE_API_KEY_LJF",
|
||||
"status": "ready",
|
||||
"deployedAt": "2026-06-25T17:28:07Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Table / modèle `Student` (dans l’application studioE5 déployée)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"organizationId": "uuid",
|
||||
"firstName": "Jean",
|
||||
"lastName": "Dupont",
|
||||
"initials": "jd",
|
||||
"activationCode": "AB3D9F",
|
||||
"activationCodeExpiresAt": "2026-06-25T18:28:07Z",
|
||||
"nodeId": "vps-8fc665eb",
|
||||
"nodeToken": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### Table / modèle `Instance` (dans l’application studioE5 déployée)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "cmqqgrur20001lw67t2bdgzkg",
|
||||
"organizationId": "uuid",
|
||||
"studentId": "uuid",
|
||||
"nodeId": "vps-8fc665eb",
|
||||
"templateId": "wordpress-wordpress-latest",
|
||||
"applicationPrefix": "wp",
|
||||
"shortId": "47",
|
||||
"subdomain": "wp-jd47",
|
||||
"fqdn": "wp-jd47.ljf.edudeploy.com",
|
||||
"port": 8001,
|
||||
"status": "running"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Sécurité et RGPD
|
||||
|
||||
### Protection de l’identité de l’élève
|
||||
|
||||
- L’URL publique ne contient pas le nom complet de l’élève.
|
||||
- Seules les initiales + un identifiant court opaque sont exposées.
|
||||
|
||||
### Isolation réseau
|
||||
|
||||
- Les agents élèves ne peuvent pas communiquer entre eux (ACL Headscale).
|
||||
- Le resolver est le seul service autorisé à joindre les agents sur leurs ports d’instance.
|
||||
|
||||
### Authentification
|
||||
|
||||
- Token unique par agent (`node.token`).
|
||||
- Clé API interne pour les endpoints serveur → agent.
|
||||
- Sessions NextAuth sur les routes API métier.
|
||||
|
||||
### Clés pré-auth Headscale
|
||||
|
||||
- Éphémères, à usage unique, 15 minutes d’expiration.
|
||||
- Non persistées côté agent.
|
||||
|
||||
---
|
||||
|
||||
## 16. Checklist de validation du deployeur
|
||||
|
||||
À l’issue d’un onboarding, les points suivants doivent être validés :
|
||||
|
||||
- [ ] L’établissement est créé dans le hub avec un tag unique.
|
||||
- [ ] Le VPS est provisionné et accessible en SSH.
|
||||
- [ ] Docker et Docker Compose sont installés.
|
||||
- [ ] Le DNS wildcard est résolu (`*.tag.edudeploy.com` → IP du VPS).
|
||||
- [ ] Le certificat wildcard est obtenu et valide.
|
||||
- [ ] La stack studioE5 est démarrée (`docker compose ps`).
|
||||
- [ ] Headscale est initialisé avec les utilisateurs et clés nécessaires.
|
||||
- [ ] Le compte administrateur de l’établissement est créé.
|
||||
- [ ] Un code d’activation peut être généré pour un élève.
|
||||
- [ ] L’agent est buildé et téléchargeable depuis le serveur de l’établissement.
|
||||
- [ ] L’agent s’active avec le code zéro-config.
|
||||
- [ ] Une instance peut être créée et son URL est accessible en HTTPS.
|
||||
- [ ] Deux instances différentes reçoivent des URL uniques.
|
||||
- [ ] Le flux HTTPS complet retourne bien HTTP 200.
|
||||
|
||||
---
|
||||
|
||||
## 17. Roadmap du deployeur
|
||||
|
||||
### Court terme (MVP)
|
||||
|
||||
- Déploiement manuel ou semi-automatisé d’un nouvel établissement sur un VPS.
|
||||
- Domaine géré par studioE5 uniquement.
|
||||
- Build des images sur le VPS cible.
|
||||
- Agent avec URL serveur hardcodée ou fournie à l’activation.
|
||||
|
||||
### Moyen terme
|
||||
|
||||
- **Agent générique** : déterminer l’URL serveur cible à l’activation (code structuré, hub de résolution, ou champ URL).
|
||||
- **Script de provisionning** : installation Docker, déploiement stack, génération secrets, DNS wildcard.
|
||||
- **Registry d’images privé** : builder une fois, déployer partout.
|
||||
- Support de domaines propres à l’établissement.
|
||||
- Support multi-registrar DNS.
|
||||
|
||||
### Long terme
|
||||
|
||||
- **Hub central multi-clients** : dashboard superadmin, gestion des versions, logs distants.
|
||||
- **Mises à jour à distance** : pousser une nouvelle version du serveur et de l’agent sur tous les déploiements.
|
||||
- **Monitoring / support** : alertes serveur down, certificat expiré, agent hors ligne.
|
||||
- **Tests automatisés** : validation du flux activation → VPN → instance → HTTPS public à chaque déploiement.
|
||||
- **Console/log intégré et barre de progression** dans l’agent.
|
||||
- Génération automatique de codes d’activation par import CSV.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"tagOwners": {
|
||||
"tag:student-agent": ["studioe5@studioe5.local"],
|
||||
"tag:resolver": ["resolver@studioe5.local"]
|
||||
},
|
||||
"acls": [
|
||||
{
|
||||
"action": "accept",
|
||||
"src": ["tag:resolver"],
|
||||
"dst": ["tag:student-agent:*"]
|
||||
},
|
||||
{
|
||||
"action": "accept",
|
||||
"src": ["tag:student-agent"],
|
||||
"dst": ["tag:resolver:2020"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
FROM prestashop/prestashop:9
|
||||
|
||||
# Apply EduBox patches so PrestaShop 9 works behind the dynamic-domain reverse proxy.
|
||||
COPY edubox-tools.patch \
|
||||
edubox-link.patch \
|
||||
edubox-frontcontroller.patch \
|
||||
edubox-shop.patch \
|
||||
edubox-shopurl.patch \
|
||||
edubox-shop-getbaseurl.patch \
|
||||
edubox-shopcontext.patch \
|
||||
edubox-asseturl.patch \
|
||||
edubox-install.patch \
|
||||
edubox-install-language.patch \
|
||||
edubox-language.patch \
|
||||
edubox-configuration.patch \
|
||||
edubox-dashboard-warning.patch \
|
||||
edubox-docker-run.patch \
|
||||
/tmp/
|
||||
RUN patch -p1 -d /var/www/html < /tmp/edubox-tools.patch && \
|
||||
patch -p1 -d /var/www/html < /tmp/edubox-link.patch && \
|
||||
patch -p1 -d /var/www/html < /tmp/edubox-frontcontroller.patch && \
|
||||
patch -p1 -d /var/www/html < /tmp/edubox-shop.patch && \
|
||||
patch -p1 -d /var/www/html < /tmp/edubox-shopurl.patch && \
|
||||
patch -p1 -d /var/www/html < /tmp/edubox-shop-getbaseurl.patch && \
|
||||
patch -p1 -d /var/www/html < /tmp/edubox-shopcontext.patch && \
|
||||
patch -p1 -d /var/www/html < /tmp/edubox-asseturl.patch && \
|
||||
patch -p1 -d /var/www/html < /tmp/edubox-install.patch && \
|
||||
patch -p1 -d /var/www/html < /tmp/edubox-install-language.patch && \
|
||||
patch -p1 -d /var/www/html < /tmp/edubox-language.patch && \
|
||||
patch -p1 -d /var/www/html < /tmp/edubox-configuration.patch && \
|
||||
patch -p1 -d /var/www/html < /tmp/edubox-dashboard-warning.patch && \
|
||||
patch -p1 -d / < /tmp/edubox-docker-run.patch && \
|
||||
rm /tmp/edubox-*.patch
|
||||
|
||||
# Apache proxy configuration
|
||||
COPY proxy.conf /etc/apache2/conf-enabled/edubox-proxy.conf
|
||||
|
||||
# Pre-download French translation pack so the installer works offline.
|
||||
# Agents may not have outbound internet access during installation.
|
||||
# The official image copies /tmp/data-ps/prestashop/ into /var/www/html on first
|
||||
# boot, so we place the pack there as well.
|
||||
COPY translations-symfony-fr-FR.zip /tmp/data-ps/prestashop/translations/sf-fr-FR.zip
|
||||
RUN chown -R www-data:www-data /tmp/data-ps/prestashop/translations
|
||||
|
||||
# Early bootstrap normalisation for X-Forwarded-* headers
|
||||
COPY defines_custom.inc.php /var/www/html/config/defines_custom.inc.php
|
||||
|
||||
# Clear caches on every start so dynamic domains/ports are picked up
|
||||
COPY edubox-clear-cache-init.sh /tmp/init-scripts/edubox-clear-cache.sh
|
||||
RUN chmod +x /tmp/init-scripts/edubox-clear-cache.sh
|
||||
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
@@ -0,0 +1,99 @@
|
||||
# EduBox PrestaShop 9 Image
|
||||
|
||||
Image Docker patchée basée sur `prestashop/prestashop:9`, conçue pour fonctionner
|
||||
avec le reverse proxy dynamique d'EduBox.
|
||||
|
||||
## Pourquoi une image patchée ?
|
||||
|
||||
PrestaShop 9 (Apache 2.4 + PHP 8.5) a plusieurs problèmes majeurs derrière EduBox :
|
||||
|
||||
1. Les headers `X-Forwarded-*` sont corrompus par Apache/PHP : `$_SERVER` les
|
||||
reçoit sous forme d'arrays au lieu de strings. On contourne ce bug via
|
||||
`getenv()` dans `config/defines_custom.inc.php`.
|
||||
2. PrestaShop utilise partout le domaine stocké en base (`ps_shop_url`) et la
|
||||
configuration `PS_SSL_ENABLED`. Derrière EduBox, le domaine public change à
|
||||
chaque instance (`<id>.alfrednobel.edudeploy.com`) et toutes les requêtes
|
||||
publiques arrivent en HTTPS. Les patches forcent l'utilisation de l'hôte et
|
||||
du protocole de la requête courante.
|
||||
3. Les agents étudiants peuvent être hors ligne. Le pack de langue français est
|
||||
donc embarqué dans l'image pour éviter tout téléchargement pendant
|
||||
l'installation.
|
||||
|
||||
## Build local
|
||||
|
||||
```bash
|
||||
cd /opt/edubox/prestashop-image
|
||||
docker build -t edubox-prestashop:9 .
|
||||
```
|
||||
|
||||
## Push sur le registry Gitea
|
||||
|
||||
```bash
|
||||
docker tag edubox-prestashop:9 \
|
||||
gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-9
|
||||
docker push \
|
||||
gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-9
|
||||
```
|
||||
|
||||
## Patches appliqués
|
||||
|
||||
| Patch | Fichier modifié | Objectif |
|
||||
|-------|-----------------|----------|
|
||||
| `edubox-tools.patch` | `classes/Tools.php` | `getShopDomain()` / `getShopDomainSsl()` utilisent `getHttpHost()` dynamiquement en conservant les ports non standards (ex. `localhost:8088`) ; `.htaccess` généré sans condition `HTTP_HOST` (images/catégories). |
|
||||
| `edubox-link.patch` | `classes/Link.php` | `getBaseLink()` et `getAdminBaseLink()` utilisent `usingSecureMode()` et `getHttpHost()`. |
|
||||
| `edubox-frontcontroller.patch` | `classes/controller/FrontController.php` | Désactive `sslRedirection()` pour éviter les boucles HTTP/HTTPS. |
|
||||
| `edubox-shop.patch` | `classes/shop/Shop.php` | `Shop::initialize()` utilise le shop par défaut sans redirection forcée. |
|
||||
| `edubox-shopurl.patch` | `classes/shop/ShopUrl.php` | `getMainShopDomain()` / `getMainShopDomainSSL()` retournent le domaine de la requête en conservant les ports non standards. |
|
||||
| `edubox-shop-getbaseurl.patch` | `classes/shop/Shop.php` | `Shop::getBaseURL()` utilise le host/port de la requête courante. |
|
||||
| `edubox-shopcontext.patch` | `src/Core/Context/ShopContext.php` | `getBaseURL()` du BO est reconstruit à partir de la requête courante. |
|
||||
| `edubox-configuration.patch` | `classes/Configuration.php` | `PS_SHOP_DOMAIN`, `PS_SHOP_DOMAIN_SSL`, `PS_SSL_ENABLED`, `_PS_BASE_URL_`, `_PS_BASE_URL_SSL_` sont résolus dynamiquement depuis la requête, pas depuis le cache DB. |
|
||||
| `edubox-asseturl.patch` | `src/Adapter/Assets/AssetUrlGeneratorTrait.php` | Les assets CCC utilisent le protocole de la requête, pas `PS_SSL_ENABLED`. |
|
||||
| `edubox-install.patch` | `src/PrestaShopBundle/Install/Install.php` | `finalize()` respecte `PS_FOLDER_ADMIN` (évite le bug overlayfs `admin` → `admin-edubox`). |
|
||||
| `edubox-install-language.patch` | `src/PrestaShopBundle/Install/Install.php` | Évite le téléchargement du pack legacy `fr.gzip` quand le pack Symfony est embarqué. |
|
||||
| `edubox-language.patch` | `classes/Language.php` | Utilise `_PS_TRANSLATIONS_DIR_` au runtime pour le cache langue ; évite le téléchargement réseau si le pack est présent. |
|
||||
| `edubox-dashboard-warning.patch` | `controllers/admin/AdminDashboardController.php` | Désactive le bandeau d’avertissement "domaine différent de SEO & URL". |
|
||||
| `edubox-docker-run.patch` | `/tmp/docker_run.sh` | Supprime un `install.lock` résiduel si une installation précédente a échoué. |
|
||||
|
||||
## Fichiers injectés
|
||||
|
||||
- `proxy.conf` : Apache truste `X-Forwarded-Proto: https` pour positionner
|
||||
`HTTPS=on` dans l'environnement PHP. Active aussi `AllowOverride All` pour
|
||||
que le `.htaccess` de PrestaShop fonctionne.
|
||||
- `config/defines_custom.inc.php` : normalise `HTTP_X_FORWARDED_HOST`,
|
||||
`HTTP_X_FORWARDED_PROTO` et `HTTP_HOST` corrompus ; définit
|
||||
`PS_TRUSTED_PROXIES` pour Symfony.
|
||||
- `translations-symfony-fr-FR.zip` → copié sous `sf-fr-FR.zip` dans
|
||||
`/var/www/html/translations/` : pack de langue Symfony français embarqué
|
||||
( PrestaShop attend le préfixe `sf-` ).
|
||||
- `edubox-clear-cache-init.sh` → `/tmp/init-scripts/edubox-clear-cache.sh` :
|
||||
vidage des caches Smarty/Symfony et des assets CCC à chaque démarrage du
|
||||
conteneur, afin que les changements de domaine/port soient pris en compte.
|
||||
|
||||
## Utilisation dans EduBox
|
||||
|
||||
Le template PrestaShop 9 dans `server/prisma/seed.ts` utilise cette image :
|
||||
|
||||
```yaml
|
||||
app:
|
||||
image: gitea.alfrednobel.edudeploy.com/yacine/edubox/edubox-prestashop:9-edubox-8
|
||||
```
|
||||
|
||||
## Mise à jour vers une nouvelle version de PrestaShop
|
||||
|
||||
Si PrestaShop sort une version `9.x.y` :
|
||||
|
||||
1. Modifier le `FROM` du Dockerfile : `FROM prestashop/prestashop:9.x.y`
|
||||
2. Relancer le build. Les patches qui échouent doivent être adaptés aux
|
||||
nouvelles lignes/code de PrestaShop.
|
||||
3. Re-tagger et pousser : `9.x.y-edubox-1`.
|
||||
4. Mettre à jour `server/prisma/seed.ts` avec le nouveau tag.
|
||||
|
||||
## Déploiement sur les agents
|
||||
|
||||
L'image doit être accessible depuis chaque agent étudiant. Deux options :
|
||||
|
||||
1. **Registry privé** (recommandé) : tagger et pousser l'image sur un registry
|
||||
(Docker Hub, registry Gitea, GHCR, etc.) puis mettre à jour
|
||||
`server/prisma/seed.ts` avec le nom complet.
|
||||
2. **Build manuel sur chaque agent** : copier ce dossier sur l'agent et lancer
|
||||
`docker build` avant le premier déploiement.
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/**
|
||||
* EduBox reverse proxy normalisation for PrestaShop 9 running behind the
|
||||
* EduBox dynamic-public-domain resolver.
|
||||
*
|
||||
* The official PrestaShop 9 + PHP 8.5 + Apache image has a bug where
|
||||
* X-Forwarded-* headers are exposed to PHP as arrays whose value is the
|
||||
* header name. getenv() returns the correct string, so we use it to
|
||||
* reconstruct $_SERVER entries used by Tools::getHttpHost/ShopDomainSSL.
|
||||
*/
|
||||
|
||||
if ($val = getenv('HTTP_X_FORWARDED_HOST')) {
|
||||
$_SERVER['HTTP_X_FORWARDED_HOST'] = $val;
|
||||
}
|
||||
if ($val = getenv('HTTP_X_FORWARDED_PROTO')) {
|
||||
$_SERVER['HTTP_X_FORWARDED_PROTO'] = $val;
|
||||
}
|
||||
|
||||
// Apache/PHP 8.5 sometimes corrupts HTTP_HOST into an array; fall back safely.
|
||||
if (!empty($_SERVER['HTTP_HOST']) && is_array($_SERVER['HTTP_HOST'])) {
|
||||
$_SERVER['HTTP_HOST'] = !empty($_SERVER['SERVER_NAME']) && !is_array($_SERVER['SERVER_NAME'])
|
||||
? $_SERVER['SERVER_NAME']
|
||||
: (getenv('HTTP_X_FORWARDED_HOST') ?: 'localhost');
|
||||
}
|
||||
|
||||
if (!empty($_SERVER['HTTPS']) && is_array($_SERVER['HTTPS'])) {
|
||||
$_SERVER['HTTPS'] = 'off';
|
||||
}
|
||||
|
||||
// Tell Symfony to trust the EduBox resolver so $request->isSecure() and
|
||||
// $request->getHost() honour X-Forwarded-* headers.
|
||||
putenv('PS_TRUSTED_PROXIES=127.0.0.1,REMOTE_ADDR');
|
||||
$_SERVER['PS_TRUSTED_PROXIES'] = '127.0.0.1,REMOTE_ADDR';
|
||||
$_ENV['PS_TRUSTED_PROXIES'] = '127.0.0.1,REMOTE_ADDR';
|
||||
@@ -0,0 +1,20 @@
|
||||
--- a/src/Adapter/Assets/AssetUrlGeneratorTrait.php
|
||||
+++ b/src/Adapter/Assets/AssetUrlGeneratorTrait.php
|
||||
@@ -49,12 +49,14 @@ trait AssetUrlGeneratorTrait
|
||||
protected function getFQDN()
|
||||
{
|
||||
if (null === $this->fqdn) {
|
||||
- if ($this->configuration->get('PS_SSL_ENABLED') && ToolsLegacy::usingSecureMode()) {
|
||||
- $this->fqdn = $this->configuration->get('_PS_BASE_URL_SSL_');
|
||||
- } else {
|
||||
+ // EduBox: rely on the current request security, not on PS_SSL_ENABLED.
|
||||
+ // Behind the reverse proxy every public request is HTTPS.
|
||||
+ if (ToolsLegacy::usingSecureMode()) {
|
||||
+ $this->fqdn = $this->configuration->get('_PS_BASE_URL_SSL_') ?: $this->configuration->get('_PS_BASE_URL_');
|
||||
+ } else {
|
||||
$this->fqdn = $this->configuration->get('_PS_BASE_URL_');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->fqdn;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
# EduBox: clear PrestaShop caches at every container start so that dynamic
|
||||
# domains/ports (localhost:PORT or reverse-proxy public URL) are picked up.
|
||||
echo "* EduBox: clearing PrestaShop caches for dynamic domain..."
|
||||
rm -rf /var/www/html/var/cache/*
|
||||
rm -rf /var/www/html/app/cache/*
|
||||
rm -rf /var/www/html/cache/smarty/cache/*
|
||||
rm -rf /var/www/html/cache/smarty/compile/*
|
||||
rm -rf /var/www/html/themes/*/assets/cache/*
|
||||
rm -rf /var/www/html/img/tmp/*
|
||||
@@ -0,0 +1,36 @@
|
||||
--- a/classes/Configuration.php 2026-06-04 14:48:44.000000000 +0000
|
||||
+++ b/classes/Configuration.php 2026-06-23 16:27:03.944472677 +0000
|
||||
@@ -210,6 +210,33 @@
|
||||
Configuration::loadConfiguration();
|
||||
}
|
||||
|
||||
+ // EduBox: dynamic public domains and ports (local access + reverse proxy).
|
||||
+ // These keys must be resolved from the current request, not from the DB cache.
|
||||
+ if ($key === 'PS_SHOP_DOMAIN' || $key === 'PS_SHOP_DOMAIN_SSL') {
|
||||
+ $host = Tools::getHttpHost(false, false, false);
|
||||
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
|
||||
+ $host = substr($host, 0, strrpos($host, ':'));
|
||||
+ }
|
||||
+ return $host;
|
||||
+ }
|
||||
+ if ($key === 'PS_SSL_ENABLED' || $key === 'PS_SSL_ENABLED_EVERYWHERE') {
|
||||
+ return Tools::usingSecureMode() ? '1' : '0';
|
||||
+ }
|
||||
+ if ($key === '_PS_BASE_URL_') {
|
||||
+ $host = Tools::getHttpHost(false, false, false);
|
||||
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
|
||||
+ $host = substr($host, 0, strrpos($host, ':'));
|
||||
+ }
|
||||
+ return 'http://' . $host;
|
||||
+ }
|
||||
+ if ($key === '_PS_BASE_URL_SSL_') {
|
||||
+ $host = Tools::getHttpHost(false, false, false);
|
||||
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
|
||||
+ $host = substr($host, 0, strrpos($host, ':'));
|
||||
+ }
|
||||
+ return 'https://' . $host;
|
||||
+ }
|
||||
+
|
||||
$idLang = self::isLangKey($key) ? (int) $idLang : 0;
|
||||
|
||||
if (self::$_new_cache_shop === null) {
|
||||
@@ -0,0 +1,49 @@
|
||||
--- a/controllers/admin/AdminDashboardController.php
|
||||
+++ b/controllers/admin/AdminDashboardController.php
|
||||
@@ -330,43 +330,9 @@
|
||||
|
||||
protected function getWarningDomainName()
|
||||
{
|
||||
- $warning = false;
|
||||
- if (Shop::isFeatureActive()) {
|
||||
- return;
|
||||
- }
|
||||
-
|
||||
- $shop = Context::getContext()->shop;
|
||||
- if ($_SERVER['HTTP_HOST'] != $shop->domain && $_SERVER['HTTP_HOST'] != $shop->domain_ssl && Tools::getValue('ajax') == false) {
|
||||
- $warning = $this->trans('You are currently connected under the following domain name:', [], 'Admin.Dashboard.Notification') . ' <span style="color: #CC0000;">' . $_SERVER['HTTP_HOST'] . '</span><br />';
|
||||
- if (Configuration::get('PS_MULTISHOP_FEATURE_ACTIVE')) {
|
||||
- $warning .= $this->trans(
|
||||
- 'This is different from the shop domain name set in the Multistore settings: "%s".',
|
||||
- [
|
||||
- '%s' => $shop->domain,
|
||||
- ],
|
||||
- 'Admin.Dashboard.Notification'
|
||||
- ) . $this->trans(
|
||||
- 'If this is your main domain, please {link}change it now{/link}.',
|
||||
- [
|
||||
- '{link}' => '<a href="' . $this->context->link->getAdminLink('AdminShopUrl', true, [], ['id_shop_url' => (int) $shop->id, 'updateshop_url' => 1]) . '">',
|
||||
- '{/link}' => '</a>',
|
||||
- ],
|
||||
- 'Admin.Dashboard.Notification'
|
||||
- );
|
||||
- } else {
|
||||
- $warning .= $this->trans('This is different from the domain name set in the "SEO & URLs" tab.', [], 'Admin.Dashboard.Notification') . '
|
||||
- ' . $this->trans(
|
||||
- 'If this is your main domain, please {link}change it now{/link}.',
|
||||
- [
|
||||
- '{link}' => '<a href="' . $this->context->link->getAdminLink('AdminMeta') . '#meta_fieldset_shop_url">',
|
||||
- '{/link}' => '</a>',
|
||||
- ],
|
||||
- 'Admin.Dashboard.Notification'
|
||||
- );
|
||||
- }
|
||||
- }
|
||||
-
|
||||
- return $warning;
|
||||
+ // EduBox: instances use dynamic public domains behind a reverse proxy.
|
||||
+ // The domain stored during installation never matches the request host.
|
||||
+ return false;
|
||||
}
|
||||
|
||||
public function ajaxProcessRefreshDashboard()
|
||||
@@ -0,0 +1,16 @@
|
||||
--- a/tmp/docker_run.sh 2026-06-20 17:57:12.682339048 +0000
|
||||
+++ b/tmp/docker_run.sh 2026-06-20 17:57:12.852338398 +0000
|
||||
@@ -21,6 +21,13 @@
|
||||
|
||||
# From now, stop at error
|
||||
set -e
|
||||
+# EduBox: if a previous installation failed, install.lock remains but PrestaShop is not configured.
|
||||
+# Remove the stale lock so the installer can run again on the next start.
|
||||
+if [ -f ./install.lock ] && [ ! -f ./config/settings.inc.php ] && [ ! -f ./app/config/parameters.php ]; then
|
||||
+ echo "\n* Stale install.lock detected, removing it to allow reinstallation ..."
|
||||
+ rm -f ./install.lock
|
||||
+fi
|
||||
+
|
||||
|
||||
if [ ! -f ./config/settings.inc.php ] && [ ! -f ./app/config/parameters.php ] && [ ! -f ./install.lock ]; then
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
--- a/classes/controller/FrontController.php
|
||||
+++ b/classes/controller/FrontController.php
|
||||
@@ -849,18 +849,9 @@
|
||||
*/
|
||||
protected function sslRedirection()
|
||||
{
|
||||
- // If we call a SSL controller without SSL or a non SSL controller with SSL, we redirect with the right protocol
|
||||
- if (Configuration::get('PS_SSL_ENABLED') && $_SERVER['REQUEST_METHOD'] != 'POST' && $this->ssl != Tools::usingSecureMode()) {
|
||||
- $this->context->cookie->disallowWriting();
|
||||
- header('HTTP/1.1 301 Moved Permanently');
|
||||
- header('Cache-Control: no-cache');
|
||||
- if ($this->ssl) {
|
||||
- header('Location: ' . Tools::getShopDomainSsl(true) . $_SERVER['REQUEST_URI']);
|
||||
- } else {
|
||||
- header('Location: ' . Tools::getShopDomain(true) . $_SERVER['REQUEST_URI']);
|
||||
- }
|
||||
- exit;
|
||||
- }
|
||||
+ // EduBox: disabled. Behind the EduBox reverse proxy every request is
|
||||
+ // served over HTTPS publicly, so PrestaShop must never redirect to HTTP.
|
||||
+ return;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,30 @@
|
||||
--- a/src/PrestaShopBundle/Install/Install.php 2026-06-20 18:07:13.506985399 +0000
|
||||
+++ b/src/PrestaShopBundle/Install/Install.php 2026-06-20 18:07:22.294363061 +0000
|
||||
@@ -622,17 +622,20 @@
|
||||
'locale' => (string) $xml->locale,
|
||||
];
|
||||
|
||||
- if (file_exists(_PS_TRANSLATIONS_DIR_ . (string) $iso . '.gzip') == false) {
|
||||
- $language = EntityLanguage::downloadLanguagePack($iso, _PS_INSTALL_VERSION_);
|
||||
+ // EduBox: skip legacy language pack download if Symfony pack is bundled
|
||||
+ $errors = [];
|
||||
+ $locale = $params_lang['locale'];
|
||||
+
|
||||
+ if (!EntityLanguage::translationPackIsInCache($locale)) {
|
||||
+ if (file_exists(_PS_TRANSLATIONS_DIR_ . (string) $iso . '.gzip') == false) {
|
||||
+ $language = EntityLanguage::downloadLanguagePack($iso, _PS_INSTALL_VERSION_);
|
||||
|
||||
- if ($language == false) {
|
||||
- throw new PrestashopInstallerException($this->translator->trans('Cannot download language pack "%iso%"', ['%iso%' => $iso], 'Install'));
|
||||
+ if ($language == false) {
|
||||
+ throw new PrestashopInstallerException($this->translator->trans('Cannot download language pack "%iso%"', ['%iso%' => $iso], 'Install'));
|
||||
+ }
|
||||
}
|
||||
}
|
||||
|
||||
- $errors = [];
|
||||
- $locale = $params_lang['locale'];
|
||||
-
|
||||
/* @todo check if a newer pack is available */
|
||||
if (!EntityLanguage::translationPackIsInCache($locale)) {
|
||||
EntityLanguage::downloadXLFLanguagePack($locale, $errors);
|
||||
@@ -0,0 +1,9 @@
|
||||
--- a/src/PrestaShopBundle/Install/Install.php
|
||||
+++ b/src/PrestaShopBundle/Install/Install.php
|
||||
@@ -1202,7 +1202,7 @@ class Install extends AbstractInstall
|
||||
{
|
||||
- $adminFolder = 'admin-dev';
|
||||
+ $adminFolder = getenv('PS_FOLDER_ADMIN') ?: 'admin-dev';
|
||||
|
||||
// If we need, we generate a random name for admin folder (for security purpose!)
|
||||
if (file_exists(_PS_ROOT_DIR_ . '/admin/')) {
|
||||
@@ -0,0 +1,36 @@
|
||||
--- a/classes/Language.php
|
||||
+++ b/classes/Language.php
|
||||
@@ -1235,6 +1235,12 @@
|
||||
*/
|
||||
public static function downloadXLFLanguagePack($locale, &$errors = [], $type = self::PACK_TYPE_SYMFONY)
|
||||
{
|
||||
+ // EduBox: if the translation pack is already present in the image,
|
||||
+ // do not try to download it (agents may be offline).
|
||||
+ if (static::translationPackIsInCache($locale, $type)) {
|
||||
+ return true;
|
||||
+ }
|
||||
+
|
||||
$file = self::getPathToCachedTranslationPack($locale, $type);
|
||||
$url = (self::PACK_TYPE_EMAILS === $type) ? self::EMAILS_LANGUAGE_PACK_URL : self::SF_LANGUAGE_PACK_URL;
|
||||
$url = str_replace(
|
||||
@@ -1697,7 +1703,9 @@
|
||||
*/
|
||||
public static function translationPackIsInCache(string $locale, string $type = self::PACK_TYPE_SYMFONY): bool
|
||||
{
|
||||
- return file_exists(self::getPathToCachedTranslationPack($locale, $type));
|
||||
+ // EduBox: use runtime constant instead of class constant, because
|
||||
+ // _PS_TRANSLATIONS_DIR_ may not be defined when this file is compiled.
|
||||
+ return file_exists(_PS_TRANSLATIONS_DIR_ . $type . '-' . $locale . '.zip');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1710,7 +1718,8 @@
|
||||
*/
|
||||
private static function getPathToCachedTranslationPack(string $locale, string $type = self::PACK_TYPE_SYMFONY): string
|
||||
{
|
||||
- return self::TRANSLATION_PACK_CACHE_DIR . $type . '-' . $locale . '.zip';
|
||||
+ // EduBox: use runtime constant instead of class constant.
|
||||
+ return _PS_TRANSLATIONS_DIR_ . $type . '-' . $locale . '.zip';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,46 @@
|
||||
--- a/classes/Link.php 2026-06-20 20:05:45.983104609 +0000
|
||||
+++ b/classes/Link.php 2026-06-20 20:05:46.195748630 +0000
|
||||
@@ -862,7 +862,7 @@
|
||||
public function getAdminBaseLink($idShop = null, $ssl = null, $relativeProtocol = false)
|
||||
{
|
||||
if (null === $ssl) {
|
||||
- $ssl = Configuration::get('PS_SSL_ENABLED');
|
||||
+ $ssl = Tools::usingSecureMode();
|
||||
}
|
||||
|
||||
if (Configuration::get('PS_MULTISHOP_FEATURE_ACTIVE')) {
|
||||
@@ -881,9 +881,10 @@
|
||||
}
|
||||
|
||||
if ($relativeProtocol) {
|
||||
- $base = '//' . ($ssl && $this->ssl_enable ? $shop->domain_ssl : $shop->domain);
|
||||
+ $base = '//' . Tools::getHttpHost(false, false, false);
|
||||
} else {
|
||||
- $base = (($ssl && $this->ssl_enable) ? 'https://' . $shop->domain_ssl : 'http://' . $shop->domain);
|
||||
+ $protocol = Tools::usingSecureMode() ? 'https://' : 'http://';
|
||||
+ $base = $protocol . Tools::getHttpHost(false, false, false);
|
||||
}
|
||||
|
||||
return $base . $shop->getBaseURI();
|
||||
@@ -1391,7 +1392,7 @@
|
||||
public function getBaseLink($idShop = null, $ssl = null, $relativeProtocol = false)
|
||||
{
|
||||
if (null === $ssl) {
|
||||
- $ssl = Configuration::get('PS_SSL_ENABLED');
|
||||
+ $ssl = Tools::usingSecureMode();
|
||||
}
|
||||
|
||||
if (Configuration::get('PS_MULTISHOP_FEATURE_ACTIVE') && $idShop !== null) {
|
||||
@@ -1401,9 +1402,10 @@
|
||||
}
|
||||
|
||||
if ($relativeProtocol) {
|
||||
- $base = '//' . ($ssl && $this->ssl_enable ? $shop->domain_ssl : $shop->domain);
|
||||
+ $base = '//' . Tools::getHttpHost(false, false, false);
|
||||
} else {
|
||||
- $base = (($ssl && $this->ssl_enable) ? 'https://' . $shop->domain_ssl : 'http://' . $shop->domain);
|
||||
+ $protocol = Tools::usingSecureMode() ? 'https://' : 'http://';
|
||||
+ $base = $protocol . Tools::getHttpHost(false, false, false);
|
||||
}
|
||||
|
||||
return $base . $shop->getBaseURI();
|
||||
@@ -0,0 +1,29 @@
|
||||
--- a/classes/shop/Shop.php
|
||||
+++ b/classes/shop/Shop.php
|
||||
@@ -489,15 +489,16 @@ class ShopCore extends ObjectModel
|
||||
*/
|
||||
public function getBaseURL($auto_secure_mode = true, $add_base_uri = true)
|
||||
{
|
||||
- if ($auto_secure_mode && Tools::usingSecureMode()) {
|
||||
- if (!$this->domain_ssl) {
|
||||
- return false;
|
||||
- }
|
||||
- $url = 'https://' . $this->domain_ssl;
|
||||
+ // EduBox: use the current request host so local access on non-standard
|
||||
+ // ports (e.g. localhost:8088) and reverse-proxy domains both work.
|
||||
+ $host = Tools::getHttpHost(false, false, false);
|
||||
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
|
||||
+ $host = substr($host, 0, strrpos($host, ':'));
|
||||
+ }
|
||||
+
|
||||
+ if ($auto_secure_mode && Tools::usingSecureMode()) {
|
||||
+ $url = 'https://' . $host;
|
||||
} else {
|
||||
- if (!$this->domain) {
|
||||
- return false;
|
||||
- }
|
||||
- $url = 'http://' . $this->domain;
|
||||
+ $url = 'http://' . $host;
|
||||
}
|
||||
|
||||
if ($add_base_uri) {
|
||||
@@ -0,0 +1,46 @@
|
||||
--- a/classes/shop/Shop.php
|
||||
+++ b/classes/shop/Shop.php
|
||||
@@ -411,38 +411,14 @@
|
||||
} else {
|
||||
$shop = new Shop($id_shop);
|
||||
if (!Validate::isLoadedObject($shop) || !$shop->active) {
|
||||
- // No shop found ... too bad, let's redirect to default shop
|
||||
- $default_shop = new Shop((int) Configuration::get('PS_SHOP_DEFAULT'));
|
||||
+ // EduBox: behind a reverse proxy with dynamic public domains,
|
||||
+ // the requested host never matches ps_shop_url. Always use the
|
||||
+ // default shop instead of redirecting to a fixed canonical URL.
|
||||
+ $shop = new Shop((int) Configuration::get('PS_SHOP_DEFAULT'));
|
||||
|
||||
- // Hmm there is something really bad in your Prestashop !
|
||||
- if (!Validate::isLoadedObject($default_shop)) {
|
||||
+ if (!Validate::isLoadedObject($shop)) {
|
||||
throw new PrestaShopException('Shop not found');
|
||||
}
|
||||
-
|
||||
- $params = $_GET;
|
||||
- unset($params['id_shop']);
|
||||
- $url = $default_shop->domain;
|
||||
- if (!Configuration::get('PS_REWRITING_SETTINGS')) {
|
||||
- $url .= $default_shop->getBaseURI() . 'index.php?' . http_build_query($params);
|
||||
- } else {
|
||||
- // Catch url with subdomain "www"
|
||||
- if (strpos($url, 'www.') === 0 && 'www.' . $_SERVER['HTTP_HOST'] === $url || $_SERVER['HTTP_HOST'] === 'www.' . $url) {
|
||||
- $url .= $_SERVER['REQUEST_URI'];
|
||||
- } else {
|
||||
- $url .= $default_shop->getBaseURI();
|
||||
- }
|
||||
-
|
||||
- if (count($params)) {
|
||||
- $url .= '?' . http_build_query($params);
|
||||
- }
|
||||
- }
|
||||
-
|
||||
- $redirect_type = Configuration::get('PS_CANONICAL_REDIRECT');
|
||||
- $redirect_code = ($redirect_type == 1 ? '302' : '301');
|
||||
- $redirect_header = ($redirect_type == 1 ? 'Found' : 'Moved Permanently');
|
||||
- header('HTTP/1.0 ' . $redirect_code . ' ' . $redirect_header);
|
||||
- header('Location: ' . Tools::getShopProtocol() . $url);
|
||||
- exit;
|
||||
} elseif (defined('_PS_ADMIN_DIR_') && empty($shop->physical_uri)) {
|
||||
$shop_default = new Shop((int) Configuration::get('PS_SHOP_DEFAULT'));
|
||||
$shop->physical_uri = $shop_default->physical_uri;
|
||||
@@ -0,0 +1,28 @@
|
||||
--- a/src/Core/Context/ShopContext.php
|
||||
+++ b/src/Core/Context/ShopContext.php
|
||||
@@ -9,6 +9,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace PrestaShop\PrestaShop\Core\Context;
|
||||
|
||||
+use Tools;
|
||||
use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint;
|
||||
|
||||
/**
|
||||
@@ -121,11 +122,12 @@ class ShopContext
|
||||
|
||||
public function getBaseURL(): string
|
||||
{
|
||||
- if ($this->secured) {
|
||||
- $url = 'https://' . $this->domainSSL;
|
||||
- } else {
|
||||
- $url = 'http://' . $this->domain;
|
||||
- }
|
||||
+ // EduBox: behind a reverse proxy with dynamic public domains the shop
|
||||
+ // URL stored in the database is never the real public URL. Rebuild the
|
||||
+ // base URL from the current request instead.
|
||||
+ $secure = Tools::usingSecureMode();
|
||||
+ $domain = $secure ? Tools::getShopDomainSsl(false, false) : Tools::getShopDomain(false, false);
|
||||
+ $url = ($secure ? 'https://' : 'http://') . $domain;
|
||||
|
||||
return $url . $this->getBaseURI();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
--- a/classes/shop/ShopUrl.php
|
||||
+++ b/classes/shop/ShopUrl.php
|
||||
@@ -175,15 +175,23 @@
|
||||
|
||||
public static function getMainShopDomain($id_shop = null)
|
||||
{
|
||||
- ShopUrl::cacheMainDomainForShop($id_shop);
|
||||
-
|
||||
- return self::$main_domain[(int) $id_shop] ?? null;
|
||||
+ // EduBox: dynamic public domain behind reverse proxy or direct local access.
|
||||
+ // Always use the request host instead of the domain stored in database.
|
||||
+ // Keep non-standard ports (e.g. localhost:8088) so local access works.
|
||||
+ $host = Tools::getHttpHost(false, false, false);
|
||||
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
|
||||
+ $host = substr($host, 0, strrpos($host, ':'));
|
||||
+ }
|
||||
+ return $host;
|
||||
}
|
||||
|
||||
public static function getMainShopDomainSSL($id_shop = null)
|
||||
{
|
||||
- ShopUrl::cacheMainDomainForShop($id_shop);
|
||||
-
|
||||
- return self::$main_domain_ssl[(int) $id_shop] ?? null;
|
||||
+ // EduBox: dynamic public domain behind reverse proxy or direct local access.
|
||||
+ $host = Tools::getHttpHost(false, false, false);
|
||||
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
|
||||
+ $host = substr($host, 0, strrpos($host, ':'));
|
||||
+ }
|
||||
+ return $host;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
--- a/classes/Tools.php 2026-06-04 14:48:44.000000000 +0000
|
||||
+++ b/classes/Tools.php 2026-06-23 16:34:13.226899992 +0000
|
||||
@@ -269,8 +269,10 @@
|
||||
*/
|
||||
public static function getShopDomain($http = false, $entities = false)
|
||||
{
|
||||
- if (!$domain = ShopUrl::getMainShopDomain()) {
|
||||
- $domain = Tools::getHttpHost();
|
||||
+ // EduBox: dynamic domain + keep non-standard ports (e.g. localhost:8088).
|
||||
+ $domain = Tools::getHttpHost(false, false, false);
|
||||
+ if (substr($domain, -3) === ':80' || substr($domain, -4) === ':443') {
|
||||
+ $domain = substr($domain, 0, strrpos($domain, ':'));
|
||||
}
|
||||
if ($entities) {
|
||||
$domain = htmlspecialchars($domain, ENT_COMPAT, 'UTF-8');
|
||||
@@ -292,14 +294,16 @@
|
||||
*/
|
||||
public static function getShopDomainSsl($http = false, $entities = false)
|
||||
{
|
||||
- if (!$domain = ShopUrl::getMainShopDomainSSL()) {
|
||||
- $domain = Tools::getHttpHost();
|
||||
+ // EduBox: dynamic domain + keep non-standard ports.
|
||||
+ $domain = Tools::getHttpHost(false, false, false);
|
||||
+ if (substr($domain, -3) === ':80' || substr($domain, -4) === ':443') {
|
||||
+ $domain = substr($domain, 0, strrpos($domain, ':'));
|
||||
}
|
||||
if ($entities) {
|
||||
$domain = htmlspecialchars($domain, ENT_COMPAT, 'UTF-8');
|
||||
}
|
||||
if ($http) {
|
||||
- $domain = static::getProtocol((bool) Configuration::get('PS_SSL_ENABLED')) . $domain;
|
||||
+ $domain = static::getProtocol(Tools::usingSecureMode()) . $domain;
|
||||
}
|
||||
|
||||
return $domain;
|
||||
@@ -2246,7 +2250,7 @@
|
||||
$rewrite_settings = (int) Configuration::get('PS_REWRITING_SETTINGS', null, null, (int) $uri['id_shop']);
|
||||
}
|
||||
|
||||
- $domain_rewrite_cond = 'RewriteCond %{HTTP_HOST} ^' . $domain . '$' . PHP_EOL;
|
||||
+ $domain_rewrite_cond = ''; // EduBox: removed HTTP_HOST condition for dynamic domains
|
||||
// Rewrite virtual multishop uri
|
||||
if ($uri['virtual']) {
|
||||
if (!$rewrite_settings) {
|
||||
@@ -0,0 +1,10 @@
|
||||
# EduBox reverse proxy handling
|
||||
# Apache sees HTTP requests from the EduBox resolver. The public request is HTTPS.
|
||||
SetEnvIf X-Forwarded-Proto ^https$ HTTPS=on
|
||||
SetEnvIf X-Forwarded-Proto ^https$ SERVER_PORT=443
|
||||
|
||||
# Enable .htaccess overrides for PrestaShop URL rewriting (images, products, etc.)
|
||||
<Directory /var/www/html>
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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'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,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}`);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const CODE_LENGTH = 6;
|
||||
const CODE_TTL_MINUTES = 60;
|
||||
|
||||
export function generateActivationCode(): { code: string; expiresAt: Date } {
|
||||
let code = "";
|
||||
const bytes = randomBytes(CODE_LENGTH);
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
|
||||
}
|
||||
const expiresAt = new Date(Date.now() + CODE_TTL_MINUTES * 60 * 1000);
|
||||
return { code, expiresAt };
|
||||
}
|
||||
|
||||
export async function generateUniqueActivationCode(retries = 5): Promise<{ code: string; expiresAt: Date }> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
const { code, expiresAt } = generateActivationCode();
|
||||
const existing = await prisma.student.findUnique({ where: { activationCode: code } });
|
||||
if (!existing) return { code, expiresAt };
|
||||
}
|
||||
throw new Error("Failed to generate a unique activation code");
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const BIN_NAME = "studioE5-agent";
|
||||
|
||||
// Build the public base URL from an incoming request, respecting common
|
||||
// reverse-proxy headers (X-Forwarded-Proto / X-Forwarded-Host).
|
||||
export function getBaseUrlFromRequest(req: Request): string {
|
||||
const headers = req.headers;
|
||||
const forwardedProto = headers.get("x-forwarded-proto");
|
||||
const forwardedHost = headers.get("x-forwarded-host");
|
||||
|
||||
if (forwardedProto && forwardedHost) {
|
||||
return `${forwardedProto}://${forwardedHost}`;
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
}
|
||||
|
||||
function findVersionFile(): string | null {
|
||||
// Try a few common paths relative to the server workspace and Next.js build output.
|
||||
const candidates = [
|
||||
path.join(process.cwd(), "..", "agent", "VERSION"),
|
||||
path.join(process.cwd(), "..", "..", "agent", "VERSION"),
|
||||
path.join(process.cwd(), "agent", "VERSION"),
|
||||
path.join(__dirname, "..", "..", "..", "agent", "VERSION"),
|
||||
path.join(__dirname, "..", "..", "agent", "VERSION"),
|
||||
"/app/agent-version",
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAgentVersion(): string {
|
||||
const versionFile = findVersionFile();
|
||||
if (versionFile) {
|
||||
return fs.readFileSync(versionFile, "utf-8").trim();
|
||||
}
|
||||
// Fallback used when the agent workspace is not mounted (should not happen).
|
||||
return "0.3.9";
|
||||
}
|
||||
|
||||
export interface AgentDownloadUrls {
|
||||
windows: string;
|
||||
windowsZip: string;
|
||||
linux: string;
|
||||
mac: string;
|
||||
}
|
||||
|
||||
export function getAgentDownloadUrls(
|
||||
version: string,
|
||||
baseUrl?: string
|
||||
): AgentDownloadUrls {
|
||||
const prefix = baseUrl ? baseUrl.replace(/\/$/, "") : "";
|
||||
return {
|
||||
windows: `${prefix}/${BIN_NAME}-v${version}.exe`,
|
||||
windowsZip: `${prefix}/${BIN_NAME}-v${version}-windows.zip`,
|
||||
linux: `${prefix}/${BIN_NAME}-v${version}`,
|
||||
mac: `${prefix}/${BIN_NAME}-v${version}-mac`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAgentVersionInfo(baseUrl?: string) {
|
||||
const version = getAgentVersion();
|
||||
return {
|
||||
version,
|
||||
downloadUrls: getAgentDownloadUrls(version, baseUrl),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "./auth-config";
|
||||
|
||||
export type ApiUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "superadmin" | "admin" | "teacher";
|
||||
establishmentId?: string;
|
||||
};
|
||||
|
||||
export async function requireAuth(): Promise<ApiUser | NextResponse> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return session.user as ApiUser;
|
||||
}
|
||||
|
||||
export function requireRole(user: ApiUser, ...allowed: string[]): NextResponse | null {
|
||||
if (!allowed.includes(user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function forbidden(): NextResponse {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
export function getScopedEstablishmentId(user: ApiUser, requested?: string | null): string | undefined | NextResponse {
|
||||
if (user.role === "superadmin") {
|
||||
return requested ?? undefined;
|
||||
}
|
||||
if (requested && requested !== user.establishmentId) {
|
||||
return forbidden();
|
||||
}
|
||||
return user.establishmentId;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
interface HeadscaleUser {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface HeadscalePreAuthKey {
|
||||
key: string;
|
||||
expiration: string;
|
||||
aclTags: string[];
|
||||
}
|
||||
|
||||
export async function getHeadscaleUserId(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
userName: string
|
||||
): Promise<string> {
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/user?name=${encodeURIComponent(userName)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Headscale list users failed: ${res.status} ${await res.text()}`
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { users: HeadscaleUser[] };
|
||||
const user = data.users.find((u) => u.name === userName);
|
||||
if (!user) {
|
||||
throw new Error(`Headscale user not found: ${userName}`);
|
||||
}
|
||||
return user.id;
|
||||
}
|
||||
|
||||
export async function createEphemeralPreAuthKey(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
userId: string,
|
||||
options: {
|
||||
expirationMinutes?: number;
|
||||
aclTags?: string[];
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
const expirationMinutes = options.expirationMinutes ?? 15;
|
||||
const aclTags = options.aclTags ?? [];
|
||||
|
||||
const expiration = new Date(
|
||||
Date.now() + expirationMinutes * 60 * 1000
|
||||
).toISOString();
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/preauthkey`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user: userId,
|
||||
reusable: false,
|
||||
ephemeral: false,
|
||||
expiration,
|
||||
aclTags,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Headscale create preauthkey failed: ${res.status} ${await res.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { preAuthKey: HeadscalePreAuthKey };
|
||||
return data.preAuthKey.key;
|
||||
}
|
||||
+286
-21
@@ -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;
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user