srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/ipc/ipc.go65
-rw-r--r--internal/ipc/server.go41
-rw-r--r--internal/proxy/proxy.go34
-rw-r--r--internal/rules/rules.go97
-rw-r--r--internal/store/store.go109
5 files changed, 344 insertions, 2 deletions
diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go
index f890715..19c9c23 100644
--- a/internal/ipc/ipc.go
+++ b/internal/ipc/ipc.go
@@ -11,12 +11,13 @@ import (
"net"
"sync"
+ "mitmux/internal/rules"
"mitmux/internal/store"
)
// Request is sent by a client to the daemon.
type Request struct {
- Type string `json:"type"` // "list", "get", "subscribe", or "repeat"
+ Type string `json:"type"` // "list", "get", "subscribe", "repeat", "rules_list", "rules_save", "rules_delete", or "rules_toggle"
Limit int `json:"limit,omitempty"`
BeforeID int64 `json:"before_id,omitempty"`
ID int64 `json:"id,omitempty"`
@@ -30,14 +31,22 @@ type Request struct {
Scheme string `json:"scheme,omitempty"`
Host string `json:"host,omitempty"`
Raw []byte `json:"raw,omitempty"`
+
+ // For "rules_save": add (Rule.ID == 0) or update (Rule.ID != 0) a
+ // match-and-replace rule. For "rules_delete"/"rules_toggle": RuleID
+ // (and RuleEnabled for toggle) identify the target.
+ Rule *rules.Rule `json:"rule,omitempty"`
+ RuleID int64 `json:"rule_id,omitempty"`
+ RuleEnabled bool `json:"rule_enabled,omitempty"`
}
// Response is sent by the daemon to a client.
type Response struct {
- Type string `json:"type"` // "list", "get", "new", "repeat", or "error"
+ Type string `json:"type"` // "list", "get", "new", "repeat", "rules", or "error"
Entries []store.Summary `json:"entries,omitempty"` // for "list"
Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat"
New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push)
+ Rules []rules.Rule `json:"rules,omitempty"` // for "rules"
Error string `json:"error,omitempty"`
}
@@ -144,6 +153,58 @@ func (c *Client) Repeat(scheme, host string, raw []byte) (*EntryDetail, error) {
return resp.Detail, nil
}
+// ListRules returns every match-and-replace rule.
+func (c *Client) ListRules() ([]rules.Rule, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.rulesRoundTrip(Request{Type: "rules_list"})
+}
+
+// SaveRule adds r (if r.ID == 0) or updates the existing rule with that
+// ID, and returns its ID.
+func (c *Client) SaveRule(r rules.Rule) (int64, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ saved, err := c.rulesRoundTrip(Request{Type: "rules_save", Rule: &r})
+ if err != nil {
+ return 0, err
+ }
+ if len(saved) == 0 {
+ return 0, errors.New("rules_save: daemon returned no rule")
+ }
+ return saved[0].ID, nil
+}
+
+// DeleteRule removes a rule.
+func (c *Client) DeleteRule(id int64) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ _, err := c.rulesRoundTrip(Request{Type: "rules_delete", RuleID: id})
+ return err
+}
+
+// SetRuleEnabled toggles a rule without touching its other fields.
+func (c *Client) SetRuleEnabled(id int64, enabled bool) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ _, err := c.rulesRoundTrip(Request{Type: "rules_toggle", RuleID: id, RuleEnabled: enabled})
+ return err
+}
+
+func (c *Client) rulesRoundTrip(req Request) ([]rules.Rule, error) {
+ if err := c.enc.Encode(req); err != nil {
+ return nil, err
+ }
+ var resp Response
+ if err := c.dec.Decode(&resp); err != nil {
+ return nil, err
+ }
+ if resp.Type == "error" {
+ return nil, errors.New(resp.Error)
+ }
+ return resp.Rules, nil
+}
+
// Subscribe opens a dedicated connection that streams newly captured
// history entries as they happen. The returned channel is closed when
// the connection ends; call the returned close func to stop early.
diff --git a/internal/ipc/server.go b/internal/ipc/server.go
index 69044af..1fe8d8f 100644
--- a/internal/ipc/server.go
+++ b/internal/ipc/server.go
@@ -8,6 +8,7 @@ import (
"net"
"sync"
+ "mitmux/internal/rules"
"mitmux/internal/store"
)
@@ -127,6 +128,46 @@ func (s *Server) handleConn(conn net.Conn) {
}
enc.Encode(Response{Type: "repeat", Detail: detailFromEntry(e)})
+ case "rules_list":
+ rs, err := s.db.ListRules()
+ if err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ enc.Encode(Response{Type: "rules", Rules: rs})
+
+ case "rules_save":
+ if req.Rule == nil {
+ enc.Encode(Response{Type: "error", Error: "rules_save: missing rule"})
+ continue
+ }
+ r := *req.Rule
+ var err error
+ if r.ID == 0 {
+ r.ID, err = s.db.AddRule(r)
+ } else {
+ err = s.db.UpdateRule(r)
+ }
+ if err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ enc.Encode(Response{Type: "rules", Rules: []rules.Rule{r}})
+
+ case "rules_delete":
+ if err := s.db.DeleteRule(req.RuleID); err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ enc.Encode(Response{Type: "rules"})
+
+ case "rules_toggle":
+ if err := s.db.SetRuleEnabled(req.RuleID, req.RuleEnabled); err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ enc.Encode(Response{Type: "rules"})
+
case "subscribe":
sub := s.hub.subscribe()
defer s.hub.unsubscribe(sub)
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
index 014c601..3b2d50d 100644
--- a/internal/proxy/proxy.go
+++ b/internal/proxy/proxy.go
@@ -35,6 +35,7 @@ import (
"golang.org/x/net/http2"
"mitmux/internal/ca"
+ "mitmux/internal/rules"
"mitmux/internal/store"
)
@@ -244,6 +245,17 @@ func (s *Server) forward(dial dialer, scheme, hostname string, w http.ResponseWr
outReq.URL.Host = hostname
outReq.RequestURI = ""
stripHopByHop(outReq.Header)
+ // Header rules are applied to outReq only, after cloning and header
+ // stripping - history's request_raw keeps showing what the client
+ // actually sent (clientTee/reqBodyCap already capture from r, not
+ // outReq), while what actually reaches the upstream server reflects
+ // the rules. That split is deliberate: match-and-replace is a wire
+ // transform, not a rewrite of the audit trail.
+ if reqRules, err := s.enabledRules("request"); err != nil {
+ log.Printf("load request rules: %v", err)
+ } else if len(reqRules) > 0 {
+ outReq.Header = rules.ApplyHeaders(outReq.Header, reqRules)
+ }
// Only needed when the client leg isn't tee-captured (HTTP/2): tee
// the body as it streams through so the reconstructed capture isn't
@@ -297,6 +309,17 @@ func (s *Server) forward(dial dialer, scheme, hostname string, w http.ResponseWr
resp.Body = io.NopCloser(respBodyCap)
}
+ // Same split as the request side: response_raw keeps reflecting what
+ // the origin server actually sent (captured below, from upstreamTee
+ // or respBodyCap, both already wired to resp.Body independent of
+ // resp.Header), while the client actually receives the rule-modified
+ // headers.
+ if respRules, err := s.enabledRules("response"); err != nil {
+ log.Printf("load response rules: %v", err)
+ } else if len(respRules) > 0 {
+ resp.Header = rules.ApplyHeaders(resp.Header, respRules)
+ }
+
stripHopByHop(resp.Header)
for k, vv := range resp.Header {
for _, v := range vv {
@@ -317,6 +340,17 @@ func (s *Server) forward(dial dialer, scheme, hostname string, w http.ResponseWr
s.record(started, duration, scheme, hostname, r, reqRaw, reqExact, respRaw, respExact, resp.StatusCode, "")
}
+// enabledRules fetches the current enabled match-and-replace rules for
+// scope ("request" or "response") fresh from the store on every call -
+// simple and always current, and cheap enough (a local, in-process
+// SQLite query) not to bother caching for how this is actually used.
+func (s *Server) enabledRules(scope string) ([]rules.Rule, error) {
+ if s.store == nil {
+ return nil, nil
+ }
+ return s.store.EnabledRules(scope)
+}
+
// record stores one history entry and notifies OnEntry.
func (s *Server) record(started time.Time, duration time.Duration, scheme, host string, r *http.Request,
reqRaw []byte, reqExact bool, respRaw []byte, respExact bool, status int, errMsg string) {
diff --git a/internal/rules/rules.go b/internal/rules/rules.go
new file mode 100644
index 0000000..1f50ec4
--- /dev/null
+++ b/internal/rules/rules.go
@@ -0,0 +1,97 @@
+// Package rules implements match-and-replace: user-defined rules that
+// rewrite request/response headers as they pass through the proxy.
+// Deliberately headers-only for now - see ApplyHeaders for why bodies
+// are a separate, harder problem (noted as a follow-up in PLAN.md).
+package rules
+
+import (
+ "bufio"
+ "net/http"
+ "net/textproto"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+// Rule is one match-and-replace rule.
+type Rule struct {
+ ID int64
+ Enabled bool
+ Name string
+ Scope string // "request" or "response"
+ Part string // "header" (only part supported so far)
+ Match string
+ Replace string
+ IsRegex bool
+ // Position orders rule application (ascending) when several rules
+ // could touch the same text.
+ Position int
+}
+
+// ApplyHeaders rewrites h in place by serializing it to a raw
+// "Name: value\r\n" block, running every enabled rule with Part=="header"
+// over that text (in Position order), and reparsing the result. Working
+// on the raw text rather than per-value substitution is what lets a rule
+// add or remove a header entirely, not just rewrite an existing value -
+// matching how Burp's header match/replace works. If a rule's output
+// doesn't parse back as valid headers, ApplyHeaders returns h unchanged
+// rather than risk sending something corrupted.
+func ApplyHeaders(h http.Header, rs []Rule) http.Header {
+ keys := make([]string, 0, len(h))
+ for k := range h {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
+ var block strings.Builder
+ for _, k := range keys {
+ for _, v := range h[k] {
+ block.WriteString(k)
+ block.WriteString(": ")
+ block.WriteString(v)
+ block.WriteString("\r\n")
+ }
+ }
+ text := block.String()
+
+ changed := false
+ for _, r := range sortedByPosition(rs) {
+ if !r.Enabled || r.Part != "header" {
+ continue
+ }
+ if next, ok := apply(text, r); ok {
+ text, changed = next, true
+ }
+ }
+ if !changed {
+ return h
+ }
+
+ tp := textproto.NewReader(bufio.NewReader(strings.NewReader(text + "\r\n")))
+ mh, err := tp.ReadMIMEHeader()
+ if err != nil {
+ return h
+ }
+ return http.Header(mh)
+}
+
+func sortedByPosition(rs []Rule) []Rule {
+ out := make([]Rule, len(rs))
+ copy(out, rs)
+ sort.SliceStable(out, func(i, j int) bool { return out[i].Position < out[j].Position })
+ return out
+}
+
+func apply(text string, r Rule) (string, bool) {
+ if r.IsRegex {
+ re, err := regexp.Compile(r.Match)
+ if err != nil {
+ return text, false
+ }
+ return re.ReplaceAllString(text, r.Replace), true
+ }
+ if r.Match == "" {
+ return text, false
+ }
+ return strings.ReplaceAll(text, r.Match, r.Replace), true
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index a3c6b03..211162e 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -12,6 +12,8 @@ import (
"time"
_ "modernc.org/sqlite"
+
+ "mitmux/internal/rules"
)
const schema = `
@@ -36,6 +38,18 @@ CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
method, host, path, request_text, response_text,
tokenize = 'unicode61 remove_diacritics 2'
);
+
+CREATE TABLE IF NOT EXISTS rules (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ name TEXT NOT NULL DEFAULT '',
+ scope TEXT NOT NULL,
+ part TEXT NOT NULL,
+ match TEXT NOT NULL,
+ replace TEXT NOT NULL,
+ is_regex INTEGER NOT NULL DEFAULT 0,
+ position INTEGER NOT NULL DEFAULT 0
+);
`
// Store is a handle to the history database. Safe for concurrent use.
@@ -312,6 +326,101 @@ func prepareFTSQuery(q string) string {
return strings.Join(fields, " ")
}
+// ListRules returns every match-and-replace rule, ordered for application.
+func (s *Store) ListRules() ([]rules.Rule, error) {
+ rows, err := s.db.Query(
+ `SELECT id, enabled, name, scope, part, match, replace, is_regex, position
+ FROM rules ORDER BY position, id`,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("list rules: %w", err)
+ }
+ defer rows.Close()
+
+ var out []rules.Rule
+ for rows.Next() {
+ var r rules.Rule
+ var enabled, isRegex int
+ if err := rows.Scan(&r.ID, &enabled, &r.Name, &r.Scope, &r.Part, &r.Match, &r.Replace, &isRegex, &r.Position); err != nil {
+ return nil, fmt.Errorf("scan rule row: %w", err)
+ }
+ r.Enabled = enabled != 0
+ r.IsRegex = isRegex != 0
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+// EnabledRules returns enabled rules for scope ("request" or
+// "response"), ordered for application.
+func (s *Store) EnabledRules(scope string) ([]rules.Rule, error) {
+ rows, err := s.db.Query(
+ `SELECT id, enabled, name, scope, part, match, replace, is_regex, position
+ FROM rules WHERE enabled = 1 AND scope = ? ORDER BY position, id`,
+ scope,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("enabled rules: %w", err)
+ }
+ defer rows.Close()
+
+ var out []rules.Rule
+ for rows.Next() {
+ var r rules.Rule
+ var enabled, isRegex int
+ if err := rows.Scan(&r.ID, &enabled, &r.Name, &r.Scope, &r.Part, &r.Match, &r.Replace, &isRegex, &r.Position); err != nil {
+ return nil, fmt.Errorf("scan rule row: %w", err)
+ }
+ r.Enabled = enabled != 0
+ r.IsRegex = isRegex != 0
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+// AddRule stores r and returns its assigned ID.
+func (s *Store) AddRule(r rules.Rule) (int64, error) {
+ res, err := s.db.Exec(
+ `INSERT INTO rules (enabled, name, scope, part, match, replace, is_regex, position)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ boolToInt(r.Enabled), r.Name, r.Scope, r.Part, r.Match, r.Replace, boolToInt(r.IsRegex), r.Position,
+ )
+ if err != nil {
+ return 0, fmt.Errorf("add rule: %w", err)
+ }
+ return res.LastInsertId()
+}
+
+// UpdateRule replaces the stored rule with the same ID as r.
+func (s *Store) UpdateRule(r rules.Rule) error {
+ _, err := s.db.Exec(
+ `UPDATE rules SET enabled = ?, name = ?, scope = ?, part = ?, match = ?, replace = ?, is_regex = ?, position = ?
+ WHERE id = ?`,
+ boolToInt(r.Enabled), r.Name, r.Scope, r.Part, r.Match, r.Replace, boolToInt(r.IsRegex), r.Position, r.ID,
+ )
+ if err != nil {
+ return fmt.Errorf("update rule %d: %w", r.ID, err)
+ }
+ return nil
+}
+
+// SetRuleEnabled toggles a rule without touching its other fields.
+func (s *Store) SetRuleEnabled(id int64, enabled bool) error {
+ _, err := s.db.Exec(`UPDATE rules SET enabled = ? WHERE id = ?`, boolToInt(enabled), id)
+ if err != nil {
+ return fmt.Errorf("set rule %d enabled: %w", id, err)
+ }
+ return nil
+}
+
+// DeleteRule removes a rule.
+func (s *Store) DeleteRule(id int64) error {
+ if _, err := s.db.Exec(`DELETE FROM rules WHERE id = ?`, id); err != nil {
+ return fmt.Errorf("delete rule %d: %w", id, err)
+ }
+ return nil
+}
+
func boolToInt(b bool) int {
if b {
return 1