mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-07-06 20:42:33 +02:00
Compare commits
16 Commits
6128dcc15e
...
v0.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
| 31bb2aa439 | |||
| b3caa9852c | |||
| 39248e2be6 | |||
| 9d0ef598a7 | |||
| 77b102791f | |||
| 2e0ad98a4a | |||
| b0afb7cb56 | |||
| acee3df1bc | |||
| 61a112a302 | |||
| 677223c9b0 | |||
| 098400ba77 | |||
| 4922f3704d | |||
| 1aa4b92bb3 | |||
| 437e8f883d | |||
| 5c74eda48b | |||
| 1190276bab |
@@ -23,6 +23,7 @@
|
|||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Project Management](#project-management)
|
- [Project Management](#project-management)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
|
- [Per-project configuration](#per-project-configuration)
|
||||||
- [CLI Flags](#cli-flags)
|
- [CLI Flags](#cli-flags)
|
||||||
- [Plugin System](#plugin-system)
|
- [Plugin System](#plugin-system)
|
||||||
- [Vim / Neovim Integration](#vim--neovim-integration)
|
- [Vim / Neovim Integration](#vim--neovim-integration)
|
||||||
@@ -40,7 +41,6 @@ It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, key
|
|||||||
<img alt="demo" src="./.github/assets/demo.gif" width="700" />
|
<img alt="demo" src="./.github/assets/demo.gif" width="700" />
|
||||||
|
|
||||||
<!-- exec: cat ./docs/legal-disclaimer.md -->
|
<!-- exec: cat ./docs/legal-disclaimer.md -->
|
||||||
|
|
||||||
## Legal Disclaimer
|
## Legal Disclaimer
|
||||||
|
|
||||||
**This tool is provided for educational purposes and authorized security testing only.**
|
**This tool is provided for educational purposes and authorized security testing only.**
|
||||||
@@ -48,7 +48,6 @@ It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, key
|
|||||||
Use Spilltea only on systems and networks you own or have explicit written permission to test. Intercepting network traffic without authorization may violate local laws (such as the Computer Fraud and Abuse Act, GDPR, or equivalent legislation in your jurisdiction).
|
Use Spilltea only on systems and networks you own or have explicit written permission to test. Intercepting network traffic without authorization may violate local laws (such as the Computer Fraud and Abuse Act, GDPR, or equivalent legislation in your jurisdiction).
|
||||||
|
|
||||||
The author(s) and contributors are not responsible for any misuse, damage, or legal consequences resulting from the use of this software. By using Spilltea, you agree that you are solely responsible for ensuring your usage is lawful and authorized.
|
The author(s) and contributors are not responsible for any misuse, damage, or legal consequences resulting from the use of this software. By using Spilltea, you agree that you are solely responsible for ensuring your usage is lawful and authorized.
|
||||||
|
|
||||||
<!-- endexec -->
|
<!-- endexec -->
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -103,16 +102,15 @@ environment.systemPackages = [ inputs.spilltea.packages.${pkgs.system}.default ]
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- exec: cat ./docs/basics.md -->
|
<!-- exec: cat ./docs/basics.md -->
|
||||||
|
|
||||||
## Project Management
|
## Project Management
|
||||||
|
|
||||||
Spilltea organizes work into **projects**. Each project maps to a SQLite database file that stores all intercepted traffic for that session & a log files.
|
Spilltea organizes work into **projects**. Each project maps to a SQLite database file that stores all intercepted traffic for that session & a log files.
|
||||||
|
|
||||||
On startup, you choose:
|
On startup, you choose:
|
||||||
|
|
||||||
- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default
|
- **New project**: enter a name, stored in `~/.local/share/spilltea/<name>/` by default
|
||||||
- **Existing project**: pick from a list of previous projects
|
- **Existing project**: pick from a list of previous projects
|
||||||
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot!
|
- **Temporary**: no name needed, stored in `/tmp/spilltea/<random-id>/` and will be deleted on your next reboot!
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -121,22 +119,39 @@ Check the default configuration with all the options [here](./internal/config/de
|
|||||||
|
|
||||||
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
||||||
|
|
||||||
|
### Per-project configuration
|
||||||
|
|
||||||
|
You can override any config value on a per-project basis by placing a `config.yaml` file inside the project directory (e.g. `~/.local/share/spilltea/projects/my-project/config.yaml`).
|
||||||
|
|
||||||
|
Only the keys present in that file are overridden; everything else falls back to the global config.
|
||||||
|
|
||||||
|
The priority order is:
|
||||||
|
|
||||||
|
1. Global config (`~/.config/spilltea/config.yaml`)
|
||||||
|
2. Project config (`<project-dir>/config.yaml`)
|
||||||
|
3. CLI flags (always win)
|
||||||
|
|
||||||
## CLI Flags
|
## CLI Flags
|
||||||
|
|
||||||
```
|
```
|
||||||
Usage: spilltea [flags]
|
A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
spilltea [flags]
|
||||||
|
|
||||||
|
Flags:
|
||||||
--add-default-config copy the default config file to the config path and exit
|
--add-default-config copy the default config file to the config path and exit
|
||||||
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
||||||
-c, --config string path to config file
|
-c, --config string path to config file
|
||||||
|
-h, --help help for spilltea
|
||||||
--host string proxy host (overrides config)
|
--host string proxy host (overrides config)
|
||||||
--plugins-dir string path to plugins dir (overrides config)
|
--plugins-dir string path to plugins dir (overrides config)
|
||||||
-p, --port int proxy port (overrides config)
|
-p, --port int proxy port (overrides config)
|
||||||
-P, --project string project name to open directly, or "tmp" for a temporary session
|
-P, --project string project name to open directly, or "tmp" for a temporary session
|
||||||
|
--ssl-insecure skip TLS certificate verification (overrides config)
|
||||||
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
||||||
-v, --version print version
|
-v, --version version for spilltea
|
||||||
```
|
```
|
||||||
|
|
||||||
<!-- endexec -->
|
<!-- endexec -->
|
||||||
|
|
||||||
## Plugin System
|
## Plugin System
|
||||||
|
|||||||
+140
-126
@@ -15,7 +15,7 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
||||||
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
||||||
flag "github.com/spf13/pflag"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
|
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
|
||||||
@@ -32,136 +32,150 @@ func init() {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
var (
|
||||||
flagConfig = flag.StringP("config", "c", "", "path to config file")
|
flagConfig string
|
||||||
flagPluginsDir = flag.String("plugins-dir", "", "path to plugins dir (overrides config)")
|
flagPluginsDir string
|
||||||
flagHost = flag.String("host", "", "proxy host (overrides config)")
|
flagHost string
|
||||||
flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)")
|
flagPort int
|
||||||
flagUpstreamProxy = flag.String("upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)")
|
flagUpstreamProxy string
|
||||||
flagVersion = flag.BoolP("version", "v", false, "print version")
|
flagProject string
|
||||||
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
|
flagSslInsecure bool
|
||||||
flagAddDefaultPlugins = flag.Bool("add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
|
flagAddDefaultPlugins bool
|
||||||
flagAddDefaultConfig = flag.Bool("add-default-config", false, "copy the default config file to the config path and exit")
|
flagAddDefaultConfig bool
|
||||||
)
|
)
|
||||||
flag.CommandLine.SetOutput(os.Stdout)
|
|
||||||
flag.Usage = func() {
|
|
||||||
fmt.Println("Usage: spilltea [flags]")
|
|
||||||
fmt.Println("")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
}
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *flagVersion {
|
rootCmd := &cobra.Command{
|
||||||
fmt.Println(version)
|
Use: "spilltea",
|
||||||
os.Exit(0)
|
Short: "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.",
|
||||||
}
|
Version: version,
|
||||||
|
SilenceUsage: true,
|
||||||
if *flagAddDefaultPlugins {
|
SilenceErrors: true,
|
||||||
home, _ := os.UserHomeDir()
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
if flagAddDefaultPlugins {
|
||||||
if *flagConfig != "" {
|
home, _ := os.UserHomeDir()
|
||||||
cfgPath = *flagConfig
|
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
||||||
}
|
if flagConfig != "" {
|
||||||
if err := config.Load(cfgPath); err != nil {
|
cfgPath = flagConfig
|
||||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
}
|
||||||
os.Exit(1)
|
if err := config.Load(cfgPath); err != nil {
|
||||||
}
|
return fmt.Errorf("config: %w", err)
|
||||||
dir := config.ExpandPath(config.Global.App.PluginsDir)
|
}
|
||||||
if *flagPluginsDir != "" {
|
dir := config.ExpandPath(config.Global.App.PluginsDir)
|
||||||
dir = *flagPluginsDir
|
if flagPluginsDir != "" {
|
||||||
}
|
dir = flagPluginsDir
|
||||||
n, err := spilltea.InstallDefaultPlugins(dir)
|
}
|
||||||
if err != nil {
|
n, err := spilltea.InstallDefaultPlugins(dir)
|
||||||
fmt.Fprintf(os.Stderr, "add-default-plugins: %v\n", err)
|
if err != nil {
|
||||||
os.Exit(1)
|
return fmt.Errorf("add-default-plugins: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("added %d plugin(s) to %s\n", n, dir)
|
fmt.Printf("added %d plugin(s) to %s\n", n, dir)
|
||||||
os.Exit(0)
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
if *flagAddDefaultConfig {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
|
||||||
if *flagConfig != "" {
|
|
||||||
cfgPath = *flagConfig
|
|
||||||
}
|
|
||||||
if err := config.WriteDefaultConfig(cfgPath); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "add-default-config: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("default config written to %s\n", cfgPath)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
cfgPath := filepath.Join(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)
|
|
||||||
}
|
|
||||||
config.Global.Version = version
|
|
||||||
|
|
||||||
if *flagPluginsDir != "" {
|
|
||||||
config.Global.App.PluginsDir = *flagPluginsDir
|
|
||||||
}
|
|
||||||
if *flagHost != "" {
|
|
||||||
config.Global.App.Host = *flagHost
|
|
||||||
}
|
|
||||||
if *flagPort != 0 {
|
|
||||||
config.Global.App.Port = *flagPort
|
|
||||||
}
|
|
||||||
if *flagUpstreamProxy != "" {
|
|
||||||
config.Global.App.UpstreamProxy = *flagUpstreamProxy
|
|
||||||
}
|
|
||||||
|
|
||||||
style.Init()
|
|
||||||
icons.Init(config.Global)
|
|
||||||
keys.Init(config.Global)
|
|
||||||
|
|
||||||
projectDir := config.ExpandPath(config.Global.App.ProjectDir)
|
|
||||||
|
|
||||||
// If --project flag is set, skip the home screen entirely.
|
|
||||||
if *flagProject != "" {
|
|
||||||
project, err := homeUI.OpenProject(projectDir, *flagProject)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "project: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
broker := intercept.NewBroker()
|
|
||||||
m := appUI.New(broker, project.Name, project.Path)
|
|
||||||
final, err := tea.NewProgram(m).Run()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if app, ok := final.(appUI.Model); ok {
|
|
||||||
if ferr := app.FatalErr(); ferr != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", ferr)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return
|
if flagAddDefaultConfig {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
||||||
|
if flagConfig != "" {
|
||||||
|
cfgPath = flagConfig
|
||||||
|
}
|
||||||
|
if err := config.WriteDefaultConfig(cfgPath); err != nil {
|
||||||
|
return fmt.Errorf("add-default-config: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("default config written to %s\n", cfgPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagProject != "" && !homeUI.IsValidProjectName(flagProject) {
|
||||||
|
return fmt.Errorf("project: invalid name %q (only lowercase letters, digits, - and _ are allowed)", flagProject)
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
||||||
|
if flagConfig != "" {
|
||||||
|
cfgPath = flagConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Load(cfgPath); err != nil {
|
||||||
|
return fmt.Errorf("config: %w", err)
|
||||||
|
}
|
||||||
|
config.Global.Version = version
|
||||||
|
|
||||||
|
sslInsecureChanged := cmd.Flags().Changed("ssl-insecure")
|
||||||
|
applyCLI := func(c *config.Config) {
|
||||||
|
if flagPluginsDir != "" {
|
||||||
|
c.App.PluginsDir = flagPluginsDir
|
||||||
|
}
|
||||||
|
if flagHost != "" {
|
||||||
|
c.App.Host = flagHost
|
||||||
|
}
|
||||||
|
if flagPort != 0 {
|
||||||
|
c.App.Port = flagPort
|
||||||
|
}
|
||||||
|
if flagUpstreamProxy != "" {
|
||||||
|
c.App.UpstreamProxy = flagUpstreamProxy
|
||||||
|
}
|
||||||
|
if sslInsecureChanged {
|
||||||
|
c.App.SslInsecure = flagSslInsecure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyCLI(config.Global)
|
||||||
|
config.SetCLIOverrides(applyCLI)
|
||||||
|
|
||||||
|
style.Init()
|
||||||
|
icons.Init(config.Global)
|
||||||
|
keys.Init(config.Global)
|
||||||
|
|
||||||
|
projectDir := config.ExpandPath(config.Global.App.ProjectDir)
|
||||||
|
|
||||||
|
if flagProject != "" {
|
||||||
|
project, err := homeUI.OpenProject(projectDir, flagProject)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("project: %w", err)
|
||||||
|
}
|
||||||
|
if err := config.MergeProjectConfig(filepath.Dir(project.Path)); err != nil {
|
||||||
|
return fmt.Errorf("project config: %w", err)
|
||||||
|
}
|
||||||
|
icons.Init(config.Global)
|
||||||
|
keys.Init(config.Global)
|
||||||
|
broker := intercept.NewBroker()
|
||||||
|
m := appUI.New(broker, project.Name, project.Path)
|
||||||
|
final, err := tea.NewProgram(m).Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tui: %w", err)
|
||||||
|
}
|
||||||
|
if app, ok := final.(appUI.Model); ok {
|
||||||
|
if ferr := app.FatalErr(); ferr != nil {
|
||||||
|
return ferr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
root := rootModel{home: homeUI.New(projectDir)}
|
||||||
|
final, err := tea.NewProgram(root).Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tui: %w", err)
|
||||||
|
}
|
||||||
|
if r, ok := final.(rootModel); ok {
|
||||||
|
if ferr := r.FatalErr(); ferr != nil {
|
||||||
|
return ferr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run home + app in a single program to avoid a blank flash on transition.
|
rootCmd.Flags().StringVarP(&flagConfig, "config", "c", "", "path to config file")
|
||||||
root := rootModel{home: homeUI.New(projectDir)}
|
rootCmd.Flags().StringVar(&flagPluginsDir, "plugins-dir", "", "path to plugins dir (overrides config)")
|
||||||
final, err := tea.NewProgram(root).Run()
|
rootCmd.Flags().StringVar(&flagHost, "host", "", "proxy host (overrides config)")
|
||||||
if err != nil {
|
rootCmd.Flags().IntVarP(&flagPort, "port", "p", 0, "proxy port (overrides config)")
|
||||||
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
rootCmd.Flags().StringVar(&flagUpstreamProxy, "upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)")
|
||||||
|
rootCmd.Flags().BoolVar(&flagSslInsecure, "ssl-insecure", false, "skip TLS certificate verification (overrides config)")
|
||||||
|
rootCmd.Flags().StringVarP(&flagProject, "project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
|
||||||
|
rootCmd.Flags().BoolVar(&flagAddDefaultPlugins, "add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
|
||||||
|
rootCmd.Flags().BoolVar(&flagAddDefaultConfig, "add-default-config", false, "copy the default config file to the config path and exit")
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if r, ok := final.(rootModel); ok {
|
|
||||||
if ferr := r.FatalErr(); ferr != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", ferr)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
||||||
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
||||||
)
|
)
|
||||||
@@ -34,6 +40,11 @@ func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
if m.state == rootStateHome {
|
if m.state == rootStateHome {
|
||||||
if sel, ok := msg.(homeUI.ProjectSelectedMsg); ok {
|
if sel, ok := msg.(homeUI.ProjectSelectedMsg); ok {
|
||||||
|
if err := config.MergeProjectConfig(filepath.Dir(sel.Project.Path)); err != nil {
|
||||||
|
log.Printf("project config: %v", err)
|
||||||
|
}
|
||||||
|
icons.Init(config.Global)
|
||||||
|
keys.Init(config.Global)
|
||||||
broker := intercept.NewBroker()
|
broker := intercept.NewBroker()
|
||||||
app := appUI.New(broker, sel.Project.Name, sel.Project.Path)
|
app := appUI.New(broker, sel.Project.Name, sel.Project.Path)
|
||||||
m.app = app
|
m.app = app
|
||||||
|
|||||||
+22
-4
@@ -4,9 +4,9 @@ Spilltea organizes work into **projects**. Each project maps to a SQLite databas
|
|||||||
|
|
||||||
On startup, you choose:
|
On startup, you choose:
|
||||||
|
|
||||||
- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default
|
- **New project**: enter a name, stored in `~/.local/share/spilltea/<name>/` by default
|
||||||
- **Existing project**: pick from a list of previous projects
|
- **Existing project**: pick from a list of previous projects
|
||||||
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot!
|
- **Temporary**: no name needed, stored in `/tmp/spilltea/<random-id>/` and will be deleted on your next reboot!
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -15,20 +15,38 @@ Check the default configuration with all the options [here](./internal/config/de
|
|||||||
|
|
||||||
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
||||||
|
|
||||||
|
### Per-project configuration
|
||||||
|
|
||||||
|
You can override any config value on a per-project basis by placing a `config.yaml` file inside the project directory (e.g. `~/.local/share/spilltea/projects/my-project/config.yaml`).
|
||||||
|
|
||||||
|
Only the keys present in that file are overridden; everything else falls back to the global config.
|
||||||
|
|
||||||
|
The priority order is:
|
||||||
|
|
||||||
|
1. Global config (`~/.config/spilltea/config.yaml`)
|
||||||
|
2. Project config (`<project-dir>/config.yaml`)
|
||||||
|
3. CLI flags (always win)
|
||||||
|
|
||||||
## CLI Flags
|
## CLI Flags
|
||||||
|
|
||||||
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
|
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
|
||||||
```
|
```
|
||||||
Usage: spilltea [flags]
|
A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
spilltea [flags]
|
||||||
|
|
||||||
|
Flags:
|
||||||
--add-default-config copy the default config file to the config path and exit
|
--add-default-config copy the default config file to the config path and exit
|
||||||
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
||||||
-c, --config string path to config file
|
-c, --config string path to config file
|
||||||
|
-h, --help help for spilltea
|
||||||
--host string proxy host (overrides config)
|
--host string proxy host (overrides config)
|
||||||
--plugins-dir string path to plugins dir (overrides config)
|
--plugins-dir string path to plugins dir (overrides config)
|
||||||
-p, --port int proxy port (overrides config)
|
-p, --port int proxy port (overrides config)
|
||||||
-P, --project string project name to open directly, or "tmp" for a temporary session
|
-P, --project string project name to open directly, or "tmp" for a temporary session
|
||||||
|
--ssl-insecure skip TLS certificate verification (overrides config)
|
||||||
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
||||||
-v, --version print version
|
-v, --version version for spilltea
|
||||||
```
|
```
|
||||||
<!-- endexec -->
|
<!-- endexec -->
|
||||||
|
|||||||
@@ -11,5 +11,7 @@
|
|||||||
# Spilltea Documentation
|
# Spilltea Documentation
|
||||||
|
|
||||||
- **Version**: `{{.Cfg.Version}}`
|
- **Version**: `{{.Cfg.Version}}`
|
||||||
|
- **Proxy**: `{{.Cfg.App.Host}}:{{.Cfg.App.Port}}`
|
||||||
|
- **SSL Insecure**: `{{.Cfg.App.SslInsecure}}`
|
||||||
- **Repository**: `https://github.com/anotherhadi/spilltea`
|
- **Repository**: `https://github.com/anotherhadi/spilltea`
|
||||||
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
|
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
|
||||||
|
|||||||
+1
-1
@@ -155,7 +155,7 @@ If the user **dismisses** a finding it is permanently hidden and will never reap
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Plugin configuration is stored in a `plugins.yaml` file alongside the project database.
|
Plugin configuration is stored in a `plugins.yaml` file alongside the project database and can be edited through the "Proxy" TUI page.
|
||||||
Each plugin is keyed by its filename (without the `.lua` extension) and has an `enable` toggle and an optional `config` block (arbitrary YAML).
|
Each plugin is keyed by its filename (without the `.lua` extension) and has an `enable` toggle and an optional `config` block (arbitrary YAML).
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
+7
-1
@@ -1,6 +1,12 @@
|
|||||||
## Configuring your browser's proxy settings
|
## Configuring your browser's proxy settings
|
||||||
|
|
||||||
We recommend installing **FoxyProxy** to manage your browser's proxies.
|
**If you use Nix**, you can launch a pre-configured browser (LibreWolf) with the proxy and certificate already set up:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run github:anotherhadi/spilltea#browser{{if or (ne .Cfg.App.Host "127.0.0.1") (ne .Cfg.App.Port 8080) (ne .Cfg.App.CertDir "~/.local/share/spilltea")}} --{{end}}{{if ne .Cfg.App.Host "127.0.0.1"}} --host {{.Cfg.App.Host}}{{end}}{{if ne .Cfg.App.Port 8080}} --port {{.Cfg.App.Port}}{{end}}{{if ne .Cfg.App.CertDir "~/.local/share/spilltea"}} --cert-dir {{.Cfg.App.CertDir}}{{end}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise, we recommend installing **FoxyProxy** to manage your browser's proxies.
|
||||||
You can install it from the [Google Chrome extension store](https://chromewebstore.google.com/) or from the [Firefox extension store](https://addons.mozilla.org/en-US/firefox/extensions)
|
You can install it from the [Google Chrome extension store](https://chromewebstore.google.com/) or from the [Firefox extension store](https://addons.mozilla.org/en-US/firefox/extensions)
|
||||||
|
|
||||||
1. Open FoxyProxy's options, then click on `Add New Proxy` button.
|
1. Open FoxyProxy's options, then click on `Add New Proxy` button.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ require (
|
|||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/yuin/gopher-lua v1.1.2
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
golang.org/x/net v0.54.0
|
golang.org/x/net v0.55.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.50.1
|
modernc.org/sqlite v1.50.1
|
||||||
)
|
)
|
||||||
@@ -41,6 +41,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/gorilla/websocket v1.5.0 // indirect
|
github.com/gorilla/websocket v1.5.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
@@ -55,6 +56,7 @@ require (
|
|||||||
github.com/satori/go.uuid v1.2.0 // indirect
|
github.com/satori/go.uuid v1.2.0 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
@@ -62,7 +64,7 @@ require (
|
|||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
|||||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
@@ -72,6 +73,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
||||||
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@@ -104,6 +107,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
@@ -116,6 +120,9 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
|||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
@@ -140,12 +147,12 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
|
|||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type Config struct {
|
|||||||
ProxyAuth string `mapstructure:"proxy_auth"`
|
ProxyAuth string `mapstructure:"proxy_auth"`
|
||||||
MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
|
MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
|
||||||
ExternalEditor string `mapstructure:"external_editor"`
|
ExternalEditor string `mapstructure:"external_editor"`
|
||||||
|
SslInsecure bool `mapstructure:"ssl_insecure"`
|
||||||
} `mapstructure:"app"`
|
} `mapstructure:"app"`
|
||||||
|
|
||||||
TUI struct {
|
TUI struct {
|
||||||
@@ -64,6 +65,43 @@ type GlobalPlugin struct {
|
|||||||
|
|
||||||
var Global *Config
|
var Global *Config
|
||||||
|
|
||||||
|
var projectCLIOverrides func(*Config)
|
||||||
|
|
||||||
|
// SetCLIOverrides stores a function that re-applies CLI flag overrides.
|
||||||
|
// It is called automatically after MergeProjectConfig so that CLI flags
|
||||||
|
// always win over both global and per-project config.
|
||||||
|
func SetCLIOverrides(fn func(*Config)) {
|
||||||
|
projectCLIOverrides = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeProjectConfig merges <projectDir>/config.yaml on top of the current
|
||||||
|
// Global config, then re-applies any registered CLI overrides.
|
||||||
|
// If no config.yaml exists in the project directory, this is a no-op.
|
||||||
|
func MergeProjectConfig(projectDir string) error {
|
||||||
|
projectConfigPath := filepath.Join(projectDir, "config.yaml")
|
||||||
|
if _, err := os.Stat(projectConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pv := viper.New()
|
||||||
|
pv.SetConfigFile(projectConfigPath)
|
||||||
|
if err := pv.ReadInConfig(); err != nil {
|
||||||
|
return fmt.Errorf("project config: %w", err)
|
||||||
|
}
|
||||||
|
if err := viper.MergeConfigMap(pv.AllSettings()); err != nil {
|
||||||
|
return fmt.Errorf("merge project config: %w", err)
|
||||||
|
}
|
||||||
|
if err := viper.Unmarshal(Global); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
Global.App.ProxyAuth = expandEnvValue(Global.App.ProxyAuth)
|
||||||
|
Global.App.UpstreamProxy = expandEnvValue(Global.App.UpstreamProxy)
|
||||||
|
if projectCLIOverrides != nil {
|
||||||
|
projectCLIOverrides(Global)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func Load(path string) error {
|
func Load(path string) error {
|
||||||
var defaults map[string]any
|
var defaults map[string]any
|
||||||
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
|
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
|
||||||
@@ -82,11 +120,16 @@ func Load(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Global = &Config{}
|
Global = &Config{}
|
||||||
return viper.Unmarshal(Global)
|
if err := viper.Unmarshal(Global); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
Global.App.ProxyAuth = expandEnvValue(Global.App.ProxyAuth)
|
||||||
|
Global.App.UpstreamProxy = expandEnvValue(Global.App.UpstreamProxy)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func WriteDefaultConfig(path string) error {
|
func WriteDefaultConfig(path string) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
return fmt.Errorf("create config dir: %w", err)
|
return fmt.Errorf("create config dir: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
|
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
|
||||||
@@ -106,6 +149,17 @@ func ExpandPath(p string) string {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expandEnvValue replaces a value starting with "$" with the corresponding
|
||||||
|
// environment variable, enabling secrets to be kept out of config files.
|
||||||
|
func expandEnvValue(s string) string {
|
||||||
|
if len(s) > 1 && s[0] == '$' {
|
||||||
|
if val := os.Getenv(s[1:]); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func flatten(prefix string, m map[string]any) map[string]any {
|
func flatten(prefix string, m map[string]any) map[string]any {
|
||||||
out := make(map[string]any)
|
out := make(map[string]any)
|
||||||
for k, v := range m {
|
for k, v := range m {
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ app:
|
|||||||
cert_dir: ~/.local/share/spilltea
|
cert_dir: ~/.local/share/spilltea
|
||||||
project_dir: ~/.local/share/spilltea
|
project_dir: ~/.local/share/spilltea
|
||||||
plugins_dir: ~/.config/spilltea/plugins
|
plugins_dir: ~/.config/spilltea/plugins
|
||||||
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888
|
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or $ENV_VAR
|
||||||
proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled). Also run: chmod 600 ~/.config/spilltea/config.yaml
|
proxy_auth: "" # require basic auth to use the spilltea proxy, format: user:pass or $ENV_VAR (empty = disabled).
|
||||||
max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB)
|
max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB)
|
||||||
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
|
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
|
||||||
|
ssl_insecure: false # bypass TLS certificate verification (enable for self-signed cert targets)
|
||||||
|
|
||||||
intercept:
|
intercept:
|
||||||
default_intercept_enabled: true
|
default_intercept_enabled: true
|
||||||
default_capture_response: false
|
default_capture_response: false
|
||||||
queue_size: 64 # max pending intercepted requests/responses before the proxy blocks
|
queue_size: 128 # max pending intercepted requests/responses before the proxy blocks
|
||||||
auto_forward_regex:
|
auto_forward_regex:
|
||||||
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ tui:
|
|||||||
keybindings:
|
keybindings:
|
||||||
global:
|
global:
|
||||||
quit: "q,ctrl+c"
|
quit: "q,ctrl+c"
|
||||||
|
escape: "esc,ctrl+c"
|
||||||
help: "?"
|
help: "?"
|
||||||
open_logs: "ctrl+g"
|
open_logs: "ctrl+g"
|
||||||
toggle_sidebar: "ctrl+b"
|
toggle_sidebar: "ctrl+b"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
type GlobalKeys struct {
|
type GlobalKeys struct {
|
||||||
Quit string `mapstructure:"quit"`
|
Quit string `mapstructure:"quit"`
|
||||||
|
Escape string `mapstructure:"escape"`
|
||||||
OpenLogs string `mapstructure:"open_logs"`
|
OpenLogs string `mapstructure:"open_logs"`
|
||||||
ToggleSidebar string `mapstructure:"toggle_sidebar"`
|
ToggleSidebar string `mapstructure:"toggle_sidebar"`
|
||||||
Help string `mapstructure:"help"`
|
Help string `mapstructure:"help"`
|
||||||
|
|||||||
+16
-5
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
@@ -25,17 +26,25 @@ func Open(path string) (*DB, error) {
|
|||||||
conn.SetMaxOpenConns(1)
|
conn.SetMaxOpenConns(1)
|
||||||
d := &DB{conn: conn, path: path}
|
d := &DB{conn: conn, path: path}
|
||||||
if err := d.migrate(); err != nil {
|
if err := d.migrate(); err != nil {
|
||||||
conn.Close()
|
if cerr := conn.Close(); cerr != nil {
|
||||||
|
log.Printf("db: close conn: %v", cerr)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
roConn, err := sql.Open("sqlite", path)
|
roConn, err := sql.Open("sqlite", path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Close()
|
if cerr := conn.Close(); cerr != nil {
|
||||||
|
log.Printf("db: close conn: %v", cerr)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
|
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
|
||||||
conn.Close()
|
if cerr := conn.Close(); cerr != nil {
|
||||||
roConn.Close()
|
log.Printf("db: close conn: %v", cerr)
|
||||||
|
}
|
||||||
|
if cerr := roConn.Close(); cerr != nil {
|
||||||
|
log.Printf("db: close roConn: %v", cerr)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
d.roConn = roConn
|
d.roConn = roConn
|
||||||
@@ -114,6 +123,8 @@ func CountEntriesAt(path string) int {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
var n int
|
var n int
|
||||||
conn.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n)
|
if err := conn.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n); err != nil {
|
||||||
|
log.Printf("db: count entries: %v", err)
|
||||||
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
|||||||
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &flagged); err != nil {
|
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &flagged); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
var tsErr error
|
||||||
|
e.Timestamp, tsErr = time.Parse(time.RFC3339, ts)
|
||||||
|
if tsErr != nil {
|
||||||
|
log.Printf("db: parse timestamp %q: %v", ts, tsErr)
|
||||||
|
}
|
||||||
e.Flagged = flagged != 0
|
e.Flagged = flagged != 0
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
@@ -121,7 +125,7 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
|||||||
// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
|
// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
|
||||||
// DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
|
// DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
|
||||||
func (d *DB) QueryEntries(where string) ([]Entry, error) {
|
func (d *DB) QueryEntries(where string) ([]Entry, error) {
|
||||||
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where)
|
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where) // #nosec G202 -- intentional: user-supplied WHERE clause on a PRAGMA query_only=ON connection
|
||||||
rows, err := d.roConn.Query(q)
|
rows, err := d.roConn.Query(q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,7 +60,11 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
|
|||||||
&e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg); err != nil {
|
&e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
var tsErr error
|
||||||
|
e.Timestamp, tsErr = time.Parse(time.RFC3339, ts)
|
||||||
|
if tsErr != nil {
|
||||||
|
log.Printf("db: parse replay timestamp %q: %v", ts, tsErr)
|
||||||
|
}
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
return entries, rows.Err()
|
return entries, rows.Err()
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
|
|||||||
CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
|
CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
|
||||||
CopyAs: binding(cfg.CopyAs, "copy as..."),
|
CopyAs: binding(cfg.CopyAs, "copy as..."),
|
||||||
Copy: binding(cfg.Copy, "copy..."),
|
Copy: binding(cfg.Copy, "copy..."),
|
||||||
Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
|
Escape: binding(cfg.Escape, "cancel"),
|
||||||
SendToReplay: binding(cfg.SendToReplay, "send to replay"),
|
SendToReplay: binding(cfg.SendToReplay, "send to replay"),
|
||||||
ScrollUp: binding(cfg.ScrollUp, "scroll up"),
|
ScrollUp: binding(cfg.ScrollUp, "scroll up"),
|
||||||
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
|
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
|||||||
func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
||||||
L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int {
|
L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int {
|
||||||
msg := L.CheckString(1)
|
msg := L.CheckString(1)
|
||||||
log.Printf("[plugin:%s] %s", p.Name, msg)
|
log.Printf("plugin %s: %s", p.Name, msg)
|
||||||
return 0
|
return 0
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[plugin:%s] create_finding error: %v", p.Name, err)
|
log.Printf("plugin %s: create_finding error: %v", p.Name, err)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
_ = inserted
|
_ = inserted
|
||||||
@@ -281,7 +281,7 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
c := exec.CommandContext(ctx, "sh", "-c", cmd)
|
c := exec.CommandContext(ctx, "sh", "-c", cmd) // #nosec G204 -- intentional: shell_pipe is a Lua plugin API for user-written scripts
|
||||||
c.Stdin = strings.NewReader(input)
|
c.Stdin = strings.NewReader(input)
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
@@ -337,7 +337,7 @@ func luaTableString(t *lua.LTable, key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
|
func pushRequest(L *lua.LState, p *Plugin, f *goproxy.Flow) *lua.LTable {
|
||||||
t := L.NewTable()
|
t := L.NewTable()
|
||||||
r := f.Request
|
r := f.Request
|
||||||
L.SetField(t, "method", lua.LString(r.Method))
|
L.SetField(t, "method", lua.LString(r.Method))
|
||||||
@@ -377,7 +377,7 @@ func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
|
|||||||
L.SetField(t, "set_url", L.NewFunction(func(L *lua.LState) int {
|
L.SetField(t, "set_url", L.NewFunction(func(L *lua.LState) int {
|
||||||
parsed, err := url.Parse(L.CheckString(2))
|
parsed, err := url.Parse(L.CheckString(2))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[plugin] set_url: %v", err)
|
log.Printf("plugin %s: set_url: %v", p.Name, err)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
r.URL = parsed
|
r.URL = parsed
|
||||||
|
|||||||
@@ -395,25 +395,25 @@ func (m *Manager) runAsyncForPlugins(hookName string, argsFor func(*Plugin) []lu
|
|||||||
|
|
||||||
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
|
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
|
||||||
return m.runSyncDecisionForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
return m.runSyncDecisionForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
||||||
return []lua.LValue{pushRequest(p.L, f)}
|
return []lua.LValue{pushRequest(p.L, p, f)}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
|
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
|
||||||
m.runAsyncForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
m.runAsyncForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
||||||
return []lua.LValue{pushRequest(p.L, f)}
|
return []lua.LValue{pushRequest(p.L, p, f)}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
|
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
|
||||||
return m.runSyncDecisionForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
return m.runSyncDecisionForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
||||||
return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
|
return []lua.LValue{pushRequest(p.L, p, f), pushResponse(p.L, f)}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
|
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
|
||||||
m.runAsyncForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
m.runAsyncForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
||||||
return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
|
return []lua.LValue{pushRequest(p.L, p, f), pushResponse(p.L, f)}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ func OpenPluginsFile(dbPath string) (*PluginsFile, error) {
|
|||||||
path: path,
|
path: path,
|
||||||
data: pluginsFileData{Plugins: make(map[string]pluginFileEntry)},
|
data: pluginsFileData{Plugins: make(map[string]pluginFileEntry)},
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path) // #nosec G304 -- path is filepath.Join(dbDir, "plugins.yaml"), hardcoded filename
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return pf, nil
|
return pf, nil
|
||||||
}
|
}
|
||||||
@@ -76,7 +77,9 @@ func (pf *PluginsFile) register(id string, defaultEnabled bool, configText strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pf.data.Plugins[id] = entry
|
pf.data.Plugins[id] = entry
|
||||||
_ = pf.save()
|
if err := pf.save(); err != nil {
|
||||||
|
log.Printf("plugin %s: register save: %v", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
|||||||
StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
|
StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
|
||||||
CaRootPath: caPath,
|
CaRootPath: caPath,
|
||||||
Upstream: cfg.UpstreamProxy,
|
Upstream: cfg.UpstreamProxy,
|
||||||
|
SslInsecure: cfg.SslInsecure,
|
||||||
}
|
}
|
||||||
|
|
||||||
p, err := goproxy.NewProxy(opts)
|
p, err := goproxy.NewProxy(opts)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -37,11 +36,9 @@ func tickCmd() tea.Cmd {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var sidebarEntries = pageRegistry
|
|
||||||
|
|
||||||
var pageShortcuts = func() map[string]page {
|
var pageShortcuts = func() map[string]page {
|
||||||
m := make(map[string]page, len(sidebarEntries))
|
m := make(map[string]page, len(pageRegistry))
|
||||||
for i, e := range sidebarEntries {
|
for i, e := range pageRegistry {
|
||||||
m[strconv.Itoa(i+1)] = e.id
|
m[strconv.Itoa(i+1)] = e.id
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
@@ -56,6 +53,7 @@ type Model struct {
|
|||||||
logFile *os.File
|
logFile *os.File
|
||||||
pluginManager *plugins.Manager
|
pluginManager *plugins.Manager
|
||||||
fatalErr error
|
fatalErr error
|
||||||
|
logFileErr error
|
||||||
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
@@ -97,8 +95,8 @@ func New(broker *intercept.Broker, name, path string) Model {
|
|||||||
|
|
||||||
d, err := db.Open(path)
|
d, err := db.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "db: %v\n", err)
|
m.fatalErr = err
|
||||||
os.Exit(1)
|
return m
|
||||||
}
|
}
|
||||||
m.database = d
|
m.database = d
|
||||||
broker.SetDB(d)
|
broker.SetDB(d)
|
||||||
@@ -131,6 +129,8 @@ func New(broker *intercept.Broker, name, path string) Model {
|
|||||||
m.logFile = lf
|
m.logFile = lf
|
||||||
log.SetOutput(lf)
|
log.SetOutput(lf)
|
||||||
logrus.SetOutput(lf)
|
logrus.SetOutput(lf)
|
||||||
|
} else {
|
||||||
|
m.logFileErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return m
|
return m
|
||||||
@@ -140,7 +140,7 @@ func (m Model) FatalErr() error { return m.fatalErr }
|
|||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
mgr := m.pluginManager
|
mgr := m.pluginManager
|
||||||
return tea.Batch(
|
cmds := []tea.Cmd{
|
||||||
intercept.WaitForRequest(m.broker),
|
intercept.WaitForRequest(m.broker),
|
||||||
intercept.WaitForResponse(m.broker),
|
intercept.WaitForResponse(m.broker),
|
||||||
tickCmd(),
|
tickCmd(),
|
||||||
@@ -149,5 +149,16 @@ func (m Model) Init() tea.Cmd {
|
|||||||
plugins.WaitForQuit(mgr),
|
plugins.WaitForQuit(mgr),
|
||||||
findingsUI.RefreshCmd(m.database),
|
findingsUI.RefreshCmd(m.database),
|
||||||
func() tea.Msg { mgr.RunOnStart(); return nil },
|
func() tea.Msg { mgr.RunOnStart(); return nil },
|
||||||
)
|
}
|
||||||
|
if m.logFileErr != nil {
|
||||||
|
err := m.logFileErr
|
||||||
|
cmds = append(cmds, func() tea.Msg {
|
||||||
|
return notificationsUI.NotificationMsg{
|
||||||
|
Title: "Warning",
|
||||||
|
Body: "Could not open log file: " + err.Error(),
|
||||||
|
Kind: notificationsUI.KindWarning,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func (m *Model) renderSidebar() string {
|
|||||||
var items strings.Builder
|
var items strings.Builder
|
||||||
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
|
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
|
||||||
|
|
||||||
for i, entry := range sidebarEntries {
|
for i, entry := range pageRegistry {
|
||||||
selected := entry.id == m.page
|
selected := entry.id == m.page
|
||||||
badgeStyle, textStyle := badgeNormal, textNormal
|
badgeStyle, textStyle := badgeNormal, textNormal
|
||||||
if selected {
|
if selected {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -21,6 +20,7 @@ import (
|
|||||||
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
|
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
|
||||||
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
|
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
|
||||||
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
|
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -162,23 +162,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
// ctrl+c always quits, even when a textarea is focused.
|
|
||||||
if msg.String() == "ctrl+c" {
|
|
||||||
m.pluginManager.RunOnQuit()
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() {
|
if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() {
|
||||||
m.pluginManager.RunOnQuit()
|
m.pluginManager.RunOnQuit()
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|
||||||
if key.Matches(msg, keys.Keys.Global.OpenLogs) {
|
if key.Matches(msg, keys.Keys.Global.OpenLogs) {
|
||||||
editor := os.Getenv("EDITOR")
|
|
||||||
if editor == "" {
|
|
||||||
editor = "vi"
|
|
||||||
}
|
|
||||||
logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log")
|
logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log")
|
||||||
return m, tea.ExecProcess(exec.Command(editor, logPath), nil)
|
return m, tea.ExecProcess(exec.Command(util.ResolveEditor(), logPath), nil) // #nosec G204 G702 -- editor from trusted config/$EDITOR, logPath is a fixed path
|
||||||
}
|
}
|
||||||
|
|
||||||
if !m.activeIsEditing() {
|
if !m.activeIsEditing() {
|
||||||
|
|||||||
+11
-9
@@ -6,25 +6,28 @@ import (
|
|||||||
ilovetui "github.com/anotherhadi/ilovetui"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (m Model) newView(content string) tea.View {
|
||||||
|
v := tea.NewView(content)
|
||||||
|
v.AltScreen = true
|
||||||
|
v.WindowTitle = "Spilltea: " + m.projectName
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
if m.width == 0 {
|
if m.width == 0 {
|
||||||
v := tea.NewView("")
|
return m.newView("")
|
||||||
v.AltScreen = true
|
|
||||||
return v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
normal := m.renderNormal()
|
normal := m.renderNormal()
|
||||||
|
|
||||||
if m.copyAs.IsOpen() {
|
if m.copyAs.IsOpen() {
|
||||||
v := tea.NewView(m.copyAs.View(normal))
|
v := m.newView(m.copyAs.View(normal))
|
||||||
v.AltScreen = true
|
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.copy.IsOpen() {
|
if m.copy.IsOpen() {
|
||||||
v := tea.NewView(m.copy.View(normal))
|
v := m.newView(m.copy.View(normal))
|
||||||
v.AltScreen = true
|
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -34,8 +37,7 @@ func (m Model) View() tea.View {
|
|||||||
rendered = m.notifications.View(normal)
|
rendered = m.notifications.View(normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
v := tea.NewView(rendered)
|
v := m.newView(rendered)
|
||||||
v.AltScreen = true
|
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type parsedRequest struct {
|
|||||||
path string
|
path string
|
||||||
host string
|
host string
|
||||||
scheme string
|
scheme string
|
||||||
headers []header // garder header{key, value} pour compat locale
|
headers []header
|
||||||
body string
|
body string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ func (m *Model) refreshViewports() {
|
|||||||
placeholder := lipgloss.Place(
|
placeholder := lipgloss.Place(
|
||||||
m.leftViewport.Width(), m.leftViewport.Height(),
|
m.leftViewport.Width(), m.leftViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
ilovetui.S.Faint.Render(util.CenterLines("<(^_^)>", "send two entries here to compare")),
|
ilovetui.S.Faint.Render(util.EmptyState(m.leftViewport.Width(), "<(^_^)>", "send two entries here to compare")),
|
||||||
)
|
)
|
||||||
m.leftViewport.SetContent(placeholder)
|
m.leftViewport.SetContent(placeholder)
|
||||||
m.rightViewport.SetContent("")
|
m.rightViewport.SetContent("")
|
||||||
@@ -312,7 +312,7 @@ func (m *Model) refreshViewports() {
|
|||||||
placeholder := lipgloss.Place(
|
placeholder := lipgloss.Place(
|
||||||
m.rightViewport.Width(), m.rightViewport.Height(),
|
m.rightViewport.Width(), m.rightViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
ilovetui.S.Faint.Render(util.CenterLines("(・3・)", "waiting for second entry…")),
|
ilovetui.S.Faint.Render(util.EmptyState(m.rightViewport.Width(), "(・3・)", "waiting for second entry…")),
|
||||||
)
|
)
|
||||||
m.rightViewport.SetContent(placeholder)
|
m.rightViewport.SetContent(placeholder)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ func readDoc(name string) string {
|
|||||||
|
|
||||||
var contentMarkdown = strings.Join([]string{
|
var contentMarkdown = strings.Join([]string{
|
||||||
readDoc("main.md"),
|
readDoc("main.md"),
|
||||||
readDoc("legal-disclaimer.md"),
|
|
||||||
readDoc("basics.md"),
|
|
||||||
readDoc("proxy.md"),
|
readDoc("proxy.md"),
|
||||||
readDoc("certificate.md"),
|
readDoc("certificate.md"),
|
||||||
|
readDoc("legal-disclaimer.md"),
|
||||||
|
readDoc("basics.md"),
|
||||||
readDoc("history.md"),
|
readDoc("history.md"),
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func (m *Model) renderList() string {
|
|||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
ilovetui.S.Faint.Render(util.CenterLines("(҂◡_◡) ᕤ", "no findings")),
|
ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(҂◡_◡) ᕤ", "no findings")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package history
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
@@ -25,7 +26,10 @@ func LoadEntriesCmd(database *db.DB) tea.Cmd {
|
|||||||
if database == nil {
|
if database == nil {
|
||||||
return EntriesLoadedMsg{}
|
return EntriesLoadedMsg{}
|
||||||
}
|
}
|
||||||
entries, _ := database.ListEntries()
|
entries, err := database.ListEntries()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("history: load entries: %v", err)
|
||||||
|
}
|
||||||
return EntriesLoadedMsg{Entries: entries}
|
return EntriesLoadedMsg{Entries: entries}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,7 +238,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, h.Flag):
|
case key.Matches(msg, h.Flag):
|
||||||
if len(m.entries) > 0 && m.database != nil {
|
if len(m.entries) > 0 && m.database != nil {
|
||||||
m.database.ToggleFlag(m.entries[m.cursor].ID)
|
if err := m.database.ToggleFlag(m.entries[m.cursor].ID); err != nil {
|
||||||
|
log.Printf("history: toggle flag: %v", err)
|
||||||
|
}
|
||||||
return m, m.RefreshCmd()
|
return m, m.RefreshCmd()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +248,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
id := m.entries[m.cursor].ID
|
id := m.entries[m.cursor].ID
|
||||||
if m.database != nil {
|
if m.database != nil {
|
||||||
m.database.DeleteEntry(id)
|
if err := m.database.DeleteEntry(id); err != nil {
|
||||||
|
log.Printf("history: delete entry: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m, LoadEntriesCmd(m.database)
|
return m, LoadEntriesCmd(m.database)
|
||||||
}
|
}
|
||||||
@@ -261,10 +269,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if hasUnflagged && e.Flagged {
|
if hasUnflagged && e.Flagged {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m.database.DeleteEntry(e.ID)
|
if err := m.database.DeleteEntry(e.ID); err != nil {
|
||||||
|
log.Printf("history: delete entry: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m.database.DeleteAllExceptFlagged()
|
if err := m.database.DeleteAllExceptFlagged(); err != nil {
|
||||||
|
log.Printf("history: delete all unflagged: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, m.clearSearch()
|
return m, m.clearSearch()
|
||||||
@@ -346,7 +358,7 @@ func (m *Model) refreshBody() {
|
|||||||
}
|
}
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
|
w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
|
||||||
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(˘・_・˘)", "no response stored"))))
|
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(w, "(˘・_・˘)", "no response stored"))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ func (m *Model) renderList() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if len(m.entries) == 0 {
|
if len(m.entries) == 0 {
|
||||||
msg := util.CenterLines("(⌐■_■)", "no history yet")
|
msg := util.EmptyState(m.listViewport.Width(), "(⌐■_■)", "no history yet")
|
||||||
if m.searchKind != searchKindOff {
|
if m.searchKind != searchKindOff {
|
||||||
msg = util.CenterLines("ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
|
msg = util.EmptyState(m.listViewport.Width(), "ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
|
||||||
}
|
}
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
|
|||||||
@@ -318,13 +318,14 @@ func (m Model) renderHelpLine() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
if fs == list.Filtering {
|
if fs == list.Filtering {
|
||||||
parts = append(parts, item("enter", "apply filter"))
|
parts = append(parts, item("enter", "apply filter"))
|
||||||
parts = append(parts, item("esc", "cancel"))
|
parts = append(parts, item(escKey, "cancel"))
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, item("↑/↓", "navigate"))
|
parts = append(parts, item("↑/↓", "navigate"))
|
||||||
if fs == list.FilterApplied {
|
if fs == list.FilterApplied {
|
||||||
parts = append(parts, item("esc", "clear filter"))
|
parts = append(parts, item(escKey, "clear filter"))
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, binding(k.Filter))
|
parts = append(parts, binding(k.Filter))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package home
|
|||||||
import (
|
import (
|
||||||
crypto "crypto/rand"
|
crypto "crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -72,7 +73,8 @@ func (m Model) handleSelection() (tea.Model, tea.Cmd) {
|
|||||||
return m, m.nameInput.Focus()
|
return m, m.nameInput.Focus()
|
||||||
case kindTemp:
|
case kindTemp:
|
||||||
dir := tempDir()
|
dir := tempDir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
log.Printf("home: create temp dir: %v", err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
@@ -90,7 +92,9 @@ func (m Model) deleteSelected() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
dir := filepath.Dir(item.path) // parent dir of data.db
|
dir := filepath.Dir(item.path) // parent dir of data.db
|
||||||
os.RemoveAll(dir)
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
log.Printf("home: remove project dir: %v", err)
|
||||||
|
}
|
||||||
idx := m.list.GlobalIndex()
|
idx := m.list.GlobalIndex()
|
||||||
m.list.RemoveItem(idx)
|
m.list.RemoveItem(idx)
|
||||||
if idx > 0 && idx >= len(m.list.Items()) {
|
if idx > 0 && idx >= len(m.list.Items()) {
|
||||||
@@ -113,7 +117,8 @@ func (m Model) updateNaming(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.mode = modeSelect
|
m.mode = modeSelect
|
||||||
m.nameInput.Blur()
|
m.nameInput.Blur()
|
||||||
dir := filepath.Join(m.projectDir, name)
|
dir := filepath.Join(m.projectDir, name)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
log.Printf("home: create project dir %q: %v", name, err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
@@ -147,14 +152,14 @@ func IsValidProjectName(s string) bool {
|
|||||||
func OpenProject(projectDir, name string) (*Project, error) {
|
func OpenProject(projectDir, name string) (*Project, error) {
|
||||||
if name == "tmp" || name == "temp" || name == "temporary" {
|
if name == "tmp" || name == "temp" || name == "temporary" {
|
||||||
dir := tempDir()
|
dir := tempDir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil
|
return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil
|
||||||
}
|
}
|
||||||
dir := filepath.Join(projectDir, name)
|
dir := filepath.Join(projectDir, name)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
@@ -171,9 +176,11 @@ func initProjectFiles(dir string) {
|
|||||||
for _, name := range []string{"data.db", "logs.log"} {
|
for _, name := range []string{"data.db", "logs.log"} {
|
||||||
p := filepath.Join(dir, name)
|
p := filepath.Join(dir, name)
|
||||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||||
f, err := os.Create(p)
|
f, err := os.Create(p) // #nosec G304 -- p is filepath.Join(dir, hardcoded_name), no traversal possible
|
||||||
if err == nil {
|
if err == nil {
|
||||||
f.Close()
|
if cerr := f.Close(); cerr != nil {
|
||||||
|
log.Printf("home: close project file: %v", cerr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
ilovetui "github.com/anotherhadi/ilovetui"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
|
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ func (m Model) View() tea.View {
|
|||||||
v := tea.NewView(content)
|
v := tea.NewView(content)
|
||||||
v.AltScreen = true
|
v.AltScreen = true
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
|
v.WindowTitle = "Spilltea"
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +71,8 @@ func (m Model) renderNamingPanel() string {
|
|||||||
Width(panelW).
|
Width(panelW).
|
||||||
Render(label + "\n" + inputLine)
|
Render(label + "\n" + inputLine)
|
||||||
|
|
||||||
hint := ilovetui.S.Faint.Render("[enter] confirm [esc] cancel")
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
|
hint := ilovetui.S.Faint.Render("[enter] confirm [" + escKey + "] cancel")
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(center(iw, panel))
|
sb.WriteString(center(iw, panel))
|
||||||
|
|||||||
@@ -2,24 +2,17 @@ package intercept
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
)
|
)
|
||||||
|
|
||||||
type interceptKeyMap struct{ width int }
|
type interceptKeyMap struct{ width int }
|
||||||
|
|
||||||
func iconBinding(b key.Binding, icon string) key.Binding {
|
|
||||||
h := b.Help()
|
|
||||||
return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(h.Key, icon+h.Desc))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (interceptKeyMap) ShortHelp() []key.Binding {
|
func (interceptKeyMap) ShortHelp() []key.Binding {
|
||||||
ic := keys.Keys.Intercept
|
ic := keys.Keys.Intercept
|
||||||
i := icons.I
|
|
||||||
return []key.Binding{
|
return []key.Binding{
|
||||||
iconBinding(ic.Forward, i.Forward),
|
ic.Forward,
|
||||||
iconBinding(ic.Drop, i.Drop),
|
ic.Drop,
|
||||||
iconBinding(ic.Edit, i.Edit),
|
ic.Edit,
|
||||||
keys.Keys.Global.Help,
|
keys.Keys.Global.Help,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func (m *Model) renderStatusBar() string {
|
|||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
if len(m.queue) == 0 {
|
if len(m.queue) == 0 {
|
||||||
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(。◕‿‿◕。)", "waiting for a request")))
|
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(。◕‿‿◕。)", "waiting for a request")))
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := util.PageBounds(m.pager, len(m.queue))
|
start, end := util.PageBounds(m.pager, len(m.queue))
|
||||||
@@ -149,7 +149,7 @@ func (m *Model) renderList() string {
|
|||||||
|
|
||||||
func (m *Model) renderResponseList() string {
|
func (m *Model) renderResponseList() string {
|
||||||
if len(m.responseQueue) == 0 {
|
if len(m.responseQueue) == 0 {
|
||||||
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(҂◡_◡)", "no response yet")))
|
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.responseViewport.Width(), "(҂◡_◡)", "no response yet")))
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
|
start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
if m.width == 0 || m.manager == nil {
|
if m.width == 0 || m.manager == nil {
|
||||||
return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(._.)~*.'", "no plugins loaded"))))
|
return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.width, "(._.)~*.'", "no plugins loaded"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
listH, detailH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.4)
|
listH, detailH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.4)
|
||||||
@@ -127,9 +127,9 @@ func (m *Model) renderStatusBar() string {
|
|||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
if len(m.filtered) == 0 {
|
if len(m.filtered) == 0 {
|
||||||
msg := util.CenterLines("(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
|
msg := util.EmptyState(m.listViewport.Width(), "(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
|
||||||
if m.filter != "" {
|
if m.filter != "" {
|
||||||
msg = util.CenterLines("= _ =", "no results")
|
msg = util.EmptyState(m.listViewport.Width(), "= _ =", "no results")
|
||||||
}
|
}
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ import (
|
|||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"compress/zlib"
|
"compress/zlib"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -79,7 +84,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
e.ResponseRaw = "Error: " + msg.err.Error()
|
e.ResponseRaw = "Error: " + msg.err.Error()
|
||||||
}
|
}
|
||||||
if m.database != nil && e.DBID != 0 {
|
if m.database != nil && e.DBID != 0 {
|
||||||
m.database.UpdateReplayEntry(entryToDB(*e))
|
if err := m.database.UpdateReplayEntry(entryToDB(*e)); err != nil {
|
||||||
|
log.Printf("replay: update entry: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
@@ -96,24 +103,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg.Button {
|
switch msg.Button {
|
||||||
case tea.MouseWheelUp:
|
case tea.MouseWheelUp:
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
m.requestViewport.ScrollLeft(6)
|
m.scrollBothViewports(true)
|
||||||
m.responseViewport.ScrollLeft(6)
|
|
||||||
} else {
|
} else {
|
||||||
m.scrollFocusedViewportVertical(-1)
|
m.scrollFocusedViewportVertical(-1)
|
||||||
}
|
}
|
||||||
case tea.MouseWheelDown:
|
case tea.MouseWheelDown:
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
m.requestViewport.ScrollRight(6)
|
m.scrollBothViewports(false)
|
||||||
m.responseViewport.ScrollRight(6)
|
|
||||||
} else {
|
} else {
|
||||||
m.scrollFocusedViewportVertical(1)
|
m.scrollFocusedViewportVertical(1)
|
||||||
}
|
}
|
||||||
case tea.MouseWheelLeft:
|
case tea.MouseWheelLeft:
|
||||||
m.requestViewport.ScrollLeft(6)
|
m.scrollBothViewports(true)
|
||||||
m.responseViewport.ScrollLeft(6)
|
|
||||||
case tea.MouseWheelRight:
|
case tea.MouseWheelRight:
|
||||||
m.requestViewport.ScrollRight(6)
|
m.scrollBothViewports(false)
|
||||||
m.responseViewport.ScrollRight(6)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +219,9 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
e := m.entries[m.cursor]
|
e := m.entries[m.cursor]
|
||||||
if m.database != nil && e.DBID != 0 {
|
if m.database != nil && e.DBID != 0 {
|
||||||
m.database.DeleteReplayEntry(e.DBID)
|
if err := m.database.DeleteReplayEntry(e.DBID); err != nil {
|
||||||
|
log.Printf("replay: delete entry: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...)
|
m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...)
|
||||||
if m.cursor >= len(m.entries) && m.cursor > 0 {
|
if m.cursor >= len(m.entries) && m.cursor > 0 {
|
||||||
@@ -229,7 +234,9 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, r.DeleteAll):
|
case key.Matches(msg, r.DeleteAll):
|
||||||
if m.database != nil {
|
if m.database != nil {
|
||||||
m.database.DeleteAllReplayEntries()
|
if err := m.database.DeleteAllReplayEntries(); err != nil {
|
||||||
|
log.Printf("replay: delete all entries: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.entries = nil
|
m.entries = nil
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
@@ -320,6 +327,16 @@ func (m *Model) setFocusedViewport(vp viewport.Model) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) scrollBothViewports(left bool) {
|
||||||
|
if left {
|
||||||
|
m.requestViewport.ScrollLeft(6)
|
||||||
|
m.responseViewport.ScrollLeft(6)
|
||||||
|
} else {
|
||||||
|
m.requestViewport.ScrollRight(6)
|
||||||
|
m.responseViewport.ScrollRight(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) scrollFocusedViewportVertical(delta int) {
|
func (m *Model) scrollFocusedViewportVertical(delta int) {
|
||||||
vp := m.focusedViewport()
|
vp := m.focusedViewport()
|
||||||
vp.SetYOffset(vp.YOffset() + delta)
|
vp.SetYOffset(vp.YOffset() + delta)
|
||||||
@@ -351,11 +368,11 @@ func (m *Model) refreshBody() {
|
|||||||
m.requestViewport.SetXOffset(0)
|
m.requestViewport.SetXOffset(0)
|
||||||
|
|
||||||
if e.Sending {
|
if e.Sending {
|
||||||
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(ノ◕ヮ◕)ノ*:・゚", "sending..."))))
|
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.responseViewport.Width(), "(ノ◕ヮ◕)ノ*:・゚", "sending..."))))
|
||||||
} else if e.ResponseRaw != "" {
|
} else if e.ResponseRaw != "" {
|
||||||
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
|
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
|
||||||
} else {
|
} else {
|
||||||
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("( •_•)>⌐■", "press send to fire"))))
|
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.responseViewport.Width(), "( •_•)>⌐■", "press send to fire"))))
|
||||||
}
|
}
|
||||||
m.responseViewport.SetYOffset(0)
|
m.responseViewport.SetYOffset(0)
|
||||||
m.responseViewport.SetXOffset(0)
|
m.responseViewport.SetXOffset(0)
|
||||||
@@ -396,11 +413,27 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
|||||||
}
|
}
|
||||||
req.Header = headers
|
req.Header = headers
|
||||||
|
|
||||||
|
tlsCfg := &tls.Config{InsecureSkipVerify: config.Global.App.SslInsecure} // #nosec G402 -- controlled by ssl_insecure config
|
||||||
|
if !config.Global.App.SslInsecure {
|
||||||
|
if certPool, err := x509.SystemCertPool(); err == nil {
|
||||||
|
caPath := filepath.Join(config.ExpandPath(config.Global.App.CertDir), "mitmproxy-ca-cert.pem")
|
||||||
|
if pem, err := os.ReadFile(caPath); err == nil {
|
||||||
|
certPool.AppendCertsFromPEM(pem)
|
||||||
|
}
|
||||||
|
tlsCfg.RootCAs = certPool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: tlsCfg,
|
||||||
|
}
|
||||||
|
if up := config.Global.App.UpstreamProxy; up != "" {
|
||||||
|
if proxyURL, err := url.Parse(up); err == nil {
|
||||||
|
transport.Proxy = http.ProxyURL(proxyURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: transport,
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
|
||||||
},
|
|
||||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func (m *Model) renderList() string {
|
|||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
ilovetui.S.Faint.Render(util.CenterLines("(╥﹏╥)", "send a request from History or Intercept")),
|
ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(╥﹏╥)", "send a request from History or Intercept")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type EditorFinishedMsg struct {
|
|||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveEditor() string {
|
func ResolveEditor() string {
|
||||||
editor := config.Global.App.ExternalEditor
|
editor := config.Global.App.ExternalEditor
|
||||||
if editor == "" {
|
if editor == "" {
|
||||||
editor = os.Getenv("EDITOR")
|
editor = os.Getenv("EDITOR")
|
||||||
@@ -29,14 +29,17 @@ func resolveEditor() string {
|
|||||||
func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cmd {
|
func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cmd {
|
||||||
f, err := os.CreateTemp("", "spilltea-*.http")
|
f, err := os.CreateTemp("", "spilltea-*.http")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("editor: create temp file: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tmpPath := f.Name()
|
tmpPath := f.Name()
|
||||||
if _, err := f.WriteString(content); err != nil {
|
if _, err := f.WriteString(content); err != nil {
|
||||||
log.Printf("editor: writing temp file: %v", err)
|
log.Printf("editor: writing temp file: %v", err)
|
||||||
}
|
}
|
||||||
f.Close()
|
if err := f.Close(); err != nil {
|
||||||
return tea.ExecProcess(exec.Command(resolveEditor(), tmpPath), func(err error) tea.Msg {
|
log.Printf("editor: closing temp file: %v", err)
|
||||||
|
}
|
||||||
|
return tea.ExecProcess(exec.Command(ResolveEditor(), tmpPath), func(err error) tea.Msg { // #nosec G204 -- editor from trusted config/$EDITOR, tmpPath from os.CreateTemp
|
||||||
defer os.Remove(tmpPath)
|
defer os.Remove(tmpPath)
|
||||||
return callback(tmpPath, err)
|
return callback(tmpPath, err)
|
||||||
})
|
})
|
||||||
@@ -47,7 +50,7 @@ func OpenExternalEditor(content string) tea.Cmd {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return EditorFinishedMsg{Err: err}
|
return EditorFinishedMsg{Err: err}
|
||||||
}
|
}
|
||||||
data, readErr := os.ReadFile(tmpPath)
|
data, readErr := os.ReadFile(tmpPath) // #nosec G304 -- tmpPath is from os.CreateTemp, controlled by this process
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return EditorFinishedMsg{Err: readErr}
|
return EditorFinishedMsg{Err: readErr}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ func Truncate(s string, max int) string {
|
|||||||
return s[:max-1] + "…"
|
return s[:max-1] + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmptyState renders centered placeholder lines, word-wrapping to fit maxW.
|
||||||
|
func EmptyState(maxW int, lines ...string) string {
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Width(maxW).
|
||||||
|
AlignHorizontal(lipgloss.Center).
|
||||||
|
Render(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
// CenterLines centers each line horizontally relative to the longest one.
|
// CenterLines centers each line horizontally relative to the longest one.
|
||||||
func CenterLines(lines ...string) string {
|
func CenterLines(lines ...string) string {
|
||||||
maxWidth := 0
|
maxWidth := 0
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{pkgs}: let
|
||||||
|
defaultCertDir = "$HOME/.local/share/spilltea";
|
||||||
|
defaultHost = "127.0.0.1";
|
||||||
|
defaultPort = "8080";
|
||||||
|
in
|
||||||
|
pkgs.writeShellApplication {
|
||||||
|
name = "spilltea-browser";
|
||||||
|
runtimeInputs = with pkgs; [nss.tools librewolf netcat-gnu procps];
|
||||||
|
text = ''
|
||||||
|
CERT_DIR="${defaultCertDir}"
|
||||||
|
HOST="${defaultHost}"
|
||||||
|
PORT="${defaultPort}"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--cert-dir) CERT_DIR="$2"; shift 2 ;;
|
||||||
|
--host) HOST="$2"; shift 2 ;;
|
||||||
|
--port) PORT="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown argument: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
CERT_FILE="$CERT_DIR/mitmproxy-ca-cert.pem"
|
||||||
|
|
||||||
|
if [[ ! -f "$CERT_FILE" ]]; then
|
||||||
|
echo "Certificate not found at $CERT_FILE"
|
||||||
|
echo "Launch spilltea first, or use --cert-dir if you changed cert_dir in your config."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! pgrep -x spilltea > /dev/null 2>&1; then
|
||||||
|
echo "spilltea is not running. Launch spilltea first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! nc -z "$HOST" "$PORT" > /dev/null 2>&1; then
|
||||||
|
echo "Nothing is listening on $HOST:$PORT."
|
||||||
|
echo "Is spilltea configured on that port? Use --host and --port if you changed them in your config."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROFILE=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$PROFILE"' EXIT
|
||||||
|
|
||||||
|
certutil -N -d sql:"$PROFILE" --empty-password
|
||||||
|
certutil -d sql:"$PROFILE" -A -n "spilltea" -t "CT,," -i "$CERT_FILE"
|
||||||
|
|
||||||
|
cat > "$PROFILE/user.js" <<EOF
|
||||||
|
user_pref("network.proxy.type", 1);
|
||||||
|
user_pref("network.proxy.http", "$HOST");
|
||||||
|
user_pref("network.proxy.http_port", $PORT);
|
||||||
|
user_pref("network.proxy.ssl", "$HOST");
|
||||||
|
user_pref("network.proxy.ssl_port", $PORT);
|
||||||
|
user_pref("network.proxy.no_proxies_on", "");
|
||||||
|
user_pref("network.stricttransportsecurity.preloadlist", false);
|
||||||
|
user_pref("dom.security.https_only_mode", false);
|
||||||
|
EOF
|
||||||
|
|
||||||
|
librewolf --profile "$PROFILE" --no-remote
|
||||||
|
'';
|
||||||
|
}
|
||||||
+12
-4
@@ -105,6 +105,10 @@ schema = 3
|
|||||||
version = 'v1.5.0'
|
version = 'v1.5.0'
|
||||||
hash = 'sha256-EYVgkSEMo4HaVrsWKqnsYRp8SSS8gNf7t+Elva02Ofc='
|
hash = 'sha256-EYVgkSEMo4HaVrsWKqnsYRp8SSS8gNf7t+Elva02Ofc='
|
||||||
|
|
||||||
|
[mod.'github.com/inconshreveable/mousetrap']
|
||||||
|
version = 'v1.1.0'
|
||||||
|
hash = 'sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE='
|
||||||
|
|
||||||
[mod.'github.com/klauspost/compress']
|
[mod.'github.com/klauspost/compress']
|
||||||
version = 'v1.17.8'
|
version = 'v1.17.8'
|
||||||
hash = 'sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs='
|
hash = 'sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs='
|
||||||
@@ -173,6 +177,10 @@ schema = 3
|
|||||||
version = 'v1.10.0'
|
version = 'v1.10.0'
|
||||||
hash = 'sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk='
|
hash = 'sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/cobra']
|
||||||
|
version = 'v1.10.2'
|
||||||
|
hash = 'sha256-nbRCTFiDCC2jKK7AHi79n7urYCMP5yDZnWtNVJrDi+k='
|
||||||
|
|
||||||
[mod.'github.com/spf13/pflag']
|
[mod.'github.com/spf13/pflag']
|
||||||
version = 'v1.0.10'
|
version = 'v1.0.10'
|
||||||
hash = 'sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU='
|
hash = 'sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU='
|
||||||
@@ -210,16 +218,16 @@ schema = 3
|
|||||||
hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4='
|
hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4='
|
||||||
|
|
||||||
[mod.'golang.org/x/net']
|
[mod.'golang.org/x/net']
|
||||||
version = 'v0.54.0'
|
version = 'v0.55.0'
|
||||||
hash = 'sha256-/EoIXzTQzK/yP/lxOyx0Z/bhns4FdPTIF4uyt4gIP80='
|
hash = 'sha256-Phi2mSmBGOJcvqPPAit3uqF3UP8SKRI9dHj6yTM3s5s='
|
||||||
|
|
||||||
[mod.'golang.org/x/sync']
|
[mod.'golang.org/x/sync']
|
||||||
version = 'v0.20.0'
|
version = 'v0.20.0'
|
||||||
hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y='
|
hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y='
|
||||||
|
|
||||||
[mod.'golang.org/x/sys']
|
[mod.'golang.org/x/sys']
|
||||||
version = 'v0.44.0'
|
version = 'v0.45.0'
|
||||||
hash = 'sha256-JDlj+PKsG6I6kjv5JyOUNreY51u5An0oZ5OZMHZSk+A='
|
hash = 'sha256-hkBoNazrDA67ER6sWhb+EKxx9nJ24+nz3zGy+zT5Hvw='
|
||||||
|
|
||||||
[mod.'golang.org/x/text']
|
[mod.'golang.org/x/text']
|
||||||
version = 'v0.37.0'
|
version = 'v0.37.0'
|
||||||
|
|||||||
+11
-1
@@ -2,13 +2,22 @@
|
|||||||
pkgs,
|
pkgs,
|
||||||
buildGoApplication,
|
buildGoApplication,
|
||||||
}: let
|
}: let
|
||||||
|
browser = import ./browser.nix {inherit pkgs;};
|
||||||
pname = "spilltea";
|
pname = "spilltea";
|
||||||
version = "0.0.6";
|
version = "0.0.7";
|
||||||
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
||||||
pkg = buildGoApplication {
|
pkg = buildGoApplication {
|
||||||
inherit pname version ldflags;
|
inherit pname version ldflags;
|
||||||
src = ../.;
|
src = ../.;
|
||||||
modules = ./gomod2nix.toml;
|
modules = ./gomod2nix.toml;
|
||||||
|
nativeBuildInputs = [pkgs.installShellFiles];
|
||||||
|
env.GOTOOLCHAIN = "local";
|
||||||
|
postInstall = ''
|
||||||
|
installShellCompletion --cmd spilltea \
|
||||||
|
--bash <($out/bin/spilltea completion bash) \
|
||||||
|
--zsh <($out/bin/spilltea completion zsh) \
|
||||||
|
--fish <($out/bin/spilltea completion fish)
|
||||||
|
'';
|
||||||
meta = with pkgs.lib; {
|
meta = with pkgs.lib; {
|
||||||
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
||||||
homepage = "https://github.com/anotherhadi/spilltea";
|
homepage = "https://github.com/anotherhadi/spilltea";
|
||||||
@@ -18,4 +27,5 @@
|
|||||||
in {
|
in {
|
||||||
"${pname}" = pkg;
|
"${pname}" = pkg;
|
||||||
default = pkg;
|
default = pkg;
|
||||||
|
browser = browser;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ in
|
|||||||
packages = with pkgs;
|
packages = with pkgs;
|
||||||
[
|
[
|
||||||
go
|
go
|
||||||
|
gosec
|
||||||
|
govulncheck
|
||||||
python3
|
python3
|
||||||
doctoc
|
doctoc
|
||||||
stylua
|
stylua
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ var PluginsFS embed.FS
|
|||||||
// InstallDefaultPlugins copies embedded default plugins into dir, skipping
|
// InstallDefaultPlugins copies embedded default plugins into dir, skipping
|
||||||
// files that already exist. Returns the number of files written.
|
// files that already exist. Returns the number of files written.
|
||||||
func InstallDefaultPlugins(dir string) (int, error) {
|
func InstallDefaultPlugins(dir string) (int, error) {
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return 0, fmt.Errorf("create plugins dir: %w", err)
|
return 0, fmt.Errorf("create plugins dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ func InstallDefaultPlugins(dir string) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return written, fmt.Errorf("read embedded %s: %w", e.Name(), err)
|
return written, fmt.Errorf("read embedded %s: %w", e.Name(), err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
if err := os.WriteFile(dst, data, 0o600); err != nil {
|
||||||
return written, fmt.Errorf("write %s: %w", dst, err)
|
return written, fmt.Errorf("write %s: %w", dst, err)
|
||||||
}
|
}
|
||||||
written++
|
written++
|
||||||
|
|||||||
Reference in New Issue
Block a user