diff options
Diffstat (limited to 'internal/store/project.go')
| -rw-r--r-- | internal/store/project.go | 245 |
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 '' +); +` |
