diff options
Diffstat (limited to 'internal/store/intercept.go')
| -rw-r--r-- | internal/store/intercept.go | 233 |
1 files changed, 233 insertions, 0 deletions
diff --git a/internal/store/intercept.go b/internal/store/intercept.go new file mode 100644 index 0000000..e86c683 --- /dev/null +++ b/internal/store/intercept.go @@ -0,0 +1,233 @@ +package store + +import ( + "net" + "net/http" + "sync" + + "github.com/shiv/internal/events" +) + +// PendingRequest is a request held at the intercept gate waiting for a +// user decision. The proxy goroutine blocks on Reply until the UI responds. +// done is closed when the request is cancelled via bypass so watchQueue +// can skip stale entries without deadlocking. +type PendingRequest struct { + Request *http.Request + Body []byte + Reply chan Decision + done chan struct{} +} + +func newPendingRequest(req *http.Request, body []byte) *PendingRequest { + return &PendingRequest{ + Request: req, + Body: body, + Reply: make(chan Decision, 1), + done: make(chan struct{}), + } +} + +// Cancel marks this pending request as cancelled. Safe to call multiple times. +func (p *PendingRequest) Cancel() { + select { + case <-p.done: + // already cancelled + default: + close(p.done) + } +} + +// IsDone returns true if this request was cancelled before the UI processed it. +func (p *PendingRequest) IsDone() bool { + select { + case <-p.done: + return true + default: + return false + } +} + +// Decision is the user's response to a pending request. +type Decision struct { + Forward bool + Request *http.Request + Body []byte +} + +// InterceptGate manages intercept state. When enabled, every request enters +// a serialising semaphore — one request is shown to the UI at a time, others +// wait in order. ForwardAll releases all currently waiting goroutines by +// closing the bypass channel and immediately replacing it so future requests +// are intercepted normally again. +type InterceptGate struct { + mu sync.RWMutex + enabled bool + + queue chan *PendingRequest // UI reads from here + sem chan struct{} // capacity 1 — one request shown at a time + + bypassMu sync.Mutex + bypass chan struct{} // closed to release all waiting goroutines + bypassClosed bool // tracks whether current bypass is already closed + + scopeMu sync.RWMutex + scopeCheckFn func(host string) bool // nil = intercept all; non-nil = intercept only if returns true +} + +// NewInterceptGate creates a new gate. Intercept is off by default. +func NewInterceptGate() *InterceptGate { + return &InterceptGate{ + queue: make(chan *PendingRequest, 1024), + sem: make(chan struct{}, 1), + bypass: make(chan struct{}), + } +} + +// Enabled returns whether intercept is currently on. +func (g *InterceptGate) Enabled() bool { + g.mu.RLock() + defer g.mu.RUnlock() + return g.enabled +} + +// SetEnabled turns intercept on or off. +// Turning off triggers bypass so all goroutines currently blocked in Hold +// are released immediately without deadlocking. +// Turning on resets the bypass channel so future requests are intercepted. +// g.mu and bypassMu are never held simultaneously to avoid deadlock with Hold. +func (g *InterceptGate) SetEnabled(v bool) { + g.mu.Lock() + g.enabled = v + g.mu.Unlock() + + if !v { + g.bypassMu.Lock() + if !g.bypassClosed { + close(g.bypass) + g.bypassClosed = true + } + g.bypassMu.Unlock() + } else { + g.bypassMu.Lock() + g.bypass = make(chan struct{}) + g.bypassClosed = false + g.bypassMu.Unlock() + } +} + +// SetScopeFilter sets a predicate that gates which requests enter the queue. +// Pass nil to intercept all requests. Pass a function returning true for +// in-scope hosts to intercept scope-only traffic. +func (g *InterceptGate) SetScopeFilter(fn func(host string) bool) { + g.scopeMu.Lock() + g.scopeCheckFn = fn + g.scopeMu.Unlock() +} + +// Hold blocks until the user makes a decision on this request. +// If intercept is off, returns immediately. +// Requests acquire the semaphore in arrival order so the UI sees exactly +// one request at a time. If bypass fires at any point during Hold, the +// goroutine exits immediately and the request is passed through unmodified. +// Any pending already in the queue is marked cancelled so watchQueue skips it. +func (g *InterceptGate) Hold(req *http.Request, body []byte) (*http.Request, []byte, bool) { + if !g.Enabled() { + return req, body, true + } + + g.scopeMu.RLock() + fn := g.scopeCheckFn + g.scopeMu.RUnlock() + if fn != nil { + host := req.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + if !fn(host) { + return req, body, true + } + } + + // Capture the current bypass reference once. This goroutine watches this + // exact channel throughout — if it gets closed (ForwardAll or SetEnabled(false)), + // we see it no matter where in Hold we are. + g.bypassMu.Lock() + bypass := g.bypass + g.bypassMu.Unlock() + + // Wait for semaphore (one slot) or bypass signal. + select { + case g.sem <- struct{}{}: + case <-bypass: + return req, body, true + } + + // Re-check enabled — may have changed while we waited for the semaphore. + if !g.Enabled() { + <-g.sem + return req, body, true + } + + // Re-check bypass — may have been closed while we raced for the semaphore. + select { + case <-bypass: + <-g.sem + return req, body, true + default: + } + + // We hold the semaphore and bypass is open. Put the request in the queue. + pending := newPendingRequest(req, body) + select { + case g.queue <- pending: + case <-bypass: + pending.Cancel() + <-g.sem + return req, body, true + } + + // Wait for the user's decision or a bypass signal. + select { + case decision := <-pending.Reply: + <-g.sem + return decision.Request, decision.Body, decision.Forward + case <-bypass: + // Bypass fired while we were waiting. Mark the pending as cancelled + // so watchQueue skips this stale queue entry, then release the semaphore. + pending.Cancel() + <-g.sem + return req, body, true + } +} + +// ForwardAll releases all goroutines currently blocked in Hold, passing their +// requests through unmodified. Future requests are intercepted normally — +// the bypass channel is replaced immediately after closing the old one. +func (g *InterceptGate) ForwardAll() { + g.bypassMu.Lock() + if !g.bypassClosed { + close(g.bypass) + g.bypassClosed = true + } + // Replace immediately so future requests are intercepted normally. + g.bypass = make(chan struct{}) + g.bypassClosed = false + g.bypassMu.Unlock() +} + +// Queue returns the channel the UI reads pending requests from. +func (g *InterceptGate) Queue() <-chan *PendingRequest { + return g.queue +} + +// HandleRequest implements events.RequestMiddleware. +// This is a thin adapter: it calls Hold and maps the result to RequestResult. +func (g *InterceptGate) HandleRequest(e events.RequestEvent) events.RequestResult { + req, body, forward := g.Hold(e.Request, e.Body) + return events.RequestResult{ + Drop: !forward, + Request: req, + Body: body, + } +} |
