// 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", or "rules_toggle" 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 "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", "rules", "intrude_result", "intrude_done", 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" // For "intrude_result": one completed attack request. IntrudeResult *IntrudeResultMsg `json:"intrude_result,omitempty"` Error string `json:"error,omitempty"` } // 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"` } // 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"` } // 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() } // 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. 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) 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) (<-chan IntrudeResultMsg, 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: "intrude", Scheme: scheme, Host: host, Raw: template, Payloads: payloads}); 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 }