// Package ipc is the protocol between mitmuxd (which owns the proxy and // the history database) and a client such as the TUI, spoken as // newline-agnostic JSON messages over a Unix domain socket. This keeps // the proxy engine running independently of any UI attached to it. package ipc import ( "encoding/json" "errors" "fmt" "net" "sync" "time" "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", "repeat", "intrude", "rules_list", "rules_save", "rules_delete", "rules_toggle", "delete_entry", or "clear_history" Limit int `json:"limit,omitempty"` BeforeID int64 `json:"before_id,omitempty"` ID int64 `json:"id,omitempty"` // For "list": a non-empty Query switches from most-recent-first to // an FTS5 search (see store.Store.Search for syntax), ranked by // relevance. Query string `json:"query,omitempty"` // For "repeat": send Raw to scheme://host exactly as given. // For "intrude": Raw is the §marked§ template - see proxy.Intrude. Scheme string `json:"scheme,omitempty"` Host string `json:"host,omitempty"` Raw []byte `json:"raw,omitempty"` // For "intrude": the payload set, applied to each marked position in // turn (Sniper-style - see proxy.Intrude). Payloads []string `json:"payloads,omitempty"` // For "intrude": optional Go regexps evaluated against each result's // response bytes. GrepMatch flags whether it matched at all; // GrepExtract additionally captures text (first submatch if the // pattern has a capturing group, else the whole match) into the // result. Either or both may be empty to skip that check. Compiled // and validated once, server-side, before the attack starts - a bad // pattern fails the same way a bad marker or empty payload set does. GrepMatch string `json:"grep_match,omitempty"` GrepExtract string `json:"grep_extract,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"` // For "set_flagged" and "delete_entry": ID identifies the history // entry. "clear_history" needs no fields at all. Flagged bool `json:"flagged,omitempty"` } // Response is sent by the daemon to a client. type Response struct { Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "intrude_result", "intrude_done", "status", "flagged", "deleted", "cleared", 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" Status *StatusMsg `json:"status,omitempty"` // for "status" // For "intrude_result": one completed attack request. IntrudeResult *IntrudeResultMsg `json:"intrude_result,omitempty"` Error string `json:"error,omitempty"` } // StatusMsg is basic daemon info for a TUI status bar. type StatusMsg struct { ProxyAddr string `json:"proxy_addr"` HistoryCount int64 `json:"history_count"` } // IntrudeResultMsg is one completed Intruder attack request. type IntrudeResultMsg struct { Position int `json:"position"` Payload string `json:"payload"` EntryID int64 `json:"entry_id"` StatusCode int `json:"status_code"` RespSize int `json:"resp_size"` Duration time.Duration `json:"duration"` Error string `json:"error,omitempty"` GrepMatch bool `json:"grep_match,omitempty"` GrepExtract string `json:"grep_extract,omitempty"` } // EntryDetail is a full history entry, raw bytes included. type EntryDetail struct { store.Summary RequestRaw []byte `json:"request_raw"` ResponseRaw []byte `json:"response_raw"` RequestExact bool `json:"request_exact"` ResponseExact bool `json:"response_exact"` // *Truncated is true when the matching *Exact is false specifically // because capture hit its size cap and dropped bytes off the end - // as opposed to false because there was never a wire-exact // representation to begin with (HTTP/2). Only meaningful alongside // a false *Exact. RequestTruncated bool `json:"request_truncated,omitempty"` ResponseTruncated bool `json:"response_truncated,omitempty"` } // Client talks to a mitmuxd instance for request/response queries // (list, get). Use Subscribe separately for the live-update stream. // // One request/response round trip is in flight on the connection at a // time, guarded by mu - a caller like the mitmux TUI dispatches each // request as its own goroutine (a Bubble Tea tea.Cmd), and without this // two overlapping calls (e.g. opening two entries in quick succession) // would interleave their JSON on the wire or hand one call the other's // response. type Client struct { mu sync.Mutex conn net.Conn dec *json.Decoder enc *json.Encoder } // Dial connects to the daemon's control socket at path. func Dial(path string) (*Client, error) { conn, err := net.Dial("unix", path) if err != nil { return nil, fmt.Errorf("dial %s: %w", path, err) } return &Client{conn: conn, dec: json.NewDecoder(conn), enc: json.NewEncoder(conn)}, nil } // Close closes the connection to the daemon. func (c *Client) Close() error { return c.conn.Close() } // SetFlagged sets the flagged marker on a history entry - a simple // "mark this, revisit later" bit, filterable via flagged:true/false in // Search. func (c *Client) SetFlagged(id int64, flagged bool) error { c.mu.Lock() defer c.mu.Unlock() if err := c.enc.Encode(Request{Type: "set_flagged", ID: id, Flagged: flagged}); err != nil { return err } var resp Response if err := c.dec.Decode(&resp); err != nil { return err } if resp.Type == "error" { return errors.New(resp.Error) } return nil } // DeleteEntry removes a single history entry. func (c *Client) DeleteEntry(id int64) error { c.mu.Lock() defer c.mu.Unlock() if err := c.enc.Encode(Request{Type: "delete_entry", ID: id}); err != nil { return err } var resp Response if err := c.dec.Decode(&resp); err != nil { return err } if resp.Type == "error" { return errors.New(resp.Error) } return nil } // ClearHistory removes every history entry. Rules are untouched. func (c *Client) ClearHistory() error { c.mu.Lock() defer c.mu.Unlock() if err := c.enc.Encode(Request{Type: "clear_history"}); err != nil { return err } var resp Response if err := c.dec.Decode(&resp); err != nil { return err } if resp.Type == "error" { return errors.New(resp.Error) } return nil } // Status returns basic daemon info for a status bar. func (c *Client) Status() (*StatusMsg, error) { c.mu.Lock() defer c.mu.Unlock() if err := c.enc.Encode(Request{Type: "status"}); 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.Status, nil } // List returns up to limit history summaries older than beforeID (0 for // the most recent), newest first. func (c *Client) List(limit int, beforeID int64) ([]store.Summary, error) { return c.list(Request{Type: "list", Limit: limit, BeforeID: beforeID}) } // Search returns up to limit history summaries matching an FTS5 query // (see store.Store.Search for syntax), ranked by relevance. func (c *Client) Search(query string, limit int, beforeID int64) ([]store.Summary, error) { return c.list(Request{Type: "list", Query: query, Limit: limit, BeforeID: beforeID}) } func (c *Client) list(req Request) ([]store.Summary, error) { c.mu.Lock() defer c.mu.Unlock() 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.Entries, nil } // Get returns the full entry (raw bytes included) for id. func (c *Client) Get(id int64) (*EntryDetail, error) { c.mu.Lock() defer c.mu.Unlock() if err := c.enc.Encode(Request{Type: "get", ID: id}); 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.Detail, nil } // Repeat sends raw to scheme://host exactly as given (no re-serialization, // no header injection) and returns the resulting entry, including the raw // response bytes. The exchange is also recorded to history. func (c *Client) Repeat(scheme, host string, raw []byte) (*EntryDetail, error) { c.mu.Lock() defer c.mu.Unlock() if err := c.enc.Encode(Request{Type: "repeat", Scheme: scheme, Host: host, Raw: raw}); 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.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. func Subscribe(path string) (<-chan store.Summary, func() error, error) { conn, err := net.Dial("unix", path) if err != nil { return nil, nil, fmt.Errorf("dial %s: %w", path, err) } if err := json.NewEncoder(conn).Encode(Request{Type: "subscribe"}); err != nil { conn.Close() return nil, nil, err } ch := make(chan store.Summary, 64) go func() { defer close(ch) dec := json.NewDecoder(conn) for { var resp Response if err := dec.Decode(&resp); err != nil { return } if resp.Type == "new" && resp.New != nil { select { case ch <- *resp.New: default: } } } }() return ch, conn.Close, nil } // Intrude starts a Sniper attack (see proxy.Intrude): template must // contain at least one §marked§ position, fuzzed in turn through // payloads. grepMatch/grepExtract are optional Go regexps evaluated // server-side against each result's response bytes (empty string // disables either check) - see IntrudeResultMsg. Unlike Subscribe's live // feed, no result is ever dropped for a slow consumer - each one is the // attack's actual data, not a notification with the real thing // recoverable elsewhere. A setup error (bad markers, empty payload set, // too many requests, an unparseable grep regexp) is returned directly // rather than through the channel. The returned channel closes when the // attack finishes or the connection is closed early. func Intrude(path, scheme, host string, template []byte, payloads []string, grepMatch, grepExtract string) (<-chan IntrudeResultMsg, func() error, error) { conn, err := net.Dial("unix", path) if err != nil { return nil, nil, fmt.Errorf("dial %s: %w", path, err) } req := Request{Type: "intrude", Scheme: scheme, Host: host, Raw: template, Payloads: payloads, GrepMatch: grepMatch, GrepExtract: grepExtract} if err := json.NewEncoder(conn).Encode(req); err != nil { conn.Close() return nil, nil, err } dec := json.NewDecoder(conn) var first Response if err := dec.Decode(&first); err != nil { conn.Close() return nil, nil, err } if first.Type == "error" { conn.Close() return nil, nil, errors.New(first.Error) } ch := make(chan IntrudeResultMsg) go func() { defer close(ch) deliver := func(resp Response) bool { switch resp.Type { case "intrude_result": if resp.IntrudeResult != nil { ch <- *resp.IntrudeResult } return true default: // "intrude_done", or anything else - stop return false } } if !deliver(first) { return } for { var resp Response if err := dec.Decode(&resp); err != nil { return } if !deliver(resp) { return } } }() return ch, conn.Close, nil }