From 4eb9dd53f5e8260d87afa018730af7b0b5581dc8 Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Wed, 13 May 2026 16:52:12 +0200 Subject: [PATCH] Change plugins behavior Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- .github/docs/plugins.md | 81 +++++++---- .github/plugins_example/ip_whitelist.lua | 48 ------- .github/plugins_example/secret_finder.lua | 35 ----- README.md | 18 +-- cmd/spilltea/main.go | 38 ++++- internal/db/db.go | 6 + internal/intercept/broker.go | 17 ++- internal/plugins/lua.go | 85 ++++++++++- internal/plugins/manager.go | 136 +++++++++++++----- internal/plugins/types.go | 40 ++++-- internal/style/components.go | 9 +- internal/style/style.go | 6 + internal/ui/app/update.go | 11 +- internal/ui/intercept/update.go | 10 ++ internal/ui/plugins/model.go | 88 ++++++++---- internal/ui/plugins/update.go | 42 +++++- internal/ui/plugins/view.go | 75 +++++++--- internal/ui/replay/update.go | 14 +- internal/ui/replay/view.go | 6 +- plugins.go | 45 ++++++ .../inject_header.lua | 18 +-- plugins/ip_filter.lua | 75 ++++++++++ plugins/scopes.lua | 78 ++++++++++ 23 files changed, 740 insertions(+), 241 deletions(-) delete mode 100644 .github/plugins_example/ip_whitelist.lua delete mode 100644 .github/plugins_example/secret_finder.lua create mode 100644 plugins.go rename {.github/plugins_example => plugins}/inject_header.lua (57%) create mode 100644 plugins/ip_filter.lua create mode 100644 plugins/scopes.lua diff --git a/.github/docs/plugins.md b/.github/docs/plugins.md index b0ae181..640b916 100644 --- a/.github/docs/plugins.md +++ b/.github/docs/plugins.md @@ -1,6 +1,7 @@ # Plugins Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic. +You can found some pre-built plugins [here](../../plugins/). ## Where to place plugins @@ -14,26 +15,28 @@ Every plugin must declare a `Plugin` table and implement the hooks it wants to u ```lua Plugin = { - name = "My Plugin", + name = "My Plugin", + description = "What this plugin does.", + priority = 0, -- higher = runs before other plugins (default: 0) -- Declare which hooks you use and whether they are synchronous. - on_start = { sync = true }, - on_request = { sync = true }, - on_response = { sync = false }, - on_history_entry = {}, - on_quit = {}, + on_start = { sync = true }, + on_request = { sync = true }, + on_response = { sync = false }, + on_history_entry = { sync = true }, } ``` ### Hook reference -| Hook | When called | Sync/async | Return value | -| ------------------------- | --------------------------- | ------------ | ------------------- | -| `on_start(config_text)` | Once at startup | always sync | ignored | -| `on_quit()` | When the app exits | always sync | ignored | -| `on_request(req)` | Every request | declared | `"drop"`, `"forward"`, or `nil` (sync only) | -| `on_response(req, res)` | Every response | declared | `"drop"`, `"forward"`, or `nil` (sync only) | -| `on_history_entry(entry)` | After a flow is saved to DB | always async | ignored | +| Hook | When called | Sync/async | Return value (sync only) | +| ------------------------- | ------------------------------------ | ------------- | ----------------------------------------------------- | +| `on_config(config_text)` | At startup and on config save | always sync | ignored | +| `on_start()` | Once at startup, after `on_config` | configurable | ignored | +| `on_quit()` | When the app exits | always sync | ignored | +| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` | +| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` | +| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) | ## Request and response objects @@ -80,7 +83,8 @@ Plugin = { log("message") -- Send a notification bubble in the TUI -notif("Title", "Body text") +-- kind is optional: "info" (default), "success", "warning", "error" +notif("Title", "Body text", "warning") -- Create a finding (shown on the Findings page, persisted in DB) create_finding({ @@ -90,8 +94,8 @@ create_finding({ severity = "high", -- info | low | medium | high | critical }) --- Check if a URL matches the current scope (whitelist/blacklist) -local ok = is_in_scope("https://example.com/api/v1") +-- Run a raw SQL query against the history DB +local rows, err = db_query("SELECT id, host FROM entries WHERE host = ?", "example.com") -- Quit the app (useful for startup checks that fail) quit("reason message") @@ -103,25 +107,44 @@ 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_start(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.). +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.). + +`on_config` is called once at startup (before `on_start`) and again every time the user saves the config in the UI. ## Sync vs async -- **`sync = true`**: spilltea waits for the hook to return before continuing. For `on_request`/`on_response` this blocks the proxy goroutine; the hook can return one of the values below. -- **`sync = false`** (or omitted for supported hooks): the hook runs in a background goroutine. Return values are ignored. Use this for analysis and findings. +- **`sync = true`**: spilltea waits for the hook to return before continuing. The hook can return a decision value (see below). +- **`sync = false`** (default): the hook runs in a background goroutine. Return values are ignored. -### Return values for `on_request` and `on_response` (sync only) +### Return values for sync hooks -| Return value | Effect | -| ------------ | ------ | -| `"drop"` | The flow is dropped immediately and never shown in the intercept panel. | -| `"forward"` | The flow is forwarded immediately without going through the intercept panel. | +**`on_request` and `on_response`:** + +| Return value | Effect | +| ------------ | --------------------------------------------------------------------------------- | +| `"drop"` | The flow is dropped immediately and never shown in the intercept panel. | +| `"forward"` | The flow is forwarded immediately without going through the intercept panel. | | `nil` | Normal behaviour: the flow appears in the intercept panel for the user to decide. | -The `sync` declaration is only meaningful for `on_request` and `on_response`. The other hooks have fixed behaviour: +**`on_history_entry` (sync only):** -- `on_start` is **always synchronous**: plugins are initialised one by one before the first request is accepted. -- `on_quit` is **always synchronous**: the app waits for all `on_quit` hooks before exiting. -- `on_history_entry` is **always asynchronous**. +| Return value | Effect | +| ------------------- | -------------------------------------- | +| `"skip"` | The entry is not saved to the DB. | +| `"keep"` or `nil` | The entry is saved normally. | -> A sync `on_request` or `on_response` hook that hangs will block traffic for that flow. There is no automatic timeout. +Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history. Async `on_history_entry` runs **after** the insert and cannot affect it. + +## Priority + +Plugins with a higher `priority` value run before plugins with a lower value (default `0`). This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins. + +```lua +Plugin = { + name = "Scopes", + priority = 100, -- runs before all default-priority plugins + ... +} +``` + +> A sync hook that hangs will block traffic for that flow. There is no automatic timeout. diff --git a/.github/plugins_example/ip_whitelist.lua b/.github/plugins_example/ip_whitelist.lua deleted file mode 100644 index 0aa8c8f..0000000 --- a/.github/plugins_example/ip_whitelist.lua +++ /dev/null @@ -1,48 +0,0 @@ --- Check that the proxy's outbound IP is in the whitelist before starting. --- Config: one allowed IP per line. Leave empty to disable the check. - -Plugin = { - name = "IP Whitelist", - on_start = {}, -} - -function on_start(config_text) - local allowed = {} - for line in config_text:gmatch("[^\n]+") do - local ip = line:match("^%s*(.-)%s*$") - if ip ~= "" then - table.insert(allowed, ip) - end - end - - if #allowed == 0 then - log("no IPs configured, skipping check") - return - end - - -- Fetch the current outbound IP via a public API. - local ok, result = pcall(function() - local handle = io.popen("curl -sf https://api.ipify.org 2>/dev/null") - if not handle then return nil end - local ip = handle:read("*a") - handle:close() - return ip and ip:match("^%s*(.-)%s*$") or nil - end) - - if not ok or not result or result == "" then - log("could not determine outbound IP, skipping check") - return - end - - log("outbound IP: " .. result) - - for _, ip in ipairs(allowed) do - if result == ip then - log("IP " .. result .. " is whitelisted") - return - end - end - - notif("IP Whitelist", "Outbound IP " .. result .. " is NOT in the whitelist!") - quit("outbound IP " .. result .. " not whitelisted") -end diff --git a/.github/plugins_example/secret_finder.lua b/.github/plugins_example/secret_finder.lua deleted file mode 100644 index 1437de3..0000000 --- a/.github/plugins_example/secret_finder.lua +++ /dev/null @@ -1,35 +0,0 @@ --- Scan response bodies for common API key / secret patterns. --- Runs asynchronously so it never delays traffic. - -Plugin = { - name = "Secret Finder", - on_response = { sync = false }, -} - -local PATTERNS = { - { pattern = "AIza[0-9A-Za-z%-_]{35}", label = "Google API Key" }, - { pattern = "AKIA[0-9A-Z]{16}", label = "AWS Access Key" }, - { pattern = "sk%-[a-zA-Z0-9]{20,}", label = "OpenAI API Key" }, - { pattern = "ghp_[a-zA-Z0-9]{36}", label = "GitHub Personal Token" }, - { pattern = "Bearer%s+[a-zA-Z0-9%-_%.]+%.[a-zA-Z0-9%-_%.]+%.[a-zA-Z0-9%-_%.]+", - label = "JWT Bearer Token" }, -} - -function on_response(req, res) - local body = res:get_body() - if body == "" then return end - - for _, p in ipairs(PATTERNS) do - if body:find(p.pattern) then - local key = p.label .. ":" .. req.host - create_finding({ - title = p.label .. " in response", - description = "**Host:** `" .. req.host .. "`\n\n" .. - "**Path:** `" .. req.path .. "`\n\n" .. - "Pattern `" .. p.pattern .. "` matched in the response body.", - key = key, - severity = "high", - }) - end - end -end diff --git a/README.md b/README.md index ede44df..9a308c7 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ On startup, you choose: ## Plugin System Spilltea supports plugins written in **Lua**. Plugins are loaded from `~/.config/spilltea/plugins/` by default and do not require recompilation or access to the source code. -For a full reference and examples, see the [plugin documentation](./.github/docs/plugins.md). +For a full reference and examples, see the [plugin documentation](./.github/docs/plugins.md) or [plugin examples](./plugins/). ## Configuration @@ -53,13 +53,15 @@ Check the default configuration with all the options [here](./internal/config/de ## CLI Flags -| Flag | Short | Description | -| ---- | ----- | ----------- | -| `--config` | `-c` | Path to config file (default: `~/.config/spilltea/config.yaml`) | -| `--host` | | Proxy host, overrides config | -| `--port` | `-p` | Proxy port, overrides config | -| `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session | -| `--version` | `-v` | Print version and exit | +| Flag | Short | Description | +| ----------------------- | ----- | ------------------------------------------------------------------------------ | +| `--config` | `-c` | Path to config file (default: `~/.config/spilltea/config.yaml`) | +| `--plugin-dir` | | Path to plugins dir, overrides config (default: `~/.config/spilltea/plugins/`) | +| `--host` | | Proxy host, overrides config | +| `--port` | `-p` | Proxy port, overrides config | +| `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session | +| `--version` | `-v` | Print version and exit | +| `--add-default-plugins` | | Add the default plugins to your plugins dir and exit | ## Deployment diff --git a/cmd/spilltea/main.go b/cmd/spilltea/main.go index 06deff8..2acd629 100644 --- a/cmd/spilltea/main.go +++ b/cmd/spilltea/main.go @@ -11,6 +11,7 @@ import ( "github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/keys" + spilltea "github.com/anotherhadi/spilltea" "github.com/anotherhadi/spilltea/internal/style" appUI "github.com/anotherhadi/spilltea/internal/ui/app" homeUI "github.com/anotherhadi/spilltea/internal/ui/home" @@ -22,11 +23,13 @@ var version = "dev" func main() { var ( - flagConfig = flag.StringP("config", "c", "", "path to config file") - flagHost = flag.String("host", "", "proxy host (overrides config)") - flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)") - flagVersion = flag.BoolP("version", "v", false, "print version") - flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`) + flagConfig = flag.StringP("config", "c", "", "path to config file") + flagPluginsDir = flag.String("plugins-dir", "", "path to plugins dir (overrides config)") + flagHost = flag.String("host", "", "proxy host (overrides config)") + flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)") + flagVersion = flag.BoolP("version", "v", false, "print version") + flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`) + flagAddDefaultPlugins = flag.Bool("add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit") ) flag.Parse() @@ -35,6 +38,28 @@ func main() { os.Exit(0) } + if *flagAddDefaultPlugins { + cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml") + if *flagConfig != "" { + cfgPath = *flagConfig + } + if err := config.Load(cfgPath); err != nil { + fmt.Fprintf(os.Stderr, "config: %v\n", err) + os.Exit(1) + } + dir := config.ExpandPath(config.Global.App.PluginsDir) + if *flagPluginsDir != "" { + dir = *flagPluginsDir + } + n, err := spilltea.InstallDefaultPlugins(dir) + if err != nil { + fmt.Fprintf(os.Stderr, "add-default-plugins: %v\n", err) + os.Exit(1) + } + fmt.Printf("added %d plugin(s) to %s\n", n, dir) + os.Exit(0) + } + if *flagProject != "" && !homeUI.IsValidProjectName(*flagProject) { fmt.Fprintf(os.Stderr, "project: invalid name %q (only lowercase letters, digits, - and _ are allowed)\n", *flagProject) os.Exit(1) @@ -51,6 +76,9 @@ func main() { } config.Global.Version = version + if *flagPluginsDir != "" { + config.Global.App.PluginsDir = *flagPluginsDir + } if *flagHost != "" { config.Global.App.Host = *flagHost } diff --git a/internal/db/db.go b/internal/db/db.go index f7ff255..61a5095 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -68,6 +68,12 @@ CREATE TABLE IF NOT EXISTS replay_entries ( return err } +// Query executes a SQL query and returns the rows. The caller must close the +// returned rows. Args are passed as positional parameters. +func (d *DB) Query(query string, args ...any) (*sql.Rows, error) { + return d.conn.Query(query, args...) +} + func (d *DB) Close() error { if d == nil { return nil diff --git a/internal/intercept/broker.go b/internal/intercept/broker.go index 2fd2717..755f384 100644 --- a/internal/intercept/broker.go +++ b/internal/intercept/broker.go @@ -44,7 +44,12 @@ type Broker struct { autoFwdMu sync.RWMutex autoFwdRegexes []*regexp.Regexp - onNewEntry func(db.Entry) + onBeforeNewEntry func(db.Entry) bool + onNewEntry func(db.Entry) +} + +func (b *Broker) SetOnBeforeNewEntry(cb func(db.Entry) bool) { + b.onBeforeNewEntry = cb } func (b *Broker) SetOnNewEntry(cb func(db.Entry)) { @@ -165,7 +170,7 @@ func (b *Broker) SaveEntry(f *proxy.Flow) { return } } - entry, err := d.InsertEntry(db.Entry{ + pending := db.Entry{ Timestamp: time.Now(), Method: r.Method, Host: r.URL.Host, @@ -173,7 +178,13 @@ func (b *Broker) SaveEntry(f *proxy.Flow) { StatusCode: status, RequestRaw: FormatRawRequest(f), ResponseRaw: FormatRawResponse(f), - }) + } + if cb := b.onBeforeNewEntry; cb != nil { + if !cb(pending) { + return + } + } + entry, err := d.InsertEntry(pending) if err == nil { if cb := b.onNewEntry; cb != nil { go cb(entry) diff --git a/internal/plugins/lua.go b/internal/plugins/lua.go index 2b33e66..f6c6d67 100644 --- a/internal/plugins/lua.go +++ b/internal/plugins/lua.go @@ -26,8 +26,9 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) { L.SetGlobal("notif", L.NewFunction(func(L *lua.LState) int { title := L.CheckString(1) body := L.CheckString(2) + kind := L.OptString(3, "info") select { - case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body}: + case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body, Kind: kind}: default: } return 0 @@ -64,7 +65,87 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) { return 0 })) -L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int { + L.SetGlobal("db_query", L.NewFunction(func(L *lua.LState) int { + if mgr.db == nil { + L.Push(lua.LNil) + L.Push(lua.LString("db not available")) + return 2 + } + query := L.CheckString(1) + var args []any + for i := 2; i <= L.GetTop(); i++ { + switch v := L.Get(i).(type) { + case lua.LString: + args = append(args, string(v)) + case lua.LNumber: + args = append(args, float64(v)) + case lua.LBool: + args = append(args, bool(v)) + default: + args = append(args, nil) + } + } + rows, err := mgr.db.Query(query, args...) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + defer rows.Close() + cols, err := rows.Columns() + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + result := L.NewTable() + rowIdx := 1 + for rows.Next() { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + row := L.NewTable() + for i, col := range cols { + switch v := vals[i].(type) { + case int64: + L.SetField(row, col, lua.LNumber(v)) + case float64: + L.SetField(row, col, lua.LNumber(v)) + case string: + L.SetField(row, col, lua.LString(v)) + case []byte: + L.SetField(row, col, lua.LString(string(v))) + case bool: + if v { + L.SetField(row, col, lua.LTrue) + } else { + L.SetField(row, col, lua.LFalse) + } + case nil: + L.SetField(row, col, lua.LNil) + } + } + L.RawSetInt(result, rowIdx, row) + rowIdx++ + } + if err := rows.Err(); err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + L.Push(result) + L.Push(lua.LNil) + return 2 + })) + + L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int { reason := L.OptString(1, "plugin requested quit") select { case mgr.Quit <- reason: diff --git a/internal/plugins/manager.go b/internal/plugins/manager.go index 63542f9..84d57bd 100644 --- a/internal/plugins/manager.go +++ b/internal/plugins/manager.go @@ -5,6 +5,7 @@ import ( "log" "os" "path/filepath" + "sort" "strings" "sync" @@ -27,12 +28,13 @@ type Manager struct { func NewManager(broker *intercept.Broker) *Manager { mgr := &Manager{ - broker: broker, + broker: broker, Notifs: make(chan PluginNotifMsg, 64), Quit: make(chan string, 4), } if broker != nil { - broker.SetOnNewEntry(mgr.RunOnHistoryEntry) + broker.SetOnBeforeNewEntry(mgr.RunSyncOnHistoryEntry) + broker.SetOnNewEntry(mgr.RunAsyncOnHistoryEntry) } return mgr } @@ -107,27 +109,41 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) { p.Name = strings.TrimSuffix(filepath.Base(path), ".lua") } - // Defaults when not overridden by the Plugin table. - hookDefaults := map[string]bool{ - "on_start": true, // always sync - "on_request": false, // async - "on_response": false, // async - "on_quit": true, // always sync - "on_history_entry": false, // always async + if s, ok := pluginTable.RawGetString("description").(lua.LString); ok { + p.Description = string(s) } - for hookName, defaultSync := range hookDefaults { - // Plugin table entry overrides the default (except on_start/on_quit/on_history_entry which are fixed). - if hookName != "on_start" && hookName != "on_quit" && hookName != "on_history_entry" { - if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok { - p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue} - continue - } + + if n, ok := pluginTable.RawGetString("priority").(lua.LNumber); ok { + p.Priority = int(n) + } + + // Hooks configurable via the Plugin table (sync field). + configurableHooks := map[string]bool{ + "on_start": false, // async by default + "on_request": false, + "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} + continue } - // Auto-detect: register the hook if the function exists as a global. if p.L.GetGlobal(hookName) != lua.LNil { p.hooks[hookName] = HookConfig{Sync: defaultSync} } } + for hookName := range fixedSyncHooks { + if p.L.GetGlobal(hookName) != lua.LNil { + p.hooks[hookName] = HookConfig{Sync: true} + } + } return p, nil } @@ -137,6 +153,7 @@ func (m *Manager) GetPlugins() []*Plugin { defer m.mu.RUnlock() out := make([]*Plugin, len(m.plugins)) copy(out, m.plugins) + sort.Slice(out, func(i, j int) bool { return out[i].Priority > out[j].Priority }) return out } @@ -179,46 +196,62 @@ func (m *Manager) SaveConfig(name, configText string) { found.mu.Lock() found.ConfigText = configText enabled := found.Enabled - hc, hasOnStart := found.hooks["on_start"] + _, hasOnConfig := found.hooks["on_config"] found.mu.Unlock() if m.db != nil { _ = m.db.SavePluginState(name, enabled, configText) } - if !hasOnStart { + if !hasOnConfig { return } - // Re-run on_start so the plugin can re-parse the new config. - if hc.Sync { - found.mu.Lock() - if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil { - log.Printf("plugin %s on_start (config reload): %v", name, err) - } - found.mu.Unlock() - } else { - go func() { - found.mu.Lock() - if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil { - log.Printf("plugin %s on_start (config reload): %v", name, err) - } - found.mu.Unlock() - }() + // 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) } + 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_start"]; !ok { + if _, ok := p.hooks["on_config"]; !ok { continue } p.mu.Lock() - if _, err := callHook(p, "on_start", lua.LString(p.ConfigText)); err != nil { - log.Printf("plugin %s on_start: %v", p.Name, err) + 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 + } + hc, ok := p.hooks["on_start"] + if !ok { + continue + } + if hc.Sync { + p.mu.Lock() + if _, err := callHook(p, "on_start"); err != nil { + log.Printf("plugin %s on_start: %v", p.Name, err) + } + p.mu.Unlock() + } else { + go func(p *Plugin) { + p.mu.Lock() + if _, err := callHook(p, "on_start"); err != nil { + log.Printf("plugin %s on_start: %v", p.Name, err) + } + p.mu.Unlock() + }(p) + } + } } func (m *Manager) RunOnQuit() { @@ -327,12 +360,37 @@ func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) { } } -func (m *Manager) RunOnHistoryEntry(e db.Entry) { +// RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving. +func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool { for _, p := range m.GetPlugins() { if !p.Enabled { continue } - if _, ok := p.hooks["on_history_entry"]; !ok { + hc, ok := p.hooks["on_history_entry"] + if !ok || !hc.Sync { + continue + } + p.mu.Lock() + result, err := callHook(p, "on_history_entry", pushEntry(p.L, e)) + p.mu.Unlock() + if err != nil { + log.Printf("plugin %s on_history_entry: %v", p.Name, err) + continue + } + if result == "skip" { + return false + } + } + return true +} + +func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) { + for _, p := range m.GetPlugins() { + if !p.Enabled { + continue + } + hc, ok := p.hooks["on_history_entry"] + if !ok || hc.Sync { continue } go func(p *Plugin) { diff --git a/internal/plugins/types.go b/internal/plugins/types.go index a74c521..9be136b 100644 --- a/internal/plugins/types.go +++ b/internal/plugins/types.go @@ -11,10 +11,12 @@ type HookConfig struct { } type Plugin struct { - Name string - FilePath string - Enabled bool - ConfigText string + Name string + Description string + FilePath string + Enabled bool + ConfigText string + Priority int L *lua.LState mu sync.Mutex @@ -35,30 +37,40 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) { } type Info struct { - Name string - FilePath string - Enabled bool - ConfigText string - Hooks map[string]HookConfig + Name string + Description string + FilePath string + Enabled bool + ConfigText string + Priority int + Hooks map[string]HookConfig } func (p *Plugin) Info() Info { + p.mu.Lock() + enabled := p.Enabled + configText := p.ConfigText + p.mu.Unlock() + hooks := make(map[string]HookConfig, len(p.hooks)) for k, v := range p.hooks { hooks[k] = v } return Info{ - Name: p.Name, - FilePath: p.FilePath, - Enabled: p.Enabled, - ConfigText: p.ConfigText, - Hooks: hooks, + Name: p.Name, + Description: p.Description, + FilePath: p.FilePath, + Enabled: enabled, + ConfigText: configText, + Priority: p.Priority, + Hooks: hooks, } } type PluginNotifMsg struct { Title string Body string + Kind string // "info", "success", "warning", "error" } type PluginQuitMsg struct { diff --git a/internal/style/components.go b/internal/style/components.go index 9c52643..105828d 100644 --- a/internal/style/components.go +++ b/internal/style/components.go @@ -28,15 +28,20 @@ func NewTextarea(showLineNumbers bool) textarea.Model { ta.Prompt = "" ta.ShowLineNumbers = showLineNumbers ta.CharLimit = 0 + ta.EndOfBufferCharacter = '~' ts := ta.Styles() ts.Focused.Base = lipgloss.NewStyle() ts.Blurred.Base = lipgloss.NewStyle() + ts.Focused.Text = lipgloss.NewStyle().Foreground(S.Text) ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text) + ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Primary).Bold(true) + ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(S.Subtle) ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle) - ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle) ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg) - ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg) ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg) + ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(S.SubtleBg) + ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle) + ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg) ta.SetStyles(ts) return ta } diff --git a/internal/style/style.go b/internal/style/style.go index 66c86ae..2999917 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -24,6 +24,7 @@ type Styles struct { Panel lipgloss.Style PanelFocused lipgloss.Style + PanelEditing lipgloss.Style PagerDotActive string PagerDotInactive string @@ -43,6 +44,7 @@ func Init(cfg *config.Config) { warning := lipgloss.Color("#" + c.Base09) // Orange: warnings success := lipgloss.Color("#" + c.Base0B) // Green: success primary := lipgloss.Color("#" + c.Base0D) // Accent: primary + purple := lipgloss.Color("#" + c.Base0E) // Purple: editing S = &Styles{ Primary: primary, @@ -66,6 +68,10 @@ func Init(cfg *config.Config) { Border(lipgloss.RoundedBorder()). BorderForeground(primary), + PanelEditing: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(purple), + PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(), PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(), } diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go index 42cf7af..f96e993 100644 --- a/internal/ui/app/update.go +++ b/internal/ui/app/update.go @@ -44,11 +44,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case plugins.PluginNotifMsg: cmd := plugins.WaitForNotif(m.pluginManager) + kind := notificationsUI.KindInfo + switch msg.Kind { + case "success": + kind = notificationsUI.KindSuccess + case "warning": + kind = notificationsUI.KindWarning + case "error": + kind = notificationsUI.KindError + } notifCmd := func() tea.Msg { return notificationsUI.NotificationMsg{ Title: msg.Title, Body: msg.Body, - Kind: notificationsUI.KindInfo, + Kind: kind, } } return m, tea.Batch(cmd, notifCmd) diff --git a/internal/ui/intercept/update.go b/internal/ui/intercept/update.go index 745a342..990039d 100644 --- a/internal/ui/intercept/update.go +++ b/internal/ui/intercept/update.go @@ -13,6 +13,16 @@ import ( func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd + // Route non-key messages to textarea when editing so internal + // textarea messages (e.g. clipboard paste) are handled correctly. + if m.editing { + if _, ok := msg.(tea.KeyPressMsg); !ok { + var taCmd tea.Cmd + m.textarea, taCmd = m.textarea.Update(msg) + cmds = append(cmds, taCmd) + } + } + switch msg := msg.(type) { case intercept.RequestArrivedMsg: if !m.interceptEnabled { diff --git a/internal/ui/plugins/model.go b/internal/ui/plugins/model.go index 14c7ea9..5eeed46 100644 --- a/internal/ui/plugins/model.go +++ b/internal/ui/plugins/model.go @@ -1,7 +1,6 @@ package plugins import ( - "os" "strings" "charm.land/bubbles/v2/help" @@ -24,19 +23,20 @@ type Model struct { filter string filtered []plugins.Info - listViewport viewport.Model - textarea textarea.Model - filterInput textinput.Model - filtering bool - pager paginator.Model - help help.Model + listViewport viewport.Model + detailViewport viewport.Model + textarea textarea.Model + filterInput textinput.Model + filtering bool + pager paginator.Model + help help.Model width int height int } func New(mgr *plugins.Manager) Model { - ta := style.NewTextarea(false) + ta := style.NewTextarea(true) ta.Placeholder = "plugin configuration..." ta.Blur() @@ -44,12 +44,13 @@ func New(mgr *plugins.Manager) Model { fi.Prompt = "" return Model{ - manager: mgr, - listViewport: style.NewViewport(), - textarea: ta, - filterInput: fi, - pager: style.NewPaginator(), - help: style.NewHelp(), + manager: mgr, + listViewport: style.NewViewport(), + detailViewport: style.NewViewport(), + textarea: ta, + filterInput: fi, + pager: style.NewPaginator(), + help: style.NewHelp(), } } @@ -88,10 +89,27 @@ func (m *Model) recalcSizes() { } m.filterInput.SetWidth(inner - 2) + + detailContentH := style.PanelContentH(detailH) + const headerH = 2 + const configFixedH = 2 // blank line + label line + textareaH := max(3, detailContentH/3) + if textareaH > 12 { + textareaH = 12 + } + configTotalH := 0 + if m.hasConfig() { + configTotalH = configFixedH + textareaH + } + descVH := max(1, detailContentH-headerH-configTotalH) + + m.detailViewport.SetWidth(inner) + m.detailViewport.SetHeight(descVH) m.textarea.SetWidth(max(1, inner-2)) - m.textarea.SetHeight(max(3, detailH-6)) + m.textarea.SetHeight(max(3, textareaH)) m.refreshListViewport() + m.syncDetailViewport() } // Refresh reloads the plugin list from the manager. @@ -126,6 +144,7 @@ func (m *Model) applyFilter() { } m.refreshListViewport() m.syncTextarea() + m.syncDetailViewport() } func (m *Model) selected() (plugins.Info, bool) { @@ -135,6 +154,15 @@ func (m *Model) selected() (plugins.Info, bool) { return m.filtered[m.cursor], true } +func (m *Model) hasConfig() bool { + info, ok := m.selected() + if !ok { + return false + } + _, has := info.Hooks["on_config"] + return has +} + func (m *Model) syncTextarea() { if m.editing { return @@ -147,6 +175,16 @@ func (m *Model) syncTextarea() { m.textarea.SetValue(info.ConfigText) } +func (m *Model) syncDetailViewport() { + info, ok := m.selected() + if !ok || info.Description == "" { + m.detailViewport.SetContent("") + return + } + desc := renderPluginDescription(info.Description, m.width-6) + m.detailViewport.SetContent(desc) +} + func (m *Model) refreshListViewport() { if m.pager.PerPage > 0 { m.pager.Page = m.cursor / m.pager.PerPage @@ -155,16 +193,11 @@ func (m *Model) refreshListViewport() { m.listViewport.SetContent(m.renderList()) } -func shortenPath(p string) string { - home := os.Getenv("HOME") - if home != "" && strings.HasPrefix(p, home) { - return "~" + p[len(home):] - } - return p +type pluginsKeyMap struct { + editing bool + hasConfig bool } -type pluginsKeyMap struct{ editing bool } - func (k pluginsKeyMap) ShortHelp() []key.Binding { pk := keys.Keys.Plugins g := keys.Keys.Global @@ -172,7 +205,14 @@ func (k pluginsKeyMap) ShortHelp() []key.Binding { esc := key.NewBinding(key.WithKeys(g.Escape.Keys()...), key.WithHelp(g.Escape.Help().Key, "save & exit")) return []key.Binding{esc} } - return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter} + scrollHint := key.NewBinding( + key.WithKeys(g.ScrollUp.Keys()...), + key.WithHelp(g.ScrollUp.Help().Key+"/"+g.ScrollDown.Help().Key, "scroll detail"), + ) + if k.hasConfig { + return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter, scrollHint} + } + return []key.Binding{pk.Toggle, pk.Filter, scrollHint} } func (k pluginsKeyMap) FullHelp() [][]key.Binding { diff --git a/internal/ui/plugins/update.go b/internal/ui/plugins/update.go index 749534d..ce240a1 100644 --- a/internal/ui/plugins/update.go +++ b/internal/ui/plugins/update.go @@ -21,7 +21,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // Route non-key messages to textarea when editing so internal + // textarea messages (e.g. clipboard paste) are handled correctly. + if m.editing { + if _, ok := msg.(tea.KeyPressMsg); !ok { + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + } + } + switch msg := msg.(type) { + case tea.MouseWheelMsg: + if !m.editing { + switch msg.Button { + case tea.MouseWheelUp: + m.detailViewport.SetYOffset(m.detailViewport.YOffset() - 1) + case tea.MouseWheelDown: + m.detailViewport.SetYOffset(m.detailViewport.YOffset() + 1) + } + } + case tea.KeyPressMsg: pk := keys.Keys.Plugins g := keys.Keys.Global @@ -90,15 +110,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, g.Up): if m.cursor > 0 { m.cursor-- - m.refreshListViewport() + m.recalcSizes() m.syncTextarea() + m.detailViewport.GotoTop() } case key.Matches(msg, g.Down): if m.cursor < len(m.filtered)-1 { m.cursor++ - m.refreshListViewport() + m.recalcSizes() m.syncTextarea() + m.detailViewport.GotoTop() } case key.Matches(msg, pk.Toggle): @@ -115,11 +137,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case key.Matches(msg, pk.EditConfig): - if _, ok := m.selected(); ok { + if _, ok := m.selected(); ok && m.hasConfig() { m.editing = true m.textarea.Focus() } + case key.Matches(msg, g.ScrollUp): + step := m.detailViewport.Height() / 2 + if step < 1 { + step = 1 + } + m.detailViewport.SetYOffset(m.detailViewport.YOffset() - step) + + case key.Matches(msg, g.ScrollDown): + step := m.detailViewport.Height() / 2 + if step < 1 { + step = 1 + } + m.detailViewport.SetYOffset(m.detailViewport.YOffset() + step) + case key.Matches(msg, g.Help): m.help.ShowAll = !m.help.ShowAll m.recalcSizes() diff --git a/internal/ui/plugins/view.go b/internal/ui/plugins/view.go index c867d24..63895ec 100644 --- a/internal/ui/plugins/view.go +++ b/internal/ui/plugins/view.go @@ -1,10 +1,13 @@ package plugins import ( + "path/filepath" "strings" + "charm.land/glamour/v2" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/anotherhadi/spilltea/internal/config" "github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/style" @@ -27,23 +30,29 @@ func (m Model) View() tea.View { func (m *Model) renderListPanel(w, h int) string { s := style.S + panelStyle := s.PanelFocused + if m.editing { + panelStyle = s.Panel + } dots := s.Faint.Render(m.pager.View()) inner := lipgloss.JoinVertical(lipgloss.Left, m.listViewport.View(), lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), ) - return style.RenderWithTitle(s.PanelFocused, icons.I.Plugin+"Plugins", inner, w, h) + return style.RenderWithTitle(panelStyle, icons.I.Plugin+"Plugins", inner, w, h) } func (m *Model) renderDetailPanel(h int) string { s := style.S + panelStyle := s.Panel + if m.editing { + panelStyle = s.PanelFocused + } info, ok := m.selected() if !ok { - return style.RenderWithTitle(s.Panel, "Config", "", m.width, h) + return style.RenderWithTitle(panelStyle, "Detail", "", m.width, h) } - var sb strings.Builder - statusSt := lipgloss.NewStyle().Foreground(s.Error) if info.Enabled { statusSt = lipgloss.NewStyle().Foreground(s.Success) @@ -52,22 +61,52 @@ func (m *Model) renderDetailPanel(h int) string { if info.Enabled { status = "enabled" } - sb.WriteString(s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n") - sb.WriteString(s.Faint.Render(shortenPath(info.FilePath)) + "\n\n") - if m.editing { - escKey := keys.Keys.Global.Escape.Help().Key - sb.WriteString(s.Faint.Render("editing config (" + escKey + " to save):")) - } else { - editKey := keys.Keys.Plugins.EditConfig.Help().Key - sb.WriteString(s.Faint.Render("config (" + editKey + " to edit):")) + pad := lipgloss.NewStyle().Padding(0, 1) + + header := pad.Render( + s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" + + s.Faint.Render(filepath.Base(info.FilePath)), + ) + + parts := []string{header, m.detailViewport.View()} + + if m.hasConfig() { + var configLabel string + if m.editing { + escKey := keys.Keys.Global.Escape.Help().Key + configLabel = pad.Render(s.Faint.Render("editing config (" + escKey + " to save):")) + } else { + editKey := keys.Keys.Plugins.EditConfig.Help().Key + configLabel = pad.Render(s.Faint.Render("config (" + editKey + " to edit):")) + } + parts = append(parts, "", configLabel, pad.Render(m.textarea.View())) } - inner := lipgloss.JoinVertical(lipgloss.Left, - lipgloss.NewStyle().Padding(0, 1).Render(sb.String()), - lipgloss.NewStyle().Padding(0, 1).Render(m.textarea.View()), + inner := lipgloss.JoinVertical(lipgloss.Left, parts...) + return style.RenderWithTitle(panelStyle, "Detail", inner, m.width, h) +} + +func renderPluginDescription(desc string, width int) string { + desc = strings.TrimSpace(desc) + lines := strings.Split(desc, "\n") + for i, l := range lines { + lines[i] = strings.TrimLeft(l, " \t") + } + desc = strings.Join(lines, "\n") + + r, err := glamour.NewTermRenderer( + glamour.WithStyles(style.GlamourStyleConfig(config.Global)), + glamour.WithWordWrap(width), ) - return style.RenderWithTitle(s.Panel, "Detail", inner, m.width, h) + if err != nil { + return desc + } + out, err := r.Render(desc) + if err != nil { + return desc + } + return strings.Trim(out, "\n") } func (m *Model) renderStatusBar() string { @@ -81,9 +120,9 @@ func (m *Model) renderStatusBar() string { escKey := keys.Keys.Global.Escape.Help().Key accent := lipgloss.NewStyle().Foreground(s.Primary) filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear")) - return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing}))) + return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig()}))) } - return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing})) + return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig()})) } func (m *Model) renderList() string { diff --git a/internal/ui/replay/update.go b/internal/ui/replay/update.go index e36776b..fb6282d 100644 --- a/internal/ui/replay/update.go +++ b/internal/ui/replay/update.go @@ -33,6 +33,18 @@ func sendCmd(entry Entry, index int) tea.Cmd { } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + // Route non-key messages to textarea when editing so internal + // textarea messages (e.g. clipboard paste) are handled correctly. + if m.editing { + if _, ok := msg.(tea.KeyPressMsg); !ok { + var taCmd tea.Cmd + m.textarea, taCmd = m.textarea.Update(msg) + cmds = append(cmds, taCmd) + } + } + switch msg := msg.(type) { case SendToReplayMsg: entry := entryFromMsg(msg) @@ -104,7 +116,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateNormalMode(msg) } - return m, nil + return m, tea.Batch(cmds...) } func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { diff --git a/internal/ui/replay/view.go b/internal/ui/replay/view.go index ff24d1b..c979ddb 100644 --- a/internal/ui/replay/view.go +++ b/internal/ui/replay/view.go @@ -33,12 +33,16 @@ func (m Model) View() tea.View { func (m *Model) renderListPanel(w, h int) string { s := style.S + panelStyle := s.PanelFocused + if m.editing { + panelStyle = s.Panel + } dots := s.Faint.Render(m.pager.View()) inner := lipgloss.JoinVertical(lipgloss.Left, m.listViewport.View(), lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), ) - return style.RenderWithTitle(s.PanelFocused, icons.I.Replay+"Replay", inner, w, h) + return style.RenderWithTitle(panelStyle, icons.I.Replay+"Replay", inner, w, h) } func (m *Model) renderRequestPanel(w, h int) string { diff --git a/plugins.go b/plugins.go new file mode 100644 index 0000000..7c05431 --- /dev/null +++ b/plugins.go @@ -0,0 +1,45 @@ +package spilltea + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +//go:embed plugins/*.lua +var PluginsFS embed.FS + +// InstallDefaultPlugins copies embedded default plugins into dir, skipping +// files that already exist. Returns the number of files written. +func InstallDefaultPlugins(dir string) (int, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return 0, fmt.Errorf("create plugins dir: %w", err) + } + + entries, err := fs.ReadDir(PluginsFS, "plugins") + if err != nil { + return 0, err + } + + written := 0 + for _, e := range entries { + if e.IsDir() { + continue + } + dst := filepath.Join(dir, e.Name()) + if _, err := os.Stat(dst); err == nil { + continue + } + data, err := PluginsFS.ReadFile("plugins/" + e.Name()) + if err != nil { + return written, fmt.Errorf("read embedded %s: %w", e.Name(), err) + } + if err := os.WriteFile(dst, data, 0o644); err != nil { + return written, fmt.Errorf("write %s: %w", dst, err) + } + written++ + } + return written, nil +} diff --git a/.github/plugins_example/inject_header.lua b/plugins/inject_header.lua similarity index 57% rename from .github/plugins_example/inject_header.lua rename to plugins/inject_header.lua index 87dd739..fa37b0c 100644 --- a/.github/plugins_example/inject_header.lua +++ b/plugins/inject_header.lua @@ -1,26 +1,28 @@ --- Inject a custom header into every request. --- Config format (one per line): Header-Name: value - Plugin = { - name = "Inject Header", - on_request = { sync = true }, + name = "Inject Header", + description = [[ +Inject custom headers into every intercepted request. + +**Config**: +- one 'Header-Name: value' per line. + ]], + on_request = { sync = true }, } local headers = {} -function on_start(config_text) +function on_config(config_text) + 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 }) end end - log("loaded " .. #headers .. " header(s)") end function on_request(req) for _, h in ipairs(headers) do req:set_header(h.name, h.value) end - return "forward" end diff --git a/plugins/ip_filter.lua b/plugins/ip_filter.lua new file mode 100644 index 0000000..ed634c9 --- /dev/null +++ b/plugins/ip_filter.lua @@ -0,0 +1,75 @@ +Plugin = { + name = "IP Filter (Whitelist/Blacklist)", + 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 + ]], + on_start = { sync = false }, +} + +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) + end + else + table.insert(whitelist, trimmed) + end + end + end +end + +function on_start() + if #whitelist == 0 and #blacklist == 0 then + return + end + + -- Fetch the current outbound IP via a public API. + local ok, result = pcall(function() + local handle = io.popen("curl -sf https://api.ipify.org 2>/dev/null") + if not handle then return nil end + local ip = handle:read("*a") + handle:close() + return ip and ip:match("^%s*(.-)%s*$") or nil + end) + + if not ok or not result or result == "" then + log("could not determine outbound IP, skipping check") + notif("IP Filter", "Could not determine outbound IP, skipping check", "warning") + return + end + + for _, ip in ipairs(blacklist) do + if result == ip then + notif("IP Filter", "Outbound IP " .. result .. " is blacklisted!", "error") + return + end + end + + if #whitelist == 0 then + return + end + + for _, ip in ipairs(whitelist) do + if result == ip then + return + end + end + + notif("IP Filter", "Outbound IP " .. result .. " is not in the whitelist!", "error") +end diff --git a/plugins/scopes.lua b/plugins/scopes.lua new file mode 100644 index 0000000..29dbb44 --- /dev/null +++ b/plugins/scopes.lua @@ -0,0 +1,78 @@ +Plugin = { + name = "Scopes", + description = [[ +Auto-forward requests and exclude them from history based on patterns. + +**Config**: +- `pattern` - whitelist: only intercept matching requests +- `!pattern` - blacklist: don't intercept matching requests and exclude from history +- lines starting with `#` are comments + +Example (ignore static assets): +``` +!%.css$ +!%.js$ +!%.png$ +``` + +Example (focus on mytarget.com, skip everything else): +``` +mytarget%.com/ +``` + +Example (intercept mytarget.com except its static assets): +``` +mytarget%.com/ +!%.css$ +!%.js$ +!%.png$ +``` + ]], + priority = 100, + on_request = { sync = true }, + on_response = { sync = true }, + on_history_entry = { sync = 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 + table.insert(blacklist, trimmed:sub(2)) + else + table.insert(whitelist, trimmed) + end + end + end +end + +local function should_skip(url) + for _, pattern in ipairs(blacklist) do + if url:match(pattern) then return true end + end + if #whitelist > 0 then + for _, pattern in ipairs(whitelist) do + if url:match(pattern) then return false end + end + return true + end + return false +end + +function on_request(req) + if should_skip(req.url) then return "forward" end +end + +function on_response(req) + if should_skip(req.url) then return "forward" end +end + +function on_history_entry(entry) + if should_skip(entry.host .. entry.path) then return "skip" end +end