diff --git a/docs/plugins.md b/docs/plugins.md index 3868f44..a6b7d7c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,5 +1,7 @@ # Plugins +> **Warning:** Plugins can execute arbitrary shell commands, read and write files via `shell_pipe`, and access all intercepted traffic. Only load plugins you trust and have reviewed. You are solely responsible for the plugins you run. + Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic. You can found some pre-built plugins [here](../../plugins/). @@ -33,7 +35,7 @@ Plugin = { | Hook | When called | Sync/async | Return value | | ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------- | -| `on_config(config_text)` | At startup and on config save | always sync | ignored | +| `on_config()` | At startup and on config save | always sync | ignored | | `on_start()` | Once at startup, after `on_config` | configurable | `false` to self-disable the plugin, otherwise ignored | | `on_quit()` | When the app exits | always sync | ignored | | `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (sync only) | @@ -120,6 +122,10 @@ if err then else log("output: " .. out) end + +-- Return the plugin's config section as a Lua table (parsed from YAML). +-- Returns an empty table if no config is set. +local cfg = get_config() ``` ### Finding deduplication @@ -128,9 +134,38 @@ A finding is identified by `(plugin_name, key)`. If a finding with that pair alr ## Configuration -Each plugin gets a **config textarea** on the Plugins page. The raw text is passed as-is to `on_config(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.). +Plugin configuration is stored in a `plugins.yaml` file alongside the project database. Each plugin has an `enable` toggle and an optional `config` block (arbitrary YAML). -`on_config` is called once at startup (before `on_start`) and again every time the user saves the config in the UI. +```yaml +plugins: + my_plugin: + enable: true + config: | + some_key: some_value + list: + - item1 + - item2 + other_plugin: + enable: false +``` + +The config block is edited from the **Plugins** page in the TUI. Inside a plugin, call `get_config()` to retrieve the config as a Lua table. + +`on_config()` is called once at startup (before `on_start`) and again every time the user saves the config in the TUI. It is the right place to read `get_config()` and populate local variables. + +```lua +local items = {} + +function on_config() + items = {} + local cfg = get_config() + if cfg and cfg.list then + for _, v in ipairs(cfg.list) do + table.insert(items, v) + end + end +end +``` ## Sync vs async diff --git a/internal/db/db.go b/internal/db/db.go index ced3d12..e0a364e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -72,12 +72,7 @@ CREATE TABLE IF NOT EXISTS replay_entries ( status_code INTEGER NOT NULL, error_msg TEXT NOT NULL ); - CREATE TABLE IF NOT EXISTS plugins ( - name TEXT PRIMARY KEY, - enabled INTEGER NOT NULL DEFAULT 1, - config_text TEXT NOT NULL DEFAULT '' - ); - CREATE TABLE IF NOT EXISTS findings ( +CREATE TABLE IF NOT EXISTS findings ( id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_name TEXT NOT NULL, dedup_key TEXT NOT NULL, diff --git a/internal/db/plugins.go b/internal/db/plugins.go deleted file mode 100644 index 2d1c013..0000000 --- a/internal/db/plugins.go +++ /dev/null @@ -1,35 +0,0 @@ -package db - -type PluginState struct { - Name string - Enabled bool - ConfigText string -} - -func (d *DB) SavePluginState(name string, enabled bool, configText string) error { - _, err := d.conn.Exec( - `INSERT INTO plugins (name, enabled, config_text) VALUES (?, ?, ?) - ON CONFLICT(name) DO UPDATE SET enabled = excluded.enabled, config_text = excluded.config_text`, - name, enabled, configText, - ) - return err -} - -func (d *DB) LoadPluginStates() ([]PluginState, error) { - rows, err := d.conn.Query(`SELECT name, enabled, config_text FROM plugins`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []PluginState - for rows.Next() { - var s PluginState - var enabled int - if err := rows.Scan(&s.Name, &enabled, &s.ConfigText); err != nil { - return nil, err - } - s.Enabled = enabled != 0 - out = append(out, s) - } - return out, rows.Err() -} diff --git a/internal/plugins/lua.go b/internal/plugins/lua.go index 922626b..ec3f3dc 100644 --- a/internal/plugins/lua.go +++ b/internal/plugins/lua.go @@ -11,6 +11,7 @@ import ( "github.com/anotherhadi/spilltea/internal/db" goproxy "github.com/lqqyt2423/go-mitmproxy/proxy" lua "github.com/yuin/gopher-lua" + "gopkg.in/yaml.v3" ) func newLuaState(mgr *Manager, p *Plugin) *lua.LState { @@ -175,6 +176,27 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) { return 0 })) + L.SetGlobal("get_config", L.NewFunction(func(L *lua.LState) int { + // p.mu is already held by the hook caller - do not lock again. + configText := p.ConfigText + if configText == "" { + L.Push(L.NewTable()) + return 1 + } + var data interface{} + if err := yaml.Unmarshal([]byte(configText), &data); err != nil || data == nil { + L.Push(L.NewTable()) + return 1 + } + lv := goToLuaValue(L, data) + if _, ok := lv.(*lua.LTable); !ok { + L.Push(L.NewTable()) + return 1 + } + L.Push(lv) + return 1 + })) + L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int { cmd := L.CheckString(1) input := L.OptString(2, "") @@ -201,6 +223,35 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) { })) } +func goToLuaValue(L *lua.LState, v interface{}) lua.LValue { + switch val := v.(type) { + case map[string]interface{}: + t := L.NewTable() + for k, v2 := range val { + L.SetField(t, k, goToLuaValue(L, v2)) + } + return t + case []interface{}: + t := L.NewTable() + for i, v2 := range val { + L.RawSetInt(t, i+1, goToLuaValue(L, v2)) + } + return t + case string: + return lua.LString(val) + case int: + return lua.LNumber(val) + case float64: + return lua.LNumber(val) + case bool: + if val { + return lua.LTrue + } + return lua.LFalse + } + return lua.LNil +} + func luaTableString(t *lua.LTable, key string) string { v := t.RawGetString(key) if s, ok := v.(lua.LString); ok { diff --git a/internal/plugins/manager.go b/internal/plugins/manager.go index d6c957a..9ed21ef 100644 --- a/internal/plugins/manager.go +++ b/internal/plugins/manager.go @@ -19,8 +19,9 @@ type Manager struct { mu sync.RWMutex plugins []*Plugin - db *db.DB - broker *intercept.Broker + db *db.DB + pluginsFile *PluginsFile + broker *intercept.Broker Notifs chan PluginNotifMsg Quit chan string @@ -43,6 +44,10 @@ func (m *Manager) SetDB(d *db.DB) { m.db = d } +func (m *Manager) SetPluginsFile(pf *PluginsFile) { + m.pluginsFile = pf +} + func (m *Manager) LoadFromDir(dir string) error { entries, err := os.ReadDir(dir) if os.IsNotExist(err) { @@ -52,17 +57,6 @@ func (m *Manager) LoadFromDir(dir string) error { return err } - var states map[string]db.PluginState - if m.db != nil { - list, err := m.db.LoadPluginStates() - if err == nil { - states = make(map[string]db.PluginState, len(list)) - for _, s := range list { - states[s.Name] = s - } - } - } - for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") { continue @@ -73,9 +67,13 @@ func (m *Manager) LoadFromDir(dir string) error { log.Printf("plugin load error %s: %v", path, err) continue } - if s, ok := states[p.Name]; ok { - p.Enabled = s.Enabled - p.ConfigText = s.ConfigText + if m.pluginsFile != nil { + if enabled, configText, found := m.pluginsFile.get(p.Name); found { + p.Enabled = enabled + p.ConfigText = configText + } else { + m.pluginsFile.register(p.Name, p.Enabled) + } } m.mu.Lock() m.plugins = append(m.plugins, p) @@ -131,12 +129,6 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) { "on_response": false, "on_history_entry": false, } - // Fixed-sync hooks: always sync, not configurable. - fixedSyncHooks := map[string]struct{}{ - "on_config": {}, - "on_quit": {}, - } - for hookName, defaultSync := range configurableHooks { if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok { p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue} @@ -146,9 +138,9 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) { p.hooks[hookName] = HookConfig{Sync: defaultSync} } } - for hookName := range fixedSyncHooks { - if p.L.GetGlobal(hookName) != lua.LNil { - p.hooks[hookName] = HookConfig{Sync: true} + for _, fixedSync := range []string{"on_config", "on_quit"} { + if p.L.GetGlobal(fixedSync) != lua.LNil { + p.hooks[fixedSync] = HookConfig{Sync: true} } } @@ -179,10 +171,9 @@ func (m *Manager) TogglePlugin(name string) { found.mu.Lock() found.Enabled = !found.Enabled enabled := found.Enabled - configText := found.ConfigText found.mu.Unlock() - if m.db != nil { - if err := m.db.SavePluginState(name, enabled, configText); err != nil { + if m.pluginsFile != nil { + if err := m.pluginsFile.setEnabled(name, enabled); err != nil { log.Printf("plugin %s: save state: %v", name, err) } } @@ -196,8 +187,8 @@ func (m *Manager) TogglePlugin(name string) { disableIfFalse := func(p *Plugin, ret lua.LValue) { if ret == lua.LFalse { p.Enabled = false - if m.db != nil { - if err := m.db.SavePluginState(p.Name, false, p.ConfigText); err != nil { + if m.pluginsFile != nil { + if err := m.pluginsFile.setEnabled(p.Name, false); err != nil { log.Printf("plugin %s: save state: %v", p.Name, err) } } @@ -241,41 +232,35 @@ func (m *Manager) SaveConfig(name, configText string) { } found.mu.Lock() found.ConfigText = configText - enabled := found.Enabled - _, hasOnConfig := found.hooks["on_config"] found.mu.Unlock() - if m.db != nil { - if err := m.db.SavePluginState(name, enabled, configText); err != nil { - log.Printf("plugin %s: save state: %v", name, err) + if m.pluginsFile != nil { + if err := m.pluginsFile.setConfig(name, configText); err != nil { + log.Printf("plugin %s: save config: %v", name, err) } } - if !hasOnConfig { + if _, ok := found.hooks["on_config"]; !ok { return } - // on_config is always sync. found.mu.Lock() - if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil { - log.Printf("plugin %s on_config (config reload): %v", name, err) + if _, err := callHook(found, "on_config"); err != nil { + log.Printf("plugin %s on_config: %v", name, err) } found.mu.Unlock() } func (m *Manager) RunOnStart() { - // on_config runs first, always sync, for every enabled plugin that has it. for _, p := range m.GetPlugins() { if !p.Enabled { continue } - if _, ok := p.hooks["on_config"]; !ok { - continue + if _, ok := p.hooks["on_config"]; ok { + p.mu.Lock() + if _, err := callHook(p, "on_config"); err != nil { + log.Printf("plugin %s on_config: %v", p.Name, err) + } + p.mu.Unlock() } - p.mu.Lock() - if _, err := callHook(p, "on_config", lua.LString(p.ConfigText)); err != nil { - log.Printf("plugin %s on_config: %v", p.Name, err) - } - p.mu.Unlock() } - // on_start runs after, sync or async depending on plugin config. for _, p := range m.GetPlugins() { if !p.Enabled { continue @@ -287,8 +272,8 @@ func (m *Manager) RunOnStart() { disableIfFalse := func(p *Plugin, ret lua.LValue) { if ret == lua.LFalse { p.Enabled = false - if m.db != nil { - if err := m.db.SavePluginState(p.Name, false, p.ConfigText); err != nil { + if m.pluginsFile != nil { + if err := m.pluginsFile.setEnabled(p.Name, false); err != nil { log.Printf("plugin %s: save state: %v", p.Name, err) } } diff --git a/internal/plugins/pluginsfile.go b/internal/plugins/pluginsfile.go new file mode 100644 index 0000000..05d05b3 --- /dev/null +++ b/internal/plugins/pluginsfile.go @@ -0,0 +1,78 @@ +package plugins + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type pluginFileEntry struct { + Enable bool `yaml:"enable"` + Config string `yaml:"config,omitempty"` +} + +type pluginsFileData struct { + Plugins map[string]pluginFileEntry `yaml:"plugins"` +} + +type PluginsFile struct { + path string + data pluginsFileData +} + +func OpenPluginsFile(dbPath string) (*PluginsFile, error) { + path := filepath.Join(filepath.Dir(dbPath), "plugins.yaml") + pf := &PluginsFile{ + path: path, + data: pluginsFileData{Plugins: make(map[string]pluginFileEntry)}, + } + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return pf, nil + } + if err != nil { + return nil, err + } + if err := yaml.Unmarshal(raw, &pf.data); err != nil { + return nil, err + } + if pf.data.Plugins == nil { + pf.data.Plugins = make(map[string]pluginFileEntry) + } + return pf, nil +} + +func (pf *PluginsFile) save() error { + raw, err := yaml.Marshal(&pf.data) + if err != nil { + return err + } + return os.WriteFile(pf.path, raw, 0o600) +} + +func (pf *PluginsFile) get(name string) (enabled bool, config string, found bool) { + e, ok := pf.data.Plugins[name] + return e.Enable, e.Config, ok +} + +func (pf *PluginsFile) register(name string, defaultEnabled bool) { + if _, ok := pf.data.Plugins[name]; !ok { + pf.data.Plugins[name] = pluginFileEntry{Enable: defaultEnabled} + _ = pf.save() + } +} + +func (pf *PluginsFile) setEnabled(name string, enabled bool) error { + e := pf.data.Plugins[name] + e.Enable = enabled + pf.data.Plugins[name] = e + return pf.save() +} + +func (pf *PluginsFile) setConfig(name string, configText string) error { + e := pf.data.Plugins[name] + e.Config = configText + pf.data.Plugins[name] = e + return pf.save() +} diff --git a/internal/ui/app/model.go b/internal/ui/app/model.go index e3ba673..c6a85f3 100644 --- a/internal/ui/app/model.go +++ b/internal/ui/app/model.go @@ -106,6 +106,12 @@ func New(broker *intercept.Broker, name, path string) Model { m.findingsPage.SetDB(d) mgr.SetDB(d) + pf, err := plugins.OpenPluginsFile(path) + if err != nil { + log.Printf("plugins file: %v", err) + } + mgr.SetPluginsFile(pf) + pluginsDir := config.ExpandPath(cfg.App.PluginsDir) if err := mgr.LoadFromDir(pluginsDir); err != nil { log.Printf("plugins: %v", err) diff --git a/plugins/inject_header.lua b/plugins/inject_header.lua index fa37b0c..c8f70e2 100644 --- a/plugins/inject_header.lua +++ b/plugins/inject_header.lua @@ -3,20 +3,26 @@ Plugin = { description = [[ Inject custom headers into every intercepted request. -**Config**: -- one 'Header-Name: value' per line. +**Config** (YAML): +```yaml +headers: + - "X-My-Header: myvalue" +``` ]], on_request = { sync = true }, } local headers = {} -function on_config(config_text) +function on_config() headers = {} - for line in config_text:gmatch("[^\n]+") do - local name, value = line:match("^([^:]+):%s*(.+)$") - if name and value then - table.insert(headers, { name = name, value = value }) + local cfg = get_config() + if cfg and cfg.headers then + for _, line in ipairs(cfg.headers) do + local name, value = line:match("^([^:]+):%s*(.+)$") + if name and value then + table.insert(headers, { name = name, value = value }) + end end end end diff --git a/plugins/ip_filter.lua b/plugins/ip_filter.lua index 6f94ddd..b5669fc 100644 --- a/plugins/ip_filter.lua +++ b/plugins/ip_filter.lua @@ -3,33 +3,34 @@ Plugin = { description = [[ Checks that the proxy's outbound IP is in an allowed list on startup. -**Config**: -- one IP per line -- prefix with `!` for a blacklist entry (blocked) -- prefix with `#` to comment it out (ignored) -- if no IPs are configured, the check is skipped +**Config** (YAML): +```yaml +ips: + - "1.2.3.4" # whitelist entry + - "!5.6.7.8" # blacklist entry (blocked) +``` +- If no IPs are configured, the check is skipped. ]], - on_start = { sync = false }, + on_start = { sync = false }, disable_by_default = true, } local whitelist = {} local blacklist = {} -function on_config(config_text) - whitelist = {} - blacklist = {} - - for line in config_text:gmatch("[^\n]+") do - local trimmed = line:match("^%s*(.-)%s*$") - if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then - if trimmed:sub(1, 1) == "!" then - local ip = trimmed:sub(2):match("^%s*(.-)%s*$") - if ip ~= "" then - table.insert(blacklist, ip) +function on_config() + whitelist, blacklist = {}, {} + local cfg = get_config() + if cfg and cfg.ips then + for _, entry in ipairs(cfg.ips) do + local trimmed = entry:match("^%s*(.-)%s*$") + if trimmed ~= "" then + if trimmed:sub(1, 1) == "!" then + local ip = trimmed:sub(2):match("^%s*(.-)%s*$") + if ip ~= "" then table.insert(blacklist, ip) end + else + table.insert(whitelist, trimmed) end - else - table.insert(whitelist, trimmed) end end end diff --git a/plugins/scopes.lua b/plugins/scopes.lua index e3850b6..86b5a74 100644 --- a/plugins/scopes.lua +++ b/plugins/scopes.lua @@ -3,43 +3,40 @@ Plugin = { description = [[ Auto-forward requests and exclude them from history based on patterns. -**Config**: -- `pattern` - whitelist: only intercept matching requests/responses and history entries -- `!pattern` - blacklist: skip matching requests/responses and history entries -- `r:pattern` - whitelist for requests/responses only (history unaffected) -- `r:!pattern` - blacklist for requests/responses only -- `h:pattern` - whitelist for history entries only (requests unaffected) -- `h:!pattern` - blacklist for history entries only -- lines starting with `#` are comments +**Config** (YAML): +```yaml +patterns: + - "pattern" # whitelist: only intercept matching requests/responses and history + - "!pattern" # blacklist: skip matching requests/responses and history + - "r:pattern" # whitelist for requests/responses only + - "r:!pattern" # blacklist for requests/responses only + - "h:pattern" # whitelist for history only + - "h:!pattern" # blacklist for history only +``` Example (ignore static assets): -``` -!%.css$ -!%.js$ -!%.png$ +```yaml +patterns: + - "!%.css$" + - "!%.js$" + - "!%.png$" ``` -Example (focus on mytarget.com, skip everything else): -``` -mytarget%.com/ +Example (focus on mytarget.com): +```yaml +patterns: + - "mytarget%.com/" ``` -Example (intercept mytarget.com except its static assets): -``` -mytarget%.com/ -!%.css$ -!%.js$ -!%.png$ -``` - -Example (disable history: whitelist never matches any real URL): -``` -h:^$ +Example (disable history): +```yaml +patterns: + - "h:^$" ``` ]], priority = 100, on_request = { sync = true }, - on_response = { sync = true }, + on_response = { sync = true }, on_history_entry = { sync = true }, } @@ -50,11 +47,13 @@ local whitelist_req = {} local blacklist_hist = {} local whitelist_hist = {} -function on_config(config_text) +function on_config() blacklist, whitelist = {}, {} blacklist_req, whitelist_req = {}, {} blacklist_hist, whitelist_hist = {}, {} - for line in config_text:gmatch("[^\n]+") do + local cfg = get_config() + if not cfg or not cfg.patterns then return end + for _, line in ipairs(cfg.patterns) do local trimmed = line:match("^%s*(.-)%s*$") if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then local scope = trimmed:match("^([rh]):")