diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/mitmux/main.go | 327 |
1 files changed, 325 insertions, 2 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index f47461c..68d42ed 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -20,6 +20,7 @@ import ( "mitmux/internal/ca" "mitmux/internal/ipc" + "mitmux/internal/rules" "mitmux/internal/store" ) @@ -71,6 +72,7 @@ const ( viewList viewMode = iota viewDetail viewRepeater + viewRules ) type detailTab int @@ -87,6 +89,16 @@ const ( focusResponse ) +type ruleField int + +const ( + fieldName ruleField = iota + fieldMatch + fieldReplace + fieldScope + fieldRegex +) + type model struct { client *ipc.Client subCh <-chan store.Summary @@ -112,6 +124,19 @@ type model struct { repeaterResult *ipc.EntryDetail sending bool + rulesTable table.Model + ruleRows []rules.Rule + + ruleForm bool // true while the add/edit form is active (vs the rule list) + ruleEditingID int64 + ruleEnabled bool + ruleName textinput.Model + ruleMatch textinput.Model + ruleReplace textinput.Model + ruleScope string // "request" or "response" + ruleRegex bool + ruleField ruleField + statusMsg string width int height int @@ -145,6 +170,24 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary) *model { si.Prompt = "/" si.Placeholder = "search - plain text, or host:example.com / AND / OR / NOT" + rulesCols := []table.Column{ + {Title: "On", Width: 3}, + {Title: "Name", Width: 16}, + {Title: "Scope", Width: 9}, + {Title: "Match", Width: 24}, + {Title: "Replace", Width: 24}, + {Title: "Regex", Width: 5}, + } + rt := table.New(table.WithColumns(rulesCols), table.WithFocused(true)) + rt.SetStyles(st) + + nameIn := textinput.New() + nameIn.Placeholder = "rule name" + matchIn := textinput.New() + matchIn.Placeholder = "match text or regex" + replaceIn := textinput.New() + replaceIn.Placeholder = "replacement" + return &model{ client: client, subCh: subCh, @@ -152,6 +195,11 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary) *model { table: t, reqArea: ta, searchInput: si, + rulesTable: rt, + ruleName: nameIn, + ruleMatch: matchIn, + ruleReplace: replaceIn, + ruleScope: "request", } } @@ -227,6 +275,106 @@ func (m *model) enterRepeater(d *ipc.EntryDetail) { m.statusMsg = "" } +type rulesLoadedMsg struct { + rules []rules.Rule + err error +} + +type ruleWriteDoneMsg struct { + action string // "saved", "deleted", "toggled" - for the status line + err error +} + +func (m *model) loadRules() tea.Msg { + rs, err := m.client.ListRules() + return rulesLoadedMsg{rules: rs, err: err} +} + +func (m *model) saveRule(r rules.Rule) tea.Cmd { + return func() tea.Msg { + _, err := m.client.SaveRule(r) + return ruleWriteDoneMsg{action: "saved", err: err} + } +} + +func (m *model) deleteSelectedRule() tea.Cmd { + row := m.rulesTable.Cursor() + if row < 0 || row >= len(m.ruleRows) { + return nil + } + id := m.ruleRows[row].ID + return func() tea.Msg { + err := m.client.DeleteRule(id) + return ruleWriteDoneMsg{action: "deleted", err: err} + } +} + +func (m *model) toggleSelectedRule() tea.Cmd { + row := m.rulesTable.Cursor() + if row < 0 || row >= len(m.ruleRows) { + return nil + } + r := m.ruleRows[row] + return func() tea.Msg { + err := m.client.SetRuleEnabled(r.ID, !r.Enabled) + return ruleWriteDoneMsg{action: "toggled", err: err} + } +} + +// enterRuleForm opens the add/edit form. r is nil to add a new rule. +func (m *model) enterRuleForm(r *rules.Rule) { + m.ruleForm = true + m.ruleField = fieldName + if r == nil { + m.ruleEditingID = 0 + m.ruleEnabled = true + m.ruleName.SetValue("") + m.ruleMatch.SetValue("") + m.ruleReplace.SetValue("") + m.ruleScope = "request" + m.ruleRegex = false + } else { + m.ruleEditingID = r.ID + m.ruleEnabled = r.Enabled + m.ruleName.SetValue(r.Name) + m.ruleMatch.SetValue(r.Match) + m.ruleReplace.SetValue(r.Replace) + m.ruleScope = r.Scope + m.ruleRegex = r.IsRegex + } + m.ruleName.Focus() + m.ruleMatch.Blur() + m.ruleReplace.Blur() +} + +func (m *model) ruleFromForm() rules.Rule { + return rules.Rule{ + ID: m.ruleEditingID, + Enabled: m.ruleEnabled, + Name: m.ruleName.Value(), + Scope: m.ruleScope, + Part: "header", + Match: m.ruleMatch.Value(), + Replace: m.ruleReplace.Value(), + IsRegex: m.ruleRegex, + } +} + +// focusRuleField moves input focus to m.ruleField, blurring the others. +func (m *model) focusRuleField() { + m.ruleName.Blur() + m.ruleMatch.Blur() + m.ruleReplace.Blur() + switch m.ruleField { + case fieldName: + m.ruleName.Focus() + case fieldMatch: + m.ruleMatch.Focus() + case fieldReplace: + m.ruleReplace.Focus() + } +} + func (m *model) Init() tea.Cmd { return tea.Batch(m.loadList, m.waitForEntry) } @@ -245,6 +393,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.reqArea.SetWidth(msg.Width) m.reqArea.SetHeight(reqHeight) m.respView = viewport.New(msg.Width, msg.Height-6-reqHeight) + + m.rulesTable.SetWidth(msg.Width) + m.rulesTable.SetHeight(msg.Height - 5) + formWidth := msg.Width - 12 + m.ruleName.Width = formWidth + m.ruleMatch.Width = formWidth + m.ruleReplace.Width = formWidth return m, nil case listLoadedMsg: @@ -300,6 +455,24 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.respView.GotoTop() return m, nil + case rulesLoadedMsg: + if msg.err != nil { + m.statusMsg = "rules error: " + msg.err.Error() + return m, nil + } + m.ruleRows = msg.rules + m.rulesTable.SetRows(rulesRowsFor(m.ruleRows)) + return m, nil + + case ruleWriteDoneMsg: + if msg.err != nil { + m.statusMsg = msg.action + " error: " + msg.err.Error() + return m, nil + } + m.ruleForm = false + m.statusMsg = "rule " + msg.action + return m, m.loadRules + case tea.KeyMsg: switch m.mode { case viewList: @@ -341,6 +514,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.searchInput.SetValue(m.query) m.searchInput.CursorEnd() return m, m.searchInput.Focus() + case "m": + m.mode = viewRules + m.statusMsg = "" + return m, m.loadRules case "esc": if m.query != "" { m.query = "" @@ -413,6 +590,78 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.respView, cmd = m.respView.Update(msg) } return m, cmd + + case viewRules: + if m.ruleForm { + switch msg.String() { + case "esc": + m.ruleForm = false + return m, nil + case "ctrl+c": + return m, tea.Quit + case "ctrl+s": + return m, m.saveRule(m.ruleFromForm()) + case "tab": + m.ruleField = (m.ruleField + 1) % 5 + m.focusRuleField() + return m, nil + case "shift+tab": + m.ruleField = (m.ruleField + 4) % 5 + m.focusRuleField() + return m, nil + } + if m.ruleField == fieldScope || m.ruleField == fieldRegex { + switch msg.String() { + case "left", "right", "enter", " ": + if m.ruleField == fieldScope { + if m.ruleScope == "request" { + m.ruleScope = "response" + } else { + m.ruleScope = "request" + } + } else { + m.ruleRegex = !m.ruleRegex + } + return m, nil + } + } + var cmd tea.Cmd + switch m.ruleField { + case fieldName: + m.ruleName, cmd = m.ruleName.Update(msg) + case fieldMatch: + m.ruleMatch, cmd = m.ruleMatch.Update(msg) + case fieldReplace: + m.ruleReplace, cmd = m.ruleReplace.Update(msg) + } + return m, cmd + } + + switch msg.String() { + case "q", "esc": + m.mode = viewList + return m, nil + case "ctrl+c": + return m, tea.Quit + case "a": + m.enterRuleForm(nil) + return m, nil + case "enter", "e": + if row := m.rulesTable.Cursor(); row >= 0 && row < len(m.ruleRows) { + sel := m.ruleRows[row] + m.enterRuleForm(&sel) + } + return m, nil + case "d": + m.statusMsg = "" + return m, m.deleteSelectedRule() + case " ": + m.statusMsg = "" + return m, m.toggleSelectedRule() + } + var cmd tea.Cmd + m.rulesTable, cmd = m.rulesTable.Update(msg) + return m, cmd } } return m, nil @@ -427,6 +676,11 @@ func (m *model) View() string { return m.detailView() case viewRepeater: return m.repeaterView() + case viewRules: + if m.ruleForm { + return m.ruleFormView() + } + return m.rulesView() default: return m.listView() } @@ -458,9 +712,9 @@ func (m *model) listView() string { b.WriteString(statusStyle.Render(m.statusMsg)) b.WriteString("\n") } - help := "↑/↓ navigate · enter view · r repeater · / search · q quit" + help := "↑/↓ navigate · enter view · r repeater · / search · m rules · q quit" if m.query != "" { - help = "↑/↓ navigate · enter view · r repeater · / search · esc clear filter · q quit" + help = "↑/↓ navigate · enter view · r repeater · / search · m rules · esc clear filter · q quit" } b.WriteString(helpStyle.Render(help)) return b.String() @@ -523,6 +777,75 @@ func (m *model) repeaterView() string { return b.String() } +func (m *model) rulesView() string { + var b strings.Builder + b.WriteString(titleStyle.Render(fmt.Sprintf(" match/replace rules (%d) - headers only for now ", len(m.ruleRows)))) + b.WriteString("\n") + b.WriteString(m.rulesTable.View()) + b.WriteString("\n") + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(m.statusMsg)) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("a add · enter/e edit · d delete · space toggle · esc back · q quit")) + return b.String() +} + +func (m *model) ruleFormView() string { + var b strings.Builder + title := " add rule " + if m.ruleEditingID != 0 { + title = fmt.Sprintf(" edit rule #%d ", m.ruleEditingID) + } + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n\n") + + label := func(field ruleField, text string) string { + if m.ruleField == field { + return tabActive.Render(text) + } + return tabInactive.Render(text) + } + + b.WriteString(label(fieldName, "Name") + "\n") + b.WriteString(m.ruleName.View() + "\n\n") + b.WriteString(label(fieldMatch, "Match") + "\n") + b.WriteString(m.ruleMatch.View() + "\n\n") + b.WriteString(label(fieldReplace, "Replace") + "\n") + b.WriteString(m.ruleReplace.View() + "\n\n") + + scopeText := fmt.Sprintf("Scope: %s (◀▶ to change)", m.ruleScope) + b.WriteString(label(fieldScope, scopeText) + "\n\n") + regexText := "Regex: off (◀▶ to change)" + if m.ruleRegex { + regexText = "Regex: on (◀▶ to change)" + } + b.WriteString(label(fieldRegex, regexText) + "\n\n") + + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(m.statusMsg)) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("tab/shift+tab move · ctrl+s save · esc cancel · ctrl+c quit")) + return b.String() +} + +func rulesRowsFor(rs []rules.Rule) []table.Row { + rows := make([]table.Row, len(rs)) + for i, r := range rs { + on := " " + if r.Enabled { + on = "✓" + } + regex := "" + if r.IsRegex { + regex = "yes" + } + rows[i] = table.Row{on, r.Name, r.Scope, r.Match, r.Replace, regex} + } + return rows +} + func exactSuffix(exact bool) string { if exact { return ", exact" |