a73x

15c7f07f

feat: load middleware config in nono-proxy main

a73x   2026-03-31 05:53

Commit message
feat: load middleware config in nono-proxy main

Wire up middleware.yaml loading and fix edge case where
failed response body read left body unreconstructed.

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

cmd/nono-proxy/main.go
Old New
@@ -44,9 +44,12 @@ func main() {
44 } 44 }
45 log.Printf("CA cert: %s/ca.pem", store) 45 log.Printf("CA cert: %s/ca.pem", store)
46 46
47 mwPath := filepath.Join(store, "middleware.yaml")
48
47 opts := []proxy.Option{ 49 opts := []proxy.Option{
48 proxy.WithRules(rulesPath), 50 proxy.WithRules(rulesPath),
49 proxy.WithCA(caCert, caKey), 51 proxy.WithCA(caCert, caKey),
52 proxy.WithMiddleware(mwPath),
50 } 53 }
51 54
52 p := proxy.New(hostsFile, opts...) 55 p := proxy.New(hostsFile, opts...)
docs/superpowers/plans/2026-03-31-middleware.md
Old New
@@ -0,0 +1,532 @@
1 # Middleware Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Add a configurable middleware system that intercepts HTTPS responses matching URL patterns and writes response bodies to files.
6
7 **Architecture:** New `middleware` package loads a YAML config mapping `host+path` patterns to `save_response` actions with a destination file. The proxy gains a `WithMiddleware` option. In the MITM loop, after reading the upstream response, middleware checks the request URL and writes the response body to the configured destination (overwriting).
8
9 **Tech Stack:** Go stdlib, `gopkg.in/yaml.v3` (already a dependency)
10
11 ---
12
13 ### Task 1: Middleware package — config loading
14
15 **Files:**
16 - Create: `middleware/middleware.go`
17 - Create: `middleware/middleware_test.go`
18
19 - [ ] **Step 1: Write the failing test for YAML loading**
20
21 In `middleware/middleware_test.go`:
22
23 ```go
24 package middleware_test
25
26 import (
27 "os"
28 "path/filepath"
29 "testing"
30
31 "github.com/xanderle/nono/middleware"
32 )
33
34 func TestNewLoadsConfig(t *testing.T) {
35 dir := t.TempDir()
36 path := filepath.Join(dir, "middleware.yaml")
37 os.WriteFile(path, []byte(`middleware:
38 - match: "api.anthropic.com/api/oauth/usage"
39 action: save_response
40 dest: "/tmp/usage.json"
41 `), 0644)
42
43 mw, err := middleware.New(path)
44 if err != nil {
45 t.Fatalf("unexpected error: %v", err)
46 }
47 if mw.RuleCount() != 1 {
48 t.Errorf("expected 1 rule, got %d", mw.RuleCount())
49 }
50 }
51
52 func TestNewReturnsEmptyForMissingFile(t *testing.T) {
53 mw, err := middleware.New("/nonexistent/middleware.yaml")
54 if err != nil {
55 t.Fatalf("unexpected error: %v", err)
56 }
57 if mw.RuleCount() != 0 {
58 t.Errorf("expected 0 rules, got %d", mw.RuleCount())
59 }
60 }
61
62 func TestNewRejectsUnknownAction(t *testing.T) {
63 dir := t.TempDir()
64 path := filepath.Join(dir, "middleware.yaml")
65 os.WriteFile(path, []byte(`middleware:
66 - match: "example.com/foo"
67 action: delete_everything
68 dest: "/tmp/out"
69 `), 0644)
70
71 _, err := middleware.New(path)
72 if err == nil {
73 t.Fatal("expected error for unknown action")
74 }
75 }
76 ```
77
78 - [ ] **Step 2: Run tests to verify they fail**
79
80 Run: `go test ./middleware/...`
81 Expected: package does not exist
82
83 - [ ] **Step 3: Implement middleware package**
84
85 In `middleware/middleware.go`:
86
87 ```go
88 package middleware
89
90 import (
91 "fmt"
92 "os"
93
94 "gopkg.in/yaml.v3"
95 )
96
97 type Rule struct {
98 Match string
99 Action string
100 Dest string
101 }
102
103 type Middleware struct {
104 rules []Rule
105 }
106
107 type yamlRule struct {
108 Match string `yaml:"match"`
109 Action string `yaml:"action"`
110 Dest string `yaml:"dest"`
111 }
112
113 type yamlConfig struct {
114 Middleware []yamlRule `yaml:"middleware"`
115 }
116
117 // New loads middleware config from a YAML file.
118 // Returns an empty Middleware if the file does not exist.
119 func New(path string) (*Middleware, error) {
120 data, err := os.ReadFile(path)
121 if err != nil {
122 if os.IsNotExist(err) {
123 return &Middleware{}, nil
124 }
125 return nil, fmt.Errorf("reading middleware config: %w", err)
126 }
127
128 var cfg yamlConfig
129 if err := yaml.Unmarshal(data, &cfg); err != nil {
130 return nil, fmt.Errorf("parsing middleware YAML: %w", err)
131 }
132
133 rules := make([]Rule, 0, len(cfg.Middleware))
134 for _, yr := range cfg.Middleware {
135 if yr.Action != "save_response" {
136 return nil, fmt.Errorf("unknown middleware action %q for match %q", yr.Action, yr.Match)
137 }
138 rules = append(rules, Rule{Match: yr.Match, Action: yr.Action, Dest: yr.Dest})
139 }
140
141 return &Middleware{rules: rules}, nil
142 }
143
144 func (m *Middleware) RuleCount() int {
145 return len(m.rules)
146 }
147 ```
148
149 - [ ] **Step 4: Run tests to verify they pass**
150
151 Run: `go test ./middleware/...`
152 Expected: PASS
153
154 - [ ] **Step 5: Commit**
155
156 ```bash
157 git add middleware/
158 git commit -m "feat: add middleware package with YAML config loading"
159 ```
160
161 ---
162
163 ### Task 2: Middleware — match and save logic
164
165 **Files:**
166 - Modify: `middleware/middleware.go`
167 - Modify: `middleware/middleware_test.go`
168
169 - [ ] **Step 1: Write failing test for Match**
170
171 Append to `middleware/middleware_test.go`:
172
173 ```go
174 func TestMatchReturnsRuleForMatchingURL(t *testing.T) {
175 dir := t.TempDir()
176 path := filepath.Join(dir, "middleware.yaml")
177 os.WriteFile(path, []byte(`middleware:
178 - match: "api.anthropic.com/api/oauth/usage"
179 action: save_response
180 dest: "/tmp/usage.json"
181 `), 0644)
182
183 mw, _ := middleware.New(path)
184 rule := mw.Match("api.anthropic.com", "/api/oauth/usage")
185 if rule == nil {
186 t.Fatal("expected a match")
187 }
188 if rule.Dest != "/tmp/usage.json" {
189 t.Errorf("expected dest /tmp/usage.json, got %s", rule.Dest)
190 }
191 }
192
193 func TestMatchReturnsNilForNoMatch(t *testing.T) {
194 dir := t.TempDir()
195 path := filepath.Join(dir, "middleware.yaml")
196 os.WriteFile(path, []byte(`middleware:
197 - match: "api.anthropic.com/api/oauth/usage"
198 action: save_response
199 dest: "/tmp/usage.json"
200 `), 0644)
201
202 mw, _ := middleware.New(path)
203 rule := mw.Match("example.com", "/other")
204 if rule != nil {
205 t.Fatal("expected no match")
206 }
207 }
208 ```
209
210 - [ ] **Step 2: Run tests to verify they fail**
211
212 Run: `go test ./middleware/...`
213 Expected: `mw.Match` undefined
214
215 - [ ] **Step 3: Implement Match**
216
217 Add to `middleware/middleware.go`:
218
219 ```go
220 // Match checks if host+path matches any middleware rule.
221 // Returns the first matching rule, or nil.
222 func (m *Middleware) Match(host, path string) *Rule {
223 url := host + path
224 for i := range m.rules {
225 if m.rules[i].Match == url {
226 return &m.rules[i]
227 }
228 }
229 return nil
230 }
231 ```
232
233 - [ ] **Step 4: Run tests to verify they pass**
234
235 Run: `go test ./middleware/...`
236 Expected: PASS
237
238 - [ ] **Step 5: Write failing test for SaveResponse**
239
240 Append to `middleware/middleware_test.go`:
241
242 ```go
243 func TestSaveResponseWritesBodyToFile(t *testing.T) {
244 dest := filepath.Join(t.TempDir(), "out.json")
245 rule := &middleware.Rule{
246 Match: "example.com/data",
247 Action: "save_response",
248 Dest: dest,
249 }
250
251 body := []byte(`{"tokens": 42}`)
252 err := rule.SaveResponse(body)
253 if err != nil {
254 t.Fatalf("unexpected error: %v", err)
255 }
256
257 got, err := os.ReadFile(dest)
258 if err != nil {
259 t.Fatalf("failed to read dest file: %v", err)
260 }
261 if string(got) != string(body) {
262 t.Errorf("expected %q, got %q", body, got)
263 }
264 }
265
266 func TestSaveResponseOverwritesExistingFile(t *testing.T) {
267 dest := filepath.Join(t.TempDir(), "out.json")
268 os.WriteFile(dest, []byte("old data"), 0644)
269
270 rule := &middleware.Rule{
271 Match: "example.com/data",
272 Action: "save_response",
273 Dest: dest,
274 }
275
276 body := []byte(`{"new": true}`)
277 rule.SaveResponse(body)
278
279 got, _ := os.ReadFile(dest)
280 if string(got) != string(body) {
281 t.Errorf("expected %q, got %q", body, got)
282 }
283 }
284 ```
285
286 - [ ] **Step 6: Run tests to verify they fail**
287
288 Run: `go test ./middleware/...`
289 Expected: `rule.SaveResponse` undefined
290
291 - [ ] **Step 7: Implement SaveResponse**
292
293 Add to `middleware/middleware.go`:
294
295 ```go
296 // SaveResponse writes the response body to the rule's destination file,
297 // overwriting any existing content.
298 func (r *Rule) SaveResponse(body []byte) error {
299 return os.WriteFile(r.Dest, body, 0644)
300 }
301 ```
302
303 - [ ] **Step 8: Run tests to verify they pass**
304
305 Run: `go test ./middleware/...`
306 Expected: PASS
307
308 - [ ] **Step 9: Commit**
309
310 ```bash
311 git add middleware/
312 git commit -m "feat: add Match and SaveResponse to middleware"
313 ```
314
315 ---
316
317 ### Task 3: Wire middleware into proxy
318
319 **Files:**
320 - Modify: `proxy/proxy.go`
321 - Modify: `proxy/proxy_test.go`
322
323 - [ ] **Step 1: Write failing test for middleware in MITM path**
324
325 Append to `proxy/proxy_test.go`:
326
327 ```go
328 func TestMiddlewareSavesResponseBody(t *testing.T) {
329 responseBody := `{"usage": 100}`
330 backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
331 w.Header().Set("Content-Type", "application/json")
332 w.WriteHeader(http.StatusOK)
333 w.Write([]byte(responseBody))
334 }))
335 defer backend.Close()
336
337 backendURL, _ := url.Parse(backend.URL)
338 host := backendURL.Hostname()
339
340 hostsFile := filepath.Join(t.TempDir(), "approved_hosts")
341 os.WriteFile(hostsFile, []byte(host+"\n"), 0644)
342
343 destFile := filepath.Join(t.TempDir(), "usage.json")
344 mwPath := filepath.Join(t.TempDir(), "middleware.yaml")
345 os.WriteFile(mwPath, []byte(fmt.Sprintf(`middleware:
346 - match: "%s/data"
347 action: save_response
348 dest: "%s"
349 `, host, destFile)), 0644)
350
351 caDir := t.TempDir()
352 caCert, caKey, err := nca.LoadOrCreate(caDir)
353 if err != nil {
354 t.Fatalf("CA setup: %v", err)
355 }
356
357 p := proxy.New(hostsFile,
358 proxy.WithCA(caCert, caKey),
359 proxy.WithUpstreamTLS(&tls.Config{InsecureSkipVerify: true}),
360 proxy.WithMiddleware(mwPath),
361 )
362 srv := httptest.NewServer(p)
363 defer srv.Close()
364
365 caPool := x509.NewCertPool()
366 caPool.AddCert(caCert)
367
368 proxyURL, _ := url.Parse(srv.URL)
369 client := &http.Client{
370 Transport: &http.Transport{
371 Proxy: http.ProxyURL(proxyURL),
372 TLSClientConfig: &tls.Config{
373 RootCAs: caPool,
374 },
375 },
376 }
377
378 resp, err := client.Get(backend.URL + "/data")
379 if err != nil {
380 t.Fatalf("unexpected error: %v", err)
381 }
382 defer resp.Body.Close()
383
384 if resp.StatusCode != http.StatusOK {
385 t.Errorf("expected 200, got %d", resp.StatusCode)
386 }
387
388 got, err := os.ReadFile(destFile)
389 if err != nil {
390 t.Fatalf("dest file not written: %v", err)
391 }
392 if string(got) != responseBody {
393 t.Errorf("expected %q, got %q", responseBody, got)
394 }
395 }
396 ```
397
398 Note: add `"fmt"` to the imports block in `proxy_test.go`.
399
400 - [ ] **Step 2: Run tests to verify they fail**
401
402 Run: `go test ./proxy/...`
403 Expected: `proxy.WithMiddleware` undefined
404
405 - [ ] **Step 3: Add WithMiddleware option and wire into MITM loop**
406
407 In `proxy/proxy.go`, add the `middleware` import:
408
409 ```go
410 import (
411 // ... existing imports ...
412 "github.com/xanderle/nono/middleware"
413 )
414 ```
415
416 Add the field to the `Proxy` struct:
417
418 ```go
419 type Proxy struct {
420 hostsFile string
421 scanner *scanner.Scanner
422 middleware *middleware.Middleware
423 caCert *x509.Certificate
424 // ... rest unchanged ...
425 }
426 ```
427
428 Add the option constructor:
429
430 ```go
431 // WithMiddleware returns an Option that loads middleware from the given config path.
432 func WithMiddleware(path string) Option {
433 return func(p *Proxy) {
434 mw, err := middleware.New(path)
435 if err != nil {
436 log.Printf("WARNING: failed to load middleware from %q: %v", path, err)
437 return
438 }
439 p.middleware = mw
440 }
441 }
442 ```
443
444 In `handleConnectMITM`, after `upstreamResp.Body.Close()` and before the `req.Close` check, add the middleware interception. Replace the response relay section:
445
446 ```go
447 // Replace this:
448 // upstreamResp.Write(tlsConn)
449 // upstreamResp.Body.Close()
450 //
451 // With this:
452 if p.middleware != nil {
453 if rule := p.middleware.Match(host, req.URL.Path); rule != nil {
454 body, err := io.ReadAll(upstreamResp.Body)
455 upstreamResp.Body.Close()
456 if err != nil {
457 log.Printf("ERROR: middleware failed to read response body from %s: %v", host, err)
458 } else {
459 if err := rule.SaveResponse(body); err != nil {
460 log.Printf("ERROR: middleware failed to save response to %s: %v", rule.Dest, err)
461 } else {
462 log.Printf("MIDDLEWARE saved %s%s -> %s", host, req.URL.Path, rule.Dest)
463 }
464 upstreamResp.Body = io.NopCloser(bytes.NewReader(body))
465 }
466 upstreamResp.Write(tlsConn)
467 } else {
468 upstreamResp.Write(tlsConn)
469 upstreamResp.Body.Close()
470 }
471 } else {
472 upstreamResp.Write(tlsConn)
473 upstreamResp.Body.Close()
474 }
475 ```
476
477 - [ ] **Step 4: Run tests to verify they pass**
478
479 Run: `go test ./proxy/...`
480 Expected: PASS
481
482 - [ ] **Step 5: Run all tests**
483
484 Run: `go test ./...`
485 Expected: PASS
486
487 - [ ] **Step 6: Commit**
488
489 ```bash
490 git add proxy/ middleware/
491 git commit -m "feat: wire middleware into proxy MITM loop"
492 ```
493
494 ---
495
496 ### Task 4: Wire middleware into main and add default config
497
498 **Files:**
499 - Modify: `cmd/nono-proxy/main.go`
500
501 - [ ] **Step 1: Add middleware loading to main**
502
503 Add `middleware` to the import path. Load middleware config from `store/middleware.yaml` and pass it as an option:
504
505 ```go
506 mwPath := filepath.Join(store, "middleware.yaml")
507
508 opts := []proxy.Option{
509 proxy.WithRules(rulesPath),
510 proxy.WithCA(caCert, caKey),
511 proxy.WithMiddleware(mwPath),
512 }
513 ```
514
515 No default config file writing — the middleware YAML is opt-in. If the file doesn't exist, `middleware.New` returns an empty middleware (no-op).
516
517 - [ ] **Step 2: Build and verify**
518
519 Run: `make build`
520 Expected: builds successfully
521
522 - [ ] **Step 3: Run all tests**
523
524 Run: `make test`
525 Expected: PASS
526
527 - [ ] **Step 4: Commit**
528
529 ```bash
530 git add cmd/nono-proxy/main.go
531 git commit -m "feat: load middleware config in nono-proxy main"
532 ```
proxy/proxy.go
Old New
@@ -252,6 +252,7 @@ func (p *Proxy) handleConnectMITM(w http.ResponseWriter, r *http.Request) {
252 upstreamResp.Body.Close() 252 upstreamResp.Body.Close()
253 if err != nil { 253 if err != nil {
254 log.Printf("ERROR: middleware failed to read response body from %s: %v", host, err) 254 log.Printf("ERROR: middleware failed to read response body from %s: %v", host, err)
255 upstreamResp.Body = io.NopCloser(bytes.NewReader(nil))
255 } else { 256 } else {
256 if err := rule.SaveResponse(body); err != nil { 257 if err := rule.SaveResponse(body); err != nil {
257 log.Printf("ERROR: middleware failed to save response to %s: %v", rule.Dest, err) 258 log.Printf("ERROR: middleware failed to save response to %s: %v", rule.Dest, err)