summaryrefslogtreecommitdiff
path: root/internal/store/project.go
diff options
context:
space:
mode:
authorspiperac <[email protected]>2026-06-15 06:42:13 +0200
committerspiperac <[email protected]>2026-06-15 06:42:13 +0200
commit0a477fd288d064d265395c88dbd0f261b723804e (patch)
tree9b129f8f41c907285b89ed8385679934bb59c14f /internal/store/project.go
Initial release
Diffstat (limited to 'internal/store/project.go')
-rw-r--r--internal/store/project.go245
1 files changed, 245 insertions, 0 deletions
diff --git a/internal/store/project.go b/internal/store/project.go
new file mode 100644
index 0000000..e379f32
--- /dev/null
+++ b/internal/store/project.go
@@ -0,0 +1,245 @@
+package store
+
+import (
+ "database/sql"
+ "fmt"
+ "sync"
+
+ _ "modernc.org/sqlite"
+)
+
+type Store struct {
+ db *sql.DB
+ writeCh chan func() error
+ done chan struct{}
+
+ Updates chan Transaction
+ Intercept *InterceptGate
+ WebSocketConnections chan WebSocketConnection
+ WebSocketFrames chan WebSocketFrame
+ LootEntries chan struct{}
+ PluginEntries chan struct{}
+
+ // pluginLogs holds in-memory log lines per plugin name.
+ // Protected by pluginLogsMu — separate from the SQLite write serialiser
+ // because log appends must never block on DB I/O.
+ pluginLogsMu sync.RWMutex
+ pluginLogs map[string][]string
+
+ // scopeCache is an in-memory copy of the scope table.
+ // Populated lazily on first InScope call and invalidated on every write.
+ // Protected by scopeCacheMu — separate from the write serialiser so
+ // InScope reads never block on DB I/O.
+ scopeCacheMu sync.RWMutex
+ scopeCache []ScopeEntry
+ scopeCacheSet bool
+}
+
+func Open(path string) (*Store, error) {
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, fmt.Errorf("store: open %s: %w", path, err)
+ }
+ db.SetMaxOpenConns(1)
+ if _, err := db.Exec(`PRAGMA journal_mode=WAL`); err != nil {
+ return nil, fmt.Errorf("store: enable WAL: %w", err)
+ }
+
+ projectStore := &Store{
+ db: db,
+ writeCh: make(chan func() error, 256),
+ done: make(chan struct{}),
+ Updates: make(chan Transaction, 256),
+ Intercept: NewInterceptGate(),
+ WebSocketConnections: make(chan WebSocketConnection, 256),
+ WebSocketFrames: make(chan WebSocketFrame, 256),
+ LootEntries: make(chan struct{}, 256),
+ PluginEntries: make(chan struct{}, 256),
+ pluginLogs: make(map[string][]string),
+ }
+ if err := projectStore.migrate(); err != nil {
+ db.Close()
+ return nil, err
+ }
+
+ go projectStore.writeLoop()
+ return projectStore, nil
+}
+
+func (s *Store) Close() error {
+ close(s.done)
+ close(s.Updates)
+ close(s.WebSocketConnections)
+ close(s.WebSocketFrames)
+ close(s.LootEntries)
+ close(s.PluginEntries)
+ close(s.Intercept.queue)
+ return s.db.Close()
+}
+
+func (s *Store) write(writeFunc func() error) error {
+ errorChan := make(chan error, 1)
+ select {
+ case s.writeCh <- func() error {
+ err := writeFunc()
+ errorChan <- err
+ return err
+ }:
+ case <-s.done:
+ return fmt.Errorf("store: closed")
+ }
+ select {
+ case err := <-errorChan:
+ return err
+ case <-s.done:
+ return fmt.Errorf("store: closed")
+ }
+}
+
+func (s *Store) writeLoop() {
+ for {
+ select {
+ case fn := <-s.writeCh:
+ _ = fn()
+ case <-s.done:
+
+ for {
+ select {
+ case fn := <-s.writeCh:
+ _ = fn()
+ default:
+ return
+ }
+ }
+ }
+ }
+}
+
+func (s *Store) migrate() error {
+ _, err := s.db.Exec(schema)
+ if err != nil {
+ return fmt.Errorf("store: migrate: %w", err)
+ }
+ // Add new columns to existing tables if they don't exist.
+ // Errors are ignored — column may already exist.
+ migrations := []string{
+ `ALTER TABLE loot ADD COLUMN raw_request TEXT DEFAULT ''`,
+ `ALTER TABLE loot ADD COLUMN raw_response TEXT DEFAULT ''`,
+ `ALTER TABLE history ADD COLUMN proto TEXT NOT NULL DEFAULT 'HTTP/1.1'`,
+ `ALTER TABLE history ADD COLUMN port INTEGER NOT NULL DEFAULT 443`,
+ `ALTER TABLE repeater_tabs ADD COLUMN tab_type TEXT NOT NULL DEFAULT 'http'`,
+ `ALTER TABLE history ADD COLUMN resp_size INTEGER NOT NULL DEFAULT 0`,
+ `CREATE INDEX IF NOT EXISTS idx_history_host_port_id ON history(host, port, id DESC)`,
+ }
+ for _, migration := range migrations {
+ s.db.Exec(migration)
+ }
+ return nil
+}
+
+const schema = `
+CREATE TABLE IF NOT EXISTS meta (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp TEXT NOT NULL,
+ host TEXT NOT NULL,
+ port INTEGER NOT NULL DEFAULT 443,
+ method TEXT NOT NULL,
+ url TEXT NOT NULL,
+ proto TEXT NOT NULL DEFAULT 'HTTP/1.1',
+ req_headers TEXT NOT NULL,
+ req_body BLOB,
+ status_code INTEGER,
+ resp_headers TEXT,
+ resp_body BLOB,
+ resp_size INTEGER NOT NULL DEFAULT 0,
+ duration_ms INTEGER,
+ tls INTEGER DEFAULT 0,
+ in_scope INTEGER DEFAULT 1
+);
+
+CREATE INDEX IF NOT EXISTS idx_history_host ON history(host);
+CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp);
+CREATE INDEX IF NOT EXISTS idx_history_scope ON history(in_scope);
+CREATE INDEX IF NOT EXISTS idx_history_dedup ON history(method, host, url, status_code);
+CREATE INDEX IF NOT EXISTS idx_history_host_id ON history(host, id DESC);
+CREATE INDEX IF NOT EXISTS idx_history_scope_id ON history(in_scope, id DESC);
+
+CREATE TABLE IF NOT EXISTS repeater_tabs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL DEFAULT 'Tab',
+ host TEXT NOT NULL,
+ port INTEGER NOT NULL DEFAULT 80,
+ tls INTEGER DEFAULT 0,
+ raw_request TEXT NOT NULL,
+ last_response TEXT,
+ position INTEGER DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS loot (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ severity TEXT NOT NULL,
+ notes TEXT,
+ history_id INTEGER REFERENCES history(id),
+ raw_request TEXT DEFAULT '',
+ raw_response TEXT DEFAULT '',
+ created_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS scope (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ host TEXT NOT NULL UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS intruder_config (
+ id INTEGER PRIMARY KEY,
+ delay_ms INTEGER DEFAULT 0,
+ stop_on_status INTEGER DEFAULT 0,
+ max_redirects INTEGER DEFAULT 10,
+ follow_redirects TEXT DEFAULT 'never',
+ timeout_ms INTEGER DEFAULT 30000,
+ raw_request TEXT DEFAULT '',
+ payloads TEXT DEFAULT ''
+);
+
+CREATE TABLE IF NOT EXISTS websocket_connections (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ history_id INTEGER REFERENCES history(id),
+ host TEXT NOT NULL,
+ url TEXT NOT NULL,
+ tls INTEGER DEFAULT 0,
+ in_scope INTEGER DEFAULT 1,
+ timestamp TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_ws_connections_host ON websocket_connections(host);
+CREATE INDEX IF NOT EXISTS idx_ws_connections_scope ON websocket_connections(in_scope);
+
+CREATE TABLE IF NOT EXISTS websocket_frames (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ connection_id INTEGER NOT NULL REFERENCES websocket_connections(id) ON DELETE CASCADE,
+ timestamp TEXT NOT NULL,
+ direction INTEGER NOT NULL, -- 0 = client→server, 1 = server→client
+ opcode INTEGER NOT NULL,
+ payload BLOB
+);
+
+CREATE INDEX IF NOT EXISTS idx_ws_frames_connection ON websocket_frames(connection_id);
+
+CREATE TABLE IF NOT EXISTS plugins (
+ name TEXT PRIMARY KEY,
+ path TEXT NOT NULL DEFAULT '',
+ enabled INTEGER NOT NULL DEFAULT 1
+);
+
+CREATE TABLE IF NOT EXISTS annotations (
+ history_id INTEGER PRIMARY KEY REFERENCES history(id) ON DELETE CASCADE,
+ comment TEXT NOT NULL DEFAULT '',
+ color TEXT NOT NULL DEFAULT ''
+);
+`