Compare commits

...

5 Commits

Author SHA1 Message Date
EduBox Dev 73b561ed33 fix(prestashop): image edubox-prestashop fonctionne en local et derrière proxy
- edubox-tools.patch : getShopDomain/Ssl conservent les ports non standards
- edubox-configuration.patch : PS_SHOP_DOMAIN, _PS_BASE_URL_, PS_SSL_ENABLED… résolus dynamiquement
- edubox-shop-getbaseurl.patch : Shop::getBaseURL() utilise le host de la requête
- edubox-shopurl.patch : getMainShopDomain conserve les ports non standards
- edubox-clear-cache-init.sh : vidage des caches à chaque démarrage
- seed.ts : passage au tag 9-edubox-9
- README mis à jour avec les nouveaux patches
2026-06-23 16:39:12 +00:00
EduBox Dev 8a9deb8ebc feat(agent): activation zéro-config – config Headscale envoyée par le serveur
- Agent: URL serveur par défaut, node_id auto-généré, config Headscale vide par défaut
- Serveur: lors de l’activation, renvoie headscaleUrl + headscaleAuthKey
- Agent: sauvegarde la config reçue et démarre Tailscale automatiquement
- docker-compose.yml: passe HEADSCALE_URL et HEADSCALE_AUTH_KEY au service server
- Mise à jour du suivi avec le flow zéro-config
2026-06-23 10:30:19 +00:00
EduBox Dev df77caf64a feat(agent): packaging Windows + téléchargement HTTPS
- Téléchargement des binaires Tailscale Windows via download-tailscale-bins.sh
- Build Windows/Linux via build.sh avec génération d’archive zip
- Caddyfile : serve les agents en HTTPS sous /studioE5-agent*
- .gitignore : ignore agent/tailscale-bin/
- Documentation du téléchargement dans SUIVI_VPN_ONDEMAND.md
2026-06-23 10:00:52 +00:00
EduBox Dev b4344e9d66 docs(vpn): mise à jour du suivi – flux web OK et étude wildcard Infomaniak 2026-06-23 09:53:02 +00:00
EduBox Dev 124543d658 feat(vpn): VPN on-demand Tailscale + agent studioE5 standalone
- Agent studioE5 standalone en Go (console + systray)
- VPN on-demand via tailscaled + tailscale up (authkey Headscale)
- Resolver/serveur dans le tailnet studioe5
- Caddy on-demand TLS pour les instances
- Nouveaux endpoints serveur /api/internal/send-to-node
- Suppression des anciens binaires edubox-agent
- Suivi dans SUIVI_VPN_ONDEMAND.md
2026-06-23 09:48:00 +00:00
62 changed files with 2289 additions and 500 deletions
+9
View File
@@ -2,6 +2,7 @@
node_modules/ node_modules/
.next/ .next/
*.log *.log
studioE5-data/
edubox-data/ edubox-data/
dist/ dist/
coverage/ coverage/
@@ -9,11 +10,19 @@ coverage/
*.dll *.dll
*.so *.so
*.dylib *.dylib
agent/studioE5-agent
agent/studioE5-agent.exe
agent/studioE5-agent-mac
agent/studioE5-agent-v*
agent/edubox-agent agent/edubox-agent
agent/edubox-agent.exe agent/edubox-agent.exe
agent/edubox-agent-mac agent/edubox-agent-mac
agent/edubox-agent-v*
server/public/studioE5-agent*
server/public/edubox-agent*
agent/ui/*.go.html agent/ui/*.go.html
headscale/*.sqlite* headscale/*.sqlite*
headscale/*.key headscale/*.key
headscale/*.state headscale/*.state
agent/resolv.conf agent/resolv.conf
agent/tailscale-bin/
+387
View File
@@ -0,0 +1,387 @@
# Analyse : Intégration de PrestaShop dans EduBox
## 1. Architecture actuelle d'EduBox (vue d'ensemble)
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Serveur cloud (ex: alfrednobel.edudeploy.com) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ Caddy │──▶│ Next.js │──▶│ Resolver Go │──▶│ PostgreSQL │ │
│ │ TLS on-demand│ │ (dashboard) │ │ (proxy inst.)│ │ (état) │ │
│ └─────────────┘ └──────────────┘ └──────────────┘ └─────────────┘ │
│ │ │ WebSocket 3001 │
│ │ ▼ │
│ │ Agent EduBox (Go) sur PC étudiant via Tailscale/Headscale │
│ │ ┌─────────────┐ │
│ └─────────────▶│ WordPress │ (mu-plugin edubox-public-url.php) │
│ │ Docker/Podman │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Flux d'une requête publique
1. Le navigateur demande `https://<instance-id>.alfrednobel.edudeploy.com/`
2. Caddy termine TLS et transmet au resolver Go (`:443 → resolver:2020`)
3. Le resolver lit PostgreSQL pour trouver le nœud (Tailscale IP) et le port de l'instance
4. Le resolver fait du reverse proxy HTTP vers `http://<tailscale-ip>:<port>`
5. Le resolver ajoute les headers `X-Forwarded-Proto: https`, `X-Forwarded-Host`, `X-Forwarded-Port: 443`
6. L'agent Go sur le PC étudiant a lancé le conteneur WordPress sur `localhost:<port>` (bind `{PORT}:80`)
7. WordPress reçoit la requête en HTTP interne mais le **mu-plugin** `edubox-public-url.php` détecte `HTTP_HOST`/`X-Forwarded-Proto` et redéfinit `WP_HOME`/`WP_SITEURL` à la volée.
### Pourquoi WordPress fonctionne
- WordPress permet de redéfinir `WP_HOME` et `WP_SITEURL` via `wp-config.php` ou un mu-plugin.
- Le mu-plugin intercepte chaque requête et calcule l'URL publique depuis les headers proxy.
- WordPress accepte d'être servi depuis plusieurs domaines simultanément (localhost + sous-domaine public).
---
## 2. Pourquoi PrestaShop est différent (et plus difficile)
### 2.1 Le domaine est stocké en base de données
PrestaShop enregistre l'URL canonique à plusieurs endroits :
- `ps_configuration` :
- `PS_SHOP_DOMAIN`
- `PS_SHOP_DOMAIN_SSL`
- `PS_SSL_ENABLED`
- `ps_shop_url` :
- `domain`
- `domain_ssl`
Ces valeurs sont écrites **une seule fois lors de l'installation automatique** (`PS_INSTALL_AUTO=1`) via `index_cli.php --domain=<PS_DOMAIN>`.
### 2.2 Redirections canoniques strictes
Dans `classes/controller/FrontController.php` :
```php
$match_url = Tools::getCurrentUrlProtocolPrefix() . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
if (!preg_match('/^' . Tools::pRegexp(rawurldecode($canonical_url), '/') . '([&?].*)?$/', $match_url)) {
// ... redirect vers $canonical_url
}
```
- Si l'URL demandée ne correspond pas exactement à l'URL canonique stockée, PrestaShop envoie une 301/302.
- L'URL canonique est générée avec `Tools::getShopDomainSsl(true)` qui combine :
- `ShopUrl::getMainShopDomainSSL()` (domaine figé en base)
- `Configuration::get('PS_SSL_ENABLED')` (protocole figé en base)
### 2.3 Détection SSL
`Tools::usingSecureMode()` truste bien `HTTP_X_FORWARDED_PROTO: https` (testé et confirmé dans `classes/Tools.php`) :
```php
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
return Tools::strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) == 'https';
}
```
Donc le resolver transmet correctement le protocole. Le problème ne vient **pas** d'un défaut de PrestaShop sur la détection SSL en lui-même.
### 2.4 Le vrai problème : l'URL canonique est figée et ne correspond pas à l'URL demandée
Scénario avec `PS_ENABLE_SSL=1` et `PS_DOMAIN=<id>.alfrednobel.edudeploy.com` :
- URL demandée : `https://<id>.alfrednobel.edudeploy.com/`
- Requête interne : `http://<tailscale-ip>:<port>/` avec `X-Forwarded-Proto: https`
- PrestaShop détecte : secure mode = true
- URL canonique : `https://<id>.alfrednobel.edudeploy.com/`
- `match_url` : `https://<id>.alfrednobol.edudeploy.com/`
- Devrait correspondre… mais dans les tests, une boucle a quand même eu lieu.
Causes probables de la boucle :
1. **Port dans `HTTP_HOST`** : le resolver envoie `req.Host = host` (sans `:443`), mais Apache/PHP peut parfois enrichir `HTTP_HOST` avec le port interne (`<id>.domain:80`) selon la configuration.
2. **Cache navigateur** : une 301 est mise en cache, masquant le fix.
3. **Apache `.htaccess` généré par PrestaShop** : contient des règles de rewrite qui peuvent interagir avec le proxy (ex: rediriger `/` vers `index.php` avec un protocole différent).
4. **Cookie/SESSION** : la session PHP ou un cookie sécurisé peut forcer une reconnexion/redirection.
### 2.5 Accès `localhost:<port>` redirige vers le domaine public
C'est le comportement normal de PrestaShop quand `PS_DOMAIN` est le domaine public. Pour EduBox, ce n'est pas bloquant si l'accès étudiant se fait via l'URL publique ou via le Tailscale IP du professeur.
---
## 3. Pistes de solutions
### Solution A — Utiliser le mécanisme natif `PS_HANDLE_DYNAMIC_DOMAIN=1` (recommandée à tester en premier)
L'image Docker officielle `prestashop/prestashop` embarque un script `docker_updt_ps_domains.php` qui est copié à la racine et utilisé comme `DirectoryIndex` quand `PS_HANDLE_DYNAMIC_DOMAIN=1`.
Ce script fait :
```php
$domain = Tools::getHttpHost();
$url = ShopUrl::getShopUrls(Configuration::get('PS_SHOP_DEFAULT'))->where('main', '=', 1)->getFirst();
if ($url) {
$url->domain = $domain;
$url->domain_ssl = $domain;
$url->save();
Configuration::updateValue('PS_SHOP_DOMAIN', $domain);
Configuration::updateValue('PS_SHOP_DOMAIN_SSL', $domain);
Tools::generateHtaccess();
Tools::generateRobotsFile();
Tools::clearSmartyCache();
Media::clearCache();
}
Tools::redirect("index.php");
```
**Avantages**
- Mécanisme officiel, pas de module à maintenir.
- Met à jour le domaine dynamiquement à chaque requête sur `/`.
**Inconvénients**
- Exécution PHP + requêtes SQL + régénération `.htaccess` à chaque requête sur `/` → latence perceptible.
- Ne gère pas nativement le HTTPS vs HTTP (domain_ssl = domain, sans tenir compte de X-Forwarded-Proto).
- Nécessite d'être combiné avec une confiance des headers proxy.
**Implémentation proposée**
Dans le `composeConfig` du template PrestaShop :
```yaml
environment:
PS_DOMAIN: {PUBLIC_DOMAIN}
PS_ENABLE_SSL: "1"
PS_HANDLE_DYNAMIC_DOMAIN: "1"
PS_INSTALL_AUTO: "1"
PS_INSTALL_DB: "0"
```
Et monter un fichier Apache `proxy.conf` dans `/etc/apache2/conf-enabled/` :
```apache
SetEnvIf X-Forwarded-Proto https HTTPS=on
SetEnvIf X-Forwarded-Proto https SERVER_PORT=443
SetEnvIf X-Forwarded-Host ^(.+)$ HTTP_HOST=$1
```
Cela permet à `Tools::usingSecureMode()` de retourner `true` et à `Tools::getHttpHost()` de retourner le bon host public.
**Risques**
- Boucle possible si `PS_ENABLE_SSL=1` mais Apache ne reçoit pas `HTTPS=on` (d'où l'importance du `SetEnvIf`).
- Performance : le `docker_updt_ps_domains.php` est exécuté à chaque hit sur `/`.
---
### Solution B — Créer un module/override PrestaShop "EduBox Public URL" (équivalent du mu-plugin WordPress)
Créer un override `override/classes/Configuration.php` (ou un module hooké tôt) qui surcharge `Configuration::get()` pour les clés `PS_SHOP_DOMAIN`, `PS_SHOP_DOMAIN_SSL` et `PS_SSL_ENABLED`.
Exemple d'override :
```php
<?php
class Configuration extends ConfigurationCore {
public static function get($id_lang = null, $id_shop_group = null, $id_shop = null) {
$key = func_num_args() > 0 && is_string(func_get_arg(0)) ? func_get_arg(0) : $id_lang;
if ($key === 'PS_SHOP_DOMAIN' || $key === 'PS_SHOP_DOMAIN_SSL') {
return Tools::getHttpHost(false, false, true); // host sans port
}
if ($key === 'PS_SSL_ENABLED') {
return Tools::usingSecureMode() ? '1' : '0';
}
return call_user_func_array(['ConfigurationCore', 'get'], func_get_args());
}
}
```
**Avantages**
- Le plus proche du mu-plugin WordPress : aucune réécriture de réponses, pas de latence sur `/`.
- Une fois l'override chargé, toutes les URLs générées par PrestaShop utilisent le domaine de la requête courante.
**Inconvénients / pièges**
- L'override doit être pris en compte par l'autoloader. Sous PrestaShop 8, il faut vider `app/cache/prod/class_index.php`, `var/cache/*` et régénérer l'index avec `PrestaShop\Autoload\PrestashopAutoload::getInstance()->generateIndex()`.
- L'image Docker officielle embarque un `class_index.php` pré-généré qu'il faut invalider.
- Si l'installation se fait avec un override déjà présent, PrestaShop peut ne pas l'activer immédiatement.
**Implémentation proposée**
1. Créer `agent/psplugins/Configuration.php` (embarqué dans l'agent).
2. Au `start`/`reset` d'une instance PrestaShop, l'agent :
- copie l'override dans `/var/www/html/override/classes/Configuration.php`
- vide les caches (`rm -rf app/cache/* var/cache/*`)
- régénère l'autoloader (`php -r "require 'config/config.inc.php'; PrestaShop\Autoload\PrestashopAutoload::getInstance()->generateIndex();"`)
3. Ajouter le montage de l'override dans le `composeConfig` PrestaShop via un placeholder `{PS_OVERRIDES_DIR}`.
4. Garder la config Apache `SetEnvIf X-Forwarded-Proto https HTTPS=on` pour la détection SSL.
**Risques**
- Fragilité des versions : l'override dépend de la signature de `ConfigurationCore::get()` qui peut changer.
- Nécessite de bien gérer les caches à chaque redémarrage du conteneur.
---
### Solution C — Installation "localhost" + réécriture complète par le proxy
Installer PrestaShop avec `PS_DOMAIN=localhost:{PORT}` et `PS_ENABLE_SSL=0`.
Le conteneur vit en HTTP interne. Le resolver réécrit :
- Le header `Location` : `http://localhost:<port>/...``https://<id>.domain/...`
- Le body HTML/CSS/JS : toutes les occurrences de `http://localhost:<port>` et `//localhost:<port>`
C'est l'approche "WordPress-like".
**Avantages**
- Pas besoin de modifier PrestaShop.
- Le conteneur est totalement agnostique du domaine public.
**Inconvénients**
- PrestaShop génère énormément d'URLs absolues (assets, liens admin, webhooks, modules, API). La réécriture body n'est jamais exhaustive.
- Les requêtes AJAX/fetch peuvent pointer vers `localhost:<port>` et échouer côté client.
- Le back-office (`/admin`) génère des redirections complexes.
- Très fragile sur le long terme.
**Verdict** : **non recommandée** pour PrestaShop (contrairement à WordPress).
---
### Solution D — Image Docker PrestaShop personnalisée (patch durable)
Créer un `Dockerfile` dérivé de `prestashop/prestashop:8.1` qui :
1. Applique un patch à `classes/Configuration.php` ou `classes/Tools.php` pour supporter nativement `X-Forwarded-Host`/`X-Forwarded-Proto`.
2. Ou embarque directement l'override EduBox + un script d'init qui vide les caches.
3. Configure Apache pour trust les headers proxy.
**Avantages**
- Totalement reproductible : pas d'opération manuelle sur le conteneur en cours de vie.
- Peut être versionnée et testée indépendamment.
**Inconvénients**
- Nécessite de maintenir et publier une image Docker.
- Ajoute une étape de build CI/CD.
- Si PrestaShop sort une nouvelle version, il faut rebaser le patch.
**Implémentation possible**
```dockerfile
FROM prestashop/prestashop:8.1
COPY edubox-proxy.conf /etc/apache2/conf-enabled/
COPY edubox-override/ /var/www/html/override/
RUN chown -R www-data:www-data /var/www/html/override
```
---
### Solution E — Proxy "intelligent" avec substitution à la volée
Remplacer le resolver Go par (ou ajouter devant) un proxy qui fait de la substitution HTML/CSS/JS beaucoup plus fine, par exemple :
- Nginx avec `sub_filter`
- Apache `mod_substitute`
- Un middleware Node.js type `http-proxy-middleware` avec `selfHandleResponse`
**Avantages**
- Peut corriger les liens absolus que PrestaShop génère.
**Inconvénients**
- Même problème que la solution C : impossible d'être exhaustif.
- Ajoute de la latence et de la complexité.
- Peut casser le JS (si on remplace des chaînes dans du code minifié).
**Verdict** : complément possible, mais pas solution principale.
---
### Solution F — Désactiver les redirections canoniques et SSL forcé
Forcer PrestaShop à ne plus faire de redirections canoniques (`PS_CANONICAL_REDIRECT=0`) et à désactiver SSL partout (`PS_SSL_ENABLED=0`, `PS_SSL_ENABLED_EVERYWHERE=0`).
**Avantages**
- Élimine les boucles de redirection.
**Inconvénients**
- Nécessite un accès au back-office pour modifier les paramètres.
- Les liens générés restent en `http://` et pointent vers le domaine d'installation.
- Mauvaise expérience utilisateur (avertissements navigateur, mixed-content si certains assets passent en http).
**Verdict** : à éviter en production.
---
## 4. Recommandation
Je recommande une **combinaison des solutions A et B**, testée méthodiquement :
### Phase 1 — Solution A (test rapide)
1. Réintroduire un template PrestaShop 8.1 dans `server/prisma/seed.ts` avec :
- `PS_DOMAIN: {PUBLIC_DOMAIN}`
- `PS_ENABLE_SSL: "1"`
- `PS_HANDLE_DYNAMIC_DOMAIN: "1"`
- montage d'une config Apache `proxy.conf` pour trust `X-Forwarded-Proto`
2. Rebuilder le serveur, relancer le seed.
3. Créer une nouvelle instance PrestaShop (pas de reset d'ancienne instance).
4. Tester avec `curl -v -L --max-redirs 5 https://<id>.domain/` et en navigation privée.
### Phase 2 — Solution B (solution cible)
Si la solution A est trop lente ou instable, passer à l'override `Configuration.php` :
1. Créer `agent/psplugins/Configuration.php`.
2. Modifier l'agent pour copier l'override et vider/régénérer les caches au démarrage d'une instance PrestaShop.
3. Ajouter le montage `{PS_OVERRIDES_DIR}` dans le compose template.
4. Conserver la config Apache `SetEnvIf X-Forwarded-Proto`.
5. Tester avec `localhost:<port>` ET `https://<id>.domain/`.
### Phase 3 (optionnel) — Solution D
Si les solutions A/B sont trop fragiles d'une version de PrestaShop à l'autre, créer une image Docker dérivée patchée et la référencer dans le template.
---
## 5. Points d'attention pour l'implémentation
### Headers proxy
Le resolver Go transmet déjà les bons headers. Vérifier qu'aucun middleware Next.js ne les modifie (le middleware actuel ne fait que `NextResponse.next()`).
### Binding de port
Le commit `dd49993` a changé le binding de `127.0.0.1:{PORT}:80` à `{PORT}:80`. Cela signifie que le conteneur PrestaShop est accessible depuis n'importe quelle interface sur le PC étudiant. C'est nécessaire car le Tailscale proxy de l'agent écoute sur toutes les interfaces. **Il ne faut pas revenir à `127.0.0.1` sauf si on change la chaîne de proxy.**
### Cache navigateur
Toujours tester en navigation privée et avec `curl -v -L --max-redirs 5` pour éviter les 301 mises en cache.
### Vider les caches PrestaShop
À chaque changement de domaine/config, il faut vider :
```bash
rm -rf /var/www/html/app/cache/*
rm -rf /var/www/html/var/cache/*
php -r "require '/var/www/html/config/config.inc.php'; PrestaShop\Autoload\PrestashopAutoload::getInstance()->generateIndex();"
```
### Logs utiles
Sur le PC étudiant :
```bash
podman logs -f <id>-app-1
podman exec <id>-app-1 cat /var/log/apache2/error.log
```
Sur le serveur :
```bash
docker logs -f edubox-resolver
docker logs -f edubox-caddy
```
---
## 6. Synthèse comparative
| Solution | Complexité | Robustesse | Perf. | Maintien | Recommandation |
|----------|-----------|------------|-------|----------|----------------|
| A — `PS_HANDLE_DYNAMIC_DOMAIN` | Faible | Moyenne | Moyenne (latence `/`) | Faible | **À tester en premier** |
| B — Override `Configuration` | Moyenne | Forte | Bonne | Moyen | **Solution cible** |
| C — localhost + rewrite proxy | Moyenne | Faible | Bonne | Faible | Non recommandée |
| D — Image Docker patchée | Forte | Très forte | Bonne | Fort | Option long terme |
| E — Proxy substitution | Moyenne | Faible | Moyenne | Faible | Complément seulement |
| F — Désactiver SSL/canonical | Faible | Faible | Bonne | Faible | À éviter |
+16 -6
View File
@@ -6,7 +6,7 @@
} }
:80 { :80 {
route /edubox-agent* { route /studioE5-agent* {
file_server { file_server {
root /usr/share/caddy/agent root /usr/share/caddy/agent
} }
@@ -22,11 +22,16 @@
} }
} }
headscale.alfrednobel.edudeploy.com { headscale.studioe5.edudeploy.com:443 {
reverse_proxy headscale:8080 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 /api/websocket* server:3001
reverse_proxy server:3000 reverse_proxy server:3000
} }
@@ -35,10 +40,15 @@ alfrednobel.edudeploy.com {
tls { tls {
on_demand on_demand
} }
route /studioE5-agent* {
file_server {
root /usr/share/caddy/agent
}
}
@instance { @instance {
not host alfrednobel.edudeploy.com not host studioe5.edudeploy.com
not host headscale.alfrednobel.edudeploy.com not host headscale.studioe5.edudeploy.com
host *.alfrednobel.edudeploy.com host *.studioe5.edudeploy.com
} }
handle @instance { handle @instance {
reverse_proxy resolver:2020 { reverse_proxy resolver:2020 {
+302
View File
@@ -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**
- Lagent ne démarre plus Tailscale au boot.
- Le VPN se lance automatiquement à la création/démarrage dune instance, ou sur commande serveur.
- Implémentation basée sur les binaires `tailscaled` + `tailscale up` (pas `tsnet`, car `tsnet` ne loguait pas automatiquement avec une authkey sur un state vierge).
3. **Commandes serveur → agent**
- Endpoint de test : `POST /api/internal/send-to-node`
- Actions supportées : `start_vpn`, `stop_vpn`, `start`, `stop`, `reset`, `delete`.
4. **Resolver/serveur dans le tailnet studioe5**
- Service `resolver-vpn` (conteneur Tailscale) partage le netns du `resolver`.
- Le resolver peut joindre les IPs Tailscale des nodes (`ping 100.64.0.x` OK).
5. **Instance WordPress démarrée avec succès**
- Le resolver a renvoyé une 302 WordPress via `http://resolver:2020/`.
6. **Activation zéro-config de lagent (modèle commercialisable)**
- Lagent démarre sans `headscale_url` ni `headscale_auth_key`.
- Lutilisateur entre seulement un code dactivation.
- Le serveur envoie la config Headscale, lagent la sauvegarde et démarre le VPN automatiquement.
## ✅ Blocage levé
**Rate limit Lets Encrypt pour `edudeploy.com` est levé.**
Le 2026-06-23 vers 09:35 UTC, Caddy a pu obtenir un certificat Lets Encrypt pour `test-wp-001.studioe5.edudeploy.com` :
```
tls.obtain: certificate obtained successfully identifier=test-wp-001.studioe5.edudeploy.com issuer=acme-v02.api.letsencrypt.org-directory
```
Le flux complet HTTPS public est désormais validé :
```bash
curl -sS -I -L https://test-wp-001.studioe5.edudeploy.com/
# => HTTP/2 302 -> HTTP/2 200 (WordPress install.php)
```
Le DNS wildcard `*.studioe5.edudeploy.com` est en place. Caddy utilise toujours `tls { on_demand }` et émet un certificat par sous-domaine dinstance.
## 🎯 Validation du flux HTTPS public
Le 2026-06-23 09:39 UTC, le flux complet a été validé :
```text
Client (HTTPS) → Caddy (:443) → resolver (:2020) → Tailnet (100.64.0.8) → agent → WordPress (:8001)
```
Résultat :
```bash
$ curl -sS -I -L https://test-wp-001.studioe5.edudeploy.com/
HTTP/2 302
location: https://test-wp-001.studioe5.edudeploy.com/wp-admin/install.php
...
HTTP/2 200
```
- Certificat Lets Encrypt obtenu automatiquement par Caddy (`tls { on_demand }`).
- Le resolver réécrit les en-têtes `Location` et le contenu HTML pour passer de `http://` à `https://`.
## 📁 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 lagent de test
```bash
pgrep -a studioe5-agent
```
### Relancer lagent de test (si besoin)
```bash
mkdir -p /tmp/studioe5-test-clienta
cat > /tmp/studioe5-test-clienta/studioE5-config.json <<EOF
{
"server": "wss://studioe5.edudeploy.com/api/websocket",
"headscale_url": "https://headscale.studioe5.edudeploy.com",
"headscale_auth_key": "$(grep HEADSCALE_AUTH_KEY /opt/studioe5-client-a/.env | cut -d= -f2)",
"node_id": "vps-8fc665eb",
"data_dir": "/tmp/studioe5-test-clienta"
}
EOF
cd /opt/studioe5-client-a/agent
./studioE5-agent -no-tray -data-dir /tmp/studioe5-test-clienta
```
### Démarrer le VPN manuellement
```bash
curl -sS -X POST https://studioe5.edudeploy.com/api/internal/send-to-node \
-H "Content-Type: application/json" \
-d '{"nodeId":"vps-8fc665eb","message":{"action":"start_vpn"}}'
```
### Voir les nodes Headscale
```bash
cd /opt/studioe5-client-a
docker compose exec -T headscale headscale nodes list studioe5
```
### Tester le resolver (depuis Caddy)
```bash
cd /opt/studioe5-client-a
docker exec studioe5-caddy curl -sS -I -H "Host: test-wp-001.studioe5.edudeploy.com" http://resolver:2020/
```
### Tester en HTTPS public (dès que la limite sera levée)
```bash
curl -sS -I -L https://test-wp-001.studioe5.edudeploy.com/
```
## 🌐 Flux complet testé via lAPI web
Test réalisé le 2026-06-23 en utilisant le compte superadmin :
1. **Authentification NextAuth** sur `/api/auth/callback/credentials`.
2. **Création dinstance** via `POST /api/instances` :
```json
{
"nodeId": "vps-8fc665eb",
"templateId": "wordpress-wordpress-latest",
"port": 8002
}
```
→ Instance créée : `cmqqgrur20001lw67t2bdgzkg`.
3. Le serveur a automatiquement envoyé laction `start` au node via WebSocket.
4. Lagent a démarré le VPN (si besoin), écrit le compose et a lancé les conteneurs WordPress.
5. Caddy a obtenu un certificat Lets Encrypt pour `cmqqgrur20001lw67t2bdgzkg.studioe5.edudeploy.com`.
6. **Validation HTTPS** :
```bash
curl -sS -I -L https://cmqqgrur20001lw67t2bdgzkg.studioe5.edudeploy.com/
# => HTTP/2 302 -> HTTP/2 200 (WordPress install.php)
```
Le flux `UI → API → WebSocket → agent → Docker → VPN → Caddy → HTTPS public` est fonctionnel.
## 💻 Téléchargement de lagent
Lagent est servi par Caddy depuis le dossier `agent/` monté dans le conteneur Caddy (`./agent:/usr/share/caddy/agent`).
### Binaires disponibles
- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.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 davoir installé Tailscale Windows séparément ou davoir 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 lagent 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 dactivation zéro-config (modèle commercialisable)
L’élève/employé na **aucune configuration technique** à saisir :
1. **Télécharger** lagent Windows (`studioE5-agent-v0.3.0-windows.zip`).
2. **Extraire** et **lancer** `studioE5-agent.exe`.
3. **Entrer le code dactivation** à 6 caractères fourni par l’établissement (affiché dans lUI locale `http://localhost:7070`).
4. Lagent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** :
- lidentité de l’élève (`studentName`)
- lURL Headscale
- la clé pré-auth Headscale
5. Lagent sauvegarde ces informations localement et **démarre automatiquement le VPN**.
6. Lagent est alors visible dans le dashboard et peut recevoir des instances.
### Configuration manuelle (mode debug / admin)
Si besoin, on peut toujours forcer une config via `data/studioE5-config.json` :
```json
{
"server": "wss://studioe5.edudeploy.com/api/websocket",
"headscale_url": "https://headscale.studioe5.edudeploy.com",
"headscale_auth_key": "CLE_PREAUTH_ICI",
"node_id": "IDENTIFIANT_DU_POSTE",
"data_dir": "C:\\studioE5-agent\\data"
}
```
> ⚠️ `headscale_auth_key` doit être une clé pré-auth réutilisable valide pour le tailnet studioe5. Ne jamais commiter cette clé.
Lancement :
```powershell
.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data
```
## 📋 Prochaines étapes à faire
- [x] ~~Attendre la fin du rate limit Lets 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 linterface web~~ → **OK** via lAPI 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 Lets Encrypt par sous-domaine dinstance**. Cela expose au rate limit de 50 certificats par domaine principal (`edudeploy.com`) sur 7 jours. Un certificat wildcard unique (`*.studioe5.edudeploy.com`) couvre tous les sous-domaines dinstances et évite ce problème.
### Contrainte technique
Un certificat wildcard nécessite le **challenge DNS-01** (le challenge HTTP-01 ne permet pas de valider `*.domain.tld`). Caddy doit donc pouvoir créer un enregistrement TXT automatiquement chez le registrar DNS.
### Infomaniak (registrar actuel)
Le DNS de `edudeploy.com` est chez **Infomaniak** :
```bash
dig NS edudeploy.com +short
# nsany1.infomaniak.com.
# nsany2.infomaniak.com.
```
Il existe un module Caddy DNS pour Infomaniak :
- Repository : `github.com/caddy-dns/infomaniak`
- Nécessite un **token API Infomaniak** avec droits DNS.
### Implémentation à envisager
1. **Générer un token API Infomaniak** (compte client A ou compte dédié avec accès au domaine).
2. **Builder une image Caddy custom** avec le module :
```dockerfile
FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/infomaniak
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
```
3. **Modifier le `Caddyfile`** pour gérer le wildcard :
```caddy
*.studioe5.edudeploy.com {
tls {
dns infomaniak {env.INFOMANIAK_API_TOKEN}
}
reverse_proxy resolver:2020 {
header_up Host {host}
}
}
```
4. **Ajouter le token dans `.env`** et le passer au conteneur Caddy.
5. Supprimer ou ajuster le bloc `:443` actuel qui utilise `on_demand` pour les instances.
### Alternative sans module DNS
Obtenir le certificat wildcard manuellement (Certbot DNS-01, acheté, etc.) et le charger dans Caddy :
```caddy
*.studioe5.edudeploy.com {
tls /data/certs/wildcard.crt /data/certs/wildcard.key
reverse_proxy resolver:2020 {
header_up Host {host}
}
}
```
Inconvénient : renouvellement manuel.
## 🔧 Notes techniques
- Le conteneur `resolver-vpn` utilise `network_mode: service:resolver` pour partager le netns avec le resolver.
- Lagent utilise `tailscaled --tun=userspace-networking` ; le resolver-vpn utilise un vrai TUN (`tailscale0`).
- Le `Caddyfile` actuel utilise `tls { on_demand }` pour les instances. En cas de nouvelle rate limit, on peut temporairement remettre `tls internal` dans le bloc `:443` pour valider le flux sans certificat public.
+70 -16
View File
@@ -2,34 +2,88 @@
set -e set -e
VERSION="0.3.0" VERSION="0.3.0"
APP_NAME="studioE5"
BIN_NAME="studioE5-agent"
LDFLAGS="-X main.version=${VERSION}" 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 export PATH=$PATH:/usr/local/go/bin
GOOS=windows GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o edubox-agent.exe . GOOS=windows GOARCH=amd64 go build -ldflags "${WIN_LDFLAGS}" -o ${BIN_NAME}.exe .
echo " edubox-agent.exe (Windows amd64)" echo " ${BIN_NAME}.exe (Windows amd64)"
cp edubox-agent.exe "edubox-agent-v${VERSION}.exe" cp ${BIN_NAME}.exe "${BIN_NAME}-v${VERSION}.exe"
echo " edubox-agent-v${VERSION}.exe (Windows amd64)" echo " ${BIN_NAME}-v${VERSION}.exe (Windows amd64)"
GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o edubox-agent . GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o ${BIN_NAME} .
echo " edubox-agent (Linux amd64)" echo " ${BIN_NAME} (Linux amd64)"
cp edubox-agent "edubox-agent-v${VERSION}" cp ${BIN_NAME} "${BIN_NAME}-v${VERSION}"
echo " edubox-agent-v${VERSION} (Linux amd64)" echo " ${BIN_NAME}-v${VERSION} (Linux amd64)"
GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o edubox-agent-mac . # macOS build requires CGO for the systray menu; skip gracefully if unavailable.
echo " edubox-agent-mac (macOS amd64)" if GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -ldflags "${LDFLAGS}" -o ${BIN_NAME}-mac . 2>/dev/null; then
cp edubox-agent-mac "edubox-agent-v${VERSION}-mac" echo " ${BIN_NAME}-mac (macOS amd64)"
echo " edubox-agent-v${VERSION}-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 # Copy versioned binaries to server/public so the dashboard can serve them
SERVER_PUBLIC="../server/public" SERVER_PUBLIC="../server/public"
if [ -d "${SERVER_PUBLIC}" ]; then if [ -d "${SERVER_PUBLIC}" ]; then
cp "edubox-agent-v${VERSION}" "${SERVER_PUBLIC}/edubox-agent-v${VERSION}" cp "${BIN_NAME}-v${VERSION}" "${SERVER_PUBLIC}/${BIN_NAME}-v${VERSION}"
cp "edubox-agent-v${VERSION}-mac" "${SERVER_PUBLIC}/edubox-agent-v${VERSION}-mac" cp "${BIN_NAME}-v${VERSION}.exe" "${SERVER_PUBLIC}/${BIN_NAME}-v${VERSION}.exe"
cp "edubox-agent-v${VERSION}.exe" "${SERVER_PUBLIC}/edubox-agent-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}" echo " Copied versioned binaries to ${SERVER_PUBLIC}"
fi 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." echo "Done."
+113
View File
@@ -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
View File
@@ -20,18 +20,20 @@ func getContainerEngine() string {
return "docker" return "docker"
} }
func writeCompose(dataDir, instanceID, compose string) error { func writeCompose(dataDir, instanceID, compose string, port int) error {
dir := instanceDir(dataDir, instanceID) dir := instanceDir(dataDir, instanceID)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return err return err
} }
// Ensure the EduBox mu-plugin is available and substitute its path // Ensure the studioE5 mu-plugin is available and substitute its path
muDir, err := writeMUPlugin(dataDir) muDir, err := writeMUPlugin(dataDir)
if err != nil { if err != nil {
return err return err
} }
compose = strings.ReplaceAll(compose, "{MU_PLUGINS_DIR}", filepath.Dir(muDir)) compose = strings.ReplaceAll(compose, "{MU_PLUGINS_DIR}", filepath.Dir(muDir))
compose = strings.ReplaceAll(compose, "{INSTANCE_ID}", instanceID)
compose = strings.ReplaceAll(compose, "{PORT}", fmt.Sprintf("%d", port))
f := filepath.Join(dir, "docker-compose.yml") f := filepath.Join(dir, "docker-compose.yml")
return os.WriteFile(f, []byte(compose), 0644) return os.WriteFile(f, []byte(compose), 0644)
@@ -115,7 +117,7 @@ fi
} }
// stripWordPressHardcodedURLs removes hardcoded WP_HOME/WP_SITEURL defines // 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 // header. This is useful when repairing older instances created before the
// mu-plugin existed. // mu-plugin existed.
func stripWordPressHardcodedURLs(dataDir, instanceID string) error { func stripWordPressHardcodedURLs(dataDir, instanceID string) error {
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
set -e
# Télécharge les binaires Tailscale Windows depuis l'installateur MSI officiel.
# Nécessite: curl, msitools (msiextract)
VERSION="${1:-1.98.4}"
ARCH="amd64"
OUTDIR="$(dirname "$0")/tailscale-bin/windows"
MSI_URL="https://pkgs.tailscale.com/stable/tailscale-setup-${VERSION}-${ARCH}.msi"
TMPDIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMPDIR"
}
trap cleanup EXIT
echo "Downloading Tailscale ${VERSION} Windows installer..."
curl -L -o "$TMPDIR/tailscale-setup.msi" "$MSI_URL"
echo "Extracting binaries..."
mkdir -p "$TMPDIR/extract"
msiextract -C "$TMPDIR/extract" "$TMPDIR/tailscale-setup.msi" >/dev/null
echo "Installing to ${OUTDIR}..."
mkdir -p "$OUTDIR"
cp "$TMPDIR/extract/PFiles64/Tailscale/tailscale.exe" "$OUTDIR/"
cp "$TMPDIR/extract/PFiles64/Tailscale/tailscaled.exe" "$OUTDIR/"
cp "$TMPDIR/extract/PFiles64/Tailscale/wintun.dll" "$OUTDIR/"
echo "Done. Installed:"
ls -lh "$OUTDIR"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
import zlib
import struct
def png_chunk(chunk_type: bytes, data: bytes) -> bytes:
return (
struct.pack(">I", len(data))
+ chunk_type
+ data
+ struct.pack(">I", zlib.crc32(chunk_type + data) & 0xFFFFFFFF)
)
def create_png(width: int, height: int, pixels: list) -> bytes:
"""Create a PNG from a 2D list of (R, G, B) tuples."""
raw = bytearray()
for row in pixels:
raw.append(0) # no filter
for r, g, b in row:
raw.extend((r, g, b))
compressed = zlib.compress(bytes(raw), 9)
ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
ihdr = png_chunk(b"IHDR", ihdr_data)
idat = png_chunk(b"IDAT", compressed)
iend = png_chunk(b"IEND", b"")
return b"\x89PNG\r\n\x1a\n" + ihdr + idat + iend
BLUE = (37, 99, 235)
WHITE = (255, 255, 255)
SIZE = 64
pixels = []
for y in range(SIZE):
row = []
for x in range(SIZE):
dx = x - SIZE // 2
dy = y - SIZE // 2
dist = (dx * dx + dy * dy) ** 0.5
if dist < SIZE * 0.25:
row.append(WHITE)
else:
row.append(BLUE)
pixels.append(row)
with open("icon.png", "wb") as f:
f.write(create_png(SIZE, SIZE, pixels))
print("Generated icon.png")
+1 -41
View File
@@ -3,52 +3,12 @@ module edubox-agent
go 1.26.4 go 1.26.4
require ( require (
fyne.io/systray v1.12.2
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
tailscale.com v1.100.0
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/creachadair/msync v0.7.1 // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gaissmai/bart v0.26.1 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/huin/goupnp v1.3.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/safchain/ethtool v0.3.0 // indirect
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad // indirect
github.com/x448/float16 v0.8.4 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
) )
+2 -224
View File
@@ -1,233 +1,11 @@
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ=
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c=
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/creachadair/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA=
github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs=
github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho=
github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8=
github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I=
github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk=
github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 h1:glgVc1ZYMjwN1Q/ITWeuSQyl029uayagaR2sjsifehc=
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA=
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad h1:Ky26FR5yZ5IKEB0xtm5A8xSTb06ImY7kxBFrvgOmJSg=
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA=
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.100.0 h1:nm/M/dEaW9RaRsGUjW2HsSDpsZ60Jwd9k4gNW9tTFiE=
tailscale.com v1.100.0/go.mod h1:DQ9YBy85DpNlSyeU2XRIWzbAu3RsGp/frv+Khg57meE=
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

+35 -22
View File
@@ -5,30 +5,23 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"time" "time"
) )
// version is injected at build time via -ldflags "-X main.version=X.Y.Z" // version is injected at build time via -ldflags "-X main.version=X.Y.Z"
var version = "dev" var version = "dev"
const AGENT_VERSION = "0.3.0" const (
AGENT_VERSION = "0.3.0"
var ( APP_NAME = "studioE5"
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")
) )
func defaultNodeID() string { var (
h, err := os.Hostname() dataDir = flag.String("data-dir", "./studioE5-data", "Répertoire de données")
if err != nil { uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX")
return "unknown" noTray = flag.Bool("no-tray", false, "Désactiver l'icône dans la barre système (mode console)")
} )
return h
}
func main() { func main() {
flag.Parse() flag.Parse()
@@ -49,19 +42,39 @@ func main() {
log.Fatalf("Cannot create data-dir: %v", err) 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 { 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 != "" { shutdownCh := make(chan struct{})
go startTailscaleAndReport(*dataDir, *nodeID, *headscaleURL, *headscaleAuthKey) 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) { func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
+90
View File
@@ -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
View File
@@ -2,104 +2,178 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"net"
"os" "os"
"io" "os/exec"
"path/filepath"
"runtime"
"sync"
"syscall"
"time" "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) { func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, error) {
// Configure tsnet to use our Headscale server tsCmdMu.Lock()
os.Setenv("TS_AUTHKEY", authKey) defer tsCmdMu.Unlock()
os.Setenv("TS_CONTROL_URL", headscaleURL)
s := &tsnet.Server{ if tsCmd != nil {
Hostname: nodeID, return tsIP, nil
Dir: dataDir,
Logf: log.Printf,
} }
if err := s.Start(); err != nil { if dataDir == "" {
return "", fmt.Errorf("tailscale start: %w", err) 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 // Bring the interface up with the auth key.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
lc, err := s.LocalClient() upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"),
if err != nil { "--socket="+tsSocket,
return "", fmt.Errorf("tailscale local client: %w", err) "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 { for {
status, err := lc.Status(ctx) out, err := exec.CommandContext(ctx, tailscaleBin("tailscale"),
"--socket="+tsSocket,
"status", "--json",
).Output()
if err != nil { if err != nil {
select {
case <-ctx.Done():
_ = tsCmd.Process.Kill()
_ = tsCmd.Wait()
tsCmd = nil
return "", fmt.Errorf("tailscale status: %w", err) 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 break
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
_ = tsCmd.Process.Kill()
_ = tsCmd.Wait()
tsCmd = nil
return "", fmt.Errorf("tailscale IP timeout") 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) log.Printf("Tailscale started with IP: %s", tsIP)
return tailscaleIP, nil return tsIP, nil
} }
func startTailscaleProxy(port int) (net.Listener, error) { func stopTailscale() {
if globalTSServer == nil { tsCmdMu.Lock()
return nil, fmt.Errorf("tailscale server not started") defer tsCmdMu.Unlock()
stopTailscaleLocked()
} }
ln, err := globalTSServer.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil { func stopTailscaleLocked() {
return nil, fmt.Errorf("tailscale listen on port %d: %w", port, err) if tsCmd == nil || tsCmd.Process == nil {
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
log.Printf("tailscale proxy accept error on port %d: %v", port, err)
return return
} }
go handleProxyConn(conn, port) if tsSocket != "" {
_ = exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down").Run()
} }
}() _ = tsCmd.Process.Kill()
log.Printf("Tailscale proxy started on port %d", port) _ = tsCmd.Wait()
return ln, nil tsCmd = nil
tsIP = ""
log.Printf("Tailscale stopped")
} }
func handleProxyConn(src net.Conn, port int) { func isTailscaleRunning() bool {
defer src.Close() tsCmdMu.Lock()
dst, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port)) defer tsCmdMu.Unlock()
if err != nil { if tsCmd == nil || tsCmd.Process == nil {
log.Printf("tailscale proxy dial localhost:%d error: %v", port, err) return false
return }
// Signal 0 checks process existence without affecting it.
return tsCmd.Process.Signal(syscall.Signal(0)) == nil
} }
defer dst.Close()
done := make(chan struct{}, 2) func getTailscaleIP() string {
go func() { tsCmdMu.Lock()
_, _ = io.Copy(dst, src) defer tsCmdMu.Unlock()
done <- struct{}{} return tsIP
}()
go func() {
_, _ = io.Copy(src, dst)
done <- struct{}{}
}()
<-done
} }
+54 -2
View File
@@ -2,9 +2,12 @@ package main
import ( import (
_ "embed" _ "embed"
"encoding/json"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"os/exec"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -20,6 +23,55 @@ func startUI(dataDir, nodeID, serverAddr string) {
fmt.Fprint(w, uiHTML) fmt.Fprint(w, uiHTML)
}) })
http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
cfg, _, err := loadOrCreateConfig(dataDir)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 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) { http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
@@ -72,9 +124,9 @@ func startUI(dataDir, nodeID, serverAddr string) {
}) })
port := "7070" port := "7070"
log.Printf("UI starting on http://localhost:%s", port) log.Printf("%s UI starting on http://localhost:%s", APP_NAME, port)
if err := http.ListenAndServe("127.0.0.1:"+port, nil); err != nil { if err := http.ListenAndServe("127.0.0.1:"+port, nil); err != nil {
log.Fatalf("UI server error: %v", err) log.Fatalf("%s UI server error: %v", APP_NAME, err)
} }
} }
+101 -3
View File
@@ -2,7 +2,7 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>EduBox Agent</title> <title>studioE5 Agent</title>
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; background: #f1f5f9; margin: 0; padding: 2rem; color: #1e293b; } 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; } .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; } h1 { font-size: 1.5rem; margin: 0 0 1rem; }
h2 { font-size: 1.125rem; 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 { 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 { 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: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; } .status { margin-top: 0.75rem; font-size: 0.9rem; min-height: 1.2rem; }
.success { color: #16a34a; } .success { color: #16a34a; }
.error { color: #dc2626; } .error { color: #dc2626; }
@@ -30,16 +34,44 @@
.instance-link { font-size: 0.875rem; color: #2563eb; text-decoration: none; font-weight: 500; } .instance-link { font-size: 0.875rem; color: #2563eb; text-decoration: none; font-weight: 500; }
.instance-link:hover { text-decoration: underline; } .instance-link:hover { text-decoration: underline; }
.empty { text-align: center; color: #64748b; padding: 1rem 0; } .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> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="card"> <div id="home-card" class="card">
<h1>EduBox Agent</h1> <h1>studioE5 Agent</h1>
<div id="main"> <div id="main">
<p class="info">Connexion en cours...</p> <p class="info">Connexion en cours...</p>
</div> </div>
</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;"> <div id="instances-card" class="card" style="display:none;">
<h2>Mes instances</h2> <h2>Mes instances</h2>
<div id="instances" class="instance-list"></div> <div id="instances" class="instance-list"></div>
@@ -49,6 +81,8 @@
<script> <script>
const ws = new WebSocket('ws://' + location.host + '/ws'); const ws = new WebSocket('ws://' + location.host + '/ws');
const main = document.getElementById('main'); const main = document.getElementById('main');
const homeCard = document.getElementById('home-card');
const settingsCard = document.getElementById('settings-card');
const instancesCard = document.getElementById('instances-card'); const instancesCard = document.getElementById('instances-card');
const instancesContainer = document.getElementById('instances'); const instancesContainer = document.getElementById('instances');
@@ -60,6 +94,7 @@
const msg = JSON.parse(ev.data); const msg = JSON.parse(ev.data);
if (msg.action === 'not_activated') { if (msg.action === 'not_activated') {
showHome();
main.innerHTML = ` main.innerHTML = `
<p>Entre ton code d'activation (6 caractères) :</p> <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()"> <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> <div id="status" class="status"></div>
`; `;
} else if (msg.action === 'activated') { } else if (msg.action === 'activated') {
showHome();
main.innerHTML = ` main.innerHTML = `
<p class="success">✅ Activé : <strong>${escapeHtml(msg.studentName || '')}</strong></p> <p class="success">✅ Activé : <strong>${escapeHtml(msg.studentName || '')}</strong></p>
<p class="info">Tes instances apparaissent ci-dessous.</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'; instancesCard.style.display = 'block';
ws.send(JSON.stringify({action: 'instances'})); ws.send(JSON.stringify({action: 'instances'}));
@@ -130,6 +169,65 @@
}).join(''); }).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) { function escapeHtml(text) {
if (text == null) return ''; if (text == null) return '';
return String(text) return String(text)
+110 -45
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"net"
"sync" "sync"
"time" "time"
@@ -22,6 +21,8 @@ type WSMessage struct {
StudentName string `json:"studentName,omitempty"` StudentName string `json:"studentName,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"` TailscaleIP string `json:"tailscaleIp,omitempty"`
HeadscaleURL string `json:"headscaleUrl,omitempty"`
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
} }
var ( var (
@@ -29,11 +30,27 @@ var (
mainConnMu sync.Mutex mainConnMu sync.Mutex
) )
// headscale config received from the server during activation.
// These are mutable because activation may happen after the agent starts.
var ( var (
tsProxies = make(map[int]net.Listener) currentHeadscaleURL string
tsProxiesMu sync.Mutex 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 { func sendMessage(msg WSMessage) error {
mainConnMu.Lock() mainConnMu.Lock()
defer mainConnMu.Unlock() 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 { for {
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil) conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
if err != nil { if err != nil {
@@ -117,6 +136,11 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
log.Println("Waiting for activation...") log.Println("Waiting for activation...")
} else { } else {
log.Printf("Already activated as %s", act.StudentName) log.Printf("Already activated as %s", act.StudentName)
// If already activated and we have credentials, ensure VPN is up.
hsURL, hsKey := getHeadscaleConfig()
if hsURL != "" && hsKey != "" {
go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey)
}
} }
// Heartbeat goroutine // Heartbeat goroutine
@@ -169,6 +193,25 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("Activated as %s", act.StudentName) 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{}{ notifyUI(map[string]interface{}{
"action": "activated", "action": "activated",
"studentName": msg.StudentName, "studentName": msg.StudentName,
@@ -176,6 +219,35 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
case "registered": case "registered":
// Server acknowledged our register message; nothing to do. // Server acknowledged our register message; nothing to do.
return 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": case "activation_failed":
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error) log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
notifyUI(map[string]interface{}{ notifyUI(map[string]interface{}{
@@ -192,7 +264,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
}); err != nil { }); err != nil {
log.Printf("upsertInstance error: %v", err) 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) log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"}) _ = 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()}) 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 return
} }
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL // 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() { go func() {
// Give the container a moment to be ready before touching wp-config.php // Give the container a moment to be ready before touching wp-config.php
time.Sleep(2 * time.Second) 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) log.Printf("stripWordPressHardcodedURLs error: %v", err)
} }
}() }()
// Start Tailscale proxy so the server can reach localhost via Tailscale IP // Ensure Tailscale is running so the server can reach the node
tsProxiesMu.Lock() go ensureTailscale(dataDir, nodeID, msg.Port)
if _, exists := tsProxies[msg.Port]; !exists {
if ln, err := startTailscaleProxy(msg.Port); err == nil {
tsProxies[msg.Port] = ln
} else {
log.Printf("startTailscaleProxy error: %v", err)
}
}
tsProxiesMu.Unlock()
status := getInstanceStatus(dataDir, msg.InstanceID) status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status}) _ = 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"}) notifyUI(map[string]interface{}{"action": "instances_updated"})
case "stop": case "stop":
log.Printf("Stop instance %s", msg.InstanceID) 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 { if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeDown error: %v", err) 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"}) notifyUI(map[string]interface{}{"action": "instances_updated"})
case "delete": case "delete":
log.Printf("Delete instance %s", msg.InstanceID) 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) dockerComposeRm(dataDir, msg.InstanceID)
removeInstance(dataDir, msg.InstanceID) removeInstance(dataDir, msg.InstanceID)
notifyUI(map[string]interface{}{"action": "instances_updated"}) notifyUI(map[string]interface{}{"action": "instances_updated"})
case "reset": case "reset":
log.Printf("Reset instance %s", msg.InstanceID) log.Printf("Reset instance %s", msg.InstanceID)
dockerComposeRm(dataDir, msg.InstanceID) dockerComposeRm(dataDir, msg.InstanceID)
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil { if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
log.Printf("writeCompose error: %v", err) log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"}) _ = 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()}) 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 return
} }
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL // 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() { go func() {
// Give the container a moment to be ready before touching wp-config.php // Give the container a moment to be ready before touching wp-config.php
time.Sleep(2 * time.Second) 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) log.Printf("stripWordPressHardcodedURLs error: %v", err)
} }
}() }()
// Start Tailscale proxy so the server can reach localhost via Tailscale IP // Ensure Tailscale is running so the server can reach the node
tsProxiesMu.Lock() go ensureTailscale(dataDir, nodeID, msg.Port)
if _, exists := tsProxies[msg.Port]; !exists {
if ln, err := startTailscaleProxy(msg.Port); err == nil {
tsProxies[msg.Port] = ln
} else {
log.Printf("startTailscaleProxy error: %v", err)
}
}
tsProxiesMu.Unlock()
status := getInstanceStatus(dataDir, msg.InstanceID) status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status}) _ = 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) 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
View File
@@ -1,18 +1,18 @@
services: services:
postgres: postgres:
image: postgres:18-alpine image: postgres:18-alpine
container_name: edubox-postgres container_name: studioe5-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: edubox POSTGRES_USER: studioe5
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: edubox POSTGRES_DB: studioe5
volumes: volumes:
- pg_data:/var/lib/postgresql - pg_data:/var/lib/postgresql
networks: networks:
- edubox - studioe5
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U edubox -d edubox"] test: ["CMD-SHELL", "pg_isready -U studioe5 -d studioe5"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -21,13 +21,9 @@ services:
build: build:
context: ./server context: ./server
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: edubox-server container_name: studioe5-server
volumes: volumes:
- ./server/public:/app/public:ro - ./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 restart: unless-stopped
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
@@ -35,28 +31,21 @@ services:
NEXTAUTH_URL: ${NEXTAUTH_URL} NEXTAUTH_URL: ${NEXTAUTH_URL}
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL} SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL}
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD} SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD}
MAIN_DOMAIN: ${MAIN_DOMAIN}
HEADSCALE_URL: ${HEADSCALE_URL} HEADSCALE_URL: ${HEADSCALE_URL}
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY} HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
MAIN_DOMAIN: ${MAIN_DOMAIN}
GITEA_URL: ${GITEA_URL}
GITEA_TOKEN: ${GITEA_TOKEN}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
networks: networks:
- edubox - studioe5
resolver: resolver:
build: build:
context: ./resolver context: ./resolver
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: edubox-resolver container_name: studioe5-resolver
restart: unless-stopped 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: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
MAIN_DOMAIN: ${MAIN_DOMAIN} MAIN_DOMAIN: ${MAIN_DOMAIN}
@@ -64,11 +53,34 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
networks: 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: caddy:
image: caddy:2-alpine image: caddy:2-alpine
container_name: edubox-caddy container_name: studioe5-caddy
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
@@ -79,11 +91,11 @@ services:
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
networks: networks:
- edubox - studioe5
headscale: headscale:
image: headscale/headscale:latest image: headscale/headscale:latest
container_name: edubox-headscale container_name: studioe5-headscale
restart: unless-stopped restart: unless-stopped
command: serve command: serve
ports: ports:
@@ -92,31 +104,15 @@ services:
volumes: volumes:
- ./headscale:/etc/headscale - ./headscale:/etc/headscale
networks: networks:
- edubox - studioe5
gitea:
image: gitea/gitea:latest
container_name: edubox-gitea
restart: unless-stopped
ports:
- "3001:3000"
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=sqlite3
- GITEA__database__PATH=/data/gitea/gitea.db
volumes:
- gitea_data:/data
networks:
- edubox
volumes: volumes:
pg_data: pg_data:
caddy_data: caddy_data:
caddy_config: caddy_config:
headscale_data: headscale_data:
gitea_data: resolver_ts_state:
networks: networks:
edubox: studioe5:
driver: bridge driver: bridge
+3 -3
View File
@@ -1,5 +1,5 @@
# Headscale configuration for EduBox # Headscale configuration for studioE5 client A
server_url: https://headscale.alfrednobel.edudeploy.com server_url: https://headscale.studioe5.edudeploy.com
listen_addr: 0.0.0.0:8080 listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090 metrics_listen_addr: 0.0.0.0:9090
grpc_listen_addr: 127.0.0.1:50443 grpc_listen_addr: 127.0.0.1:50443
@@ -14,7 +14,7 @@ prefixes:
dns: dns:
magic_dns: true magic_dns: true
base_domain: edubox.local base_domain: studioe5.local
nameservers: nameservers:
global: global:
- 1.1.1.1 - 1.1.1.1
+52
View File
@@ -0,0 +1,52 @@
FROM prestashop/prestashop:9
# Apply EduBox patches so PrestaShop 9 works behind the dynamic-domain reverse proxy.
COPY edubox-tools.patch \
edubox-link.patch \
edubox-frontcontroller.patch \
edubox-shop.patch \
edubox-shopurl.patch \
edubox-shop-getbaseurl.patch \
edubox-shopcontext.patch \
edubox-asseturl.patch \
edubox-install.patch \
edubox-install-language.patch \
edubox-language.patch \
edubox-configuration.patch \
edubox-dashboard-warning.patch \
edubox-docker-run.patch \
/tmp/
RUN patch -p1 -d /var/www/html < /tmp/edubox-tools.patch && \
patch -p1 -d /var/www/html < /tmp/edubox-link.patch && \
patch -p1 -d /var/www/html < /tmp/edubox-frontcontroller.patch && \
patch -p1 -d /var/www/html < /tmp/edubox-shop.patch && \
patch -p1 -d /var/www/html < /tmp/edubox-shopurl.patch && \
patch -p1 -d /var/www/html < /tmp/edubox-shop-getbaseurl.patch && \
patch -p1 -d /var/www/html < /tmp/edubox-shopcontext.patch && \
patch -p1 -d /var/www/html < /tmp/edubox-asseturl.patch && \
patch -p1 -d /var/www/html < /tmp/edubox-install.patch && \
patch -p1 -d /var/www/html < /tmp/edubox-install-language.patch && \
patch -p1 -d /var/www/html < /tmp/edubox-language.patch && \
patch -p1 -d /var/www/html < /tmp/edubox-configuration.patch && \
patch -p1 -d /var/www/html < /tmp/edubox-dashboard-warning.patch && \
patch -p1 -d / < /tmp/edubox-docker-run.patch && \
rm /tmp/edubox-*.patch
# Apache proxy configuration
COPY proxy.conf /etc/apache2/conf-enabled/edubox-proxy.conf
# Pre-download French translation pack so the installer works offline.
# Agents may not have outbound internet access during installation.
# The official image copies /tmp/data-ps/prestashop/ into /var/www/html on first
# boot, so we place the pack there as well.
COPY translations-symfony-fr-FR.zip /tmp/data-ps/prestashop/translations/sf-fr-FR.zip
RUN chown -R www-data:www-data /tmp/data-ps/prestashop/translations
# Early bootstrap normalisation for X-Forwarded-* headers
COPY defines_custom.inc.php /var/www/html/config/defines_custom.inc.php
# Clear caches on every start so dynamic domains/ports are picked up
COPY edubox-clear-cache-init.sh /tmp/init-scripts/edubox-clear-cache.sh
RUN chmod +x /tmp/init-scripts/edubox-clear-cache.sh
RUN chown -R www-data:www-data /var/www/html
+99
View File
@@ -0,0 +1,99 @@
# EduBox PrestaShop 9 Image
Image Docker patchée basée sur `prestashop/prestashop:9`, conçue pour fonctionner
avec le reverse proxy dynamique d'EduBox.
## Pourquoi une image patchée ?
PrestaShop 9 (Apache 2.4 + PHP 8.5) a plusieurs problèmes majeurs derrière EduBox :
1. Les headers `X-Forwarded-*` sont corrompus par Apache/PHP : `$_SERVER` les
reçoit sous forme d'arrays au lieu de strings. On contourne ce bug via
`getenv()` dans `config/defines_custom.inc.php`.
2. PrestaShop utilise partout le domaine stocké en base (`ps_shop_url`) et la
configuration `PS_SSL_ENABLED`. Derrière EduBox, le domaine public change à
chaque instance (`<id>.alfrednobel.edudeploy.com`) et toutes les requêtes
publiques arrivent en HTTPS. Les patches forcent l'utilisation de l'hôte et
du protocole de la requête courante.
3. Les agents étudiants peuvent être hors ligne. Le pack de langue français est
donc embarqué dans l'image pour éviter tout téléchargement pendant
l'installation.
## Build local
```bash
cd /opt/edubox/prestashop-image
docker build -t edubox-prestashop:9 .
```
## Push sur le registry Gitea
```bash
docker tag edubox-prestashop:9 \
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 davertissement "domaine différent de SEO & URL". |
| `edubox-docker-run.patch` | `/tmp/docker_run.sh` | Supprime un `install.lock` résiduel si une installation précédente a échoué. |
## Fichiers injectés
- `proxy.conf` : Apache truste `X-Forwarded-Proto: https` pour positionner
`HTTPS=on` dans l'environnement PHP. Active aussi `AllowOverride All` pour
que le `.htaccess` de PrestaShop fonctionne.
- `config/defines_custom.inc.php` : normalise `HTTP_X_FORWARDED_HOST`,
`HTTP_X_FORWARDED_PROTO` et `HTTP_HOST` corrompus ; définit
`PS_TRUSTED_PROXIES` pour Symfony.
- `translations-symfony-fr-FR.zip` → copié sous `sf-fr-FR.zip` dans
`/var/www/html/translations/` : pack de langue Symfony français embarqué
( PrestaShop attend le préfixe `sf-` ).
- `edubox-clear-cache-init.sh``/tmp/init-scripts/edubox-clear-cache.sh` :
vidage des caches Smarty/Symfony et des assets CCC à chaque démarrage du
conteneur, afin que les changements de domaine/port soient pris en compte.
## Utilisation dans EduBox
Le template PrestaShop 9 dans `server/prisma/seed.ts` utilise cette image :
```yaml
app:
image: 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.
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* EduBox reverse proxy normalisation for PrestaShop 9 running behind the
* EduBox dynamic-public-domain resolver.
*
* The official PrestaShop 9 + PHP 8.5 + Apache image has a bug where
* X-Forwarded-* headers are exposed to PHP as arrays whose value is the
* header name. getenv() returns the correct string, so we use it to
* reconstruct $_SERVER entries used by Tools::getHttpHost/ShopDomainSSL.
*/
if ($val = getenv('HTTP_X_FORWARDED_HOST')) {
$_SERVER['HTTP_X_FORWARDED_HOST'] = $val;
}
if ($val = getenv('HTTP_X_FORWARDED_PROTO')) {
$_SERVER['HTTP_X_FORWARDED_PROTO'] = $val;
}
// Apache/PHP 8.5 sometimes corrupts HTTP_HOST into an array; fall back safely.
if (!empty($_SERVER['HTTP_HOST']) && is_array($_SERVER['HTTP_HOST'])) {
$_SERVER['HTTP_HOST'] = !empty($_SERVER['SERVER_NAME']) && !is_array($_SERVER['SERVER_NAME'])
? $_SERVER['SERVER_NAME']
: (getenv('HTTP_X_FORWARDED_HOST') ?: 'localhost');
}
if (!empty($_SERVER['HTTPS']) && is_array($_SERVER['HTTPS'])) {
$_SERVER['HTTPS'] = 'off';
}
// Tell Symfony to trust the EduBox resolver so $request->isSecure() and
// $request->getHost() honour X-Forwarded-* headers.
putenv('PS_TRUSTED_PROXIES=127.0.0.1,REMOTE_ADDR');
$_SERVER['PS_TRUSTED_PROXIES'] = '127.0.0.1,REMOTE_ADDR';
$_ENV['PS_TRUSTED_PROXIES'] = '127.0.0.1,REMOTE_ADDR';
+20
View File
@@ -0,0 +1,20 @@
--- a/src/Adapter/Assets/AssetUrlGeneratorTrait.php
+++ b/src/Adapter/Assets/AssetUrlGeneratorTrait.php
@@ -49,12 +49,14 @@ trait AssetUrlGeneratorTrait
protected function getFQDN()
{
if (null === $this->fqdn) {
- if ($this->configuration->get('PS_SSL_ENABLED') && ToolsLegacy::usingSecureMode()) {
- $this->fqdn = $this->configuration->get('_PS_BASE_URL_SSL_');
- } else {
+ // EduBox: rely on the current request security, not on PS_SSL_ENABLED.
+ // Behind the reverse proxy every public request is HTTPS.
+ if (ToolsLegacy::usingSecureMode()) {
+ $this->fqdn = $this->configuration->get('_PS_BASE_URL_SSL_') ?: $this->configuration->get('_PS_BASE_URL_');
+ } else {
$this->fqdn = $this->configuration->get('_PS_BASE_URL_');
}
}
return $this->fqdn;
}
@@ -0,0 +1,10 @@
#!/bin/bash
# EduBox: clear PrestaShop caches at every container start so that dynamic
# domains/ports (localhost:PORT or reverse-proxy public URL) are picked up.
echo "* EduBox: clearing PrestaShop caches for dynamic domain..."
rm -rf /var/www/html/var/cache/*
rm -rf /var/www/html/app/cache/*
rm -rf /var/www/html/cache/smarty/cache/*
rm -rf /var/www/html/cache/smarty/compile/*
rm -rf /var/www/html/themes/*/assets/cache/*
rm -rf /var/www/html/img/tmp/*
@@ -0,0 +1,36 @@
--- a/classes/Configuration.php 2026-06-04 14:48:44.000000000 +0000
+++ b/classes/Configuration.php 2026-06-23 16:27:03.944472677 +0000
@@ -210,6 +210,33 @@
Configuration::loadConfiguration();
}
+ // EduBox: dynamic public domains and ports (local access + reverse proxy).
+ // These keys must be resolved from the current request, not from the DB cache.
+ if ($key === 'PS_SHOP_DOMAIN' || $key === 'PS_SHOP_DOMAIN_SSL') {
+ $host = Tools::getHttpHost(false, false, false);
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
+ $host = substr($host, 0, strrpos($host, ':'));
+ }
+ return $host;
+ }
+ if ($key === 'PS_SSL_ENABLED' || $key === 'PS_SSL_ENABLED_EVERYWHERE') {
+ return Tools::usingSecureMode() ? '1' : '0';
+ }
+ if ($key === '_PS_BASE_URL_') {
+ $host = Tools::getHttpHost(false, false, false);
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
+ $host = substr($host, 0, strrpos($host, ':'));
+ }
+ return 'http://' . $host;
+ }
+ if ($key === '_PS_BASE_URL_SSL_') {
+ $host = Tools::getHttpHost(false, false, false);
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
+ $host = substr($host, 0, strrpos($host, ':'));
+ }
+ return 'https://' . $host;
+ }
+
$idLang = self::isLangKey($key) ? (int) $idLang : 0;
if (self::$_new_cache_shop === null) {
@@ -0,0 +1,49 @@
--- a/controllers/admin/AdminDashboardController.php
+++ b/controllers/admin/AdminDashboardController.php
@@ -330,43 +330,9 @@
protected function getWarningDomainName()
{
- $warning = false;
- if (Shop::isFeatureActive()) {
- return;
- }
-
- $shop = Context::getContext()->shop;
- if ($_SERVER['HTTP_HOST'] != $shop->domain && $_SERVER['HTTP_HOST'] != $shop->domain_ssl && Tools::getValue('ajax') == false) {
- $warning = $this->trans('You are currently connected under the following domain name:', [], 'Admin.Dashboard.Notification') . ' <span style="color: #CC0000;">' . $_SERVER['HTTP_HOST'] . '</span><br />';
- if (Configuration::get('PS_MULTISHOP_FEATURE_ACTIVE')) {
- $warning .= $this->trans(
- 'This is different from the shop domain name set in the Multistore settings: "%s".',
- [
- '%s' => $shop->domain,
- ],
- 'Admin.Dashboard.Notification'
- ) . $this->trans(
- 'If this is your main domain, please {link}change it now{/link}.',
- [
- '{link}' => '<a href="' . $this->context->link->getAdminLink('AdminShopUrl', true, [], ['id_shop_url' => (int) $shop->id, 'updateshop_url' => 1]) . '">',
- '{/link}' => '</a>',
- ],
- 'Admin.Dashboard.Notification'
- );
- } else {
- $warning .= $this->trans('This is different from the domain name set in the "SEO & URLs" tab.', [], 'Admin.Dashboard.Notification') . '
- ' . $this->trans(
- 'If this is your main domain, please {link}change it now{/link}.',
- [
- '{link}' => '<a href="' . $this->context->link->getAdminLink('AdminMeta') . '#meta_fieldset_shop_url">',
- '{/link}' => '</a>',
- ],
- 'Admin.Dashboard.Notification'
- );
- }
- }
-
- return $warning;
+ // EduBox: instances use dynamic public domains behind a reverse proxy.
+ // The domain stored during installation never matches the request host.
+ return false;
}
public function ajaxProcessRefreshDashboard()
+16
View File
@@ -0,0 +1,16 @@
--- a/tmp/docker_run.sh 2026-06-20 17:57:12.682339048 +0000
+++ b/tmp/docker_run.sh 2026-06-20 17:57:12.852338398 +0000
@@ -21,6 +21,13 @@
# From now, stop at error
set -e
+# EduBox: if a previous installation failed, install.lock remains but PrestaShop is not configured.
+# Remove the stale lock so the installer can run again on the next start.
+if [ -f ./install.lock ] && [ ! -f ./config/settings.inc.php ] && [ ! -f ./app/config/parameters.php ]; then
+ echo "\n* Stale install.lock detected, removing it to allow reinstallation ..."
+ rm -f ./install.lock
+fi
+
if [ ! -f ./config/settings.inc.php ] && [ ! -f ./app/config/parameters.php ] && [ ! -f ./install.lock ]; then
@@ -0,0 +1,24 @@
--- a/classes/controller/FrontController.php
+++ b/classes/controller/FrontController.php
@@ -849,18 +849,9 @@
*/
protected function sslRedirection()
{
- // If we call a SSL controller without SSL or a non SSL controller with SSL, we redirect with the right protocol
- if (Configuration::get('PS_SSL_ENABLED') && $_SERVER['REQUEST_METHOD'] != 'POST' && $this->ssl != Tools::usingSecureMode()) {
- $this->context->cookie->disallowWriting();
- header('HTTP/1.1 301 Moved Permanently');
- header('Cache-Control: no-cache');
- if ($this->ssl) {
- header('Location: ' . Tools::getShopDomainSsl(true) . $_SERVER['REQUEST_URI']);
- } else {
- header('Location: ' . Tools::getShopDomain(true) . $_SERVER['REQUEST_URI']);
- }
- exit;
- }
+ // EduBox: disabled. Behind the EduBox reverse proxy every request is
+ // served over HTTPS publicly, so PrestaShop must never redirect to HTTP.
+ return;
}
/**
@@ -0,0 +1,30 @@
--- a/src/PrestaShopBundle/Install/Install.php 2026-06-20 18:07:13.506985399 +0000
+++ b/src/PrestaShopBundle/Install/Install.php 2026-06-20 18:07:22.294363061 +0000
@@ -622,17 +622,20 @@
'locale' => (string) $xml->locale,
];
- if (file_exists(_PS_TRANSLATIONS_DIR_ . (string) $iso . '.gzip') == false) {
- $language = EntityLanguage::downloadLanguagePack($iso, _PS_INSTALL_VERSION_);
+ // EduBox: skip legacy language pack download if Symfony pack is bundled
+ $errors = [];
+ $locale = $params_lang['locale'];
+
+ if (!EntityLanguage::translationPackIsInCache($locale)) {
+ if (file_exists(_PS_TRANSLATIONS_DIR_ . (string) $iso . '.gzip') == false) {
+ $language = EntityLanguage::downloadLanguagePack($iso, _PS_INSTALL_VERSION_);
- if ($language == false) {
- throw new PrestashopInstallerException($this->translator->trans('Cannot download language pack "%iso%"', ['%iso%' => $iso], 'Install'));
+ if ($language == false) {
+ throw new PrestashopInstallerException($this->translator->trans('Cannot download language pack "%iso%"', ['%iso%' => $iso], 'Install'));
+ }
}
}
- $errors = [];
- $locale = $params_lang['locale'];
-
/* @todo check if a newer pack is available */
if (!EntityLanguage::translationPackIsInCache($locale)) {
EntityLanguage::downloadXLFLanguagePack($locale, $errors);
+9
View File
@@ -0,0 +1,9 @@
--- a/src/PrestaShopBundle/Install/Install.php
+++ b/src/PrestaShopBundle/Install/Install.php
@@ -1202,7 +1202,7 @@ class Install extends AbstractInstall
{
- $adminFolder = 'admin-dev';
+ $adminFolder = getenv('PS_FOLDER_ADMIN') ?: 'admin-dev';
// If we need, we generate a random name for admin folder (for security purpose!)
if (file_exists(_PS_ROOT_DIR_ . '/admin/')) {
+36
View File
@@ -0,0 +1,36 @@
--- a/classes/Language.php
+++ b/classes/Language.php
@@ -1235,6 +1235,12 @@
*/
public static function downloadXLFLanguagePack($locale, &$errors = [], $type = self::PACK_TYPE_SYMFONY)
{
+ // EduBox: if the translation pack is already present in the image,
+ // do not try to download it (agents may be offline).
+ if (static::translationPackIsInCache($locale, $type)) {
+ return true;
+ }
+
$file = self::getPathToCachedTranslationPack($locale, $type);
$url = (self::PACK_TYPE_EMAILS === $type) ? self::EMAILS_LANGUAGE_PACK_URL : self::SF_LANGUAGE_PACK_URL;
$url = str_replace(
@@ -1697,7 +1703,9 @@
*/
public static function translationPackIsInCache(string $locale, string $type = self::PACK_TYPE_SYMFONY): bool
{
- return file_exists(self::getPathToCachedTranslationPack($locale, $type));
+ // EduBox: use runtime constant instead of class constant, because
+ // _PS_TRANSLATIONS_DIR_ may not be defined when this file is compiled.
+ return file_exists(_PS_TRANSLATIONS_DIR_ . $type . '-' . $locale . '.zip');
}
/**
@@ -1710,7 +1718,8 @@
*/
private static function getPathToCachedTranslationPack(string $locale, string $type = self::PACK_TYPE_SYMFONY): string
{
- return self::TRANSLATION_PACK_CACHE_DIR . $type . '-' . $locale . '.zip';
+ // EduBox: use runtime constant instead of class constant.
+ return _PS_TRANSLATIONS_DIR_ . $type . '-' . $locale . '.zip';
}
/**
+46
View File
@@ -0,0 +1,46 @@
--- a/classes/Link.php 2026-06-20 20:05:45.983104609 +0000
+++ b/classes/Link.php 2026-06-20 20:05:46.195748630 +0000
@@ -862,7 +862,7 @@
public function getAdminBaseLink($idShop = null, $ssl = null, $relativeProtocol = false)
{
if (null === $ssl) {
- $ssl = Configuration::get('PS_SSL_ENABLED');
+ $ssl = Tools::usingSecureMode();
}
if (Configuration::get('PS_MULTISHOP_FEATURE_ACTIVE')) {
@@ -881,9 +881,10 @@
}
if ($relativeProtocol) {
- $base = '//' . ($ssl && $this->ssl_enable ? $shop->domain_ssl : $shop->domain);
+ $base = '//' . Tools::getHttpHost(false, false, false);
} else {
- $base = (($ssl && $this->ssl_enable) ? 'https://' . $shop->domain_ssl : 'http://' . $shop->domain);
+ $protocol = Tools::usingSecureMode() ? 'https://' : 'http://';
+ $base = $protocol . Tools::getHttpHost(false, false, false);
}
return $base . $shop->getBaseURI();
@@ -1391,7 +1392,7 @@
public function getBaseLink($idShop = null, $ssl = null, $relativeProtocol = false)
{
if (null === $ssl) {
- $ssl = Configuration::get('PS_SSL_ENABLED');
+ $ssl = Tools::usingSecureMode();
}
if (Configuration::get('PS_MULTISHOP_FEATURE_ACTIVE') && $idShop !== null) {
@@ -1401,9 +1402,10 @@
}
if ($relativeProtocol) {
- $base = '//' . ($ssl && $this->ssl_enable ? $shop->domain_ssl : $shop->domain);
+ $base = '//' . Tools::getHttpHost(false, false, false);
} else {
- $base = (($ssl && $this->ssl_enable) ? 'https://' . $shop->domain_ssl : 'http://' . $shop->domain);
+ $protocol = Tools::usingSecureMode() ? 'https://' : 'http://';
+ $base = $protocol . Tools::getHttpHost(false, false, false);
}
return $base . $shop->getBaseURI();
@@ -0,0 +1,29 @@
--- a/classes/shop/Shop.php
+++ b/classes/shop/Shop.php
@@ -489,15 +489,16 @@ class ShopCore extends ObjectModel
*/
public function getBaseURL($auto_secure_mode = true, $add_base_uri = true)
{
- if ($auto_secure_mode && Tools::usingSecureMode()) {
- if (!$this->domain_ssl) {
- return false;
- }
- $url = 'https://' . $this->domain_ssl;
+ // EduBox: use the current request host so local access on non-standard
+ // ports (e.g. localhost:8088) and reverse-proxy domains both work.
+ $host = Tools::getHttpHost(false, false, false);
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
+ $host = substr($host, 0, strrpos($host, ':'));
+ }
+
+ if ($auto_secure_mode && Tools::usingSecureMode()) {
+ $url = 'https://' . $host;
} else {
- if (!$this->domain) {
- return false;
- }
- $url = 'http://' . $this->domain;
+ $url = 'http://' . $host;
}
if ($add_base_uri) {
+46
View File
@@ -0,0 +1,46 @@
--- a/classes/shop/Shop.php
+++ b/classes/shop/Shop.php
@@ -411,38 +411,14 @@
} else {
$shop = new Shop($id_shop);
if (!Validate::isLoadedObject($shop) || !$shop->active) {
- // No shop found ... too bad, let's redirect to default shop
- $default_shop = new Shop((int) Configuration::get('PS_SHOP_DEFAULT'));
+ // EduBox: behind a reverse proxy with dynamic public domains,
+ // the requested host never matches ps_shop_url. Always use the
+ // default shop instead of redirecting to a fixed canonical URL.
+ $shop = new Shop((int) Configuration::get('PS_SHOP_DEFAULT'));
- // Hmm there is something really bad in your Prestashop !
- if (!Validate::isLoadedObject($default_shop)) {
+ if (!Validate::isLoadedObject($shop)) {
throw new PrestaShopException('Shop not found');
}
-
- $params = $_GET;
- unset($params['id_shop']);
- $url = $default_shop->domain;
- if (!Configuration::get('PS_REWRITING_SETTINGS')) {
- $url .= $default_shop->getBaseURI() . 'index.php?' . http_build_query($params);
- } else {
- // Catch url with subdomain "www"
- if (strpos($url, 'www.') === 0 && 'www.' . $_SERVER['HTTP_HOST'] === $url || $_SERVER['HTTP_HOST'] === 'www.' . $url) {
- $url .= $_SERVER['REQUEST_URI'];
- } else {
- $url .= $default_shop->getBaseURI();
- }
-
- if (count($params)) {
- $url .= '?' . http_build_query($params);
- }
- }
-
- $redirect_type = Configuration::get('PS_CANONICAL_REDIRECT');
- $redirect_code = ($redirect_type == 1 ? '302' : '301');
- $redirect_header = ($redirect_type == 1 ? 'Found' : 'Moved Permanently');
- header('HTTP/1.0 ' . $redirect_code . ' ' . $redirect_header);
- header('Location: ' . Tools::getShopProtocol() . $url);
- exit;
} elseif (defined('_PS_ADMIN_DIR_') && empty($shop->physical_uri)) {
$shop_default = new Shop((int) Configuration::get('PS_SHOP_DEFAULT'));
$shop->physical_uri = $shop_default->physical_uri;
+28
View File
@@ -0,0 +1,28 @@
--- a/src/Core/Context/ShopContext.php
+++ b/src/Core/Context/ShopContext.php
@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace PrestaShop\PrestaShop\Core\Context;
+use Tools;
use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint;
/**
@@ -121,11 +122,12 @@ class ShopContext
public function getBaseURL(): string
{
- if ($this->secured) {
- $url = 'https://' . $this->domainSSL;
- } else {
- $url = 'http://' . $this->domain;
- }
+ // EduBox: behind a reverse proxy with dynamic public domains the shop
+ // URL stored in the database is never the real public URL. Rebuild the
+ // base URL from the current request instead.
+ $secure = Tools::usingSecureMode();
+ $domain = $secure ? Tools::getShopDomainSsl(false, false) : Tools::getShopDomain(false, false);
+ $url = ($secure ? 'https://' : 'http://') . $domain;
return $url . $this->getBaseURI();
}
+32
View File
@@ -0,0 +1,32 @@
--- a/classes/shop/ShopUrl.php
+++ b/classes/shop/ShopUrl.php
@@ -175,15 +175,23 @@
public static function getMainShopDomain($id_shop = null)
{
- ShopUrl::cacheMainDomainForShop($id_shop);
-
- return self::$main_domain[(int) $id_shop] ?? null;
+ // EduBox: dynamic public domain behind reverse proxy or direct local access.
+ // Always use the request host instead of the domain stored in database.
+ // Keep non-standard ports (e.g. localhost:8088) so local access works.
+ $host = Tools::getHttpHost(false, false, false);
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
+ $host = substr($host, 0, strrpos($host, ':'));
+ }
+ return $host;
}
public static function getMainShopDomainSSL($id_shop = null)
{
- ShopUrl::cacheMainDomainForShop($id_shop);
-
- return self::$main_domain_ssl[(int) $id_shop] ?? null;
+ // EduBox: dynamic public domain behind reverse proxy or direct local access.
+ $host = Tools::getHttpHost(false, false, false);
+ if (substr($host, -3) === ':80' || substr($host, -4) === ':443') {
+ $host = substr($host, 0, strrpos($host, ':'));
+ }
+ return $host;
}
}
+44
View File
@@ -0,0 +1,44 @@
--- a/classes/Tools.php 2026-06-04 14:48:44.000000000 +0000
+++ b/classes/Tools.php 2026-06-23 16:34:13.226899992 +0000
@@ -269,8 +269,10 @@
*/
public static function getShopDomain($http = false, $entities = false)
{
- if (!$domain = ShopUrl::getMainShopDomain()) {
- $domain = Tools::getHttpHost();
+ // EduBox: dynamic domain + keep non-standard ports (e.g. localhost:8088).
+ $domain = Tools::getHttpHost(false, false, false);
+ if (substr($domain, -3) === ':80' || substr($domain, -4) === ':443') {
+ $domain = substr($domain, 0, strrpos($domain, ':'));
}
if ($entities) {
$domain = htmlspecialchars($domain, ENT_COMPAT, 'UTF-8');
@@ -292,14 +294,16 @@
*/
public static function getShopDomainSsl($http = false, $entities = false)
{
- if (!$domain = ShopUrl::getMainShopDomainSSL()) {
- $domain = Tools::getHttpHost();
+ // EduBox: dynamic domain + keep non-standard ports.
+ $domain = Tools::getHttpHost(false, false, false);
+ if (substr($domain, -3) === ':80' || substr($domain, -4) === ':443') {
+ $domain = substr($domain, 0, strrpos($domain, ':'));
}
if ($entities) {
$domain = htmlspecialchars($domain, ENT_COMPAT, 'UTF-8');
}
if ($http) {
- $domain = static::getProtocol((bool) Configuration::get('PS_SSL_ENABLED')) . $domain;
+ $domain = static::getProtocol(Tools::usingSecureMode()) . $domain;
}
return $domain;
@@ -2246,7 +2250,7 @@
$rewrite_settings = (int) Configuration::get('PS_REWRITING_SETTINGS', null, null, (int) $uri['id_shop']);
}
- $domain_rewrite_cond = 'RewriteCond %{HTTP_HOST} ^' . $domain . '$' . PHP_EOL;
+ $domain_rewrite_cond = ''; // EduBox: removed HTTP_HOST condition for dynamic domains
// Rewrite virtual multishop uri
if ($uri['virtual']) {
if (!$rewrite_settings) {
+10
View File
@@ -0,0 +1,10 @@
# EduBox reverse proxy handling
# Apache sees HTTP requests from the EduBox resolver. The public request is HTTPS.
SetEnvIf X-Forwarded-Proto ^https$ HTTPS=on
SetEnvIf X-Forwarded-Proto ^https$ SERVER_PORT=443
# Enable .htaccess overrides for PrestaShop URL rewriting (images, products, etc.)
<Directory /var/www/html>
AllowOverride All
Require all granted
</Directory>
Binary file not shown.
+1 -1
View File
@@ -6,7 +6,7 @@ export default function LoginPage() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-50"> <div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md p-8 space-y-6 bg-white rounded-lg shadow-md"> <div className="w-full max-w-md p-8 space-y-6 bg-white rounded-lg shadow-md">
<h1 className="text-2xl font-bold text-center text-gray-900">EduBox V2</h1> <h1 className="text-2xl font-bold text-center text-gray-900">studioE5</h1>
<p className="text-center text-muted-foreground">Connexion à la plateforme</p> <p className="text-center text-muted-foreground">Connexion à la plateforme</p>
<LoginForm /> <LoginForm />
</div> </div>
+4 -1
View File
@@ -11,7 +11,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ ok: false }, { status: 400 }); 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 }); return NextResponse.json({ ok: true });
} }
+4 -3
View File
@@ -1,12 +1,13 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
const AGENT_VERSION = "0.3.0"; const AGENT_VERSION = "0.3.0";
const AGENT_BIN_NAME = "studioE5-agent";
export async function GET() { export async function GET() {
return NextResponse.json({ return NextResponse.json({
version: AGENT_VERSION, version: AGENT_VERSION,
windows: `/edubox-agent-v${AGENT_VERSION}.exe`, windows: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}.exe`,
linux: `/edubox-agent-v${AGENT_VERSION}`, linux: `/${AGENT_BIN_NAME}-v${AGENT_VERSION}`,
mac: `/edubox-agent-v${AGENT_VERSION}-mac`, 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 });
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function DashboardNav({ role }: { role: string }) {
return ( return (
<nav className="w-64 bg-white border-r flex flex-col"> <nav className="w-64 bg-white border-r flex flex-col">
<div className="p-6 border-b"> <div className="p-6 border-b">
<h2 className="text-xl font-bold text-primary">EduBox</h2> <h2 className="text-xl font-bold text-primary">studioE5</h2>
</div> </div>
<div className="flex-1 p-4 space-y-1"> <div className="flex-1 p-4 space-y-1">
{links.map((link) => ( {links.map((link) => (
+3 -2
View File
@@ -1,6 +1,7 @@
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
const AGENT_VERSION = "0.3.0"; const AGENT_VERSION = "0.3.0";
const AGENT_BIN_NAME = "studioE5-agent";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -15,8 +16,8 @@ export default function DownloadPage() {
<CardTitle>Windows</CardTitle> <CardTitle>Windows</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour Windows (64 bits)</p> <p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Windows (64 bits)</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> <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> </CardContent>
</Card> </Card>
</div> </div>
+1 -1
View File
@@ -5,7 +5,7 @@ import "./globals.css";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "EduBox V2", title: "studioE5",
description: "Plateforme de gestion d'instances pour l'enseignement BTS", description: "Plateforme de gestion d'instances pour l'enseignement BTS",
}; };
+7 -1
View File
@@ -54,7 +54,13 @@ export function initWebSocketServer(wss: WebSocketServer) {
create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() }, create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() },
}); });
console.log("[WS] Activated:", student.firstName, student.lastName, "on", nodeId); 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; return;
} }
+35 -5
View File
@@ -19,9 +19,6 @@ async function main() {
}, },
}); });
// Remove obsolete PrestaShop templates from previous seeds
await prisma.template.deleteMany({ where: { type: "prestashop" } });
const templates = [ const templates = [
{ {
name: "WordPress latest vierge", name: "WordPress latest vierge",
@@ -53,20 +50,53 @@ async function main() {
dbPassword: "wordpress", dbPassword: "wordpress",
dbRootPassword: "rootpassword", 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) { for (const t of templates) {
const dbHost = "db"; const dbHost = "db";
const dbPort = "3306"; 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_NAME: ${t.dbName}
WORDPRESS_DB_USER: ${t.dbUser} WORDPRESS_DB_USER: ${t.dbUser}
WORDPRESS_DB_PASSWORD: ${t.dbPassword} WORDPRESS_DB_PASSWORD: ${t.dbPassword}
WORDPRESS_DB_PREFIX: wp_ WORDPRESS_DB_PREFIX: wp_
# No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`; # No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`;
const appVolumes = ` volumes: const appVolumes = isPrestaShop
? ` volumes:
- app_data:/var/www/html`
: ` volumes:
- app_data:/var/www/html - app_data:/var/www/html
- {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`; - {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
set -e set -e
echo "=== EduBox V2 Setup ===" echo "=== studioE5 Client A Setup ==="
# Configure UFW # Configure UFW
echo "Configuring firewall..." echo "Configuring firewall..."