15c7f07f
feat: load middleware config in nono-proxy main
a73x 2026-03-31 05:53
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>
diff --git a/cmd/nono-proxy/main.go b/cmd/nono-proxy/main.go index 9b3c61e..4605617 100644 --- a/cmd/nono-proxy/main.go +++ b/cmd/nono-proxy/main.go @@ -44,9 +44,12 @@ func main() { } log.Printf("CA cert: %s/ca.pem", store) mwPath := filepath.Join(store, "middleware.yaml") opts := []proxy.Option{ proxy.WithRules(rulesPath), proxy.WithCA(caCert, caKey), proxy.WithMiddleware(mwPath), } p := proxy.New(hostsFile, opts...) diff --git a/docs/superpowers/plans/2026-03-31-middleware.md b/docs/superpowers/plans/2026-03-31-middleware.md new file mode 100644 index 0000000..8068220 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-middleware.md @@ -0,0 +1,532 @@ # Middleware Implementation Plan > **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. **Goal:** Add a configurable middleware system that intercepts HTTPS responses matching URL patterns and writes response bodies to files. **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). **Tech Stack:** Go stdlib, `gopkg.in/yaml.v3` (already a dependency) --- ### Task 1: Middleware package — config loading **Files:** - Create: `middleware/middleware.go` - Create: `middleware/middleware_test.go` - [ ] **Step 1: Write the failing test for YAML loading** In `middleware/middleware_test.go`: ```go package middleware_test import ( "os" "path/filepath" "testing" "github.com/xanderle/nono/middleware" ) func TestNewLoadsConfig(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "middleware.yaml") os.WriteFile(path, []byte(`middleware: - match: "api.anthropic.com/api/oauth/usage" action: save_response dest: "/tmp/usage.json" `), 0644) mw, err := middleware.New(path) if err != nil { t.Fatalf("unexpected error: %v", err) } if mw.RuleCount() != 1 { t.Errorf("expected 1 rule, got %d", mw.RuleCount()) } } func TestNewReturnsEmptyForMissingFile(t *testing.T) { mw, err := middleware.New("/nonexistent/middleware.yaml") if err != nil { t.Fatalf("unexpected error: %v", err) } if mw.RuleCount() != 0 { t.Errorf("expected 0 rules, got %d", mw.RuleCount()) } } func TestNewRejectsUnknownAction(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "middleware.yaml") os.WriteFile(path, []byte(`middleware: - match: "example.com/foo" action: delete_everything dest: "/tmp/out" `), 0644) _, err := middleware.New(path) if err == nil { t.Fatal("expected error for unknown action") } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `go test ./middleware/...` Expected: package does not exist - [ ] **Step 3: Implement middleware package** In `middleware/middleware.go`: ```go package middleware import ( "fmt" "os" "gopkg.in/yaml.v3" ) type Rule struct { Match string Action string Dest string } type Middleware struct { rules []Rule } type yamlRule struct { Match string `yaml:"match"` Action string `yaml:"action"` Dest string `yaml:"dest"` } type yamlConfig struct { Middleware []yamlRule `yaml:"middleware"` } // New loads middleware config from a YAML file. // Returns an empty Middleware if the file does not exist. func New(path string) (*Middleware, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return &Middleware{}, nil } return nil, fmt.Errorf("reading middleware config: %w", err) } var cfg yamlConfig if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parsing middleware YAML: %w", err) } rules := make([]Rule, 0, len(cfg.Middleware)) for _, yr := range cfg.Middleware { if yr.Action != "save_response" { return nil, fmt.Errorf("unknown middleware action %q for match %q", yr.Action, yr.Match) } rules = append(rules, Rule{Match: yr.Match, Action: yr.Action, Dest: yr.Dest}) } return &Middleware{rules: rules}, nil } func (m *Middleware) RuleCount() int { return len(m.rules) } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `go test ./middleware/...` Expected: PASS - [ ] **Step 5: Commit** ```bash git add middleware/ git commit -m "feat: add middleware package with YAML config loading" ``` --- ### Task 2: Middleware — match and save logic **Files:** - Modify: `middleware/middleware.go` - Modify: `middleware/middleware_test.go` - [ ] **Step 1: Write failing test for Match** Append to `middleware/middleware_test.go`: ```go func TestMatchReturnsRuleForMatchingURL(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "middleware.yaml") os.WriteFile(path, []byte(`middleware: - match: "api.anthropic.com/api/oauth/usage" action: save_response dest: "/tmp/usage.json" `), 0644) mw, _ := middleware.New(path) rule := mw.Match("api.anthropic.com", "/api/oauth/usage") if rule == nil { t.Fatal("expected a match") } if rule.Dest != "/tmp/usage.json" { t.Errorf("expected dest /tmp/usage.json, got %s", rule.Dest) } } func TestMatchReturnsNilForNoMatch(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "middleware.yaml") os.WriteFile(path, []byte(`middleware: - match: "api.anthropic.com/api/oauth/usage" action: save_response dest: "/tmp/usage.json" `), 0644) mw, _ := middleware.New(path) rule := mw.Match("example.com", "/other") if rule != nil { t.Fatal("expected no match") } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `go test ./middleware/...` Expected: `mw.Match` undefined - [ ] **Step 3: Implement Match** Add to `middleware/middleware.go`: ```go // Match checks if host+path matches any middleware rule. // Returns the first matching rule, or nil. func (m *Middleware) Match(host, path string) *Rule { url := host + path for i := range m.rules { if m.rules[i].Match == url { return &m.rules[i] } } return nil } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `go test ./middleware/...` Expected: PASS - [ ] **Step 5: Write failing test for SaveResponse** Append to `middleware/middleware_test.go`: ```go func TestSaveResponseWritesBodyToFile(t *testing.T) { dest := filepath.Join(t.TempDir(), "out.json") rule := &middleware.Rule{ Match: "example.com/data", Action: "save_response", Dest: dest, } body := []byte(`{"tokens": 42}`) err := rule.SaveResponse(body) if err != nil { t.Fatalf("unexpected error: %v", err) } got, err := os.ReadFile(dest) if err != nil { t.Fatalf("failed to read dest file: %v", err) } if string(got) != string(body) { t.Errorf("expected %q, got %q", body, got) } } func TestSaveResponseOverwritesExistingFile(t *testing.T) { dest := filepath.Join(t.TempDir(), "out.json") os.WriteFile(dest, []byte("old data"), 0644) rule := &middleware.Rule{ Match: "example.com/data", Action: "save_response", Dest: dest, } body := []byte(`{"new": true}`) rule.SaveResponse(body) got, _ := os.ReadFile(dest) if string(got) != string(body) { t.Errorf("expected %q, got %q", body, got) } } ``` - [ ] **Step 6: Run tests to verify they fail** Run: `go test ./middleware/...` Expected: `rule.SaveResponse` undefined - [ ] **Step 7: Implement SaveResponse** Add to `middleware/middleware.go`: ```go // SaveResponse writes the response body to the rule's destination file, // overwriting any existing content. func (r *Rule) SaveResponse(body []byte) error { return os.WriteFile(r.Dest, body, 0644) } ``` - [ ] **Step 8: Run tests to verify they pass** Run: `go test ./middleware/...` Expected: PASS - [ ] **Step 9: Commit** ```bash git add middleware/ git commit -m "feat: add Match and SaveResponse to middleware" ``` --- ### Task 3: Wire middleware into proxy **Files:** - Modify: `proxy/proxy.go` - Modify: `proxy/proxy_test.go` - [ ] **Step 1: Write failing test for middleware in MITM path** Append to `proxy/proxy_test.go`: ```go func TestMiddlewareSavesResponseBody(t *testing.T) { responseBody := `{"usage": 100}` backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(responseBody)) })) defer backend.Close() backendURL, _ := url.Parse(backend.URL) host := backendURL.Hostname() hostsFile := filepath.Join(t.TempDir(), "approved_hosts") os.WriteFile(hostsFile, []byte(host+"\n"), 0644) destFile := filepath.Join(t.TempDir(), "usage.json") mwPath := filepath.Join(t.TempDir(), "middleware.yaml") os.WriteFile(mwPath, []byte(fmt.Sprintf(`middleware: - match: "%s/data" action: save_response dest: "%s" `, host, destFile)), 0644) caDir := t.TempDir() caCert, caKey, err := nca.LoadOrCreate(caDir) if err != nil { t.Fatalf("CA setup: %v", err) } p := proxy.New(hostsFile, proxy.WithCA(caCert, caKey), proxy.WithUpstreamTLS(&tls.Config{InsecureSkipVerify: true}), proxy.WithMiddleware(mwPath), ) srv := httptest.NewServer(p) defer srv.Close() caPool := x509.NewCertPool() caPool.AddCert(caCert) proxyURL, _ := url.Parse(srv.URL) client := &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyURL(proxyURL), TLSClientConfig: &tls.Config{ RootCAs: caPool, }, }, } resp, err := client.Get(backend.URL + "/data") if err != nil { t.Fatalf("unexpected error: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) } got, err := os.ReadFile(destFile) if err != nil { t.Fatalf("dest file not written: %v", err) } if string(got) != responseBody { t.Errorf("expected %q, got %q", responseBody, got) } } ``` Note: add `"fmt"` to the imports block in `proxy_test.go`. - [ ] **Step 2: Run tests to verify they fail** Run: `go test ./proxy/...` Expected: `proxy.WithMiddleware` undefined - [ ] **Step 3: Add WithMiddleware option and wire into MITM loop** In `proxy/proxy.go`, add the `middleware` import: ```go import ( // ... existing imports ... "github.com/xanderle/nono/middleware" ) ``` Add the field to the `Proxy` struct: ```go type Proxy struct { hostsFile string scanner *scanner.Scanner middleware *middleware.Middleware caCert *x509.Certificate // ... rest unchanged ... } ``` Add the option constructor: ```go // WithMiddleware returns an Option that loads middleware from the given config path. func WithMiddleware(path string) Option { return func(p *Proxy) { mw, err := middleware.New(path) if err != nil { log.Printf("WARNING: failed to load middleware from %q: %v", path, err) return } p.middleware = mw } } ``` In `handleConnectMITM`, after `upstreamResp.Body.Close()` and before the `req.Close` check, add the middleware interception. Replace the response relay section: ```go // Replace this: // upstreamResp.Write(tlsConn) // upstreamResp.Body.Close() // // With this: if p.middleware != nil { if rule := p.middleware.Match(host, req.URL.Path); rule != nil { body, err := io.ReadAll(upstreamResp.Body) upstreamResp.Body.Close() if err != nil { log.Printf("ERROR: middleware failed to read response body from %s: %v", host, err) } else { if err := rule.SaveResponse(body); err != nil { log.Printf("ERROR: middleware failed to save response to %s: %v", rule.Dest, err) } else { log.Printf("MIDDLEWARE saved %s%s -> %s", host, req.URL.Path, rule.Dest) } upstreamResp.Body = io.NopCloser(bytes.NewReader(body)) } upstreamResp.Write(tlsConn) } else { upstreamResp.Write(tlsConn) upstreamResp.Body.Close() } } else { upstreamResp.Write(tlsConn) upstreamResp.Body.Close() } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `go test ./proxy/...` Expected: PASS - [ ] **Step 5: Run all tests** Run: `go test ./...` Expected: PASS - [ ] **Step 6: Commit** ```bash git add proxy/ middleware/ git commit -m "feat: wire middleware into proxy MITM loop" ``` --- ### Task 4: Wire middleware into main and add default config **Files:** - Modify: `cmd/nono-proxy/main.go` - [ ] **Step 1: Add middleware loading to main** Add `middleware` to the import path. Load middleware config from `store/middleware.yaml` and pass it as an option: ```go mwPath := filepath.Join(store, "middleware.yaml") opts := []proxy.Option{ proxy.WithRules(rulesPath), proxy.WithCA(caCert, caKey), proxy.WithMiddleware(mwPath), } ``` 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). - [ ] **Step 2: Build and verify** Run: `make build` Expected: builds successfully - [ ] **Step 3: Run all tests** Run: `make test` Expected: PASS - [ ] **Step 4: Commit** ```bash git add cmd/nono-proxy/main.go git commit -m "feat: load middleware config in nono-proxy main" ``` diff --git a/proxy/proxy.go b/proxy/proxy.go index f336224..1a7c1ce 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -252,6 +252,7 @@ func (p *Proxy) handleConnectMITM(w http.ResponseWriter, r *http.Request) { upstreamResp.Body.Close() if err != nil { log.Printf("ERROR: middleware failed to read response body from %s: %v", host, err) upstreamResp.Body = io.NopCloser(bytes.NewReader(nil)) } else { if err := rule.SaveResponse(body); err != nil { log.Printf("ERROR: middleware failed to save response to %s: %v", rule.Dest, err)