Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions internal/cli/clear.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions internal/cli/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"sort"
"text/tabwriter"
"time"
)

// entryRow is a subset of storage.Entry used for table display (deserialized from JSON).
Expand All @@ -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"`
Expand Down
87 changes: 87 additions & 0 deletions internal/cli/tail.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
}
7 changes: 6 additions & 1 deletion internal/daemon/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand All @@ -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"`
}
28 changes: 28 additions & 0 deletions internal/daemon/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 7 additions & 7 deletions internal/proxy/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
24 changes: 24 additions & 0 deletions internal/storage/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/storage/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}