diff --git a/.gitignore b/.gitignore index 3c23462..158874e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ .next/ *.log +studioE5-data/ edubox-data/ dist/ coverage/ @@ -9,9 +10,16 @@ coverage/ *.dll *.so *.dylib +agent/studioE5-agent +agent/studioE5-agent.exe +agent/studioE5-agent-mac +agent/studioE5-agent-v* agent/edubox-agent agent/edubox-agent.exe agent/edubox-agent-mac +agent/edubox-agent-v* +server/public/studioE5-agent* +server/public/edubox-agent* agent/ui/*.go.html headscale/*.sqlite* headscale/*.key diff --git a/ANALYSE_PRESTASHOP.md b/ANALYSE_PRESTASHOP.md new file mode 100644 index 0000000..1ba1b34 --- /dev/null +++ b/ANALYSE_PRESTASHOP.md @@ -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://.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://:` +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:` (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=`. + +### 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=.alfrednobel.edudeploy.com` : + +- URL demandée : `https://.alfrednobel.edudeploy.com/` +- Requête interne : `http://:/` avec `X-Forwarded-Proto: https` +- PrestaShop détecte : secure mode = true +- URL canonique : `https://.alfrednobel.edudeploy.com/` +- `match_url` : `https://.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 (`.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:` 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 + 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:/...` → `https://.domain/...` +- Le body HTML/CSS/JS : toutes les occurrences de `http://localhost:` et `//localhost:` + +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:` 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://.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:` ET `https://.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 -app-1 +podman exec -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 | diff --git a/Caddyfile b/Caddyfile index ab5a894..a1b1bce 100644 --- a/Caddyfile +++ b/Caddyfile @@ -6,7 +6,7 @@ } :80 { - route /edubox-agent* { + route /studioE5-agent* { file_server { root /usr/share/caddy/agent } @@ -22,11 +22,11 @@ } } -headscale.alfrednobel.edudeploy.com { +headscale.studioe5.edudeploy.com:443 { reverse_proxy headscale:8080 } -alfrednobel.edudeploy.com { +studioe5.edudeploy.com:443 { reverse_proxy /api/websocket* server:3001 reverse_proxy server:3000 } @@ -36,9 +36,9 @@ alfrednobel.edudeploy.com { on_demand } @instance { - not host alfrednobel.edudeploy.com - not host headscale.alfrednobel.edudeploy.com - host *.alfrednobel.edudeploy.com + not host studioe5.edudeploy.com + not host headscale.studioe5.edudeploy.com + host *.studioe5.edudeploy.com } handle @instance { reverse_proxy resolver:2020 { diff --git a/SUIVI_VPN_ONDEMAND.md b/SUIVI_VPN_ONDEMAND.md new file mode 100644 index 0000000..f3435b6 --- /dev/null +++ b/SUIVI_VPN_ONDEMAND.md @@ -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 `/studioE5-config.json` + - Mode console : `-no-tray` + +2. **VPN on-demand dans l'agent** + - L’agent ne démarre plus Tailscale au boot. + - Le VPN se lance automatiquement à la création/démarrage d’une instance, ou sur commande serveur. + - Implémentation basée sur les binaires `tailscaled` + `tailscale up` (pas `tsnet`, car `tsnet` ne loguait pas automatiquement avec une authkey sur un state vierge). + +3. **Commandes serveur → agent** + - Endpoint de test : `POST /api/internal/send-to-node` + - Actions supportées : `start_vpn`, `stop_vpn`, `start`, `stop`, `reset`, `delete`. + +4. **Resolver/serveur dans le tailnet studioe5** + - Service `resolver-vpn` (conteneur Tailscale) partage le netns du `resolver`. + - Le resolver peut joindre les IPs Tailscale des nodes (`ping 100.64.0.x` OK). + +5. **Instance WordPress démarrée avec succès** + - Le resolver a renvoyé une 302 WordPress via `http://resolver:2020/`. + +## ✅ Blocage levé + +**Rate limit Let’s Encrypt pour `edudeploy.com` est levé.** +Le 2026-06-23 vers 09:35 UTC, Caddy a pu obtenir un certificat Let’s Encrypt pour `test-wp-001.studioe5.edudeploy.com` : +``` +tls.obtain: certificate obtained successfully identifier=test-wp-001.studioe5.edudeploy.com issuer=acme-v02.api.letsencrypt.org-directory +``` + +Le flux complet HTTPS public est désormais validé : +```bash +curl -sS -I -L https://test-wp-001.studioe5.edudeploy.com/ +# => HTTP/2 302 -> HTTP/2 200 (WordPress install.php) +``` + +Le DNS wildcard `*.studioe5.edudeploy.com` est en place. Caddy utilise toujours `tls { on_demand }` et émet un certificat par sous-domaine d’instance. + +## 🎯 Validation du flux HTTPS public + +Le 2026-06-23 09:39 UTC, le flux complet a été validé : + +```text +Client (HTTPS) → Caddy (:443) → resolver (:2020) → Tailnet (100.64.0.8) → agent → WordPress (:8001) +``` + +Résultat : +```bash +$ curl -sS -I -L https://test-wp-001.studioe5.edudeploy.com/ +HTTP/2 302 +location: https://test-wp-001.studioe5.edudeploy.com/wp-admin/install.php +... +HTTP/2 200 +``` + +- Certificat Let’s Encrypt obtenu automatiquement par Caddy (`tls { on_demand }`). +- Le resolver réécrit les en-têtes `Location` et le contenu HTML pour passer de `http://` à `https://`. + +## 📁 Fichiers modifiés (non exhaustif) + +- `agent/tailscale.go` – lancement `tailscaled` + `tailscale up`, gestion start/stop/status. +- `agent/websocket.go` – handlers `start_vpn` / `stop_vpn`, `ensureTailscale()`. +- `agent/docker.go` – remplacement des placeholders `{PORT}` et `{INSTANCE_ID}` dans les compose. +- `docker-compose.yml` – ajout du sidecar `resolver-vpn`, suppression des `cap_add`/`ip route` obsolètes sur `server`/`resolver`. +- `Caddyfile` – configuration on-demand TLS pour les instances. +- `.env` – clé pré-auth Headscale mise à jour (clé réutilisable). + +## 🧪 Tests / environnement de test actuel + +Agent de test lancé en arrière-plan : +- data-dir : `/tmp/studioe5-test-clienta` +- node-id : `vps-8fc665eb` +- tailnet IP actuelle : `100.64.0.8` +- PID : `3151830` (lancé le 2026-06-23 09:36 UTC) + +Instance de test créée : +- ID : `test-wp-001` +- Node : `vps-8fc665eb` +- Port : `8001` +- Template : `wordpress-wordpress-latest` +- État : WordPress répond sur `http://127.0.0.1:8001` **et en HTTPS public sur `https://test-wp-001.studioe5.edudeploy.com/`**. + +## 🛠️ Commandes utiles pour reprendre + +### Voir l’agent de test +```bash +pgrep -a studioe5-agent +``` + +### Relancer l’agent de test (si besoin) +```bash +mkdir -p /tmp/studioe5-test-clienta +cat > /tmp/studioe5-test-clienta/studioE5-config.json </dev/null; then + echo " ${BIN_NAME}-mac (macOS amd64)" + cp ${BIN_NAME}-mac "${BIN_NAME}-v${VERSION}-mac" + echo " ${BIN_NAME}-v${VERSION}-mac (macOS amd64)" + MAC_BUILT=1 +else + echo " ${BIN_NAME}-mac (macOS amd64) - skipped, CGO required for systray" + MAC_BUILT=0 +fi # Copy versioned binaries to server/public so the dashboard can serve them SERVER_PUBLIC="../server/public" if [ -d "${SERVER_PUBLIC}" ]; then - cp "edubox-agent-v${VERSION}" "${SERVER_PUBLIC}/edubox-agent-v${VERSION}" - cp "edubox-agent-v${VERSION}-mac" "${SERVER_PUBLIC}/edubox-agent-v${VERSION}-mac" - cp "edubox-agent-v${VERSION}.exe" "${SERVER_PUBLIC}/edubox-agent-v${VERSION}.exe" + cp "${BIN_NAME}-v${VERSION}" "${SERVER_PUBLIC}/${BIN_NAME}-v${VERSION}" + cp "${BIN_NAME}-v${VERSION}.exe" "${SERVER_PUBLIC}/${BIN_NAME}-v${VERSION}.exe" + if [ "$MAC_BUILT" = "1" ]; then + cp "${BIN_NAME}-v${VERSION}-mac" "${SERVER_PUBLIC}/${BIN_NAME}-v${VERSION}-mac" + fi echo " Copied versioned binaries to ${SERVER_PUBLIC}" fi diff --git a/agent/config.go b/agent/config.go new file mode 100644 index 0000000..fd52327 --- /dev/null +++ b/agent/config.go @@ -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) +} diff --git a/agent/docker.go b/agent/docker.go index 5724fab..de5e9ff 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -20,18 +20,20 @@ func getContainerEngine() string { return "docker" } -func writeCompose(dataDir, instanceID, compose string) error { +func writeCompose(dataDir, instanceID, compose string, port int) error { dir := instanceDir(dataDir, instanceID) if err := os.MkdirAll(dir, 0755); err != nil { return err } - // Ensure the EduBox mu-plugin is available and substitute its path + // Ensure the studioE5 mu-plugin is available and substitute its path muDir, err := writeMUPlugin(dataDir) if err != nil { return err } compose = strings.ReplaceAll(compose, "{MU_PLUGINS_DIR}", filepath.Dir(muDir)) + compose = strings.ReplaceAll(compose, "{INSTANCE_ID}", instanceID) + compose = strings.ReplaceAll(compose, "{PORT}", fmt.Sprintf("%d", port)) f := filepath.Join(dir, "docker-compose.yml") return os.WriteFile(f, []byte(compose), 0644) @@ -115,7 +117,7 @@ fi } // stripWordPressHardcodedURLs removes hardcoded WP_HOME/WP_SITEURL defines -// from wp-config.php so the EduBox mu-plugin can compute them from the Host +// from wp-config.php so the studioE5 mu-plugin can compute them from the Host // header. This is useful when repairing older instances created before the // mu-plugin existed. func stripWordPressHardcodedURLs(dataDir, instanceID string) error { diff --git a/agent/edubox-agent-v0.2.7 b/agent/edubox-agent-v0.2.7 deleted file mode 100755 index 6afc263..0000000 Binary files a/agent/edubox-agent-v0.2.7 and /dev/null differ diff --git a/agent/edubox-agent-v0.2.7-mac b/agent/edubox-agent-v0.2.7-mac deleted file mode 100755 index 3ebd552..0000000 Binary files a/agent/edubox-agent-v0.2.7-mac and /dev/null differ diff --git a/agent/edubox-agent-v0.2.8 b/agent/edubox-agent-v0.2.8 deleted file mode 100755 index eae6243..0000000 Binary files a/agent/edubox-agent-v0.2.8 and /dev/null differ diff --git a/agent/edubox-agent-v0.2.8-mac b/agent/edubox-agent-v0.2.8-mac deleted file mode 100755 index d9bcc12..0000000 Binary files a/agent/edubox-agent-v0.2.8-mac and /dev/null differ diff --git a/agent/edubox-agent-v0.3.0 b/agent/edubox-agent-v0.3.0 deleted file mode 100755 index a4a24e8..0000000 Binary files a/agent/edubox-agent-v0.3.0 and /dev/null differ diff --git a/agent/edubox-agent-v0.3.0-mac b/agent/edubox-agent-v0.3.0-mac deleted file mode 100755 index f3e9bfa..0000000 Binary files a/agent/edubox-agent-v0.3.0-mac and /dev/null differ diff --git a/agent/generate_icon.py b/agent/generate_icon.py new file mode 100644 index 0000000..1224094 --- /dev/null +++ b/agent/generate_icon.py @@ -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") diff --git a/agent/go.mod b/agent/go.mod index f3420f8..de627c4 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -3,52 +3,12 @@ module edubox-agent go 1.26.4 require ( + fyne.io/systray v1.12.2 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 - tailscale.com v1.100.0 ) require ( - filippo.io/edwards25519 v1.2.0 // indirect - github.com/akutz/memconn v0.1.0 // indirect - github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect - github.com/coder/websocket v1.8.12 // indirect - github.com/creachadair/msync v0.7.1 // indirect - github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/gaissmai/bart v0.26.1 // indirect - github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/google/btree v1.1.3 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/hdevalence/ed25519consensus v0.2.0 // indirect - github.com/huin/goupnp v1.3.0 // indirect - github.com/jsimonetti/rtnetlink v1.4.0 // indirect - github.com/klauspost/compress v1.18.5 // indirect - github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect - github.com/mdlayher/socket v0.5.0 // indirect - github.com/mitchellh/go-ps v1.0.0 // indirect - github.com/pires/go-proxyproto v0.8.1 // indirect - github.com/safchain/ethtool v0.3.0 // indirect - github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect - github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect - github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect - github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect - github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect - github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad // indirect - github.com/x448/float16 v0.8.4 // indirect - go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect - go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - golang.zx2c4.com/wireguard/windows v0.5.3 // indirect - gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect ) diff --git a/agent/go.sum b/agent/go.sum index 3e947f5..d1d10bc 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -1,233 +1,11 @@ -9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= -9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= -filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= -filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= -filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= -github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= -github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= -github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg= -github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y= -github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= -github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= -github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= -github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ= -github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= -github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= -github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= -github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= -github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= -github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= -github.com/creachadair/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA= -github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= -github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho= -github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008= -github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= -github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= -github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= -github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= -github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= -github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= -github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= -github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= -github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= -github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= -github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= -github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= -github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= -github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= -github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8= -github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI= -github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= -github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= -github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= -github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= -github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= -github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= -github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= -github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= -github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= -github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= -github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= -github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= -github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= -github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= -github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= -github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= -github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= -github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= -github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= -github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= -github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= -github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= -github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= -github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= -github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= -github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= -github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= -github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= -github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= -github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= -github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= -github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= -github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= -github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE= -github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= -github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 h1:glgVc1ZYMjwN1Q/ITWeuSQyl029uayagaR2sjsifehc= -github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw= -github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= -github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= -github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM= -github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= -github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= -github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= -github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= -github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= -github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= -github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= -github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= -github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= -github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= -github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= -github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad h1:Ky26FR5yZ5IKEB0xtm5A8xSTb06ImY7kxBFrvgOmJSg= -github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI= -github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= -github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= -github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= -github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= -github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= -github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= -github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= -github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= -github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= -github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= -go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= -go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= -go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= -golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= -golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= -golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= -golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= -golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= -golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= -gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= -honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= -honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= -howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= -howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= -software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= -software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.100.0 h1:nm/M/dEaW9RaRsGUjW2HsSDpsZ60Jwd9k4gNW9tTFiE= -tailscale.com v1.100.0/go.mod h1:DQ9YBy85DpNlSyeU2XRIWzbAu3RsGp/frv+Khg57meE= diff --git a/agent/icon.png b/agent/icon.png new file mode 100644 index 0000000..810a867 Binary files /dev/null and b/agent/icon.png differ diff --git a/agent/main.go b/agent/main.go index 18a9ccf..2973666 100644 --- a/agent/main.go +++ b/agent/main.go @@ -5,21 +5,22 @@ import ( "log" "os" "path/filepath" + "runtime" "time" ) // version is injected at build time via -ldflags "-X main.version=X.Y.Z" var version = "dev" -const AGENT_VERSION = "0.3.0" +const ( + AGENT_VERSION = "0.3.0" + APP_NAME = "studioE5" +) var ( - serverAddr = flag.String("server", "ws://localhost:3001", "Adresse WebSocket du serveur") - nodeID = flag.String("node-id", defaultNodeID(), "ID du nœud (défaut: hostname)") - dataDir = flag.String("data-dir", "./edubox-data", "Répertoire de données") - uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX") - headscaleURL = flag.String("headscale-url", "", "URL du serveur Headscale (ex: http://151.80.60.98:8080)") - headscaleAuthKey = flag.String("headscale-auth-key", "", "Clé d'authentification Headscale") + dataDir = flag.String("data-dir", "./studioE5-data", "Répertoire de données") + uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX") + noTray = flag.Bool("no-tray", false, "Désactiver l'icône dans la barre système (mode console)") ) func defaultNodeID() string { @@ -49,19 +50,52 @@ func main() { log.Fatalf("Cannot create data-dir: %v", err) } - log.Printf("[EduBox Agent] version=%s node=%s data-dir=%s", AGENT_VERSION, *nodeID, *dataDir) + cfg, 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 { - 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 != "" { - go startTailscaleAndReport(*dataDir, *nodeID, *headscaleURL, *headscaleAuthKey) + shutdownCh := make(chan struct{}) + if *noTray { + log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME) + <-shutdownCh + return } - select {} + // Run tray on its own locked OS thread; keep main blocked so the process + // does not exit when systray is not available (e.g. headless Linux). + go func() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + runTray(APP_NAME, shutdownCh) + }() + + <-shutdownCh } func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) { diff --git a/agent/systray.go b/agent/systray.go new file mode 100644 index 0000000..fd64a12 --- /dev/null +++ b/agent/systray.go @@ -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, " ", "") +} diff --git a/agent/tailscale.go b/agent/tailscale.go index f66125e..2719d1c 100644 --- a/agent/tailscale.go +++ b/agent/tailscale.go @@ -2,104 +2,178 @@ package main import ( "context" + "encoding/json" "fmt" "log" - "net" "os" - "io" + "os/exec" + "path/filepath" + "runtime" + "sync" + "syscall" "time" - - "tailscale.com/tsnet" ) -var globalTSServer *tsnet.Server +var ( + tsCmd *exec.Cmd + tsCmdMu sync.Mutex + tsIP string + tsDataDir string + tsSocket string +) + +type tailscaleStatus struct { + Self struct { + TailscaleIPs []string `json:"TailscaleIPs"` + } `json:"Self"` +} + +func tailscaleBin(name string) string { + // Prefer bundled binaries (tailscale-bin//tailscaled etc.). + ex, err := os.Executable() + if err == nil { + bundled := filepath.Join(filepath.Dir(ex), "tailscale-bin", runtime.GOOS, name) + if runtime.GOOS == "windows" { + bundled += ".exe" + } + if _, err := os.Stat(bundled); err == nil { + return bundled + } + } + if p, err := exec.LookPath(name); err == nil { + return p + } + return name +} func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, error) { - // Configure tsnet to use our Headscale server - os.Setenv("TS_AUTHKEY", authKey) - os.Setenv("TS_CONTROL_URL", headscaleURL) + tsCmdMu.Lock() + defer tsCmdMu.Unlock() - s := &tsnet.Server{ - Hostname: nodeID, - Dir: dataDir, - Logf: log.Printf, + if tsCmd != nil { + return tsIP, nil } - if err := s.Start(); err != nil { - return "", fmt.Errorf("tailscale start: %w", err) + if dataDir == "" { + return "", fmt.Errorf("tailscale data dir is empty") + } + tsDataDir = filepath.Join(dataDir, "tailscale") + if err := os.MkdirAll(tsDataDir, 0700); err != nil { + return "", fmt.Errorf("create tailscale dir: %w", err) + } + tsSocket = filepath.Join(tsDataDir, "tailscaled.sock") + stateFile := filepath.Join(tsDataDir, "tailscaled.state") + + log.Printf("Starting tailscaled for node %s", nodeID) + tsCmd = exec.Command(tailscaleBin("tailscaled"), + "--state="+stateFile, + "--socket="+tsSocket, + "--tun=userspace-networking", + ) + tsCmd.Stdout = os.Stdout + tsCmd.Stderr = os.Stderr + if err := tsCmd.Start(); err != nil { + tsCmd = nil + return "", fmt.Errorf("start tailscaled: %w", err) } - globalTSServer = s + // Give tailscaled a moment to start listening. + time.Sleep(1 * time.Second) - // Wait for Tailscale to come up and retrieve IP - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + // Bring the interface up with the auth key. + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - lc, err := s.LocalClient() - if err != nil { - return "", fmt.Errorf("tailscale local client: %w", err) + upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"), + "--socket="+tsSocket, + "up", + "--authkey="+authKey, + "--login-server="+headscaleURL, + "--hostname="+nodeID, + "--accept-dns=false", + "--operator=root", + ) + upCmd.Stdout = os.Stdout + upCmd.Stderr = os.Stderr + if err := upCmd.Run(); err != nil { + _ = tsCmd.Process.Kill() + _ = tsCmd.Wait() + tsCmd = nil + return "", fmt.Errorf("tailscale up: %w", err) } - var tailscaleIP string + // Wait for an IP address. for { - status, err := lc.Status(ctx) + out, err := exec.CommandContext(ctx, tailscaleBin("tailscale"), + "--socket="+tsSocket, + "status", "--json", + ).Output() if err != nil { - return "", fmt.Errorf("tailscale status: %w", err) + select { + case <-ctx.Done(): + _ = tsCmd.Process.Kill() + _ = tsCmd.Wait() + tsCmd = nil + return "", fmt.Errorf("tailscale status: %w", err) + default: + time.Sleep(1 * time.Second) + continue + } } - if status.Self != nil && len(status.Self.TailscaleIPs) > 0 { - tailscaleIP = status.Self.TailscaleIPs[0].String() + var st tailscaleStatus + if err := json.Unmarshal(out, &st); err == nil && len(st.Self.TailscaleIPs) > 0 { + tsIP = st.Self.TailscaleIPs[0] break } select { case <-ctx.Done(): + _ = tsCmd.Process.Kill() + _ = tsCmd.Wait() + tsCmd = nil return "", fmt.Errorf("tailscale IP timeout") - case <-time.After(1 * time.Second): + default: + time.Sleep(1 * time.Second) } } - log.Printf("Tailscale started with IP: %s", tailscaleIP) - return tailscaleIP, nil + log.Printf("Tailscale started with IP: %s", tsIP) + return tsIP, nil } -func startTailscaleProxy(port int) (net.Listener, error) { - if globalTSServer == nil { - return nil, fmt.Errorf("tailscale server not started") - } - ln, err := globalTSServer.Listen("tcp", fmt.Sprintf(":%d", port)) - if err != nil { - return nil, fmt.Errorf("tailscale listen on port %d: %w", port, err) - } - go func() { - for { - conn, err := ln.Accept() - if err != nil { - log.Printf("tailscale proxy accept error on port %d: %v", port, err) - return - } - go handleProxyConn(conn, port) - } - }() - log.Printf("Tailscale proxy started on port %d", port) - return ln, nil +func stopTailscale() { + tsCmdMu.Lock() + defer tsCmdMu.Unlock() + stopTailscaleLocked() } -func handleProxyConn(src net.Conn, port int) { - defer src.Close() - dst, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port)) - if err != nil { - log.Printf("tailscale proxy dial localhost:%d error: %v", port, err) +func stopTailscaleLocked() { + if tsCmd == nil || tsCmd.Process == nil { return } - defer dst.Close() - - done := make(chan struct{}, 2) - go func() { - _, _ = io.Copy(dst, src) - done <- struct{}{} - }() - go func() { - _, _ = io.Copy(src, dst) - done <- struct{}{} - }() - <-done + if tsSocket != "" { + _ = exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down").Run() + } + _ = tsCmd.Process.Kill() + _ = tsCmd.Wait() + tsCmd = nil + tsIP = "" + log.Printf("Tailscale stopped") } + +func isTailscaleRunning() bool { + tsCmdMu.Lock() + defer tsCmdMu.Unlock() + if tsCmd == nil || tsCmd.Process == nil { + return false + } + // Signal 0 checks process existence without affecting it. + return tsCmd.Process.Signal(syscall.Signal(0)) == nil +} + +func getTailscaleIP() string { + tsCmdMu.Lock() + defer tsCmdMu.Unlock() + return tsIP +} + + diff --git a/agent/ui.go b/agent/ui.go index e97cce6..321c0cc 100644 --- a/agent/ui.go +++ b/agent/ui.go @@ -2,9 +2,12 @@ package main import ( _ "embed" + "encoding/json" "fmt" "log" "net/http" + "os" + "os/exec" "github.com/gorilla/websocket" ) @@ -20,6 +23,55 @@ func startUI(dataDir, nodeID, serverAddr string) { fmt.Fprint(w, uiHTML) }) + http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + cfg, _, err := loadOrCreateConfig(dataDir) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // Do not expose the auth key in plain GET unless requested; for local UI it is fine. + json.NewEncoder(w).Encode(cfg) + case http.MethodPost: + var cfg AgentConfig + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if cfg.DataDir == "" { + cfg.DataDir = dataDir + } + if err := saveConfig(dataDir, &cfg); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + http.HandleFunc("/api/restart", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + w.WriteHeader(http.StatusNoContent) + go func() { + cmd := exec.Command(os.Args[0], os.Args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Start(); err != nil { + log.Printf("Restart failed: %v", err) + return + } + os.Exit(0) + }() + }) + http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { @@ -72,9 +124,9 @@ func startUI(dataDir, nodeID, serverAddr string) { }) port := "7070" - log.Printf("UI starting on http://localhost:%s", port) + log.Printf("%s UI starting on http://localhost:%s", APP_NAME, port) if err := http.ListenAndServe("127.0.0.1:"+port, nil); err != nil { - log.Fatalf("UI server error: %v", err) + log.Fatalf("%s UI server error: %v", APP_NAME, err) } } diff --git a/agent/ui/index.html b/agent/ui/index.html index e72b720..b5aece4 100644 --- a/agent/ui/index.html +++ b/agent/ui/index.html @@ -2,7 +2,7 @@ - EduBox Agent + studioE5 Agent
-
-

EduBox Agent

+
+

studioE5 Agent

Connexion en cours...

+ + +