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
This commit is contained in:
EduBox Dev
2026-06-23 09:48:00 +00:00
parent dd49993157
commit 124543d658
40 changed files with 1303 additions and 485 deletions
+8
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,9 +10,16 @@ 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
+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 |
+6 -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,11 @@
} }
} }
headscale.alfrednobel.edudeploy.com { headscale.studioe5.edudeploy.com:443 {
reverse_proxy headscale:8080 reverse_proxy headscale:8080
} }
alfrednobel.edudeploy.com { studioe5.edudeploy.com:443 {
reverse_proxy /api/websocket* server:3001 reverse_proxy /api/websocket* server:3001
reverse_proxy server:3000 reverse_proxy server:3000
} }
@@ -36,9 +36,9 @@ alfrednobel.edudeploy.com {
on_demand on_demand
} }
@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 {
+148
View File
@@ -0,0 +1,148 @@
# 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/`.
## ✅ 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/
```
## 📋 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).
- [ ] **Nettoyer linstance test et lagent test**, puis **committer les modifications** (il reste beaucoup de fichiers modifiés/non suivis ; voir `git status`).
- [ ] **Si le wildcard DNS est stable**, envisager dobtenir un certificat wildcard unique pour `*.studioe5.edudeploy.com` (évite d’émettre un certificat par instance et donc de retomber dans les rate limits). À étudier côté Caddy (`tls { on_demand }` vs certificat wildcard géré manuellement).
- [ ] **Tester le flux complet depuis linterface web** (activation dun élève, création dinstance via lUI).
- [ ] **Packager les binaires Tailscale pour Windows** dans `agent/tailscale-bin/windows/`.
- [ ] **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.).
## 🔧 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.
+29 -16
View File
@@ -2,33 +2,46 @@
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
# 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
echo " Copied versioned binaries to ${SERVER_PUBLIC}" echo " Copied versioned binaries to ${SERVER_PUBLIC}"
fi fi
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"encoding/json"
"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"
// defaultConfig returns sensible defaults for a first run.
func defaultConfig(dataDir string) *AgentConfig {
return &AgentConfig{
Server: "ws://localhost:3001",
HeadscaleURL: "",
HeadscaleAuthKey: "",
NodeID: defaultNodeID(),
DataDir: dataDir,
}
}
// 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
}
var cfg AgentConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, false, err
}
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)
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(cp, data, 0644)
}
+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 {
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

+46 -12
View File
@@ -5,21 +5,22 @@ 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"
APP_NAME = "studioE5"
)
var ( var (
serverAddr = flag.String("server", "ws://localhost:3001", "Adresse WebSocket du serveur") dataDir = flag.String("data-dir", "./studioE5-data", "Répertoire de données")
nodeID = flag.String("node-id", defaultNodeID(), "ID du nœud (défaut: hostname)")
dataDir = flag.String("data-dir", "./edubox-data", "Répertoire de données")
uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX") uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX")
headscaleURL = flag.String("headscale-url", "", "URL du serveur Headscale (ex: http://151.80.60.98:8080)") noTray = flag.Bool("no-tray", false, "Désactiver l'icône dans la barre système (mode console)")
headscaleAuthKey = flag.String("headscale-auth-key", "", "Clé d'authentification Headscale")
) )
func defaultNodeID() string { func defaultNodeID() string {
@@ -49,19 +50,52 @@ 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, created, err := loadOrCreateConfig(*dataDir)
if err != nil {
log.Fatalf("Cannot load config: %v", err)
}
if cfg.Server == "" {
cfg.Server = "ws://localhost:3001"
}
if cfg.NodeID == "" {
cfg.NodeID = defaultNodeID()
}
if cfg.DataDir == "" {
cfg.DataDir = *dataDir
}
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)
if created {
go openBrowser(uiURL + "#settings")
}
} }
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, " ", "")
}
+136 -62
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 {
return nil, fmt.Errorf("tailscale listen on port %d: %w", port, err) func stopTailscaleLocked() {
} 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
} }
defer dst.Close() // Signal 0 checks process existence without affecting it.
return tsCmd.Process.Signal(syscall.Signal(0)) == nil
done := make(chan struct{}, 2)
go func() {
_, _ = io.Copy(dst, src)
done <- struct{}{}
}()
go func() {
_, _ = io.Copy(src, dst)
done <- struct{}{}
}()
<-done
} }
func getTailscaleIP() string {
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
return tsIP
}
+54 -2
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)
+64 -50
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"net"
"sync" "sync"
"time" "time"
@@ -29,11 +28,6 @@ var (
mainConnMu sync.Mutex mainConnMu sync.Mutex
) )
var (
tsProxies = make(map[int]net.Listener)
tsProxiesMu sync.Mutex
)
func sendMessage(msg WSMessage) error { func sendMessage(msg WSMessage) error {
mainConnMu.Lock() mainConnMu.Lock()
defer mainConnMu.Unlock() defer mainConnMu.Unlock()
@@ -86,7 +80,7 @@ func notifyUI(msg map[string]interface{}) {
} }
} }
func startWebSocket(serverAddr, nodeID, dataDir string) { func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) {
for { for {
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil) conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
if err != nil { if err != nil {
@@ -144,7 +138,7 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
break break
} }
log.Printf("WS received from server: action=%s", msg.Action) log.Printf("WS received from server: action=%s", msg.Action)
handleMessage(conn, msg, dataDir, nodeID) handleMessage(conn, msg, dataDir, nodeID, headscaleURL, headscaleAuthKey)
} }
close(done) close(done)
@@ -157,7 +151,7 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
} }
} }
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) { func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headscaleURL, headscaleAuthKey string) {
switch msg.Action { switch msg.Action {
case "activated": case "activated":
log.Printf("handleMessage: activated received, student=%s", msg.StudentName) log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
@@ -176,6 +170,34 @@ 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")
if headscaleURL == "" || headscaleAuthKey == "" {
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, headscaleURL, headscaleAuthKey)
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 +214,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 +227,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 +235,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, headscaleURL, headscaleAuthKey, 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 +244,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 +254,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 +273,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 +281,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, headscaleURL, headscaleAuthKey, 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 +292,28 @@ 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, headscaleURL, headscaleAuthKey string, port int) {
if headscaleURL == "" || headscaleAuthKey == "" {
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, headscaleURL, headscaleAuthKey)
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
}
}
+38 -44
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,19 @@ 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}
HEADSCALE_URL: ${HEADSCALE_URL}
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
MAIN_DOMAIN: ${MAIN_DOMAIN} 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 +51,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 +89,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 +102,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
+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",
}; };
+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-8",
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..."