feat: add session replay with terminal playback via xterm.js
Persist byte-level I/O events from SSH sessions to SQLite and add a web
UI to replay them with original timing. Events are buffered in memory
and flushed every 2s to avoid blocking SSH I/O on database writes.
- Add session_events table (migration 002)
- Add SessionEvent type and storage methods (SQLite + MemoryStore)
- Change RecordingChannel to support multiple callbacks
- Add EventRecorder for buffered event persistence
- Add session detail page with xterm.js terminal replay
- Add /api/sessions/{id}/events JSON endpoint
- Linkify session IDs in dashboard and active sessions
- Vendor xterm.js v5.3.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ type MemoryStore struct {
|
||||
LoginAttempts []LoginAttempt
|
||||
Sessions map[string]*Session
|
||||
SessionLogs []SessionLog
|
||||
SessionEvents []SessionEvent
|
||||
}
|
||||
|
||||
// NewMemoryStore returns a new empty MemoryStore.
|
||||
@@ -101,6 +102,55 @@ func (m *MemoryStore) AppendSessionLog(_ context.Context, sessionID, input, outp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetSession(_ context.Context, sessionID string) (*Session, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
s, ok := m.Sessions[sessionID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
copy := *s
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetSessionLogs(_ context.Context, sessionID string) ([]SessionLog, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var logs []SessionLog
|
||||
for _, l := range m.SessionLogs {
|
||||
if l.SessionID == sessionID {
|
||||
logs = append(logs, l)
|
||||
}
|
||||
}
|
||||
sort.Slice(logs, func(i, j int) bool {
|
||||
return logs[i].Timestamp.Before(logs[j].Timestamp)
|
||||
})
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) AppendSessionEvents(_ context.Context, events []SessionEvent) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.SessionEvents = append(m.SessionEvents, events...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetSessionEvents(_ context.Context, sessionID string) ([]SessionEvent, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var events []SessionEvent
|
||||
for _, e := range m.SessionEvents {
|
||||
if e.SessionID == sessionID {
|
||||
events = append(events, e)
|
||||
}
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) DeleteRecordsBefore(_ context.Context, cutoff time.Time) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -136,6 +186,16 @@ func (m *MemoryStore) DeleteRecordsBefore(_ context.Context, cutoff time.Time) (
|
||||
}
|
||||
m.SessionLogs = keptLogs
|
||||
|
||||
keptEvents := m.SessionEvents[:0]
|
||||
for _, e := range m.SessionEvents {
|
||||
if _, ok := m.Sessions[e.SessionID]; ok {
|
||||
keptEvents = append(keptEvents, e)
|
||||
} else {
|
||||
total++
|
||||
}
|
||||
}
|
||||
m.SessionEvents = keptEvents
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
|
||||
9
internal/storage/migrations/002_session_events.sql
Normal file
9
internal/storage/migrations/002_session_events.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE session_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
timestamp TEXT NOT NULL,
|
||||
direction INTEGER NOT NULL,
|
||||
data BLOB NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_session_events_session_id ON session_events(session_id);
|
||||
@@ -25,8 +25,8 @@ func TestMigrateCreatesTablesAndVersion(t *testing.T) {
|
||||
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
|
||||
t.Fatalf("query version: %v", err)
|
||||
}
|
||||
if version != 1 {
|
||||
t.Errorf("version = %d, want 1", version)
|
||||
if version != 2 {
|
||||
t.Errorf("version = %d, want 2", version)
|
||||
}
|
||||
|
||||
// Verify tables exist by inserting into them.
|
||||
@@ -64,8 +64,8 @@ func TestMigrateIdempotent(t *testing.T) {
|
||||
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
|
||||
t.Fatalf("query version: %v", err)
|
||||
}
|
||||
if version != 1 {
|
||||
t.Errorf("version = %d after double migrate, want 1", version)
|
||||
if version != 2 {
|
||||
t.Errorf("version = %d after double migrate, want 2", version)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,111 @@ func (s *SQLiteStore) AppendSessionLog(ctx context.Context, sessionID, input, ou
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Session, error) {
|
||||
var sess Session
|
||||
var connectedAt string
|
||||
var disconnectedAt sql.NullString
|
||||
var humanScore sql.NullFloat64
|
||||
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, ip, username, shell_name, connected_at, disconnected_at, human_score
|
||||
FROM sessions WHERE id = ?`, sessionID).Scan(
|
||||
&sess.ID, &sess.IP, &sess.Username, &sess.ShellName,
|
||||
&connectedAt, &disconnectedAt, &humanScore,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying session: %w", err)
|
||||
}
|
||||
|
||||
sess.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
|
||||
if disconnectedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, disconnectedAt.String)
|
||||
sess.DisconnectedAt = &t
|
||||
}
|
||||
if humanScore.Valid {
|
||||
sess.HumanScore = &humanScore.Float64
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetSessionLogs(ctx context.Context, sessionID string) ([]SessionLog, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, session_id, timestamp, input, output
|
||||
FROM session_logs WHERE session_id = ?
|
||||
ORDER BY timestamp`, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying session logs: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var logs []SessionLog
|
||||
for rows.Next() {
|
||||
var l SessionLog
|
||||
var ts string
|
||||
if err := rows.Scan(&l.ID, &l.SessionID, &ts, &l.Input, &l.Output); err != nil {
|
||||
return nil, fmt.Errorf("scanning session log: %w", err)
|
||||
}
|
||||
l.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
||||
logs = append(logs, l)
|
||||
}
|
||||
return logs, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) AppendSessionEvents(ctx context.Context, events []SessionEvent) error {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO session_events (session_id, timestamp, direction, data)
|
||||
VALUES (?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("preparing statement: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, e := range events {
|
||||
_, err := stmt.ExecContext(ctx, e.SessionID, e.Timestamp.UTC().Format(time.RFC3339Nano), e.Direction, e.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inserting session event: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetSessionEvents(ctx context.Context, sessionID string) ([]SessionEvent, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT session_id, timestamp, direction, data
|
||||
FROM session_events WHERE session_id = ?
|
||||
ORDER BY id`, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying session events: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var events []SessionEvent
|
||||
for rows.Next() {
|
||||
var e SessionEvent
|
||||
var ts string
|
||||
if err := rows.Scan(&e.SessionID, &ts, &e.Direction, &e.Data); err != nil {
|
||||
return nil, fmt.Errorf("scanning session event: %w", err)
|
||||
}
|
||||
e.Timestamp, _ = time.Parse(time.RFC3339Nano, ts)
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) DeleteRecordsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||
cutoffStr := cutoff.UTC().Format(time.RFC3339)
|
||||
|
||||
@@ -105,15 +210,26 @@ func (s *SQLiteStore) DeleteRecordsBefore(ctx context.Context, cutoff time.Time)
|
||||
|
||||
var total int64
|
||||
|
||||
// Delete session logs for old sessions.
|
||||
// Delete session events for old sessions.
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM session_events WHERE session_id IN (
|
||||
SELECT id FROM sessions WHERE connected_at < ?
|
||||
)`, cutoffStr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("deleting session events: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
total += n
|
||||
|
||||
// Delete session logs for old sessions.
|
||||
res, err = tx.ExecContext(ctx, `
|
||||
DELETE FROM session_logs WHERE session_id IN (
|
||||
SELECT id FROM sessions WHERE connected_at < ?
|
||||
)`, cutoffStr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("deleting session logs: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
n, _ = res.RowsAffected()
|
||||
total += n
|
||||
|
||||
// Delete old sessions.
|
||||
|
||||
@@ -36,6 +36,14 @@ type SessionLog struct {
|
||||
Output string
|
||||
}
|
||||
|
||||
// SessionEvent represents a single I/O event recorded during a session.
|
||||
type SessionEvent struct {
|
||||
SessionID string
|
||||
Timestamp time.Time
|
||||
Direction int // 0=input (client→server), 1=output (server→client)
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// DashboardStats holds aggregate counts for the web dashboard.
|
||||
type DashboardStats struct {
|
||||
TotalAttempts int64
|
||||
@@ -88,6 +96,18 @@ type Store interface {
|
||||
// If activeOnly is true, only sessions with no disconnected_at are returned.
|
||||
GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error)
|
||||
|
||||
// GetSession returns a single session by ID.
|
||||
GetSession(ctx context.Context, sessionID string) (*Session, error)
|
||||
|
||||
// GetSessionLogs returns all log entries for a session ordered by timestamp.
|
||||
GetSessionLogs(ctx context.Context, sessionID string) ([]SessionLog, error)
|
||||
|
||||
// AppendSessionEvents batch-inserts session events.
|
||||
AppendSessionEvents(ctx context.Context, events []SessionEvent) error
|
||||
|
||||
// GetSessionEvents returns all events for a session ordered by id.
|
||||
GetSessionEvents(ctx context.Context, sessionID string) ([]SessionEvent, error)
|
||||
|
||||
// Close releases any resources held by the store.
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -194,6 +194,128 @@ func TestGetTopIPs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSession(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
s, err := store.GetSession(context.Background(), "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if s != nil {
|
||||
t.Errorf("expected nil, got %+v", s)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("found", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
ctx := context.Background()
|
||||
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
s, err := store.GetSession(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatal("expected session, got nil")
|
||||
}
|
||||
if s.ID != id || s.IP != "10.0.0.1" || s.Username != "root" || s.ShellName != "bash" {
|
||||
t.Errorf("unexpected session: %+v", s)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSessionLogs(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
store := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
if err := store.AppendSessionLog(ctx, id, "ls", "file1\nfile2"); err != nil {
|
||||
t.Fatalf("AppendSessionLog: %v", err)
|
||||
}
|
||||
if err := store.AppendSessionLog(ctx, id, "pwd", "/home/root"); err != nil {
|
||||
t.Fatalf("AppendSessionLog: %v", err)
|
||||
}
|
||||
|
||||
logs, err := store.GetSessionLogs(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionLogs: %v", err)
|
||||
}
|
||||
if len(logs) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(logs))
|
||||
}
|
||||
if logs[0].Input != "ls" {
|
||||
t.Errorf("logs[0].Input = %q, want %q", logs[0].Input, "ls")
|
||||
}
|
||||
if logs[1].Input != "pwd" {
|
||||
t.Errorf("logs[1].Input = %q, want %q", logs[1].Input, "pwd")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionEvents(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
events, err := store.GetSessionEvents(context.Background(), "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionEvents: %v", err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("expected empty, got %d", len(events))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("append and retrieve", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
events := []SessionEvent{
|
||||
{SessionID: id, Timestamp: now, Direction: 0, Data: []byte("ls\n")},
|
||||
{SessionID: id, Timestamp: now.Add(100 * time.Millisecond), Direction: 1, Data: []byte("file1\nfile2\n")},
|
||||
{SessionID: id, Timestamp: now.Add(200 * time.Millisecond), Direction: 0, Data: []byte("pwd\n")},
|
||||
}
|
||||
if err := store.AppendSessionEvents(ctx, events); err != nil {
|
||||
t.Fatalf("AppendSessionEvents: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.GetSessionEvents(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionEvents: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].Direction != 0 || string(got[0].Data) != "ls\n" {
|
||||
t.Errorf("got[0] = %+v", got[0])
|
||||
}
|
||||
if got[1].Direction != 1 || string(got[1].Data) != "file1\nfile2\n" {
|
||||
t.Errorf("got[1] = %+v", got[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("append empty", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
if err := store.AppendSessionEvents(context.Background(), nil); err != nil {
|
||||
t.Fatalf("AppendSessionEvents(nil): %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRecentSessions(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user