diff --git a/internal/config/config.go b/internal/config/config.go index d5da9d1..0f474bc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,7 @@ type Config struct { ProxyAuth string `mapstructure:"proxy_auth"` MaxBodySizeMB int `mapstructure:"max_body_size_mb"` ExternalEditor string `mapstructure:"external_editor"` + SslInsecure bool `mapstructure:"ssl_insecure"` } `mapstructure:"app"` TUI struct { @@ -82,7 +83,12 @@ func Load(path string) error { } Global = &Config{} - return viper.Unmarshal(Global) + if err := viper.Unmarshal(Global); err != nil { + return err + } + Global.App.ProxyAuth = expandEnvValue(Global.App.ProxyAuth) + Global.App.UpstreamProxy = expandEnvValue(Global.App.UpstreamProxy) + return nil } func WriteDefaultConfig(path string) error { @@ -106,6 +112,17 @@ func ExpandPath(p string) string { return p } +// expandEnvValue replaces a value starting with "$" with the corresponding +// environment variable, enabling secrets to be kept out of config files. +func expandEnvValue(s string) string { + if len(s) > 1 && s[0] == '$' { + if val := os.Getenv(s[1:]); val != "" { + return val + } + } + return s +} + func flatten(prefix string, m map[string]any) map[string]any { out := make(map[string]any) for k, v := range m { diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml index 0f570ce..f2db8c6 100644 --- a/internal/config/default_config.yaml +++ b/internal/config/default_config.yaml @@ -4,15 +4,16 @@ app: cert_dir: ~/.local/share/spilltea project_dir: ~/.local/share/spilltea plugins_dir: ~/.config/spilltea/plugins - upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888 - proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled). Also run: chmod 600 ~/.config/spilltea/config.yaml + upstream_proxy: "" # e.g. http://corporate-proxy:8888 or $ENV_VAR + proxy_auth: "" # require basic auth to use the spilltea proxy, format: user:pass or $ENV_VAR (empty = disabled). max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB) external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait) + ssl_insecure: false # bypass TLS certificate verification (enable for self-signed cert targets) intercept: default_intercept_enabled: true default_capture_response: false - queue_size: 64 # max pending intercepted requests/responses before the proxy blocks + queue_size: 128 # max pending intercepted requests/responses before the proxy blocks auto_forward_regex: - '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$' @@ -31,6 +32,7 @@ tui: keybindings: global: quit: "q,ctrl+c" + escape: "esc,ctrl+c" help: "?" open_logs: "ctrl+g" toggle_sidebar: "ctrl+b" diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 5052009..8d2fa73 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -2,6 +2,7 @@ package config type GlobalKeys struct { Quit string `mapstructure:"quit"` + Escape string `mapstructure:"escape"` OpenLogs string `mapstructure:"open_logs"` ToggleSidebar string `mapstructure:"toggle_sidebar"` Help string `mapstructure:"help"` diff --git a/internal/keys/global.go b/internal/keys/global.go index 07775c2..e4753b2 100644 --- a/internal/keys/global.go +++ b/internal/keys/global.go @@ -41,7 +41,7 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap { CycleFocus: binding(cfg.CycleFocus, "cycle focus"), CopyAs: binding(cfg.CopyAs, "copy as..."), Copy: binding(cfg.Copy, "copy..."), - Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + Escape: binding(cfg.Escape, "cancel"), SendToReplay: binding(cfg.SendToReplay, "send to replay"), ScrollUp: binding(cfg.ScrollUp, "scroll up"), ScrollDown: binding(cfg.ScrollDown, "scroll down"), diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index cac8d36..08188d5 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -120,6 +120,7 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error { StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024, CaRootPath: caPath, Upstream: cfg.UpstreamProxy, + SslInsecure: cfg.SslInsecure, } p, err := goproxy.NewProxy(opts) diff --git a/internal/ui/app/model.go b/internal/ui/app/model.go index c7c174e..79120f5 100644 --- a/internal/ui/app/model.go +++ b/internal/ui/app/model.go @@ -36,11 +36,9 @@ func tickCmd() tea.Cmd { }) } -var sidebarEntries = pageRegistry - var pageShortcuts = func() map[string]page { - m := make(map[string]page, len(sidebarEntries)) - for i, e := range sidebarEntries { + m := make(map[string]page, len(pageRegistry)) + for i, e := range pageRegistry { m[strconv.Itoa(i+1)] = e.id } return m @@ -55,6 +53,7 @@ type Model struct { logFile *os.File pluginManager *plugins.Manager fatalErr error + logFileErr error width int height int @@ -96,7 +95,8 @@ func New(broker *intercept.Broker, name, path string) Model { d, err := db.Open(path) if err != nil { - log.Fatalf("db: %v", err) + m.fatalErr = err + return m } m.database = d broker.SetDB(d) @@ -129,6 +129,8 @@ func New(broker *intercept.Broker, name, path string) Model { m.logFile = lf log.SetOutput(lf) logrus.SetOutput(lf) + } else { + m.logFileErr = err } return m @@ -138,7 +140,7 @@ func (m Model) FatalErr() error { return m.fatalErr } func (m Model) Init() tea.Cmd { mgr := m.pluginManager - return tea.Batch( + cmds := []tea.Cmd{ intercept.WaitForRequest(m.broker), intercept.WaitForResponse(m.broker), tickCmd(), @@ -147,5 +149,16 @@ func (m Model) Init() tea.Cmd { plugins.WaitForQuit(mgr), findingsUI.RefreshCmd(m.database), func() tea.Msg { mgr.RunOnStart(); return nil }, - ) + } + if m.logFileErr != nil { + err := m.logFileErr + cmds = append(cmds, func() tea.Msg { + return notificationsUI.NotificationMsg{ + Title: "Warning", + Body: "Could not open log file: " + err.Error(), + Kind: notificationsUI.KindWarning, + } + }) + } + return tea.Batch(cmds...) } diff --git a/internal/ui/app/sidebar.go b/internal/ui/app/sidebar.go index 0dff550..945170b 100644 --- a/internal/ui/app/sidebar.go +++ b/internal/ui/app/sidebar.go @@ -62,7 +62,7 @@ func (m *Model) renderSidebar() string { var items strings.Builder badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true) - for i, entry := range sidebarEntries { + for i, entry := range pageRegistry { selected := entry.id == m.page badgeStyle, textStyle := badgeNormal, textNormal if selected { diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go index a7dedbc..73bfdab 100644 --- a/internal/ui/app/update.go +++ b/internal/ui/app/update.go @@ -2,7 +2,6 @@ package app import ( "log" - "os" "os/exec" "path/filepath" @@ -21,6 +20,7 @@ import ( historyUI "github.com/anotherhadi/spilltea/internal/ui/history" interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept" replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" + "github.com/anotherhadi/spilltea/internal/util" ) func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -162,23 +162,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case tea.KeyPressMsg: - // ctrl+c always quits, even when a textarea is focused. - if msg.String() == "ctrl+c" { - m.pluginManager.RunOnQuit() - return m, tea.Quit - } if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() { m.pluginManager.RunOnQuit() return m, tea.Quit } if key.Matches(msg, keys.Keys.Global.OpenLogs) { - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "vi" - } logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log") - return m, tea.ExecProcess(exec.Command(editor, logPath), nil) // #nosec G204 G702 -- editor from trusted $EDITOR env var, logPath is a fixed path + return m, tea.ExecProcess(exec.Command(util.ResolveEditor(), logPath), nil) // #nosec G204 G702 -- editor from trusted config/$EDITOR, logPath is a fixed path } if !m.activeIsEditing() { diff --git a/internal/ui/components/copyas/formats.go b/internal/ui/components/copyas/formats.go index 15331fe..3e4129b 100644 --- a/internal/ui/components/copyas/formats.go +++ b/internal/ui/components/copyas/formats.go @@ -16,7 +16,7 @@ type parsedRequest struct { path string host string scheme string - headers []header // garder header{key, value} pour compat locale + headers []header body string } diff --git a/internal/ui/history/update.go b/internal/ui/history/update.go index 32ac5bd..ac1371b 100644 --- a/internal/ui/history/update.go +++ b/internal/ui/history/update.go @@ -26,7 +26,10 @@ func LoadEntriesCmd(database *db.DB) tea.Cmd { if database == nil { return EntriesLoadedMsg{} } - entries, _ := database.ListEntries() + entries, err := database.ListEntries() + if err != nil { + log.Printf("history: load entries: %v", err) + } return EntriesLoadedMsg{Entries: entries} } } diff --git a/internal/ui/home/model.go b/internal/ui/home/model.go index aa649d4..acd0216 100644 --- a/internal/ui/home/model.go +++ b/internal/ui/home/model.go @@ -318,13 +318,14 @@ func (m Model) renderHelpLine() string { } var parts []string + escKey := keys.Keys.Global.Escape.Help().Key if fs == list.Filtering { parts = append(parts, item("enter", "apply filter")) - parts = append(parts, item("esc", "cancel")) + parts = append(parts, item(escKey, "cancel")) } else { parts = append(parts, item("↑/↓", "navigate")) if fs == list.FilterApplied { - parts = append(parts, item("esc", "clear filter")) + parts = append(parts, item(escKey, "clear filter")) } else { parts = append(parts, binding(k.Filter)) } diff --git a/internal/ui/home/view.go b/internal/ui/home/view.go index cfc6f3e..350d746 100644 --- a/internal/ui/home/view.go +++ b/internal/ui/home/view.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/ui/components/teapot" ) @@ -70,7 +71,8 @@ func (m Model) renderNamingPanel() string { Width(panelW). Render(label + "\n" + inputLine) - hint := ilovetui.S.Faint.Render("[enter] confirm [esc] cancel") + escKey := keys.Keys.Global.Escape.Help().Key + hint := ilovetui.S.Faint.Render("[enter] confirm [" + escKey + "] cancel") var sb strings.Builder sb.WriteString(center(iw, panel)) diff --git a/internal/ui/replay/update.go b/internal/ui/replay/update.go index a738e46..f11c798 100644 --- a/internal/ui/replay/update.go +++ b/internal/ui/replay/update.go @@ -5,10 +5,14 @@ import ( "compress/gzip" "compress/zlib" "crypto/tls" + "crypto/x509" "fmt" "io" "log" "net/http" + "net/url" + "os" + "path/filepath" "strings" "time" @@ -99,24 +103,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Button { case tea.MouseWheelUp: if msg.Mod.Contains(tea.ModShift) { - m.requestViewport.ScrollLeft(6) - m.responseViewport.ScrollLeft(6) + m.scrollBothViewports(true) } else { m.scrollFocusedViewportVertical(-1) } case tea.MouseWheelDown: if msg.Mod.Contains(tea.ModShift) { - m.requestViewport.ScrollRight(6) - m.responseViewport.ScrollRight(6) + m.scrollBothViewports(false) } else { m.scrollFocusedViewportVertical(1) } case tea.MouseWheelLeft: - m.requestViewport.ScrollLeft(6) - m.responseViewport.ScrollLeft(6) + m.scrollBothViewports(true) case tea.MouseWheelRight: - m.requestViewport.ScrollRight(6) - m.responseViewport.ScrollRight(6) + m.scrollBothViewports(false) } } @@ -327,6 +327,16 @@ func (m *Model) setFocusedViewport(vp viewport.Model) { } } +func (m *Model) scrollBothViewports(left bool) { + if left { + m.requestViewport.ScrollLeft(6) + m.responseViewport.ScrollLeft(6) + } else { + m.requestViewport.ScrollRight(6) + m.responseViewport.ScrollRight(6) + } +} + func (m *Model) scrollFocusedViewportVertical(delta int) { vp := m.focusedViewport() vp.SetYOffset(vp.YOffset() + delta) @@ -403,11 +413,27 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) { } req.Header = headers + tlsCfg := &tls.Config{InsecureSkipVerify: config.Global.App.SslInsecure} // #nosec G402 -- controlled by ssl_insecure config + if !config.Global.App.SslInsecure { + if certPool, err := x509.SystemCertPool(); err == nil { + caPath := filepath.Join(config.ExpandPath(config.Global.App.CertDir), "mitmproxy-ca-cert.pem") + if pem, err := os.ReadFile(caPath); err == nil { + certPool.AppendCertsFromPEM(pem) + } + tlsCfg.RootCAs = certPool + } + } + transport := &http.Transport{ + TLSClientConfig: tlsCfg, + } + if up := config.Global.App.UpstreamProxy; up != "" { + if proxyURL, err := url.Parse(up); err == nil { + transport.Proxy = http.ProxyURL(proxyURL) + } + } client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 -- intentional for replay feature - }, + Timeout: 30 * time.Second, + Transport: transport, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, diff --git a/internal/util/editor.go b/internal/util/editor.go index 9d1787c..6f41993 100644 --- a/internal/util/editor.go +++ b/internal/util/editor.go @@ -15,7 +15,7 @@ type EditorFinishedMsg struct { Err error } -func resolveEditor() string { +func ResolveEditor() string { editor := config.Global.App.ExternalEditor if editor == "" { editor = os.Getenv("EDITOR") @@ -39,7 +39,7 @@ func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cm if err := f.Close(); err != nil { log.Printf("editor: closing temp file: %v", err) } - return tea.ExecProcess(exec.Command(resolveEditor(), tmpPath), func(err error) tea.Msg { // #nosec G204 -- editor from trusted config/$EDITOR, tmpPath from os.CreateTemp + return tea.ExecProcess(exec.Command(ResolveEditor(), tmpPath), func(err error) tea.Msg { // #nosec G204 -- editor from trusted config/$EDITOR, tmpPath from os.CreateTemp defer os.Remove(tmpPath) return callback(tmpPath, err) })