Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73b561ed33 | |||
| 8a9deb8ebc | |||
| df77caf64a | |||
| b4344e9d66 | |||
| 124543d658 |
@@ -2,6 +2,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
*.log
|
||||
studioE5-data/
|
||||
edubox-data/
|
||||
dist/
|
||||
coverage/
|
||||
@@ -9,11 +10,19 @@ 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/
|
||||
|
||||
@@ -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,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
headscale.alfrednobel.edudeploy.com {
|
||||
headscale.studioe5.edudeploy.com:443 {
|
||||
reverse_proxy headscale:8080
|
||||
}
|
||||
|
||||
alfrednobel.edudeploy.com {
|
||||
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 +40,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,302 @@
|
||||
# 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://`.
|
||||
|
||||
## 📁 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 : `3151830` (lancé le 2026-06-23 09:36 UTC)
|
||||
|
||||
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/`**.
|
||||
|
||||
## 🛠️ 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.0-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.0.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.0`
|
||||
|
||||
### 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.0-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.0-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
|
||||
```
|
||||
|
||||
## 📋 Prochaines étapes à faire
|
||||
|
||||
- [x] ~~Attendre la fin du rate limit Let’s Encrypt~~ (levé le 2026-06-23).
|
||||
- [x] ~~Relancer un test HTTPS sur `https://test-wp-001.studioe5.edudeploy.com/`~~ → **OK** (HTTP/2 200).
|
||||
- [x] ~~Créer une branche dédiée et commiter les modifications VPN on-demand~~ → branche `feat/studioe5-vpn-ondemand`, commit `124543d`. Push vers Gitea à faire dès que le remote sera accessible (actuellement `localhost:3001` et `gitea.alfrednobel.edudeploy.com` injoignables).
|
||||
- [x] ~~Tester le flux complet depuis l’interface web~~ → **OK** via l’API authentifiée (`POST /api/instances`), instance `cmqqgrur20001lw67t2bdgzkg` accessible en HTTPS public.
|
||||
- [ ] **Obtenir un certificat wildcard** pour `*.studioe5.edudeploy.com` (voir étude ci-dessous).
|
||||
- [ ] **Nettoyer les instances/agent de test** une fois le wildcard en place et le push effectué.
|
||||
- [x] ~~Packager les binaires Tailscale pour Windows~~ → **OK**, `download-tailscale-bins.sh` + `studioE5-agent-v0.3.0-windows.zip` prêt.
|
||||
- [ ] **Nettoyer les anciens nodes/volumes Headscale** créés pendant les tests.
|
||||
- [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, etc.).
|
||||
|
||||
## 🔒 É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.
|
||||
+70
-16
@@ -2,34 +2,88 @@
|
||||
set -e
|
||||
|
||||
VERSION="0.3.0"
|
||||
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."
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
+5
-3
@@ -20,18 +20,20 @@ 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)
|
||||
@@ -115,7 +117,7 @@ fi
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
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,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 |
+35
-22
@@ -5,30 +5,23 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// version is injected at build time via -ldflags "-X main.version=X.Y.Z"
|
||||
var version = "dev"
|
||||
|
||||
const AGENT_VERSION = "0.3.0"
|
||||
|
||||
var (
|
||||
serverAddr = flag.String("server", "ws://localhost:3001", "Adresse WebSocket du serveur")
|
||||
nodeID = flag.String("node-id", defaultNodeID(), "ID du nœud (défaut: hostname)")
|
||||
dataDir = flag.String("data-dir", "./edubox-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")
|
||||
const (
|
||||
AGENT_VERSION = "0.3.0"
|
||||
APP_NAME = "studioE5"
|
||||
)
|
||||
|
||||
func defaultNodeID() string {
|
||||
h, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return h
|
||||
}
|
||||
var (
|
||||
dataDir = flag.String("data-dir", "./studioE5-data", "Répertoire de données")
|
||||
uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX")
|
||||
noTray = flag.Bool("no-tray", false, "Désactiver l'icône dans la barre système (mode console)")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
@@ -49,19 +42,39 @@ 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)
|
||||
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, AGENT_VERSION, cfg.NodeID, *dataDir, cfg.Server)
|
||||
|
||||
if *uiEnabled {
|
||||
go startUI(*dataDir, *nodeID, *serverAddr)
|
||||
go startUI(*dataDir, cfg.NodeID, cfg.Server)
|
||||
}
|
||||
|
||||
go startWebSocket(*serverAddr, *nodeID, *dataDir)
|
||||
go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
|
||||
|
||||
if *headscaleURL != "" && *headscaleAuthKey != "" {
|
||||
go startTailscaleAndReport(*dataDir, *nodeID, *headscaleURL, *headscaleAuthKey)
|
||||
shutdownCh := make(chan struct{})
|
||||
if *noTray {
|
||||
log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME)
|
||||
<-shutdownCh
|
||||
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
|
||||
}
|
||||
|
||||
func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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}
|
||||
}
|
||||
|
||||
if err := exec.Command(cmd, args...).Start(); err != nil {
|
||||
log.Printf("Failed to open browser: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeName(name string) string {
|
||||
return strings.ReplaceAll(name, " ", "")
|
||||
}
|
||||
+134
-60
@@ -2,104 +2,178 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"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)
|
||||
}
|
||||
tsSocket = filepath.Join(tsDataDir, "tailscaled.sock")
|
||||
stateFile := filepath.Join(tsDataDir, "tailscaled.state")
|
||||
|
||||
log.Printf("Starting tailscaled for node %s", nodeID)
|
||||
tsCmd = exec.Command(tailscaleBin("tailscaled"),
|
||||
"--state="+stateFile,
|
||||
"--socket="+tsSocket,
|
||||
"--tun=userspace-networking",
|
||||
)
|
||||
tsCmd.Stdout = os.Stdout
|
||||
tsCmd.Stderr = os.Stderr
|
||||
if err := tsCmd.Start(); err != nil {
|
||||
tsCmd = nil
|
||||
return "", fmt.Errorf("start tailscaled: %w", 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(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
lc, err := s.LocalClient()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tailscale local client: %w", err)
|
||||
upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"),
|
||||
"--socket="+tsSocket,
|
||||
"up",
|
||||
"--authkey="+authKey,
|
||||
"--login-server="+headscaleURL,
|
||||
"--hostname="+nodeID,
|
||||
"--accept-dns=false",
|
||||
"--operator=root",
|
||||
)
|
||||
upCmd.Stdout = os.Stdout
|
||||
upCmd.Stderr = os.Stderr
|
||||
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)
|
||||
out, err := exec.CommandContext(ctx, tailscaleBin("tailscale"),
|
||||
"--socket="+tsSocket,
|
||||
"status", "--json",
|
||||
).Output()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = tsCmd.Process.Kill()
|
||||
_ = tsCmd.Wait()
|
||||
tsCmd = nil
|
||||
return "", fmt.Errorf("tailscale status: %w", err)
|
||||
default:
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
if status.Self != nil && len(status.Self.TailscaleIPs) > 0 {
|
||||
tailscaleIP = status.Self.TailscaleIPs[0].String()
|
||||
}
|
||||
var st tailscaleStatus
|
||||
if err := json.Unmarshal(out, &st); err == nil && len(st.Self.TailscaleIPs) > 0 {
|
||||
tsIP = st.Self.TailscaleIPs[0]
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = tsCmd.Process.Kill()
|
||||
_ = tsCmd.Wait()
|
||||
tsCmd = nil
|
||||
return "", fmt.Errorf("tailscale IP timeout")
|
||||
case <-time.After(1 * time.Second):
|
||||
default:
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Tailscale started with IP: %s", tailscaleIP)
|
||||
return tailscaleIP, nil
|
||||
log.Printf("Tailscale started with IP: %s", tsIP)
|
||||
return tsIP, nil
|
||||
}
|
||||
|
||||
func startTailscaleProxy(port int) (net.Listener, error) {
|
||||
if globalTSServer == nil {
|
||||
return nil, fmt.Errorf("tailscale server not started")
|
||||
func stopTailscale() {
|
||||
tsCmdMu.Lock()
|
||||
defer tsCmdMu.Unlock()
|
||||
stopTailscaleLocked()
|
||||
}
|
||||
ln, err := globalTSServer.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tailscale listen on port %d: %w", port, err)
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Printf("tailscale proxy accept error on port %d: %v", port, err)
|
||||
|
||||
func stopTailscaleLocked() {
|
||||
if tsCmd == nil || tsCmd.Process == nil {
|
||||
return
|
||||
}
|
||||
go handleProxyConn(conn, port)
|
||||
if tsSocket != "" {
|
||||
_ = exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down").Run()
|
||||
}
|
||||
}()
|
||||
log.Printf("Tailscale proxy started on port %d", port)
|
||||
return ln, nil
|
||||
_ = tsCmd.Process.Kill()
|
||||
_ = tsCmd.Wait()
|
||||
tsCmd = nil
|
||||
tsIP = ""
|
||||
log.Printf("Tailscale stopped")
|
||||
}
|
||||
|
||||
func handleProxyConn(src net.Conn, port int) {
|
||||
defer src.Close()
|
||||
dst, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port))
|
||||
if err != nil {
|
||||
log.Printf("tailscale proxy dial localhost:%d error: %v", port, err)
|
||||
return
|
||||
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
|
||||
}
|
||||
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
|
||||
func getTailscaleIP() string {
|
||||
tsCmdMu.Lock()
|
||||
defer tsCmdMu.Unlock()
|
||||
return tsIP
|
||||
}
|
||||
|
||||
|
||||
|
||||
+54
-2
@@ -2,9 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@@ -20,6 +23,55 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
||||
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
|
||||
}
|
||||
// Do not expose the auth key in plain GET unless requested; for local UI it is fine.
|
||||
json.NewEncoder(w).Encode(cfg)
|
||||
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:]...)
|
||||
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("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
@@ -72,9 +124,9 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+101
-3
@@ -2,7 +2,7 @@
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>EduBox Agent</title>
|
||||
<title>studioE5 Agent</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; background: #f1f5f9; margin: 0; padding: 2rem; color: #1e293b; }
|
||||
@@ -10,9 +10,13 @@
|
||||
.card { background: white; border-radius: 12px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 1rem; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 1rem; }
|
||||
h2 { font-size: 1.125rem; margin: 0 0 1rem; }
|
||||
label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.25rem; color: #475569; }
|
||||
input { width: 100%; padding: 0.6rem; border: 1px solid #cbd5e1; border-radius: 8px; margin-bottom: 0.75rem; font-size: 1rem; }
|
||||
input:read-only { background: #f1f5f9; }
|
||||
button { width: 100%; padding: 0.7rem; background: #2563eb; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 1rem; }
|
||||
button:hover { background: #1d4ed8; }
|
||||
button.secondary { background: #e2e8f0; color: #1e293b; }
|
||||
button.secondary:hover { background: #cbd5e1; }
|
||||
.status { margin-top: 0.75rem; font-size: 0.9rem; min-height: 1.2rem; }
|
||||
.success { color: #16a34a; }
|
||||
.error { color: #dc2626; }
|
||||
@@ -30,16 +34,44 @@
|
||||
.instance-link { font-size: 0.875rem; color: #2563eb; text-decoration: none; font-weight: 500; }
|
||||
.instance-link:hover { text-decoration: underline; }
|
||||
.empty { text-align: center; color: #64748b; padding: 1rem 0; }
|
||||
.toolbar { display: flex; gap: 0.5rem; margin-top: 1rem; }
|
||||
.toolbar button { flex: 1; }
|
||||
.note { font-size: 0.8rem; color: #64748b; margin-top: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h1>EduBox Agent</h1>
|
||||
<div id="home-card" class="card">
|
||||
<h1>studioE5 Agent</h1>
|
||||
<div id="main">
|
||||
<p class="info">Connexion en cours...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings-card" class="card" style="display:none;">
|
||||
<h2>Paramètres</h2>
|
||||
<form id="settings-form" onsubmit="saveSettings(event)">
|
||||
<label for="cfg-server">Serveur WebSocket</label>
|
||||
<input type="text" id="cfg-server" placeholder="ws://localhost:3001">
|
||||
|
||||
<label for="cfg-node">ID du nœud</label>
|
||||
<input type="text" id="cfg-node" placeholder="MON-PC">
|
||||
|
||||
<label for="cfg-headscale-url">URL Headscale</label>
|
||||
<input type="text" id="cfg-headscale-url" placeholder="https://headscale.exemple.com">
|
||||
|
||||
<label for="cfg-headscale-key">Clé Headscale</label>
|
||||
<input type="password" id="cfg-headscale-key" placeholder="hskey-auth-...">
|
||||
|
||||
<label for="cfg-data-dir">Répertoire de données</label>
|
||||
<input type="text" id="cfg-data-dir" readonly>
|
||||
|
||||
<button type="submit">Enregistrer et redémarrer</button>
|
||||
</form>
|
||||
<div id="settings-status" class="status"></div>
|
||||
<p class="note">Le redémarrage est nécessaire pour prendre en compte les nouveaux paramètres.</p>
|
||||
</div>
|
||||
|
||||
<div id="instances-card" class="card" style="display:none;">
|
||||
<h2>Mes instances</h2>
|
||||
<div id="instances" class="instance-list"></div>
|
||||
@@ -49,6 +81,8 @@
|
||||
<script>
|
||||
const ws = new WebSocket('ws://' + location.host + '/ws');
|
||||
const main = document.getElementById('main');
|
||||
const homeCard = document.getElementById('home-card');
|
||||
const settingsCard = document.getElementById('settings-card');
|
||||
const instancesCard = document.getElementById('instances-card');
|
||||
const instancesContainer = document.getElementById('instances');
|
||||
|
||||
@@ -60,6 +94,7 @@
|
||||
const msg = JSON.parse(ev.data);
|
||||
|
||||
if (msg.action === 'not_activated') {
|
||||
showHome();
|
||||
main.innerHTML = `
|
||||
<p>Entre ton code d'activation (6 caractères) :</p>
|
||||
<input type="text" id="code" maxlength="6" placeholder="XXXXXX" onkeydown="if(event.key==='Enter')activate()">
|
||||
@@ -67,9 +102,13 @@
|
||||
<div id="status" class="status"></div>
|
||||
`;
|
||||
} else if (msg.action === 'activated') {
|
||||
showHome();
|
||||
main.innerHTML = `
|
||||
<p class="success">✅ Activé : <strong>${escapeHtml(msg.studentName || '')}</strong></p>
|
||||
<p class="info">Tes instances apparaissent ci-dessous.</p>
|
||||
<div class="toolbar">
|
||||
<button class="secondary" onclick="showSettings()">⚙️ Paramètres</button>
|
||||
</div>
|
||||
`;
|
||||
instancesCard.style.display = 'block';
|
||||
ws.send(JSON.stringify({action: 'instances'}));
|
||||
@@ -130,6 +169,65 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/config');
|
||||
const cfg = await res.json();
|
||||
document.getElementById('cfg-server').value = cfg.server || '';
|
||||
document.getElementById('cfg-node').value = cfg.node_id || '';
|
||||
document.getElementById('cfg-headscale-url').value = cfg.headscale_url || '';
|
||||
document.getElementById('cfg-headscale-key').value = cfg.headscale_auth_key || '';
|
||||
document.getElementById('cfg-data-dir').value = cfg.data_dir || '';
|
||||
} catch (err) {
|
||||
document.getElementById('settings-status').innerHTML = `<span class="error">Erreur chargement config</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(event) {
|
||||
event.preventDefault();
|
||||
const status = document.getElementById('settings-status');
|
||||
status.innerHTML = 'Enregistrement...';
|
||||
const cfg = {
|
||||
server: document.getElementById('cfg-server').value.trim(),
|
||||
node_id: document.getElementById('cfg-node').value.trim(),
|
||||
headscale_url: document.getElementById('cfg-headscale-url').value.trim(),
|
||||
headscale_auth_key: document.getElementById('cfg-headscale-key').value.trim(),
|
||||
data_dir: document.getElementById('cfg-data-dir').value.trim()
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(cfg)
|
||||
});
|
||||
if (res.ok) {
|
||||
status.innerHTML = '<span class="success">✅ Enregistré. Redémarrage en cours...</span>';
|
||||
await fetch('/api/restart', {method: 'POST'});
|
||||
setTimeout(() => location.reload(), 3000);
|
||||
} else {
|
||||
status.innerHTML = `<span class="error">❌ Erreur ${res.status}</span>`;
|
||||
}
|
||||
} catch (err) {
|
||||
status.innerHTML = `<span class="error">❌ ${escapeHtml(err.message)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showSettings() {
|
||||
homeCard.style.display = 'none';
|
||||
instancesCard.style.display = 'none';
|
||||
settingsCard.style.display = 'block';
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
function showHome() {
|
||||
homeCard.style.display = 'block';
|
||||
settingsCard.style.display = 'none';
|
||||
}
|
||||
|
||||
if (location.hash === '#settings') {
|
||||
showSettings();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (text == null) return '';
|
||||
return String(text)
|
||||
|
||||
+110
-45
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -22,6 +21,8 @@ type WSMessage struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -29,11 +30,27 @@ 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
|
||||
}
|
||||
|
||||
func sendMessage(msg WSMessage) error {
|
||||
mainConnMu.Lock()
|
||||
defer mainConnMu.Unlock()
|
||||
@@ -86,7 +103,9 @@ func notifyUI(msg map[string]interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func startWebSocket(serverAddr, nodeID, dataDir string) {
|
||||
func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) {
|
||||
setHeadscaleConfig(headscaleURL, headscaleAuthKey)
|
||||
|
||||
for {
|
||||
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
|
||||
if err != nil {
|
||||
@@ -117,6 +136,11 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
|
||||
log.Println("Waiting for activation...")
|
||||
} else {
|
||||
log.Printf("Already activated as %s", act.StudentName)
|
||||
// If already activated and we have credentials, ensure VPN is up.
|
||||
hsURL, hsKey := getHeadscaleConfig()
|
||||
if hsURL != "" && hsKey != "" {
|
||||
go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat goroutine
|
||||
@@ -169,6 +193,25 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
log.Printf("Activated as %s", act.StudentName)
|
||||
}
|
||||
}
|
||||
|
||||
// The server also sends Headscale credentials on activation.
|
||||
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
|
||||
cfg.HeadscaleAuthKey = msg.HeadscaleAuthKey
|
||||
if err := saveConfig(dataDir, cfg); err != nil {
|
||||
log.Printf("saveConfig error: %v", err)
|
||||
} else {
|
||||
log.Printf("Saved Headscale config received from server")
|
||||
}
|
||||
}
|
||||
go startTailscaleAndReport(dataDir, nodeID, msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
||||
}
|
||||
|
||||
notifyUI(map[string]interface{}{
|
||||
"action": "activated",
|
||||
"studentName": msg.StudentName,
|
||||
@@ -176,6 +219,35 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
case "registered":
|
||||
// Server acknowledged our register message; nothing to do.
|
||||
return
|
||||
case "start_vpn":
|
||||
log.Printf("Server requested VPN start")
|
||||
hsURL, hsKey := getHeadscaleConfig()
|
||||
if hsURL == "" || hsKey == "" {
|
||||
log.Printf("Cannot start VPN: headscale config missing")
|
||||
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
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()})
|
||||
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
|
||||
}
|
||||
}()
|
||||
case "stop_vpn":
|
||||
log.Printf("Server requested VPN stop")
|
||||
stopTailscale()
|
||||
sendMessage(WSMessage{Action: "vpn_stopped", NodeID: nodeID})
|
||||
case "activation_failed":
|
||||
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
|
||||
notifyUI(map[string]interface{}{
|
||||
@@ -192,7 +264,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
}); err != nil {
|
||||
log.Printf("upsertInstance error: %v", err)
|
||||
}
|
||||
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil {
|
||||
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); 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()})
|
||||
@@ -205,7 +277,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
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.
|
||||
// so the studioE5 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)
|
||||
@@ -213,16 +285,8 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
log.Printf("stripWordPressHardcodedURLs error: %v", err)
|
||||
}
|
||||
}()
|
||||
// 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()
|
||||
// Ensure Tailscale is running so the server can reach the node
|
||||
go ensureTailscale(dataDir, nodeID, msg.Port)
|
||||
|
||||
status := getInstanceStatus(dataDir, msg.InstanceID)
|
||||
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
|
||||
@@ -230,15 +294,6 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||
case "stop":
|
||||
log.Printf("Stop instance %s", msg.InstanceID)
|
||||
// Stop Tailscale proxy for this instance port
|
||||
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)
|
||||
}
|
||||
tsProxiesMu.Unlock()
|
||||
}
|
||||
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
|
||||
log.Printf("dockerComposeDown error: %v", err)
|
||||
}
|
||||
@@ -249,21 +304,13 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||
case "delete":
|
||||
log.Printf("Delete instance %s", msg.InstanceID)
|
||||
tsProxiesMu.Lock()
|
||||
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)
|
||||
}
|
||||
}
|
||||
tsProxiesMu.Unlock()
|
||||
dockerComposeRm(dataDir, msg.InstanceID)
|
||||
removeInstance(dataDir, msg.InstanceID)
|
||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||
case "reset":
|
||||
log.Printf("Reset instance %s", msg.InstanceID)
|
||||
dockerComposeRm(dataDir, msg.InstanceID)
|
||||
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil {
|
||||
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); 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()})
|
||||
@@ -276,7 +323,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
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.
|
||||
// so the studioE5 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)
|
||||
@@ -284,16 +331,8 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
log.Printf("stripWordPressHardcodedURLs error: %v", err)
|
||||
}
|
||||
}()
|
||||
// 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()
|
||||
// Ensure Tailscale is running so the server can reach the node
|
||||
go ensureTailscale(dataDir, nodeID, msg.Port)
|
||||
|
||||
status := getInstanceStatus(dataDir, msg.InstanceID)
|
||||
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
|
||||
@@ -303,3 +342,29 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
||||
log.Printf("Unknown action: %s", msg.Action)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+39
-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,9 @@ 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"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
@@ -35,28 +31,21 @@ 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}
|
||||
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 +53,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_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 +91,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 +104,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
151.80.60.98:3001/yacine/edubox/edubox-prestashop:9-edubox-9
|
||||
docker push \
|
||||
151.80.60.98:3001/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: 151.80.60.98:3001/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>
|
||||
|
||||
@@ -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,12 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const AGENT_VERSION = "0.3.0";
|
||||
const AGENT_BIN_NAME = "studioE5-agent";
|
||||
|
||||
export async function GET() {
|
||||
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`,
|
||||
windows: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}.exe`,
|
||||
linux: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}`,
|
||||
mac: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}-mac`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sendToNode } from "@/lib/websocket";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
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 });
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
|
||||
const AGENT_VERSION = "0.3.0";
|
||||
const AGENT_BIN_NAME = "studioE5-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -15,8 +16,8 @@ export default function DownloadPage() {
|
||||
<CardTitle>Windows</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)</p>
|
||||
<a href={`/${AGENT_BIN_NAME}-v${AGENT_VERSION}.exe`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -54,7 +54,13 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
||||
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}` }));
|
||||
ws.send(JSON.stringify({
|
||||
action: "activated",
|
||||
studentId: student.id,
|
||||
studentName: `${student.firstName} ${student.lastName}`,
|
||||
headscaleUrl: process.env.HEADSCALE_URL,
|
||||
headscaleAuthKey: process.env.HEADSCALE_AUTH_KEY,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+35
-5
@@ -19,9 +19,6 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
// Remove obsolete PrestaShop templates from previous seeds
|
||||
await prisma.template.deleteMany({ where: { type: "prestashop" } });
|
||||
|
||||
const templates = [
|
||||
{
|
||||
name: "WordPress latest vierge",
|
||||
@@ -53,20 +50,53 @@ async function main() {
|
||||
dbPassword: "wordpress",
|
||||
dbRootPassword: "rootpassword",
|
||||
},
|
||||
{
|
||||
name: "PrestaShop 9 vierge (edubox)",
|
||||
type: "prestashop",
|
||||
dockerImage: "151.80.60.98:3001/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 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 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`;
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user