a73x

70c1cc4e

feat: wire middleware into proxy MITM loop

a73x   2026-03-31 05:49

Commit message
feat: wire middleware into proxy MITM loop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

proxy/proxy.go
Old New
@@ -16,6 +16,7 @@ import (
16 "sync" 16 "sync"
17 17
18 "github.com/xanderle/nono/ca" 18 "github.com/xanderle/nono/ca"
19 "github.com/xanderle/nono/middleware"
19 "github.com/xanderle/nono/scanner" 20 "github.com/xanderle/nono/scanner"
20 ) 21 )
21 22
@@ -34,6 +35,18 @@ func WithRules(rulesPath string) Option {
34 } 35 }
35 } 36 }
36 37
38 // WithMiddleware returns an Option that loads middleware from the given config path.
39 func WithMiddleware(path string) Option {
40 return func(p *Proxy) {
41 mw, err := middleware.New(path)
42 if err != nil {
43 log.Printf("WARNING: failed to load middleware from %q: %v", path, err)
44 return
45 }
46 p.middleware = mw
47 }
48 }
49
37 // WithCA returns an Option that enables MITM interception using the given CA cert and key. 50 // WithCA returns an Option that enables MITM interception using the given CA cert and key.
38 func WithCA(cert *x509.Certificate, key *ecdsa.PrivateKey) Option { 51 func WithCA(cert *x509.Certificate, key *ecdsa.PrivateKey) Option {
39 return func(p *Proxy) { 52 return func(p *Proxy) {
@@ -54,6 +67,7 @@ func WithUpstreamTLS(cfg *tls.Config) Option {
54 type Proxy struct { 67 type Proxy struct {
55 hostsFile string 68 hostsFile string
56 scanner *scanner.Scanner 69 scanner *scanner.Scanner
70 middleware *middleware.Middleware
57 caCert *x509.Certificate 71 caCert *x509.Certificate
58 caKey *ecdsa.PrivateKey 72 caKey *ecdsa.PrivateKey
59 certCache map[string]*tls.Certificate 73 certCache map[string]*tls.Certificate
@@ -232,8 +246,29 @@ func (p *Proxy) handleConnectMITM(w http.ResponseWriter, r *http.Request) {
232 return 246 return
233 } 247 }
234 248
235 upstreamResp.Write(tlsConn) 249 if p.middleware != nil {
236 upstreamResp.Body.Close() 250 if rule := p.middleware.Match(host, req.URL.Path); rule != nil {
251 body, err := io.ReadAll(upstreamResp.Body)
252 upstreamResp.Body.Close()
253 if err != nil {
254 log.Printf("ERROR: middleware failed to read response body from %s: %v", host, err)
255 } else {
256 if err := rule.SaveResponse(body); err != nil {
257 log.Printf("ERROR: middleware failed to save response to %s: %v", rule.Dest, err)
258 } else {
259 log.Printf("MIDDLEWARE saved %s%s -> %s", host, req.URL.Path, rule.Dest)
260 }
261 upstreamResp.Body = io.NopCloser(bytes.NewReader(body))
262 }
263 upstreamResp.Write(tlsConn)
264 } else {
265 upstreamResp.Write(tlsConn)
266 upstreamResp.Body.Close()
267 }
268 } else {
269 upstreamResp.Write(tlsConn)
270 upstreamResp.Body.Close()
271 }
237 272
238 if req.Close || upstreamResp.Close { 273 if req.Close || upstreamResp.Close {
239 return 274 return
proxy/proxy_test.go
Old New
@@ -3,6 +3,7 @@ package proxy_test
3 import ( 3 import (
4 "crypto/tls" 4 "crypto/tls"
5 "crypto/x509" 5 "crypto/x509"
6 "fmt"
6 "net/http" 7 "net/http"
7 "net/http/httptest" 8 "net/http/httptest"
8 "net/url" 9 "net/url"
@@ -288,3 +289,72 @@ func TestShouldAllowCleanHTTPSRequest(t *testing.T) {
288 t.Errorf("expected 200, got %d", resp.StatusCode) 289 t.Errorf("expected 200, got %d", resp.StatusCode)
289 } 290 }
290 } 291 }
292
293 func TestMiddlewareSavesResponseBody(t *testing.T) {
294 responseBody := `{"usage": 100}`
295 backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
296 w.Header().Set("Content-Type", "application/json")
297 w.WriteHeader(http.StatusOK)
298 w.Write([]byte(responseBody))
299 }))
300 defer backend.Close()
301
302 backendURL, _ := url.Parse(backend.URL)
303 host := backendURL.Hostname()
304
305 hostsFile := filepath.Join(t.TempDir(), "approved_hosts")
306 os.WriteFile(hostsFile, []byte(host+"\n"), 0644)
307
308 destFile := filepath.Join(t.TempDir(), "usage.json")
309 mwPath := filepath.Join(t.TempDir(), "middleware.yaml")
310 os.WriteFile(mwPath, []byte(fmt.Sprintf(`middleware:
311 - match: "%s/data"
312 action: save_response
313 dest: "%s"
314 `, host, destFile)), 0644)
315
316 caDir := t.TempDir()
317 caCert, caKey, err := nca.LoadOrCreate(caDir)
318 if err != nil {
319 t.Fatalf("CA setup: %v", err)
320 }
321
322 p := proxy.New(hostsFile,
323 proxy.WithCA(caCert, caKey),
324 proxy.WithUpstreamTLS(&tls.Config{InsecureSkipVerify: true}),
325 proxy.WithMiddleware(mwPath),
326 )
327 srv := httptest.NewServer(p)
328 defer srv.Close()
329
330 caPool := x509.NewCertPool()
331 caPool.AddCert(caCert)
332
333 proxyURL, _ := url.Parse(srv.URL)
334 client := &http.Client{
335 Transport: &http.Transport{
336 Proxy: http.ProxyURL(proxyURL),
337 TLSClientConfig: &tls.Config{
338 RootCAs: caPool,
339 },
340 },
341 }
342
343 resp, err := client.Get(backend.URL + "/data")
344 if err != nil {
345 t.Fatalf("unexpected error: %v", err)
346 }
347 defer resp.Body.Close()
348
349 if resp.StatusCode != http.StatusOK {
350 t.Errorf("expected 200, got %d", resp.StatusCode)
351 }
352
353 got, err := os.ReadFile(destFile)
354 if err != nil {
355 t.Fatalf("dest file not written: %v", err)
356 }
357 if string(got) != responseBody {
358 t.Errorf("expected %q, got %q", responseBody, got)
359 }
360 }