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
> **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
+1 -6
View File
@@ -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,
-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"
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 {
+30 -45
View File
@@ -20,6 +20,7 @@ type Manager struct {
plugins []*Plugin
db *db.DB
pluginsFile *PluginsFile
broker *intercept.Broker
Notifs chan PluginNotifMsg
@@ -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", lua.LString(p.ConfigText)); err != nil {
if _, err := callHook(p, "on_config"); 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)
}
}
+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)
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)
+10 -4
View File
@@ -3,22 +3,28 @@ 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 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
function on_request(req)
+16 -15
View File
@@ -3,11 +3,13 @@ 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 },
disable_by_default = true,
@@ -16,23 +18,22 @@ Checks that the proxy's outbound IP is in an allowed list on startup.
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
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
if ip ~= "" then table.insert(blacklist, ip) end
else
table.insert(whitelist, trimmed)
end
end
end
end
end
function on_start()
+27 -28
View File
@@ -3,38 +3,35 @@ 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,
@@ -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]):")