plugin's config is now in yaml

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-20 11:43:26 +02:00
parent b547a79d6e
commit 4251e4fb2a
10 changed files with 270 additions and 149 deletions
+38 -3
View File
@@ -1,5 +1,7 @@
# Plugins # 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. Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
You can found some pre-built plugins [here](../../plugins/). You can found some pre-built plugins [here](../../plugins/).
@@ -33,7 +35,7 @@ Plugin = {
| Hook | When called | Sync/async | Return value | | 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_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_quit()` | When the app exits | always sync | ignored |
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (sync only) | | `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (sync only) |
@@ -120,6 +122,10 @@ if err then
else else
log("output: " .. out) log("output: " .. out)
end 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 ### Finding deduplication
@@ -128,9 +134,38 @@ A finding is identified by `(plugin_name, key)`. If a finding with that pair alr
## Configuration ## 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 ## Sync vs async
+1 -6
View File
@@ -72,12 +72,7 @@ CREATE TABLE IF NOT EXISTS replay_entries (
status_code INTEGER NOT NULL, status_code INTEGER NOT NULL,
error_msg TEXT NOT NULL error_msg TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS plugins ( CREATE TABLE IF NOT EXISTS findings (
name TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
config_text TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS findings (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_name TEXT NOT NULL, plugin_name TEXT NOT NULL,
dedup_key TEXT NOT NULL, dedup_key TEXT NOT NULL,
-35
View File
@@ -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()
}
+51
View File
@@ -11,6 +11,7 @@ import (
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy" goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
lua "github.com/yuin/gopher-lua" lua "github.com/yuin/gopher-lua"
"gopkg.in/yaml.v3"
) )
func newLuaState(mgr *Manager, p *Plugin) *lua.LState { func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
@@ -175,6 +176,27 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
return 0 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 { L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int {
cmd := L.CheckString(1) cmd := L.CheckString(1)
input := L.OptString(2, "") 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 { func luaTableString(t *lua.LTable, key string) string {
v := t.RawGetString(key) v := t.RawGetString(key)
if s, ok := v.(lua.LString); ok { if s, ok := v.(lua.LString); ok {
+35 -50
View File
@@ -19,8 +19,9 @@ type Manager struct {
mu sync.RWMutex mu sync.RWMutex
plugins []*Plugin plugins []*Plugin
db *db.DB db *db.DB
broker *intercept.Broker pluginsFile *PluginsFile
broker *intercept.Broker
Notifs chan PluginNotifMsg Notifs chan PluginNotifMsg
Quit chan string Quit chan string
@@ -43,6 +44,10 @@ func (m *Manager) SetDB(d *db.DB) {
m.db = d m.db = d
} }
func (m *Manager) SetPluginsFile(pf *PluginsFile) {
m.pluginsFile = pf
}
func (m *Manager) LoadFromDir(dir string) error { func (m *Manager) LoadFromDir(dir string) error {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -52,17 +57,6 @@ func (m *Manager) LoadFromDir(dir string) error {
return err 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 { for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") { if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
continue continue
@@ -73,9 +67,13 @@ func (m *Manager) LoadFromDir(dir string) error {
log.Printf("plugin load error %s: %v", path, err) log.Printf("plugin load error %s: %v", path, err)
continue continue
} }
if s, ok := states[p.Name]; ok { if m.pluginsFile != nil {
p.Enabled = s.Enabled if enabled, configText, found := m.pluginsFile.get(p.Name); found {
p.ConfigText = s.ConfigText p.Enabled = enabled
p.ConfigText = configText
} else {
m.pluginsFile.register(p.Name, p.Enabled)
}
} }
m.mu.Lock() m.mu.Lock()
m.plugins = append(m.plugins, p) m.plugins = append(m.plugins, p)
@@ -131,12 +129,6 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
"on_response": false, "on_response": false,
"on_history_entry": 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 { for hookName, defaultSync := range configurableHooks {
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok { if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue} 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} p.hooks[hookName] = HookConfig{Sync: defaultSync}
} }
} }
for hookName := range fixedSyncHooks { for _, fixedSync := range []string{"on_config", "on_quit"} {
if p.L.GetGlobal(hookName) != lua.LNil { if p.L.GetGlobal(fixedSync) != lua.LNil {
p.hooks[hookName] = HookConfig{Sync: true} p.hooks[fixedSync] = HookConfig{Sync: true}
} }
} }
@@ -179,10 +171,9 @@ func (m *Manager) TogglePlugin(name string) {
found.mu.Lock() found.mu.Lock()
found.Enabled = !found.Enabled found.Enabled = !found.Enabled
enabled := found.Enabled enabled := found.Enabled
configText := found.ConfigText
found.mu.Unlock() found.mu.Unlock()
if m.db != nil { if m.pluginsFile != nil {
if err := m.db.SavePluginState(name, enabled, configText); err != nil { if err := m.pluginsFile.setEnabled(name, enabled); err != nil {
log.Printf("plugin %s: save state: %v", name, err) 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) { disableIfFalse := func(p *Plugin, ret lua.LValue) {
if ret == lua.LFalse { if ret == lua.LFalse {
p.Enabled = false p.Enabled = false
if m.db != nil { if m.pluginsFile != nil {
if err := m.db.SavePluginState(p.Name, false, p.ConfigText); err != nil { if err := m.pluginsFile.setEnabled(p.Name, false); err != nil {
log.Printf("plugin %s: save state: %v", p.Name, err) 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.mu.Lock()
found.ConfigText = configText found.ConfigText = configText
enabled := found.Enabled
_, hasOnConfig := found.hooks["on_config"]
found.mu.Unlock() found.mu.Unlock()
if m.db != nil { if m.pluginsFile != nil {
if err := m.db.SavePluginState(name, enabled, configText); err != nil { if err := m.pluginsFile.setConfig(name, configText); err != nil {
log.Printf("plugin %s: save state: %v", name, err) log.Printf("plugin %s: save config: %v", name, err)
} }
} }
if !hasOnConfig { if _, ok := found.hooks["on_config"]; !ok {
return return
} }
// on_config is always sync.
found.mu.Lock() found.mu.Lock()
if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil { if _, err := callHook(found, "on_config"); err != nil {
log.Printf("plugin %s on_config (config reload): %v", name, err) log.Printf("plugin %s on_config: %v", name, err)
} }
found.mu.Unlock() found.mu.Unlock()
} }
func (m *Manager) RunOnStart() { func (m *Manager) RunOnStart() {
// on_config runs first, always sync, for every enabled plugin that has it.
for _, p := range m.GetPlugins() { for _, p := range m.GetPlugins() {
if !p.Enabled { if !p.Enabled {
continue continue
} }
if _, ok := p.hooks["on_config"]; !ok { if _, ok := p.hooks["on_config"]; ok {
continue 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() { for _, p := range m.GetPlugins() {
if !p.Enabled { if !p.Enabled {
continue continue
@@ -287,8 +272,8 @@ func (m *Manager) RunOnStart() {
disableIfFalse := func(p *Plugin, ret lua.LValue) { disableIfFalse := func(p *Plugin, ret lua.LValue) {
if ret == lua.LFalse { if ret == lua.LFalse {
p.Enabled = false p.Enabled = false
if m.db != nil { if m.pluginsFile != nil {
if err := m.db.SavePluginState(p.Name, false, p.ConfigText); err != nil { if err := m.pluginsFile.setEnabled(p.Name, false); err != nil {
log.Printf("plugin %s: save state: %v", p.Name, err) log.Printf("plugin %s: save state: %v", p.Name, err)
} }
} }
+78
View File
@@ -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()
}
+6
View File
@@ -106,6 +106,12 @@ func New(broker *intercept.Broker, name, path string) Model {
m.findingsPage.SetDB(d) m.findingsPage.SetDB(d)
mgr.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) pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
if err := mgr.LoadFromDir(pluginsDir); err != nil { if err := mgr.LoadFromDir(pluginsDir); err != nil {
log.Printf("plugins: %v", err) log.Printf("plugins: %v", err)
+13 -7
View File
@@ -3,20 +3,26 @@ Plugin = {
description = [[ description = [[
Inject custom headers into every intercepted request. Inject custom headers into every intercepted request.
**Config**: **Config** (YAML):
- one 'Header-Name: value' per line. ```yaml
headers:
- "X-My-Header: myvalue"
```
]], ]],
on_request = { sync = true }, on_request = { sync = true },
} }
local headers = {} local headers = {}
function on_config(config_text) function on_config()
headers = {} headers = {}
for line in config_text:gmatch("[^\n]+") do local cfg = get_config()
local name, value = line:match("^([^:]+):%s*(.+)$") if cfg and cfg.headers then
if name and value then for _, line in ipairs(cfg.headers) do
table.insert(headers, { name = name, value = value }) local name, value = line:match("^([^:]+):%s*(.+)$")
if name and value then
table.insert(headers, { name = name, value = value })
end
end end
end end
end end
+20 -19
View File
@@ -3,33 +3,34 @@ Plugin = {
description = [[ description = [[
Checks that the proxy's outbound IP is in an allowed list on startup. Checks that the proxy's outbound IP is in an allowed list on startup.
**Config**: **Config** (YAML):
- one IP per line ```yaml
- prefix with `!` for a blacklist entry (blocked) ips:
- prefix with `#` to comment it out (ignored) - "1.2.3.4" # whitelist entry
- if no IPs are configured, the check is skipped - "!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, disable_by_default = true,
} }
local whitelist = {} local whitelist = {}
local blacklist = {} local blacklist = {}
function on_config(config_text) function on_config()
whitelist = {} whitelist, blacklist = {}, {}
blacklist = {} local cfg = get_config()
if cfg and cfg.ips then
for line in config_text:gmatch("[^\n]+") do for _, entry in ipairs(cfg.ips) do
local trimmed = line:match("^%s*(.-)%s*$") local trimmed = entry:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then if trimmed ~= "" then
if trimmed:sub(1, 1) == "!" then if trimmed:sub(1, 1) == "!" then
local ip = trimmed:sub(2):match("^%s*(.-)%s*$") local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
if ip ~= "" then if ip ~= "" then table.insert(blacklist, ip) end
table.insert(blacklist, ip) else
table.insert(whitelist, trimmed)
end end
else
table.insert(whitelist, trimmed)
end end
end end
end end
+28 -29
View File
@@ -3,43 +3,40 @@ Plugin = {
description = [[ description = [[
Auto-forward requests and exclude them from history based on patterns. Auto-forward requests and exclude them from history based on patterns.
**Config**: **Config** (YAML):
- `pattern` - whitelist: only intercept matching requests/responses and history entries ```yaml
- `!pattern` - blacklist: skip matching requests/responses and history entries patterns:
- `r:pattern` - whitelist for requests/responses only (history unaffected) - "pattern" # whitelist: only intercept matching requests/responses and history
- `r:!pattern` - blacklist for requests/responses only - "!pattern" # blacklist: skip matching requests/responses and history
- `h:pattern` - whitelist for history entries only (requests unaffected) - "r:pattern" # whitelist for requests/responses only
- `h:!pattern` - blacklist for history entries only - "r:!pattern" # blacklist for requests/responses only
- lines starting with `#` are comments - "h:pattern" # whitelist for history only
- "h:!pattern" # blacklist for history only
```
Example (ignore static assets): Example (ignore static assets):
``` ```yaml
!%.css$ patterns:
!%.js$ - "!%.css$"
!%.png$ - "!%.js$"
- "!%.png$"
``` ```
Example (focus on mytarget.com, skip everything else): Example (focus on mytarget.com):
``` ```yaml
mytarget%.com/ patterns:
- "mytarget%.com/"
``` ```
Example (intercept mytarget.com except its static assets): Example (disable history):
``` ```yaml
mytarget%.com/ patterns:
!%.css$ - "h:^$"
!%.js$
!%.png$
```
Example (disable history: whitelist never matches any real URL):
```
h:^$
``` ```
]], ]],
priority = 100, priority = 100,
on_request = { sync = true }, on_request = { sync = true },
on_response = { sync = true }, on_response = { sync = true },
on_history_entry = { sync = true }, on_history_entry = { sync = true },
} }
@@ -50,11 +47,13 @@ local whitelist_req = {}
local blacklist_hist = {} local blacklist_hist = {}
local whitelist_hist = {} local whitelist_hist = {}
function on_config(config_text) function on_config()
blacklist, whitelist = {}, {} blacklist, whitelist = {}, {}
blacklist_req, whitelist_req = {}, {} blacklist_req, whitelist_req = {}, {}
blacklist_hist, whitelist_hist = {}, {} 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*$") local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
local scope = trimmed:match("^([rh]):") local scope = trimmed:match("^([rh]):")