agent v0.3.15: mode proxy auto/manuel, correction auto-update et conservation systray, animation UI update
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
|||||||
0.3.10
|
0.3.15
|
||||||
|
|||||||
+12
-2
@@ -14,8 +14,18 @@ type AgentConfig struct {
|
|||||||
Server string `json:"server"`
|
Server string `json:"server"`
|
||||||
HeadscaleURL string `json:"headscale_url"`
|
HeadscaleURL string `json:"headscale_url"`
|
||||||
HeadscaleAuthKey string `json:"headscale_auth_key"`
|
HeadscaleAuthKey string `json:"headscale_auth_key"`
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
DataDir string `json:"data_dir"`
|
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"
|
const configFileName = "studioE5-config.json"
|
||||||
|
|||||||
+2
-2
@@ -74,8 +74,8 @@ func main() {
|
|||||||
go startUI(*dataDir, cfg.NodeID, cfg.Server)
|
go startUI(*dataDir, cfg.NodeID, cfg.Server)
|
||||||
}
|
}
|
||||||
|
|
||||||
go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
|
go startWebSocket(cfg, cfg.NodeID, *dataDir)
|
||||||
go updateCheckerLoop(*dataDir, cfg.Server)
|
go updateCheckerLoop(cfg, *dataDir)
|
||||||
|
|
||||||
shutdownCh := make(chan struct{})
|
shutdownCh := make(chan struct{})
|
||||||
|
|
||||||
|
|||||||
+150
@@ -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},
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -64,6 +64,8 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
|||||||
"headscale_auth_key": cfg.HeadscaleAuthKey,
|
"headscale_auth_key": cfg.HeadscaleAuthKey,
|
||||||
"node_id": cfg.NodeID,
|
"node_id": cfg.NodeID,
|
||||||
"data_dir": cfg.DataDir,
|
"data_dir": cfg.DataDir,
|
||||||
|
"proxy_url": cfg.ProxyURL,
|
||||||
|
"proxy_mode": cfg.ProxyMode,
|
||||||
"version": version,
|
"version": version,
|
||||||
"server_version": serverVersion,
|
"server_version": serverVersion,
|
||||||
"update_available": updateAvailable,
|
"update_available": updateAvailable,
|
||||||
@@ -124,7 +126,7 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
|||||||
"percent": "10",
|
"percent": "10",
|
||||||
"message": "Téléchargement de la mise à jour...",
|
"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)
|
log.Printf("Agent update failed: %v", err)
|
||||||
broadcastUI(map[string]interface{}{
|
broadcastUI(map[string]interface{}{
|
||||||
"action": "update_progress",
|
"action": "update_progress",
|
||||||
|
|||||||
+39
-4
@@ -454,6 +454,25 @@
|
|||||||
border-color: rgba(0,0,0,0.1);
|
border-color: rgba(0,0,0,0.1);
|
||||||
border-top-color: var(--text-secondary);
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -565,6 +584,16 @@
|
|||||||
<label for="cfg-data-dir">Répertoire de données</label>
|
<label for="cfg-data-dir">Répertoire de données</label>
|
||||||
<input type="text" id="cfg-data-dir" readonly>
|
<input type="text" id="cfg-data-dir" readonly>
|
||||||
|
|
||||||
|
<label for="cfg-proxy-mode">Mode proxy</label>
|
||||||
|
<select id="cfg-proxy-mode">
|
||||||
|
<option value="disabled">Désactivé</option>
|
||||||
|
<option value="auto">Automatique (recommandé)</option>
|
||||||
|
<option value="enabled">Activé</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="cfg-proxy-url">URL du proxy</label>
|
||||||
|
<input type="text" id="cfg-proxy-url" placeholder="http://10.0.0.5:3128">
|
||||||
|
|
||||||
<button type="submit">Enregistrer et redémarrer</button>
|
<button type="submit">Enregistrer et redémarrer</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="settings-status" class="status"></div>
|
<div id="settings-status" class="status"></div>
|
||||||
@@ -904,6 +933,8 @@
|
|||||||
document.getElementById('cfg-headscale-url').value = cfg.headscale_url || '';
|
document.getElementById('cfg-headscale-url').value = cfg.headscale_url || '';
|
||||||
document.getElementById('cfg-headscale-key').value = cfg.headscale_auth_key || '';
|
document.getElementById('cfg-headscale-key').value = cfg.headscale_auth_key || '';
|
||||||
document.getElementById('cfg-data-dir').value = cfg.data_dir || '';
|
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-version').textContent = cfg.version || 'dev';
|
||||||
document.getElementById('detail-nodeid').textContent = cfg.node_id || '-';
|
document.getElementById('detail-nodeid').textContent = cfg.node_id || '-';
|
||||||
document.getElementById('detail-server').textContent = cfg.server || '-';
|
document.getElementById('detail-server').textContent = cfg.server || '-';
|
||||||
@@ -926,12 +957,14 @@
|
|||||||
|
|
||||||
function showUpdateProgress(percent, message) {
|
function showUpdateProgress(percent, message) {
|
||||||
const banner = document.getElementById('update-banner');
|
const banner = document.getElementById('update-banner');
|
||||||
|
const pct = parseInt(percent || '0', 10);
|
||||||
banner.innerHTML = `
|
banner.innerHTML = `
|
||||||
<div class="service-item" style="background: var(--info-bg); border-radius: 8px; padding: 0.75rem; margin-bottom: 0.75rem;">
|
<div class="service-item" style="background: var(--info-bg); border-radius: 8px; padding: 0.75rem; margin-bottom: 0.75rem;">
|
||||||
<div class="service-icon" style="background: var(--info);">⬇️</div>
|
<div class="service-icon" style="background: var(--info);"><span class="spin">↻</span></div>
|
||||||
<div>
|
<div style="flex: 1;">
|
||||||
<div class="service-text">Mise à jour en cours</div>
|
<div class="service-text">Mise à jour en cours</div>
|
||||||
<div class="service-detail">${escapeHtml(message || '')} (${percent || 0}%)</div>
|
<div class="service-detail">${escapeHtml(message || '')}</div>
|
||||||
|
<div class="progress-bar"><div style="width: ${pct}%"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -958,7 +991,9 @@
|
|||||||
node_id: document.getElementById('cfg-node').value.trim(),
|
node_id: document.getElementById('cfg-node').value.trim(),
|
||||||
headscale_url: document.getElementById('cfg-headscale-url').value.trim(),
|
headscale_url: document.getElementById('cfg-headscale-url').value.trim(),
|
||||||
headscale_auth_key: document.getElementById('cfg-headscale-key').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 {
|
try {
|
||||||
const res = await fetch('/api/config', {
|
const res = await fetch('/api/config', {
|
||||||
|
|||||||
+70
-25
@@ -10,6 +10,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -27,15 +28,30 @@ type AgentVersionInfo struct {
|
|||||||
} `json:"downloadUrls"`
|
} `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
|
// checkForUpdate fetches the latest agent version from the server and compares
|
||||||
// it with the running binary's version.
|
// it with the running binary's version.
|
||||||
func checkForUpdate(serverBaseURL string) (*AgentVersionInfo, bool, error) {
|
func checkForUpdate(cfg *AgentConfig) (*AgentVersionInfo, bool, error) {
|
||||||
if serverBaseURL == "" {
|
if cfg == nil || cfg.Server == "" {
|
||||||
return nil, false, fmt.Errorf("no server URL configured")
|
return nil, false, fmt.Errorf("no server URL configured")
|
||||||
}
|
}
|
||||||
wsURL := strings.TrimSuffix(serverBaseURL, "/api/websocket")
|
url := httpBaseURL(cfg.Server) + "/api/agent/version"
|
||||||
url := wsURL + "/api/agent/version"
|
client := httpClientWithProxy(cfg)
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client.Timeout = 30 * time.Second
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
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.
|
// 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")
|
updateDir := filepath.Join(dataDir, "update")
|
||||||
if err := os.MkdirAll(updateDir, 0755); err != nil {
|
if err := os.MkdirAll(updateDir, 0755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -72,7 +88,8 @@ func downloadUpdate(dataDir, downloadURL string) (string, error) {
|
|||||||
dest := filepath.Join(updateDir, "studioE5-agent-new"+ext)
|
dest := filepath.Join(updateDir, "studioE5-agent-new"+ext)
|
||||||
log.Printf("Downloading update from %s to %s", downloadURL, dest)
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -99,35 +116,62 @@ func downloadUpdate(dataDir, downloadURL string) (string, error) {
|
|||||||
return dest, nil
|
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
|
// 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 {
|
func applyUpdate(currentPath, newPath, dataDir string) error {
|
||||||
pid := os.Getpid()
|
pid := os.Getpid()
|
||||||
|
restartArgs := os.Args[1:]
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "windows":
|
case "windows":
|
||||||
return applyUpdateWindows(currentPath, newPath, dataDir, pid)
|
return applyUpdateWindows(currentPath, newPath, dataDir, pid, restartArgs)
|
||||||
default:
|
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")
|
scriptPath := filepath.Join(dataDir, "update", "apply-update.ps1")
|
||||||
|
argsList := formatArgsForShell(restartArgs)
|
||||||
|
if argsList == "" {
|
||||||
|
argsList = ""
|
||||||
|
} else {
|
||||||
|
argsList = "$startArgs = @(" + argsList + ")"
|
||||||
|
}
|
||||||
script := fmt.Sprintf(`$old = "%s"
|
script := fmt.Sprintf(`$old = "%s"
|
||||||
$new = "%s"
|
$new = "%s"
|
||||||
$pid = %d
|
$targetPid = %d
|
||||||
$args = '-no-tray', '-data-dir', '%s'
|
%s
|
||||||
Wait-Process -Id $pid -ErrorAction SilentlyContinue
|
Wait-Process -Id $targetPid -ErrorAction SilentlyContinue
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
Move-Item -Path $new -Destination $old -Force
|
Move-Item -Path $new -Destination $old -Force
|
||||||
Start-Process -FilePath $old -ArgumentList $args -WindowStyle Hidden
|
if ($startArgs) {
|
||||||
`, currentPath, newPath, pid, dataDir)
|
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 {
|
if err := os.WriteFile(scriptPath, []byte(script), 0644); err != nil {
|
||||||
return err
|
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.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
hideWindow(cmd)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -136,8 +180,9 @@ Start-Process -FilePath $old -ArgumentList $args -WindowStyle Hidden
|
|||||||
return nil
|
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")
|
scriptPath := filepath.Join(dataDir, "update", "apply-update.sh")
|
||||||
|
argsList := formatArgsForShell(restartArgs)
|
||||||
script := fmt.Sprintf(`#!/bin/bash
|
script := fmt.Sprintf(`#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
old="%s"
|
old="%s"
|
||||||
@@ -147,8 +192,8 @@ while kill -0 "$pid" 2>/dev/null; do sleep 1; done
|
|||||||
sleep 2
|
sleep 2
|
||||||
mv "$new" "$old"
|
mv "$new" "$old"
|
||||||
chmod +x "$old"
|
chmod +x "$old"
|
||||||
nohup "$old" -no-tray -data-dir "%s" >/dev/null 2>&1 &
|
nohup "$old" %s >/dev/null 2>&1 &
|
||||||
`, currentPath, newPath, pid, dataDir)
|
`, currentPath, newPath, pid, argsList)
|
||||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
return err
|
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.
|
// startAgentUpdate performs the full update flow: download + replace + restart.
|
||||||
func startAgentUpdate(dataDir, serverBaseURL string) error {
|
func startAgentUpdate(cfg *AgentConfig, dataDir string) error {
|
||||||
info, available, err := checkForUpdate(serverBaseURL)
|
info, available, err := checkForUpdate(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update check failed: %w", err)
|
return fmt.Errorf("update check failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -192,7 +237,7 @@ func startAgentUpdate(dataDir, serverBaseURL string) error {
|
|||||||
if downloadURL == "" {
|
if downloadURL == "" {
|
||||||
return fmt.Errorf("no download URL for %s", runtime.GOOS)
|
return fmt.Errorf("no download URL for %s", runtime.GOOS)
|
||||||
}
|
}
|
||||||
newPath, err := downloadUpdate(dataDir, downloadURL)
|
newPath, err := downloadUpdate(cfg, dataDir, downloadURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("download failed: %w", err)
|
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.
|
// updateCheckerLoop periodically checks for agent updates and notifies the UI.
|
||||||
func updateCheckerLoop(dataDir, serverBaseURL string) {
|
func updateCheckerLoop(cfg *AgentConfig, dataDir string) {
|
||||||
for {
|
for {
|
||||||
info, available, err := checkForUpdate(serverBaseURL)
|
info, available, err := checkForUpdate(cfg)
|
||||||
if err == nil && available && info != nil {
|
if err == nil && available && info != nil {
|
||||||
log.Printf("Agent update available: %s (current: %s)", info.Version, version)
|
log.Printf("Agent update available: %s (current: %s)", info.Version, version)
|
||||||
setServerAgentVersion(info.Version)
|
setServerAgentVersion(info.Version)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-4
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -169,8 +170,107 @@ func notifyUI(msg map[string]interface{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) {
|
// directDialer returns a websocket.Dialer that never uses a proxy.
|
||||||
setHeadscaleConfig(headscaleURL, headscaleAuthKey)
|
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 {
|
for {
|
||||||
token, _ := loadNodeToken(dataDir)
|
token, _ := loadNodeToken(dataDir)
|
||||||
@@ -179,14 +279,14 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
|
|||||||
headers.Set("Authorization", "Bearer "+token)
|
headers.Set("Authorization", "Bearer "+token)
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, headers)
|
conn, err := dialServerWithFallback(cfg, serverAddr, headers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("WS connect error: %v, retrying in 5s...", err)
|
log.Printf("WS connect error: %v, retrying in 5s...", err)
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
continue
|
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()
|
mainConnMu.Lock()
|
||||||
mainConn = conn
|
mainConn = conn
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getAgentVersionInfo } from "@/lib/agent-version";
|
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
return NextResponse.json(getAgentVersionInfo());
|
const baseUrl = getBaseUrlFromRequest(request);
|
||||||
|
return NextResponse.json(getAgentVersionInfo(baseUrl));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getAgentVersionInfo } from "@/lib/agent-version";
|
import { getAgentVersionInfo, getBaseUrlFromRequest } from "@/lib/agent-version";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
const info = getAgentVersionInfo();
|
const baseUrl = getBaseUrlFromRequest(request);
|
||||||
|
const info = getAgentVersionInfo(baseUrl);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
version: info.version,
|
version: info.version,
|
||||||
windows: info.downloadUrls.windows,
|
windows: info.downloadUrls.windows,
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function DownloadPage() {
|
export default async function DownloadPage() {
|
||||||
const info = getAgentVersionInfo();
|
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;
|
const { version, downloadUrls } = info;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,21 @@ import path from "path";
|
|||||||
|
|
||||||
const BIN_NAME = "studioE5-agent";
|
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 {
|
function findVersionFile(): string | null {
|
||||||
// Try a few common paths relative to the server workspace and Next.js build output.
|
// Try a few common paths relative to the server workspace and Next.js build output.
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -37,19 +52,23 @@ export interface AgentDownloadUrls {
|
|||||||
mac: string;
|
mac: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAgentDownloadUrls(version: string): AgentDownloadUrls {
|
export function getAgentDownloadUrls(
|
||||||
|
version: string,
|
||||||
|
baseUrl?: string
|
||||||
|
): AgentDownloadUrls {
|
||||||
|
const prefix = baseUrl ? baseUrl.replace(/\/$/, "") : "";
|
||||||
return {
|
return {
|
||||||
windows: `/${BIN_NAME}-v${version}.exe`,
|
windows: `${prefix}/${BIN_NAME}-v${version}.exe`,
|
||||||
windowsZip: `/${BIN_NAME}-v${version}-windows.zip`,
|
windowsZip: `${prefix}/${BIN_NAME}-v${version}-windows.zip`,
|
||||||
linux: `/${BIN_NAME}-v${version}`,
|
linux: `${prefix}/${BIN_NAME}-v${version}`,
|
||||||
mac: `/${BIN_NAME}-v${version}-mac`,
|
mac: `${prefix}/${BIN_NAME}-v${version}-mac`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAgentVersionInfo() {
|
export function getAgentVersionInfo(baseUrl?: string) {
|
||||||
const version = getAgentVersion();
|
const version = getAgentVersion();
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
downloadUrls: getAgentDownloadUrls(version),
|
downloadUrls: getAgentDownloadUrls(version, baseUrl),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user