diff --git a/Overlord-Client/cmd/agent/handlers/files.go b/Overlord-Client/cmd/agent/handlers/files.go index f692ead..de68a98 100644 --- a/Overlord-Client/cmd/agent/handlers/files.go +++ b/Overlord-Client/cmd/agent/handlers/files.go @@ -17,6 +17,7 @@ import ( "runtime" "strings" "sync" + "sync/atomic" "time" "unicode/utf8" @@ -468,6 +469,26 @@ func HandleFileUpload(ctx context.Context, env *agentRuntime.Env, cmdID string, return wire.WriteMsg(ctx, env.Conn, result) } +type progressReader struct { + r io.Reader + bytes atomic.Int64 +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + if n > 0 { + p.bytes.Add(int64(n)) + } + return n, err +} + +const ( + httpUploadMaxAttempts = 6 + httpUploadStallTimeout = 45 * time.Second + httpUploadInitBackoff = 1 * time.Second + httpUploadMaxBackoff = 30 * time.Second +) + func HandleFileUploadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID string, destPath string, sourceURL string, expectedSize int64) error { parsed, err := url.Parse(strings.TrimSpace(sourceURL)) if err != nil || parsed == nil || parsed.Host == "" { @@ -490,27 +511,14 @@ func HandleFileUploadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID stri tlsConfig.RootCAs = pool } - client := &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) - if err != nil { - return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()}) - } - if token := strings.TrimSpace(env.Cfg.AgentToken); token != "" { - req.Header.Set("x-agent-token", token) - } - if id := strings.TrimSpace(env.Cfg.ID); id != "" { - req.Header.Set("x-overlord-client-id", id) - } - - resp, err := client.Do(req) - if err != nil { - return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()}) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: fmt.Sprintf("upload fetch failed: status %d", resp.StatusCode)}) + transport := &http.Transport{ + TLSClientConfig: tlsConfig, + ResponseHeaderTimeout: 30 * time.Second, + TLSHandshakeTimeout: 20 * time.Second, + IdleConnTimeout: 60 * time.Second, + DisableCompression: true, } + client := &http.Client{Transport: transport} dir := filepath.Dir(destPath) if dir != "." { @@ -520,25 +528,149 @@ func HandleFileUploadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID stri } tmpPath := destPath + ".httpuploading" - f, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + f, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_RDWR, 0644) if err != nil { return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()}) } - written, copyErr := io.Copy(f, resp.Body) - closeErr := f.Close() - if copyErr != nil { - _ = os.Remove(tmpPath) - return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: copyErr.Error()}) + var lastErr error + backoff := httpUploadInitBackoff + + for attempt := 1; attempt <= httpUploadMaxAttempts; attempt++ { + offset, seekErr := f.Seek(0, io.SeekEnd) + if seekErr != nil { + _ = f.Close() + _ = os.Remove(tmpPath) + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: seekErr.Error()}) + } + if expectedSize > 0 && offset >= expectedSize { + break + } + + attemptCtx, attemptCancel := context.WithCancel(ctx) + + req, reqErr := http.NewRequestWithContext(attemptCtx, http.MethodGet, parsed.String(), nil) + if reqErr != nil { + attemptCancel() + lastErr = reqErr + break + } + if token := strings.TrimSpace(env.Cfg.AgentToken); token != "" { + req.Header.Set("x-agent-token", token) + } + if id := strings.TrimSpace(env.Cfg.ID); id != "" { + req.Header.Set("x-overlord-client-id", id) + } + if offset > 0 { + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset)) + } + + resp, doErr := client.Do(req) + if doErr != nil { + attemptCancel() + lastErr = doErr + if ctx.Err() != nil { + break + } + sleepBackoff(ctx, backoff) + backoff = nextBackoff(backoff) + continue + } + + if offset > 0 && resp.StatusCode == http.StatusOK { + if _, err := f.Seek(0, io.SeekStart); err != nil { + _ = resp.Body.Close() + attemptCancel() + _ = f.Close() + _ = os.Remove(tmpPath) + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()}) + } + if err := f.Truncate(0); err != nil { + _ = resp.Body.Close() + attemptCancel() + _ = f.Close() + _ = os.Remove(tmpPath) + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()}) + } + offset = 0 + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + status := resp.StatusCode + _ = resp.Body.Close() + attemptCancel() + lastErr = fmt.Errorf("upload fetch failed: status %d", status) + // 4xx (not 416) won't get better on retry — bail. + if status >= 400 && status < 500 && status != http.StatusRequestedRangeNotSatisfiable { + break + } + sleepBackoff(ctx, backoff) + backoff = nextBackoff(backoff) + continue + } + + pr := &progressReader{r: resp.Body} + watchdogDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(httpUploadStallTimeout / 3) + defer ticker.Stop() + last := pr.bytes.Load() + lastChange := time.Now() + for { + select { + case <-watchdogDone: + return + case <-attemptCtx.Done(): + return + case <-ticker.C: + cur := pr.bytes.Load() + if cur != last { + last = cur + lastChange = time.Now() + continue + } + if time.Since(lastChange) >= httpUploadStallTimeout { + attemptCancel() + return + } + } + } + }() + + n, copyErr := io.Copy(f, pr) + close(watchdogDone) + _ = resp.Body.Close() + attemptCancel() + + if copyErr == nil { + lastErr = nil + _ = n + break + } + + lastErr = copyErr + if ctx.Err() != nil { + break + } + sleepBackoff(ctx, backoff) + backoff = nextBackoff(backoff) } - if closeErr != nil { + + if lastErr != nil { + _ = f.Close() + _ = os.Remove(tmpPath) + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: lastErr.Error()}) + } + + written, _ := f.Seek(0, io.SeekEnd) + if closeErr := f.Close(); closeErr != nil { _ = os.Remove(tmpPath) return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: closeErr.Error()}) } if expectedSize > 0 && written != expectedSize { _ = os.Remove(tmpPath) - return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "upload size mismatch"}) + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: fmt.Sprintf("upload size mismatch: got %d, expected %d", written, expectedSize)}) } _ = os.Remove(destPath) @@ -550,6 +682,23 @@ func HandleFileUploadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID stri return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true}) } +func sleepBackoff(ctx context.Context, d time.Duration) { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + case <-t.C: + } +} + +func nextBackoff(d time.Duration) time.Duration { + next := d * 2 + if next > httpUploadMaxBackoff { + return httpUploadMaxBackoff + } + return next +} + func HandleFileDelete(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error { log.Printf("file_delete: %s", path) diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index b6ad99c..3278efc 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -537,6 +537,63 @@ function renderPluginMenu(plugins) { } } +function renderScriptMenu(scripts) { + const section = document.getElementById("script-section"); + const container = document.getElementById("script-menu"); + if (!section || !container) return; + container.innerHTML = ""; + + if (!Array.isArray(scripts) || scripts.length === 0) { + section.classList.add("hidden"); + return; + } + + section.classList.remove("hidden"); + const sorted = scripts.slice().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + for (const script of sorted) { + const btn = document.createElement("button"); + btn.className = "ctx-item ctx-plugin-item"; + btn.dataset.scriptId = script.id; + btn.title = `${script.scriptType} — click to open in Scripts with this client preselected`; + + const labelIcon = document.createElement("i"); + labelIcon.className = "fa-solid fa-scroll ctx-icon text-cyan-400"; + btn.appendChild(labelIcon); + + const label = document.createElement("span"); + label.className = "ctx-plugin-label"; + label.textContent = script.name || script.id; + btn.appendChild(label); + + const badge = document.createElement("span"); + badge.className = "ctx-plugin-badge"; + badge.textContent = script.scriptType || ""; + btn.appendChild(badge); + + container.appendChild(btn); + } +} + +let _scriptMenuCache = null; +async function loadSavedScriptsForMenu() { + const section = document.getElementById("script-section"); + const container = document.getElementById("script-menu"); + if (!section || !container) return; + + if (_scriptMenuCache) renderScriptMenu(_scriptMenuCache); + + try { + const res = await fetch("/api/saved-scripts"); + if (!res.ok) return; + const data = await res.json(); + const scripts = Array.isArray(data.items) ? data.items : []; + _scriptMenuCache = scripts; + renderScriptMenu(scripts); + } catch { + // ignore — cached render (if any) is already showing + } +} + async function loadPluginsForClient(clientId) { const section = document.getElementById("plugin-section"); const container = document.getElementById("plugin-menu"); @@ -599,6 +656,7 @@ function initializeRenderer() { const notificationsMuted = card?.dataset.notificationsMuted === "true"; openMenu(id, x, y, setContext, { isOnline: isClientOnline(id), notificationsMuted }); loadPluginsForClient(id); + loadSavedScriptsForMenu(); }, openModal, requestPreview, @@ -1362,6 +1420,16 @@ menu.addEventListener("click", async (e) => { closeMenu(clearContext); return; } + const scriptId = target.dataset.scriptId; + if (scriptId) { + window.open( + `/scripts?clientId=${encodeURIComponent(contextCard)}&scriptId=${encodeURIComponent(scriptId)}`, + "_blank", + "noopener", + ); + closeMenu(clearContext); + return; + } const open = target.dataset.open; if (open === "console") { window.open(`/${contextCard}/console`, "_blank", "noopener"); diff --git a/Overlord-Server/public/assets/scripts.js b/Overlord-Server/public/assets/scripts.js index 644c03b..8b68d09 100644 --- a/Overlord-Server/public/assets/scripts.js +++ b/Overlord-Server/public/assets/scripts.js @@ -746,9 +746,42 @@ function renderTemplatePalette() { } } +function applyContextMenuParams() { + const params = new URLSearchParams(window.location.search); + const clientId = params.get("clientId"); + const scriptId = params.get("scriptId"); + if (!clientId && !scriptId) return; + + if (clientId) { + const found = allClients.find((c) => c.id === clientId); + if (found) { + selectedClients.clear(); + selectedClients.add(clientId); + renderClients(); + } else { + showToast(`Client ${clientId.substring(0, 8)} is not online`, "warning", 4000); + } + } + + if (scriptId) { + const script = cachedSavedScripts.find((s) => s.id === scriptId); + if (script) { + setEditorValue(script.content); + scriptType.value = script.scriptType; + scriptSaveName.value = script.name; + setEditorMode(script.scriptType); + showToast(`Loaded "${script.name}" — ready to run`, "success", 3000); + } else { + showToast("Saved script not found", "error", 4000); + } + } +} + checkAuth(); -loadClients(); -loadSavedScripts().then(() => renderSavedScripts()); +Promise.all([ + loadClients(), + loadSavedScripts().then(() => renderSavedScripts()), +]).then(applyContextMenuParams); loadAutoTasks(); renderTemplatePalette(); diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js index 41f78a5..cc9865b 100644 --- a/Overlord-Server/public/assets/ui.js +++ b/Overlord-Server/public/assets/ui.js @@ -241,12 +241,14 @@ const mainRowsHTML = `` ).join("") + `
` + + `` + ``; const subPanelsHTML = MENU_GROUPS.map(g => `