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 @@
-
⬇️
-
+
↻
+
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),
};
}