70c1cc4e
feat: wire middleware into proxy MITM loop
a73x 2026-03-31 05:49
Commit message
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 | } | ||