diff --git a/internal/cli/clear.go b/internal/cli/clear.go new file mode 100644 index 0000000..6ff27ff --- /dev/null +++ b/internal/cli/clear.go @@ -0,0 +1,38 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ghostsecurity/reaper/internal/daemon" +) + +var clearCmd = &cobra.Command{ + Use: "clear", + Short: "Clear all proxy log entries", + RunE: runClear, +} + +func init() { + rootCmd.AddCommand(clearCmd) +} + +func runClear(cmd *cobra.Command, args []string) error { + dataDir, err := daemon.DataDir() + if err != nil { + return err + } + + client := daemon.NewClient(dataDir) + resp, err := client.Send(daemon.Request{Command: "clear"}) + if err != nil { + return fmt.Errorf("no running daemon found: %w", err) + } + if !resp.OK { + return fmt.Errorf("clear failed: %s", resp.Error) + } + + fmt.Println("all entries cleared") + return nil +} diff --git a/internal/cli/format.go b/internal/cli/format.go index 88735dc..51f5271 100644 --- a/internal/cli/format.go +++ b/internal/cli/format.go @@ -6,6 +6,7 @@ import ( "os" "sort" "text/tabwriter" + "time" ) // entryRow is a subset of storage.Entry used for table display (deserialized from JSON). @@ -18,6 +19,7 @@ type entryRow struct { Query string `json:"Query"` StatusCode int `json:"StatusCode"` DurationMs int64 `json:"DurationMs"` + Timestamp time.Time `json:"Timestamp"` // These fields are present but not used for table display RequestHeaders http.Header `json:"RequestHeaders"` RequestBody []byte `json:"RequestBody"` diff --git a/internal/cli/tail.go b/internal/cli/tail.go new file mode 100644 index 0000000..5603d14 --- /dev/null +++ b/internal/cli/tail.go @@ -0,0 +1,87 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/ghostsecurity/reaper/internal/daemon" +) + +var tailCmd = &cobra.Command{ + Use: "tail", + Short: "Stream new proxy log entries in real-time", + SilenceUsage: true, + RunE: runTail, +} + +func init() { + rootCmd.AddCommand(tailCmd) +} + +func runTail(cmd *cobra.Command, args []string) error { + dataDir, err := daemon.DataDir() + if err != nil { + return err + } + + client := daemon.NewClient(dataDir) + + // Establish high-water mark from existing entries + params, _ := json.Marshal(daemon.LogsParams{Limit: 1}) + resp, err := client.Send(daemon.Request{Command: "logs", Params: params}) + if err != nil { + return fmt.Errorf("no running daemon found: %w", err) + } + if !resp.OK { + return fmt.Errorf("%s", resp.Error) + } + + var lastID int64 + var existing []entryRow + if err := json.Unmarshal(resp.Data, &existing); err == nil && len(existing) > 0 { + lastID = existing[0].ID // logs returns DESC order, so first is highest + } + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + fmt.Println("tailing proxy logs... (ctrl+c to stop)") + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + tailParams, _ := json.Marshal(daemon.TailParams{AfterID: lastID}) + resp, err := client.Send(daemon.Request{Command: "tail", Params: tailParams}) + if err != nil { + return fmt.Errorf("daemon connection lost") + } + if !resp.OK { + return fmt.Errorf("%s", resp.Error) + } + + var entries []entryRow + if err := json.Unmarshal(resp.Data, &entries); err != nil { + continue + } + + for _, e := range entries { + ts := e.Timestamp.Local().Format("15:04:05") + url := fmt.Sprintf("%s://%s%s", e.Scheme, e.Host, e.Path) + fmt.Printf("%s %s %s %d %dms\n", ts, e.Method, url, e.StatusCode, e.DurationMs) + if e.ID > lastID { + lastID = e.ID + } + } + } + } +} diff --git a/internal/daemon/protocol.go b/internal/daemon/protocol.go index a72399f..ea14750 100644 --- a/internal/daemon/protocol.go +++ b/internal/daemon/protocol.go @@ -3,7 +3,7 @@ package daemon import "encoding/json" type Request struct { - Command string `json:"command"` // "logs", "search", "get", "req", "res", "shutdown" + Command string `json:"command"` // "logs", "search", "get", "req", "res", "tail", "clear", "shutdown" Params json.RawMessage `json:"params"` } @@ -28,6 +28,11 @@ type SearchRequestParams struct { Offset int `json:"offset,omitempty"` } +type TailParams struct { + AfterID int64 `json:"after_id"` + Limit int `json:"limit"` +} + type GetParams struct { ID int64 `json:"id"` } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 3717a8d..7b0e160 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -81,6 +81,10 @@ func (s *IPCServer) route(req Request) Response { return s.handleSearch(req.Params) case "get", "req", "res": return s.handleGet(req.Command, req.Params) + case "tail": + return s.handleTail(req.Params) + case "clear": + return s.handleClear() case "shutdown": return s.handleShutdown() case "ping": @@ -155,6 +159,30 @@ func (s *IPCServer) handleGet(command string, params json.RawMessage) Response { return Response{OK: true, Data: data} } +func (s *IPCServer) handleTail(params json.RawMessage) Response { + var p TailParams + if len(params) > 0 { + if err := json.Unmarshal(params, &p); err != nil { + return Response{Error: "invalid params"} + } + } + + entries, err := s.store.ListAfter(p.AfterID, p.Limit) + if err != nil { + return Response{Error: err.Error()} + } + + data, _ := json.Marshal(entries) + return Response{OK: true, Data: data} +} + +func (s *IPCServer) handleClear() Response { + if err := s.store.Clear(); err != nil { + return Response{Error: err.Error()} + } + return Response{OK: true} +} + func (s *IPCServer) handleShutdown() Response { go func() { close(s.shutdown) diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 74fac00..06081c8 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -17,13 +17,13 @@ import ( type nullStore struct{} -func (s *nullStore) Save(e *storage.Entry) error { return nil } -func (s *nullStore) Get(id int64) (*storage.Entry, error) { return nil, fmt.Errorf("not found") } -func (s *nullStore) List(l, o int) ([]*storage.Entry, error) { return nil, nil } -func (s *nullStore) Search(p storage.SearchParams) ([]*storage.Entry, error) { - return nil, nil -} -func (s *nullStore) Close() error { return nil } +func (s *nullStore) Save(e *storage.Entry) error { return nil } +func (s *nullStore) Get(id int64) (*storage.Entry, error) { return nil, fmt.Errorf("not found") } +func (s *nullStore) List(l, o int) ([]*storage.Entry, error) { return nil, nil } +func (s *nullStore) Search(p storage.SearchParams) ([]*storage.Entry, error) { return nil, nil } +func (s *nullStore) ListAfter(afterID int64, limit int) ([]*storage.Entry, error) { return nil, nil } +func (s *nullStore) Clear() error { return nil } +func (s *nullStore) Close() error { return nil } func startTestProxy(t *testing.T, domains []string, transport http.RoundTripper) (*Proxy, net.Listener) { t.Helper() diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 77a1d64..2732466 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -20,6 +20,7 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { if err != nil { return nil, fmt.Errorf("opening database: %w", err) } + db.SetMaxOpenConns(1) if err := createSchema(db); err != nil { db.Close() @@ -118,6 +119,29 @@ func (s *SQLiteStore) List(limit, offset int) ([]*Entry, error) { return scanEntries(rows) } +func (s *SQLiteStore) ListAfter(afterID int64, limit int) ([]*Entry, error) { + if limit <= 0 { + limit = 100 + } + rows, err := s.db.Query( + `SELECT id, method, scheme, host, path, query, request_headers, request_body, status_code, response_headers, response_body, created_at, duration_ms + FROM entries WHERE id > ? ORDER BY id ASC LIMIT ?`, afterID, limit, + ) + if err != nil { + return nil, fmt.Errorf("querying entries: %w", err) + } + defer rows.Close() + return scanEntries(rows) +} + +func (s *SQLiteStore) Clear() error { + _, err := s.db.Exec("DELETE FROM entries") + if err != nil { + return fmt.Errorf("clearing entries: %w", err) + } + return nil +} + func (s *SQLiteStore) Search(params SearchParams) ([]*Entry, error) { var conditions []string var args []any diff --git a/internal/storage/store.go b/internal/storage/store.go index 5324c63..1d40d75 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -4,6 +4,8 @@ type Store interface { Save(entry *Entry) error Get(id int64) (*Entry, error) List(limit, offset int) ([]*Entry, error) + ListAfter(afterID int64, limit int) ([]*Entry, error) Search(params SearchParams) ([]*Entry, error) + Clear() error Close() error }