summaryrefslogtreecommitdiff
path: root/internal/store/intercept.go
blob: e86c683e2ad72142a1c43f6fbb178dd129fc44b1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
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,
	}
}