summaryrefslogtreecommitdiff
path: root/internal/store/intercept.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store/intercept.go')
-rw-r--r--internal/store/intercept.go233
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,
+ }
+}