feat(agent): activation zéro-config – config Headscale envoyée par le serveur
- Agent: URL serveur par défaut, node_id auto-généré, config Headscale vide par défaut - Serveur: lors de l’activation, renvoie headscaleUrl + headscaleAuthKey - Agent: sauvegarde la config reçue et démarre Tailscale automatiquement - docker-compose.yml: passe HEADSCALE_URL et HEADSCALE_AUTH_KEY au service server - Mise à jour du suivi avec le flow zéro-config
This commit is contained in:
+36
-17
@@ -23,6 +23,11 @@
|
|||||||
5. **Instance WordPress démarrée avec succès**
|
5. **Instance WordPress démarrée avec succès**
|
||||||
- Le resolver a renvoyé une 302 WordPress via `http://resolver:2020/`.
|
- Le resolver a renvoyé une 302 WordPress via `http://resolver:2020/`.
|
||||||
|
|
||||||
|
6. **Activation zéro-config de l’agent (modèle commercialisable)**
|
||||||
|
- L’agent démarre sans `headscale_url` ni `headscale_auth_key`.
|
||||||
|
- L’utilisateur entre seulement un code d’activation.
|
||||||
|
- Le serveur envoie la config Headscale, l’agent la sauvegarde et démarre le VPN automatiquement.
|
||||||
|
|
||||||
## ✅ Blocage levé
|
## ✅ Blocage levé
|
||||||
|
|
||||||
**Rate limit Let’s Encrypt pour `edudeploy.com` est levé.**
|
**Rate limit Let’s Encrypt pour `edudeploy.com` est levé.**
|
||||||
@@ -181,27 +186,41 @@ cd /opt/studioe5-client-a/agent
|
|||||||
|
|
||||||
Le `build.sh` génère automatiquement `studioE5-agent-v0.3.0-windows.zip` et copie les binaires versionnés dans `server/public/`.
|
Le `build.sh` génère automatiquement `studioE5-agent-v0.3.0-windows.zip` et copie les binaires versionnés dans `server/public/`.
|
||||||
|
|
||||||
### Configuration minimale Windows
|
### Flow d’activation zéro-config (modèle commercialisable)
|
||||||
|
|
||||||
1. Extraire `studioE5-agent-v0.3.0-windows.zip` dans `C:\studioE5-agent`.
|
L’élève/employé n’a **aucune configuration technique** à saisir :
|
||||||
2. Créer le dossier `C:\studioE5-agent\data`.
|
|
||||||
3. Créer `C:\studioE5-agent\data\studioE5-config.json` :
|
1. **Télécharger** l’agent Windows (`studioE5-agent-v0.3.0-windows.zip`).
|
||||||
```json
|
2. **Extraire** et **lancer** `studioE5-agent.exe`.
|
||||||
{
|
3. **Entrer le code d’activation** à 6 caractères fourni par l’établissement (affiché dans l’UI locale `http://localhost:7070`).
|
||||||
"server": "wss://studioe5.edudeploy.com/api/websocket",
|
4. L’agent contacte le serveur, le serveur vérifie le code et renvoie **automatiquement** :
|
||||||
"headscale_url": "https://headscale.studioe5.edudeploy.com",
|
- l’identité de l’élève (`studentName`)
|
||||||
"headscale_auth_key": "CLE_PREAUTH_ICI",
|
- l’URL Headscale
|
||||||
"node_id": "IDENTIFIANT_DU_POSTE",
|
- la clé pré-auth Headscale
|
||||||
"data_dir": "C:\\studioE5-agent\\data"
|
5. L’agent sauvegarde ces informations localement et **démarre automatiquement le VPN**.
|
||||||
}
|
6. L’agent est alors visible dans le dashboard et peut recevoir des instances.
|
||||||
```
|
|
||||||
4. Lancer l’agent en mode console :
|
### Configuration manuelle (mode debug / admin)
|
||||||
```powershell
|
|
||||||
.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data
|
Si besoin, on peut toujours forcer une config via `data/studioE5-config.json` :
|
||||||
```
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": "wss://studioe5.edudeploy.com/api/websocket",
|
||||||
|
"headscale_url": "https://headscale.studioe5.edudeploy.com",
|
||||||
|
"headscale_auth_key": "CLE_PREAUTH_ICI",
|
||||||
|
"node_id": "IDENTIFIANT_DU_POSTE",
|
||||||
|
"data_dir": "C:\\studioE5-agent\\data"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
> ⚠️ `headscale_auth_key` doit être une clé pré-auth réutilisable valide pour le tailnet studioe5. Ne jamais commiter cette clé.
|
> ⚠️ `headscale_auth_key` doit être une clé pré-auth réutilisable valide pour le tailnet studioe5. Ne jamais commiter cette clé.
|
||||||
|
|
||||||
|
Lancement :
|
||||||
|
```powershell
|
||||||
|
.\studioE5-agent.exe -no-tray -data-dir C:\studioE5-agent\data
|
||||||
|
```
|
||||||
|
|
||||||
## 📋 Prochaines étapes à faire
|
## 📋 Prochaines étapes à faire
|
||||||
|
|
||||||
- [x] ~~Attendre la fin du rate limit Let’s Encrypt~~ (levé le 2026-06-23).
|
- [x] ~~Attendre la fin du rate limit Let’s Encrypt~~ (levé le 2026-06-23).
|
||||||
|
|||||||
+7
-7
@@ -43,23 +43,23 @@ with zipfile.ZipFile("${ZIP_NAME}", 'w', zipfile.ZIP_DEFLATED) as zf:
|
|||||||
zf.write("${BIN_NAME}.exe", "${BIN_NAME}.exe")
|
zf.write("${BIN_NAME}.exe", "${BIN_NAME}.exe")
|
||||||
for f in ["tailscale.exe", "tailscaled.exe", "wintun.dll"]:
|
for f in ["tailscale.exe", "tailscaled.exe", "wintun.dll"]:
|
||||||
zf.write(f"tailscale-bin/windows/{f}", f"tailscale-bin/windows/{f}")
|
zf.write(f"tailscale-bin/windows/{f}", f"tailscale-bin/windows/{f}")
|
||||||
readme = """${APP_NAME} Agent - Windows
|
readme = r"""${APP_NAME} Agent - Windows
|
||||||
=======================
|
=======================
|
||||||
1. Extract this archive to a folder (e.g. C:\\${APP_NAME}-agent).
|
1. Extract this archive to a folder (e.g. C:\${APP_NAME}-agent).
|
||||||
2. Create a data folder (e.g. C:\\${APP_NAME}-agent\\data).
|
2. Create a data folder (e.g. C:\${APP_NAME}-agent\data).
|
||||||
3. Create the config file data\\${BIN_NAME}-config.json:
|
3. Create the config file data\${BIN_NAME}-config.json:
|
||||||
{
|
{
|
||||||
"server": "wss://studioe5.edudeploy.com/api/websocket",
|
"server": "wss://studioe5.edudeploy.com/api/websocket",
|
||||||
"headscale_url": "https://headscale.studioe5.edudeploy.com",
|
"headscale_url": "https://headscale.studioe5.edudeploy.com",
|
||||||
"headscale_auth_key": "YOUR_PREAUTH_KEY",
|
"headscale_auth_key": "YOUR_PREAUTH_KEY",
|
||||||
"node_id": "YOUR_NODE_ID",
|
"node_id": "YOUR_NODE_ID",
|
||||||
"data_dir": "C:\\\\\\\\${APP_NAME}-agent\\\\\\\\data"
|
"data_dir": "C:\\${APP_NAME}-agent\\data"
|
||||||
}
|
}
|
||||||
4. Run the agent:
|
4. Run the agent:
|
||||||
${BIN_NAME}.exe -no-tray -data-dir C:\\${APP_NAME}-agent\\data
|
${BIN_NAME}.exe -no-tray -data-dir C:\${APP_NAME}-agent\data
|
||||||
|
|
||||||
Tailscale binaries (tailscale.exe, tailscaled.exe, wintun.dll) are bundled
|
Tailscale binaries (tailscale.exe, tailscaled.exe, wintun.dll) are bundled
|
||||||
in tailscale-bin\\windows\\ and used automatically by the agent.
|
in tailscale-bin\windows\ and used automatically by the agent.
|
||||||
"""
|
"""
|
||||||
zf.writestr("README-Windows.txt", readme)
|
zf.writestr("README-Windows.txt", readme)
|
||||||
print(f" ${ZIP_NAME}")
|
print(f" ${ZIP_NAME}")
|
||||||
|
|||||||
+51
-6
@@ -1,7 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
@@ -17,17 +20,55 @@ type AgentConfig struct {
|
|||||||
|
|
||||||
const configFileName = "studioE5-config.json"
|
const configFileName = "studioE5-config.json"
|
||||||
|
|
||||||
|
// defaultServerURL is the production WebSocket endpoint baked into the agent.
|
||||||
|
// It can be overridden by the config file for self-hosted or test setups.
|
||||||
|
const defaultServerURL = "wss://studioe5.edudeploy.com/api/websocket"
|
||||||
|
|
||||||
|
// uniqueNodeID returns a stable-ish unique identifier for this machine.
|
||||||
|
// It combines the hostname with a short random suffix so every install is distinct.
|
||||||
|
func uniqueNodeID() string {
|
||||||
|
h, err := os.Hostname()
|
||||||
|
if err != nil || h == "" {
|
||||||
|
h = "node"
|
||||||
|
}
|
||||||
|
b := make([]byte, 4)
|
||||||
|
if _, err := rand.Read(b); err == nil {
|
||||||
|
return fmt.Sprintf("%s-%s", h, hex.EncodeToString(b))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%d", h, os.Getpid())
|
||||||
|
}
|
||||||
|
|
||||||
// defaultConfig returns sensible defaults for a first run.
|
// defaultConfig returns sensible defaults for a first run.
|
||||||
|
// The user only needs to provide an activation code; Headscale credentials are
|
||||||
|
// delivered by the server during activation.
|
||||||
func defaultConfig(dataDir string) *AgentConfig {
|
func defaultConfig(dataDir string) *AgentConfig {
|
||||||
return &AgentConfig{
|
return &AgentConfig{
|
||||||
Server: "ws://localhost:3001",
|
Server: defaultServerURL,
|
||||||
HeadscaleURL: "",
|
HeadscaleURL: "",
|
||||||
HeadscaleAuthKey: "",
|
HeadscaleAuthKey: "",
|
||||||
NodeID: defaultNodeID(),
|
NodeID: uniqueNodeID(),
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeWithDefaults fills missing fields from disk with sensible defaults.
|
||||||
|
func mergeWithDefaults(cfg *AgentConfig, dataDir string) *AgentConfig {
|
||||||
|
defaults := defaultConfig(dataDir)
|
||||||
|
if cfg == nil {
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
if cfg.Server == "" {
|
||||||
|
cfg.Server = defaults.Server
|
||||||
|
}
|
||||||
|
if cfg.NodeID == "" {
|
||||||
|
cfg.NodeID = defaults.NodeID
|
||||||
|
}
|
||||||
|
if cfg.DataDir == "" {
|
||||||
|
cfg.DataDir = defaults.DataDir
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
// configPath returns the absolute path to the config file.
|
// configPath returns the absolute path to the config file.
|
||||||
func configPath(dataDir string) string {
|
func configPath(dataDir string) string {
|
||||||
return filepath.Join(dataDir, configFileName)
|
return filepath.Join(dataDir, configFileName)
|
||||||
@@ -43,11 +84,12 @@ func loadOrCreateConfig(dataDir string) (*AgentConfig, bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
var cfg AgentConfig
|
cfg := &AgentConfig{}
|
||||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
if err := json.Unmarshal(data, cfg); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
return &cfg, false, nil
|
cfg = mergeWithDefaults(cfg, dataDir)
|
||||||
|
return cfg, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := defaultConfig(dataDir)
|
cfg := defaultConfig(dataDir)
|
||||||
@@ -60,9 +102,12 @@ func loadOrCreateConfig(dataDir string) (*AgentConfig, bool, error) {
|
|||||||
// saveConfig writes the config file to disk.
|
// saveConfig writes the config file to disk.
|
||||||
func saveConfig(dataDir string, cfg *AgentConfig) error {
|
func saveConfig(dataDir string, cfg *AgentConfig) error {
|
||||||
cp := configPath(dataDir)
|
cp := configPath(dataDir)
|
||||||
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
data, err := json.MarshalIndent(cfg, "", " ")
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(cp, data, 0644)
|
return os.WriteFile(cp, data, 0600)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-22
@@ -23,14 +23,6 @@ var (
|
|||||||
noTray = flag.Bool("no-tray", false, "Désactiver l'icône dans la barre système (mode console)")
|
noTray = flag.Bool("no-tray", false, "Désactiver l'icône dans la barre système (mode console)")
|
||||||
)
|
)
|
||||||
|
|
||||||
func defaultNodeID() string {
|
|
||||||
h, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -50,21 +42,11 @@ func main() {
|
|||||||
log.Fatalf("Cannot create data-dir: %v", err)
|
log.Fatalf("Cannot create data-dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, created, err := loadOrCreateConfig(*dataDir)
|
cfg, _, err := loadOrCreateConfig(*dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Cannot load config: %v", err)
|
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 {
|
if err := saveConfig(*dataDir, cfg); err != nil {
|
||||||
log.Fatalf("Cannot save config: %v", err)
|
log.Fatalf("Cannot save config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -73,9 +55,6 @@ func main() {
|
|||||||
|
|
||||||
if *uiEnabled {
|
if *uiEnabled {
|
||||||
go startUI(*dataDir, cfg.NodeID, cfg.Server)
|
go startUI(*dataDir, cfg.NodeID, cfg.Server)
|
||||||
if created {
|
|
||||||
go openBrowser(uiURL + "#settings")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
|
go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
|
||||||
|
|||||||
+71
-20
@@ -10,17 +10,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type WSMessage struct {
|
type WSMessage struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
NodeID string `json:"nodeId,omitempty"`
|
NodeID string `json:"nodeId,omitempty"`
|
||||||
Code string `json:"code,omitempty"`
|
Code string `json:"code,omitempty"`
|
||||||
InstanceID string `json:"instanceId,omitempty"`
|
InstanceID string `json:"instanceId,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Port int `json:"port,omitempty"`
|
Port int `json:"port,omitempty"`
|
||||||
ComposeConfig string `json:"composeConfig,omitempty"`
|
ComposeConfig string `json:"composeConfig,omitempty"`
|
||||||
StudentId string `json:"studentId,omitempty"`
|
StudentId string `json:"studentId,omitempty"`
|
||||||
StudentName string `json:"studentName,omitempty"`
|
StudentName string `json:"studentName,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
TailscaleIP string `json:"tailscaleIp,omitempty"`
|
TailscaleIP string `json:"tailscaleIp,omitempty"`
|
||||||
|
HeadscaleURL string `json:"headscaleUrl,omitempty"`
|
||||||
|
HeadscaleAuthKey string `json:"headscaleAuthKey,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -28,6 +30,27 @@ var (
|
|||||||
mainConnMu sync.Mutex
|
mainConnMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// headscale config received from the server during activation.
|
||||||
|
// These are mutable because activation may happen after the agent starts.
|
||||||
|
var (
|
||||||
|
currentHeadscaleURL string
|
||||||
|
currentHeadscaleAuthKey string
|
||||||
|
headscaleConfigMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func setHeadscaleConfig(url, authKey string) {
|
||||||
|
headscaleConfigMu.Lock()
|
||||||
|
currentHeadscaleURL = url
|
||||||
|
currentHeadscaleAuthKey = authKey
|
||||||
|
headscaleConfigMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHeadscaleConfig() (string, string) {
|
||||||
|
headscaleConfigMu.Lock()
|
||||||
|
defer headscaleConfigMu.Unlock()
|
||||||
|
return currentHeadscaleURL, currentHeadscaleAuthKey
|
||||||
|
}
|
||||||
|
|
||||||
func sendMessage(msg WSMessage) error {
|
func sendMessage(msg WSMessage) error {
|
||||||
mainConnMu.Lock()
|
mainConnMu.Lock()
|
||||||
defer mainConnMu.Unlock()
|
defer mainConnMu.Unlock()
|
||||||
@@ -81,6 +104,8 @@ func notifyUI(msg map[string]interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) {
|
func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) {
|
||||||
|
setHeadscaleConfig(headscaleURL, headscaleAuthKey)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
|
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,6 +136,11 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
|
|||||||
log.Println("Waiting for activation...")
|
log.Println("Waiting for activation...")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Already activated as %s", act.StudentName)
|
log.Printf("Already activated as %s", act.StudentName)
|
||||||
|
// If already activated and we have credentials, ensure VPN is up.
|
||||||
|
hsURL, hsKey := getHeadscaleConfig()
|
||||||
|
if hsURL != "" && hsKey != "" {
|
||||||
|
go startTailscaleAndReport(dataDir, nodeID, hsURL, hsKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heartbeat goroutine
|
// Heartbeat goroutine
|
||||||
@@ -138,7 +168,7 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
|
|||||||
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, headscaleURL, headscaleAuthKey)
|
handleMessage(conn, msg, dataDir, nodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
close(done)
|
close(done)
|
||||||
@@ -151,7 +181,7 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headscaleURL, headscaleAuthKey string) {
|
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID 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)
|
||||||
@@ -163,6 +193,25 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headsca
|
|||||||
log.Printf("Activated as %s", act.StudentName)
|
log.Printf("Activated as %s", act.StudentName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server also sends Headscale credentials on activation.
|
||||||
|
if msg.HeadscaleURL != "" && msg.HeadscaleAuthKey != "" {
|
||||||
|
setHeadscaleConfig(msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
||||||
|
cfg, _, err := loadOrCreateConfig(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("loadOrCreateConfig error: %v", err)
|
||||||
|
} else {
|
||||||
|
cfg.HeadscaleURL = msg.HeadscaleURL
|
||||||
|
cfg.HeadscaleAuthKey = msg.HeadscaleAuthKey
|
||||||
|
if err := saveConfig(dataDir, cfg); err != nil {
|
||||||
|
log.Printf("saveConfig error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Saved Headscale config received from server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go startTailscaleAndReport(dataDir, nodeID, msg.HeadscaleURL, msg.HeadscaleAuthKey)
|
||||||
|
}
|
||||||
|
|
||||||
notifyUI(map[string]interface{}{
|
notifyUI(map[string]interface{}{
|
||||||
"action": "activated",
|
"action": "activated",
|
||||||
"studentName": msg.StudentName,
|
"studentName": msg.StudentName,
|
||||||
@@ -172,13 +221,14 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headsca
|
|||||||
return
|
return
|
||||||
case "start_vpn":
|
case "start_vpn":
|
||||||
log.Printf("Server requested VPN start")
|
log.Printf("Server requested VPN start")
|
||||||
if headscaleURL == "" || headscaleAuthKey == "" {
|
hsURL, hsKey := getHeadscaleConfig()
|
||||||
|
if hsURL == "" || hsKey == "" {
|
||||||
log.Printf("Cannot start VPN: headscale config missing")
|
log.Printf("Cannot start VPN: headscale config missing")
|
||||||
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
|
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
ip, err := startTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey)
|
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("start_vpn error: %v", err)
|
log.Printf("start_vpn error: %v", err)
|
||||||
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
|
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
|
||||||
@@ -236,7 +286,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headsca
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
// Ensure Tailscale is running so the server can reach the node
|
// Ensure Tailscale is running so the server can reach the node
|
||||||
go ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey, msg.Port)
|
go ensureTailscale(dataDir, nodeID, msg.Port)
|
||||||
|
|
||||||
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})
|
||||||
@@ -282,7 +332,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headsca
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
// Ensure Tailscale is running so the server can reach the node
|
// Ensure Tailscale is running so the server can reach the node
|
||||||
go ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey, msg.Port)
|
go ensureTailscale(dataDir, nodeID, msg.Port)
|
||||||
|
|
||||||
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})
|
||||||
@@ -293,8 +343,9 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headsca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey string, port int) {
|
func ensureTailscale(dataDir, nodeID string, port int) {
|
||||||
if headscaleURL == "" || headscaleAuthKey == "" {
|
hsURL, hsKey := getHeadscaleConfig()
|
||||||
|
if hsURL == "" || hsKey == "" {
|
||||||
log.Printf("Cannot ensure Tailscale: headscale config missing")
|
log.Printf("Cannot ensure Tailscale: headscale config missing")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -302,7 +353,7 @@ func ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey string, por
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Tailscale not running, starting it for instance port %d", port)
|
log.Printf("Tailscale not running, starting it for instance port %d", port)
|
||||||
ip, err := startTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey)
|
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ensureTailscale start error: %v", err)
|
log.Printf("ensureTailscale start error: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ services:
|
|||||||
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL}
|
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL}
|
||||||
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD}
|
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD}
|
||||||
MAIN_DOMAIN: ${MAIN_DOMAIN}
|
MAIN_DOMAIN: ${MAIN_DOMAIN}
|
||||||
|
HEADSCALE_URL: ${HEADSCALE_URL}
|
||||||
|
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -54,7 +54,13 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() },
|
create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() },
|
||||||
});
|
});
|
||||||
console.log("[WS] Activated:", student.firstName, student.lastName, "on", nodeId);
|
console.log("[WS] Activated:", student.firstName, student.lastName, "on", nodeId);
|
||||||
ws.send(JSON.stringify({ action: "activated", studentId: student.id, studentName: `${student.firstName} ${student.lastName}` }));
|
ws.send(JSON.stringify({
|
||||||
|
action: "activated",
|
||||||
|
studentId: student.id,
|
||||||
|
studentName: `${student.firstName} ${student.lastName}`,
|
||||||
|
headscaleUrl: process.env.HEADSCALE_URL,
|
||||||
|
headscaleAuthKey: process.env.HEADSCALE_AUTH_KEY,
|
||||||
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user