From adab165274016b886cf9a0821544f5ecf43e3228 Mon Sep 17 00:00:00 2001 From: EduBox Dev Date: Sun, 28 Jun 2026 19:53:19 +0000 Subject: [PATCH] agent v0.3.15: mode proxy auto/manuel, correction auto-update et conservation systray, animation UI update --- agent/VERSION | 2 +- agent/config.go | 14 ++- agent/main.go | 4 +- agent/proxy.go | 150 +++++++++++++++++++++++++ agent/ui.go | 4 +- agent/ui/index.html | 43 ++++++- agent/update.go | 95 +++++++++++----- agent/update_test.go | 21 ++++ agent/websocket.go | 108 +++++++++++++++++- server/app/api/agent/version/route.ts | 7 +- server/app/api/download/route.ts | 7 +- server/app/dashboard/download/page.tsx | 11 +- server/lib/agent-version.ts | 33 ++++-- 13 files changed, 444 insertions(+), 55 deletions(-) create mode 100644 agent/proxy.go create mode 100644 agent/update_test.go diff --git a/agent/VERSION b/agent/VERSION index 5503126..9e29e10 100644 --- a/agent/VERSION +++ b/agent/VERSION @@ -1 +1 @@ -0.3.10 +0.3.15 diff --git a/agent/config.go b/agent/config.go index 9384070..9efeb03 100644 --- a/agent/config.go +++ b/agent/config.go @@ -14,8 +14,18 @@ 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"` + NodeID string `json:"node_id"` + DataDir string `json:"data_dir"` + // ProxyURL is an optional HTTP(S) proxy used for all outbound agent traffic + // (WebSocket, update checks, downloads). + ProxyURL string `json:"proxy_url,omitempty"` + // ProxyMode controls how the proxy is used: + // - "disabled" : never use the proxy. + // - "auto" : the agent tries direct connections first and falls back to + // the proxy after a few failures (useful when moving between + // home network and school network). + // - "enabled" : always use the proxy. + ProxyMode string `json:"proxy_mode,omitempty"` } const configFileName = "studioE5-config.json" diff --git a/agent/main.go b/agent/main.go index d6a8d23..458199d 100644 --- a/agent/main.go +++ b/agent/main.go @@ -74,8 +74,8 @@ func main() { go startUI(*dataDir, cfg.NodeID, cfg.Server) } - go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey) - go updateCheckerLoop(*dataDir, cfg.Server) + go startWebSocket(cfg, cfg.NodeID, *dataDir) + go updateCheckerLoop(cfg, *dataDir) shutdownCh := make(chan struct{}) diff --git a/agent/proxy.go b/agent/proxy.go new file mode 100644 index 0000000..b573520 --- /dev/null +++ b/agent/proxy.go @@ -0,0 +1,150 @@ +package main + +import ( + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + ProxyModeDisabled = "disabled" + ProxyModeAuto = "auto" + ProxyModeEnabled = "enabled" +) + +// autoProxyLockDuration is the minimum time we stay in proxy mode once the +// agent automatically switched to it. This prevents flip-flopping on short +// network blips. +const autoProxyLockDuration = 5 * time.Minute + +// proxyState tracks the runtime proxy decision in "auto" mode. It is guarded +// by proxyMu. +var ( + proxyMu sync.RWMutex + proxyActive bool + proxyLockedUntil time.Time +) + +// proxyMode normalizes the configured proxy mode. +func proxyMode(cfg *AgentConfig) string { + if cfg == nil { + return ProxyModeDisabled + } + switch strings.ToLower(strings.TrimSpace(cfg.ProxyMode)) { + case ProxyModeEnabled: + return ProxyModeEnabled + case ProxyModeAuto: + return ProxyModeAuto + default: + return ProxyModeDisabled + } +} + +// IsProxyActive reports whether outbound requests should currently go through +// the configured proxy. In "enabled" mode it always returns true; in "auto" +// mode it reflects the last automatic decision. +func IsProxyActive() bool { + proxyMu.RLock() + defer proxyMu.RUnlock() + return proxyActive +} + +// setProxyActive updates the runtime proxy decision and, in auto mode, locks +// the decision for autoProxyLockDuration to avoid flip-flopping. +func setProxyActive(active bool) bool { + proxyMu.Lock() + defer proxyMu.Unlock() + changed := proxyActive != active + proxyActive = active + if active { + proxyLockedUntil = time.Now().Add(autoProxyLockDuration) + } + return changed +} + +// resetProxyState disables the automatic proxy decision. Call this when the +// configuration changes. +func resetProxyState() { + proxyMu.Lock() + proxyActive = false + proxyLockedUntil = time.Time{} + proxyMu.Unlock() +} + +// canRetryDirect reports whether enough time has passed to try a direct +// connection again while in auto-proxy mode. +func canRetryDirect() bool { + proxyMu.RLock() + defer proxyMu.RUnlock() + return time.Now().After(proxyLockedUntil) +} + +// proxyURL parses and validates the configured proxy URL. +func proxyURL(cfg *AgentConfig) *url.URL { + if cfg == nil || cfg.ProxyURL == "" { + return nil + } + u, err := url.Parse(cfg.ProxyURL) + if err != nil { + return nil + } + return u +} + +// proxyFunc returns a proxy selection function for http.Transport. It returns +// nil when the proxy should not be used. +func proxyFunc(cfg *AgentConfig) func(*http.Request) (*url.URL, error) { + mode := proxyMode(cfg) + u := proxyURL(cfg) + + switch mode { + case ProxyModeEnabled: + if u == nil { + return nil + } + return func(*http.Request) (*url.URL, error) { return u, nil } + case ProxyModeAuto: + if u == nil { + return nil + } + if !IsProxyActive() { + return nil + } + return func(*http.Request) (*url.URL, error) { return u, nil } + default: + return nil + } +} + +// websocketDialer returns a websocket.Dialer configured for the current proxy +// mode and state. +func websocketDialer(cfg *AgentConfig) *websocket.Dialer { + d := websocket.DefaultDialer + fn := proxyFunc(cfg) + if fn == nil { + return d + } + return &websocket.Dialer{ + Proxy: fn, + HandshakeTimeout: d.HandshakeTimeout, + ReadBufferSize: d.ReadBufferSize, + WriteBufferSize: d.WriteBufferSize, + EnableCompression: d.EnableCompression, + } +} + +// httpClientWithProxy returns an http.Client configured for the current proxy +// mode and state. +func httpClientWithProxy(cfg *AgentConfig) *http.Client { + fn := proxyFunc(cfg) + if fn == nil { + return http.DefaultClient + } + return &http.Client{ + Transport: &http.Transport{Proxy: fn}, + } +} diff --git a/agent/ui.go b/agent/ui.go index 80249a0..4c51dc5 100644 --- a/agent/ui.go +++ b/agent/ui.go @@ -64,6 +64,8 @@ func startUI(dataDir, nodeID, serverAddr string) { "headscale_auth_key": cfg.HeadscaleAuthKey, "node_id": cfg.NodeID, "data_dir": cfg.DataDir, + "proxy_url": cfg.ProxyURL, + "proxy_mode": cfg.ProxyMode, "version": version, "server_version": serverVersion, "update_available": updateAvailable, @@ -124,7 +126,7 @@ func startUI(dataDir, nodeID, serverAddr string) { "percent": "10", "message": "Téléchargement de la mise à jour...", }) - if err := startAgentUpdate(dataDir, cfg.Server); err != nil { + if err := startAgentUpdate(cfg, dataDir); err != nil { log.Printf("Agent update failed: %v", err) broadcastUI(map[string]interface{}{ "action": "update_progress", diff --git a/agent/ui/index.html b/agent/ui/index.html index 45b9b1a..f05170f 100644 --- a/agent/ui/index.html +++ b/agent/ui/index.html @@ -454,6 +454,25 @@ border-color: rgba(0,0,0,0.1); border-top-color: var(--text-secondary); } + + .spin { + display: inline-block; + animation: spin 1s linear infinite; + } + + .progress-bar { + background: rgba(0,0,0,0.1); + border-radius: 4px; + height: 6px; + margin-top: 0.5rem; + overflow: hidden; + } + + .progress-bar > div { + background: var(--info); + height: 100%; + transition: width 0.3s ease; + } @@ -565,6 +584,16 @@ + + + + + +
@@ -904,6 +933,8 @@ document.getElementById('cfg-headscale-url').value = cfg.headscale_url || ''; document.getElementById('cfg-headscale-key').value = cfg.headscale_auth_key || ''; document.getElementById('cfg-data-dir').value = cfg.data_dir || ''; + document.getElementById('cfg-proxy-mode').value = cfg.proxy_mode || 'disabled'; + document.getElementById('cfg-proxy-url').value = cfg.proxy_url || ''; document.getElementById('detail-version').textContent = cfg.version || 'dev'; document.getElementById('detail-nodeid').textContent = cfg.node_id || '-'; document.getElementById('detail-server').textContent = cfg.server || '-'; @@ -926,12 +957,14 @@ function showUpdateProgress(percent, message) { const banner = document.getElementById('update-banner'); + const pct = parseInt(percent || '0', 10); banner.innerHTML = `
-
⬇️
-
+
+
Mise à jour en cours
-
${escapeHtml(message || '')} (${percent || 0}%)
+
${escapeHtml(message || '')}
+
`; @@ -958,7 +991,9 @@ node_id: document.getElementById('cfg-node').value.trim(), headscale_url: document.getElementById('cfg-headscale-url').value.trim(), headscale_auth_key: document.getElementById('cfg-headscale-key').value.trim(), - data_dir: document.getElementById('cfg-data-dir').value.trim() + data_dir: document.getElementById('cfg-data-dir').value.trim(), + proxy_mode: document.getElementById('cfg-proxy-mode').value, + proxy_url: document.getElementById('cfg-proxy-url').value.trim() }; try { const res = await fetch('/api/config', { diff --git a/agent/update.go b/agent/update.go index 2b47d8c..65227ac 100644 --- a/agent/update.go +++ b/agent/update.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "time" ) @@ -27,15 +28,30 @@ type AgentVersionInfo struct { } `json:"downloadUrls"` } +// httpBaseURL converts a WebSocket server URL into the corresponding HTTP(S) +// base URL, stripping the /api/websocket path if present. +func httpBaseURL(serverURL string) string { + u := serverURL + switch { + case strings.HasPrefix(u, "wss://"): + u = "https://" + strings.TrimPrefix(u, "wss://") + case strings.HasPrefix(u, "ws://"): + u = "http://" + strings.TrimPrefix(u, "ws://") + } + u = strings.TrimSuffix(u, "/api/websocket/") + u = strings.TrimSuffix(u, "/api/websocket") + return strings.TrimSuffix(u, "/") +} + // checkForUpdate fetches the latest agent version from the server and compares // it with the running binary's version. -func checkForUpdate(serverBaseURL string) (*AgentVersionInfo, bool, error) { - if serverBaseURL == "" { +func checkForUpdate(cfg *AgentConfig) (*AgentVersionInfo, bool, error) { + if cfg == nil || cfg.Server == "" { return nil, false, fmt.Errorf("no server URL configured") } - wsURL := strings.TrimSuffix(serverBaseURL, "/api/websocket") - url := wsURL + "/api/agent/version" - client := &http.Client{Timeout: 30 * time.Second} + url := httpBaseURL(cfg.Server) + "/api/agent/version" + client := httpClientWithProxy(cfg) + client.Timeout = 30 * time.Second resp, err := client.Get(url) if err != nil { return nil, false, err @@ -60,7 +76,7 @@ func checkForUpdate(serverBaseURL string) (*AgentVersionInfo, bool, error) { } // downloadUpdate downloads the new agent binary to the update directory. -func downloadUpdate(dataDir, downloadURL string) (string, error) { +func downloadUpdate(cfg *AgentConfig, dataDir, downloadURL string) (string, error) { updateDir := filepath.Join(dataDir, "update") if err := os.MkdirAll(updateDir, 0755); err != nil { return "", err @@ -72,7 +88,8 @@ func downloadUpdate(dataDir, downloadURL string) (string, error) { dest := filepath.Join(updateDir, "studioE5-agent-new"+ext) log.Printf("Downloading update from %s to %s", downloadURL, dest) - resp, err := http.Get(downloadURL) + client := httpClientWithProxy(cfg) + resp, err := client.Get(downloadURL) if err != nil { return "", err } @@ -99,35 +116,62 @@ func downloadUpdate(dataDir, downloadURL string) (string, error) { return dest, nil } +// formatArgsForShell returns the given arguments as a safely quoted string +// suitable for embedding in shell/PowerShell scripts. +func formatArgsForShell(args []string) string { + if len(args) == 0 { + return "" + } + quoted := make([]string, len(args)) + for i, a := range args { + quoted[i] = strconv.Quote(a) + } + return strings.Join(quoted, " ") +} + // applyUpdate replaces the running binary with the downloaded one using an -// external helper script, then exits the current process. +// external helper script, then exits the current process. The new process is +// started with the same arguments as the current one so that tray/console mode +// is preserved. func applyUpdate(currentPath, newPath, dataDir string) error { pid := os.Getpid() + restartArgs := os.Args[1:] switch runtime.GOOS { case "windows": - return applyUpdateWindows(currentPath, newPath, dataDir, pid) + return applyUpdateWindows(currentPath, newPath, dataDir, pid, restartArgs) default: - return applyUpdateUnix(currentPath, newPath, dataDir, pid) + return applyUpdateUnix(currentPath, newPath, dataDir, pid, restartArgs) } } -func applyUpdateWindows(currentPath, newPath, dataDir string, pid int) error { +func applyUpdateWindows(currentPath, newPath, dataDir string, pid int, restartArgs []string) error { scriptPath := filepath.Join(dataDir, "update", "apply-update.ps1") + argsList := formatArgsForShell(restartArgs) + if argsList == "" { + argsList = "" + } else { + argsList = "$startArgs = @(" + argsList + ")" + } script := fmt.Sprintf(`$old = "%s" $new = "%s" -$pid = %d -$args = '-no-tray', '-data-dir', '%s' -Wait-Process -Id $pid -ErrorAction SilentlyContinue +$targetPid = %d +%s +Wait-Process -Id $targetPid -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 Move-Item -Path $new -Destination $old -Force -Start-Process -FilePath $old -ArgumentList $args -WindowStyle Hidden -`, currentPath, newPath, pid, dataDir) +if ($startArgs) { + Start-Process -FilePath $old -ArgumentList $startArgs -WindowStyle Hidden +} else { + Start-Process -FilePath $old -WindowStyle Hidden +} +`, currentPath, newPath, pid, argsList) if err := os.WriteFile(scriptPath, []byte(script), 0644); err != nil { return err } - cmd := exec.Command("powershell.exe", "-ExecutionPolicy", "Bypass", "-File", scriptPath) + cmd := exec.Command("powershell.exe", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", scriptPath) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + hideWindow(cmd) if err := cmd.Start(); err != nil { return err } @@ -136,8 +180,9 @@ Start-Process -FilePath $old -ArgumentList $args -WindowStyle Hidden return nil } -func applyUpdateUnix(currentPath, newPath, dataDir string, pid int) error { +func applyUpdateUnix(currentPath, newPath, dataDir string, pid int, restartArgs []string) error { scriptPath := filepath.Join(dataDir, "update", "apply-update.sh") + argsList := formatArgsForShell(restartArgs) script := fmt.Sprintf(`#!/bin/bash set -e old="%s" @@ -147,8 +192,8 @@ while kill -0 "$pid" 2>/dev/null; do sleep 1; done sleep 2 mv "$new" "$old" chmod +x "$old" -nohup "$old" -no-tray -data-dir "%s" >/dev/null 2>&1 & -`, currentPath, newPath, pid, dataDir) +nohup "$old" %s >/dev/null 2>&1 & +`, currentPath, newPath, pid, argsList) if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { return err } @@ -164,8 +209,8 @@ nohup "$old" -no-tray -data-dir "%s" >/dev/null 2>&1 & } // startAgentUpdate performs the full update flow: download + replace + restart. -func startAgentUpdate(dataDir, serverBaseURL string) error { - info, available, err := checkForUpdate(serverBaseURL) +func startAgentUpdate(cfg *AgentConfig, dataDir string) error { + info, available, err := checkForUpdate(cfg) if err != nil { return fmt.Errorf("update check failed: %w", err) } @@ -192,7 +237,7 @@ func startAgentUpdate(dataDir, serverBaseURL string) error { if downloadURL == "" { return fmt.Errorf("no download URL for %s", runtime.GOOS) } - newPath, err := downloadUpdate(dataDir, downloadURL) + newPath, err := downloadUpdate(cfg, dataDir, downloadURL) if err != nil { return fmt.Errorf("download failed: %w", err) } @@ -206,9 +251,9 @@ func startAgentUpdate(dataDir, serverBaseURL string) error { } // updateCheckerLoop periodically checks for agent updates and notifies the UI. -func updateCheckerLoop(dataDir, serverBaseURL string) { +func updateCheckerLoop(cfg *AgentConfig, dataDir string) { for { - info, available, err := checkForUpdate(serverBaseURL) + info, available, err := checkForUpdate(cfg) if err == nil && available && info != nil { log.Printf("Agent update available: %s (current: %s)", info.Version, version) setServerAgentVersion(info.Version) diff --git a/agent/update_test.go b/agent/update_test.go new file mode 100644 index 0000000..70f7691 --- /dev/null +++ b/agent/update_test.go @@ -0,0 +1,21 @@ +package main + +import "testing" + +func TestHTTPBaseURL(t *testing.T) { + cases := []struct { + in, want string + }{ + {"wss://studioe5.edudeploy.com/api/websocket", "https://studioe5.edudeploy.com"}, + {"ws://localhost:3000/api/websocket", "http://localhost:3000"}, + {"https://studioe5.edudeploy.com/api/websocket", "https://studioe5.edudeploy.com"}, + {"https://studioe5.edudeploy.com", "https://studioe5.edudeploy.com"}, + {"wss://example.com/api/websocket/", "https://example.com"}, + } + for _, c := range cases { + got := httpBaseURL(c.in) + if got != c.want { + t.Errorf("httpBaseURL(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/agent/websocket.go b/agent/websocket.go index 7746c1c..208c39a 100644 --- a/agent/websocket.go +++ b/agent/websocket.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "net/http" + "net/url" "sync" "time" @@ -169,8 +170,107 @@ func notifyUI(msg map[string]interface{}) { } } -func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) { - setHeadscaleConfig(headscaleURL, headscaleAuthKey) +// directDialer returns a websocket.Dialer that never uses a proxy. +func directDialer() *websocket.Dialer { + d := websocket.DefaultDialer + return &websocket.Dialer{ + Proxy: nil, + HandshakeTimeout: d.HandshakeTimeout, + ReadBufferSize: d.ReadBufferSize, + WriteBufferSize: d.WriteBufferSize, + EnableCompression: d.EnableCompression, + } +} + +// proxyOnlyDialer returns a websocket.Dialer that always uses the configured +// proxy URL, ignoring the current auto-proxy state. +func proxyOnlyDialer(cfg *AgentConfig) *websocket.Dialer { + d := websocket.DefaultDialer + u := proxyURL(cfg) + if u == nil { + return d + } + return &websocket.Dialer{ + Proxy: func(*http.Request) (*url.URL, error) { return u, nil }, + HandshakeTimeout: d.HandshakeTimeout, + ReadBufferSize: d.ReadBufferSize, + WriteBufferSize: d.WriteBufferSize, + EnableCompression: d.EnableCompression, + } +} + +// dialServerWithFallback attempts to connect to the WebSocket server according +// to the configured proxy mode. In auto mode it tries direct connections first +// and falls back to the proxy after a few failures. +func dialServerWithFallback(cfg *AgentConfig, serverAddr string, headers http.Header) (*websocket.Conn, error) { + mode := proxyMode(cfg) + + switch mode { + case ProxyModeDisabled: + conn, _, err := directDialer().Dial(serverAddr, headers) + return conn, err + case ProxyModeEnabled: + conn, _, err := websocketDialer(cfg).Dial(serverAddr, headers) + return conn, err + } + + // Auto mode. + u := proxyURL(cfg) + if u == nil { + conn, _, err := directDialer().Dial(serverAddr, headers) + return conn, err + } + + // If we are currently in auto-proxy mode, try direct again only after the + // lock duration has expired. Otherwise stay on the proxy. + if IsProxyActive() { + if canRetryDirect() { + log.Println("Auto proxy: retrying direct connection after lock period") + conn, _, err := directDialer().Dial(serverAddr, headers) + if err == nil { + if setProxyActive(false) { + log.Println("Auto proxy: switched back to direct connection") + } + return conn, nil + } + log.Printf("Auto proxy: direct retry failed (%v), keeping proxy", err) + } + conn, _, err := proxyOnlyDialer(cfg).Dial(serverAddr, headers) + if err != nil { + // Proxy failed too: clear the active flag so next round restarts the + // direct-first fallback sequence. + setProxyActive(false) + } + return conn, err + } + + // Not currently in proxy mode: try direct up to 3 times, then proxy. + for i := 0; i < 3; i++ { + conn, _, err := directDialer().Dial(serverAddr, headers) + if err == nil { + return conn, nil + } + log.Printf("Auto proxy: direct attempt %d/%d failed: %v", i+1, 3, err) + if i < 2 { + time.Sleep(3 * time.Second) + } + } + + log.Println("Auto proxy: falling back to proxy") + conn, _, err := proxyOnlyDialer(cfg).Dial(serverAddr, headers) + if err == nil { + if setProxyActive(true) { + log.Println("Auto proxy: switched to proxy") + } + } else { + log.Printf("Auto proxy: proxy fallback failed: %v", err) + } + return conn, err +} + +func startWebSocket(cfg *AgentConfig, nodeID, dataDir string) { + setHeadscaleConfig(cfg.HeadscaleURL, cfg.HeadscaleAuthKey) + serverAddr := cfg.Server for { token, _ := loadNodeToken(dataDir) @@ -179,14 +279,14 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey headers.Set("Authorization", "Bearer "+token) } - conn, _, err := websocket.DefaultDialer.Dial(serverAddr, headers) + conn, err := dialServerWithFallback(cfg, serverAddr, headers) if err != nil { log.Printf("WS connect error: %v, retrying in 5s...", err) time.Sleep(5 * time.Second) continue } - log.Printf("WS connected to %s (token=%v)", serverAddr, token != "") + log.Printf("WS connected to %s (token=%v, proxy=%v)", serverAddr, token != "", IsProxyActive()) mainConnMu.Lock() mainConn = conn diff --git a/server/app/api/agent/version/route.ts b/server/app/api/agent/version/route.ts index 44ba0c0..a19892d 100644 --- a/server/app/api/agent/version/route.ts +++ b/server/app/api/agent/version/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; -import { getAgentVersionInfo } from "@/lib/agent-version"; +import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version"; -export async function GET() { - return NextResponse.json(getAgentVersionInfo()); +export async function GET(request: Request) { + const baseUrl = getBaseUrlFromRequest(request); + return NextResponse.json(getAgentVersionInfo(baseUrl)); } diff --git a/server/app/api/download/route.ts b/server/app/api/download/route.ts index 50556ba..a2b4931 100644 --- a/server/app/api/download/route.ts +++ b/server/app/api/download/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from "next/server"; -import { getAgentVersionInfo } from "@/lib/agent-version"; +import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version"; -export async function GET() { - const info = getAgentVersionInfo(); +export async function GET(request: Request) { + const baseUrl = getBaseUrlFromRequest(request); + const info = getAgentVersionInfo(baseUrl); return NextResponse.json({ version: info.version, windows: info.downloadUrls.windows, diff --git a/server/app/dashboard/download/page.tsx b/server/app/dashboard/download/page.tsx index 92565fe..b65721c 100644 --- a/server/app/dashboard/download/page.tsx +++ b/server/app/dashboard/download/page.tsx @@ -1,10 +1,15 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -import { getAgentVersionInfo } from "@/lib/agent-version"; +import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version"; +import { headers } from "next/headers"; export const dynamic = "force-dynamic"; -export default function DownloadPage() { - const info = getAgentVersionInfo(); +export default async function DownloadPage() { + const h = await headers(); + const proto = h.get("x-forwarded-proto") ?? "https"; + const host = h.get("x-forwarded-host") ?? h.get("host") ?? ""; + const baseUrl = host ? `${proto}://${host}` : undefined; + const info = getAgentVersionInfo(baseUrl); const { version, downloadUrls } = info; return ( diff --git a/server/lib/agent-version.ts b/server/lib/agent-version.ts index 4e1152b..c4bea1f 100644 --- a/server/lib/agent-version.ts +++ b/server/lib/agent-version.ts @@ -3,6 +3,21 @@ import path from "path"; const BIN_NAME = "studioE5-agent"; +// Build the public base URL from an incoming request, respecting common +// reverse-proxy headers (X-Forwarded-Proto / X-Forwarded-Host). +export function getBaseUrlFromRequest(req: Request): string { + const headers = req.headers; + const forwardedProto = headers.get("x-forwarded-proto"); + const forwardedHost = headers.get("x-forwarded-host"); + + if (forwardedProto && forwardedHost) { + return `${forwardedProto}://${forwardedHost}`; + } + + const url = new URL(req.url); + return `${url.protocol}//${url.host}`; +} + function findVersionFile(): string | null { // Try a few common paths relative to the server workspace and Next.js build output. const candidates = [ @@ -37,19 +52,23 @@ export interface AgentDownloadUrls { mac: string; } -export function getAgentDownloadUrls(version: string): AgentDownloadUrls { +export function getAgentDownloadUrls( + version: string, + baseUrl?: string +): AgentDownloadUrls { + const prefix = baseUrl ? baseUrl.replace(/\/$/, "") : ""; return { - windows: `/${BIN_NAME}-v${version}.exe`, - windowsZip: `/${BIN_NAME}-v${version}-windows.zip`, - linux: `/${BIN_NAME}-v${version}`, - mac: `/${BIN_NAME}-v${version}-mac`, + windows: `${prefix}/${BIN_NAME}-v${version}.exe`, + windowsZip: `${prefix}/${BIN_NAME}-v${version}-windows.zip`, + linux: `${prefix}/${BIN_NAME}-v${version}`, + mac: `${prefix}/${BIN_NAME}-v${version}-mac`, }; } -export function getAgentVersionInfo() { +export function getAgentVersionInfo(baseUrl?: string) { const version = getAgentVersion(); return { version, - downloadUrls: getAgentDownloadUrls(version), + downloadUrls: getAgentDownloadUrls(version, baseUrl), }; }