fix: resolve deadlock, data races and path traversal vulnerabilities
- task.go: fix deadlock in ToStatusUpdate() — calling Percent() (which RLocks) while already holding RLock caused deadlock when a writer was waiting; compute percent inline instead - usenet.go: fix data race in Cancel() — tracker and taskDir were read without the mutex while Download() writes them under it; read all fields under the same lock - upnp.go: fix UPnP Remove() blocking shutdown — run cleanup in goroutine with 10s deadline (removeNATPMP worst case is 3s dial + 5s deadline) - daemon.go: add path traversal protection for stream requests — validate sr.FilePath is within configured directories before os.Stat; defends against compromised API server sending arbitrary paths - client.go: add wakeClient without timeout for long-poll wake endpoint where context controls cancellation - sync.go: trigger immediate sync when entering watching mode so stream requests are picked up without waiting for the next scheduled interval
This commit is contained in:
parent
78c16c295e
commit
ef4f38d324
6 changed files with 146 additions and 13 deletions
|
|
@ -16,6 +16,9 @@ type Client struct {
|
|||
baseURL string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
// wakeClient has no built-in timeout — used exclusively for the long-poll
|
||||
// wake endpoint where the context controls cancellation.
|
||||
wakeClient *http.Client
|
||||
userAgent string
|
||||
}
|
||||
|
||||
|
|
@ -27,7 +30,10 @@ func NewClient(baseURL, apiKey, userAgent string) *Client {
|
|||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
userAgent: userAgent,
|
||||
// wakeClient has no built-in timeout — the context controls it.
|
||||
// The server holds the connection for up to 28s before responding.
|
||||
wakeClient: &http.Client{},
|
||||
userAgent: userAgent,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +182,36 @@ func (c *Client) ReportWatchProgress(ctx context.Context, update WatchProgressUp
|
|||
return nil
|
||||
}
|
||||
|
||||
// WaitForWake blocks until the server sends a wake signal, the long-poll
|
||||
// timeout elapses, or ctx is cancelled. Returns true when a wake signal
|
||||
// was received (caller should sync immediately), false on timeout/cancel.
|
||||
func (c *Client) WaitForWake(ctx context.Context) (bool, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/internal/agent/wake", nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("create wake request: %w", err)
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.wakeClient.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("wake request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||
return false, &HTTPError{StatusCode: resp.StatusCode, Message: string(body)}
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Wake bool `json:"wake"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return false, fmt.Errorf("decode wake response: %w", err)
|
||||
}
|
||||
return result.Wake, nil
|
||||
}
|
||||
|
||||
// doPost sends a JSON POST request and decodes the response.
|
||||
func (c *Client) doPost(ctx context.Context, path string, body any, dst any) error {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ const (
|
|||
// SyncIntervalWatching is the sync interval when someone is viewing the web UI.
|
||||
SyncIntervalWatching = 3 * time.Second
|
||||
// SyncIntervalIdle is the sync interval when nobody is watching.
|
||||
SyncIntervalIdle = 60 * time.Second
|
||||
// Keep this short enough to pick up stream requests quickly without hammering the server.
|
||||
SyncIntervalIdle = 10 * time.Second
|
||||
)
|
||||
|
||||
// SyncClient handles bidirectional state synchronization between the CLI and server.
|
||||
|
|
@ -68,6 +69,9 @@ func (sc *SyncClient) TriggerSync() {
|
|||
|
||||
// Run starts the adaptive sync loop. Blocks until ctx is cancelled.
|
||||
func (sc *SyncClient) Run(ctx context.Context) error {
|
||||
// Start wake listener in background — triggers immediate syncs on demand.
|
||||
go sc.runWakeListener(ctx)
|
||||
|
||||
// Initial sync immediately
|
||||
sc.doSync(ctx)
|
||||
|
||||
|
|
@ -174,6 +178,38 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) {
|
|||
}
|
||||
}
|
||||
|
||||
// runWakeListener holds a long-poll connection to /api/internal/agent/wake.
|
||||
// When the server resolves it with wake=true (e.g., a stream was requested),
|
||||
// it triggers an immediate sync so the CLI acts in <100ms instead of waiting
|
||||
// for the next scheduled interval. Reconnects immediately after each response
|
||||
// so coverage is continuous. Runs until ctx is cancelled.
|
||||
func (sc *SyncClient) runWakeListener(ctx context.Context) {
|
||||
const retryDelay = 2 * time.Second
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
woke, err := sc.client.WaitForWake(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("wake listener: %v (retrying in %s)", err, retryDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
continue
|
||||
}
|
||||
if woke {
|
||||
log.Printf("wake signal received — syncing immediately")
|
||||
sc.TriggerSync()
|
||||
}
|
||||
// On timeout (woke=false) or after a wake, reconnect immediately.
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *SyncClient) adjustInterval(watching bool) {
|
||||
prev := sc.watching.Load()
|
||||
sc.watching.Store(watching)
|
||||
|
|
@ -189,6 +225,12 @@ func (sc *SyncClient) adjustInterval(watching bool) {
|
|||
log.Printf("sync: interval=%s (watching=%v)", newInterval, watching)
|
||||
}
|
||||
|
||||
// Trigger an immediate sync when entering watching mode so stream requests
|
||||
// are picked up right away without waiting for the next scheduled interval.
|
||||
if watching && !prev {
|
||||
sc.TriggerSync()
|
||||
}
|
||||
|
||||
if prev != watching && sc.OnWatchingChange != nil {
|
||||
sc.OnWatchingChange(watching)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue