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:
@@ -42,6 +42,7 @@ Key settings:
|
|||||||
- `shell.fake_user` — override username in prompt; empty uses the authenticated user
|
- `shell.fake_user` — override username in prompt; empty uses the authenticated user
|
||||||
- `web.enabled` — enable the web dashboard (default `false`)
|
- `web.enabled` — enable the web dashboard (default `false`)
|
||||||
- `web.listen_addr` — web dashboard listen address (default `:8080`)
|
- `web.listen_addr` — web dashboard listen address (default `:8080`)
|
||||||
|
- Session detail pages at `/sessions/{id}` include terminal replay via xterm.js
|
||||||
- `detection.enabled` — enable human detection scoring (default `false`)
|
- `detection.enabled` — enable human detection scoring (default `false`)
|
||||||
- `detection.threshold` — score threshold (0.0–1.0) for flagging sessions (default `0.6`)
|
- `detection.threshold` — score threshold (0.0–1.0) for flagging sessions (default `0.6`)
|
||||||
- `detection.update_interval` — how often to recompute scores (default `5s`)
|
- `detection.update_interval` — how often to recompute scores (default `5s`)
|
||||||
|
|||||||
@@ -213,12 +213,18 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request
|
|||||||
// Wrap channel in RecordingChannel.
|
// Wrap channel in RecordingChannel.
|
||||||
recorder := shell.NewRecordingChannel(channel)
|
recorder := shell.NewRecordingChannel(channel)
|
||||||
|
|
||||||
|
// Always record session events for replay.
|
||||||
|
eventRec := shell.NewEventRecorder(sessionID, s.store, s.logger)
|
||||||
|
eventRec.Start(context.Background())
|
||||||
|
defer eventRec.Close()
|
||||||
|
recorder.AddCallback(eventRec.RecordEvent)
|
||||||
|
|
||||||
// Set up detection scorer if enabled.
|
// Set up detection scorer if enabled.
|
||||||
var scorer *detection.Scorer
|
var scorer *detection.Scorer
|
||||||
var scoreCancel context.CancelFunc
|
var scoreCancel context.CancelFunc
|
||||||
if s.cfg.Detection.Enabled {
|
if s.cfg.Detection.Enabled {
|
||||||
scorer = detection.NewScorer()
|
scorer = detection.NewScorer()
|
||||||
recorder.WithCallback(func(ts time.Time, direction int, data []byte) {
|
recorder.AddCallback(func(ts time.Time, direction int, data []byte) {
|
||||||
scorer.RecordEvent(ts, direction, data)
|
scorer.RecordEvent(ts, direction, data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
92
internal/shell/eventrecorder.go
Normal file
92
internal/shell/eventrecorder.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package shell
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventRecorder buffers I/O events in memory and periodically flushes them to
|
||||||
|
// a storage.Store. It is designed to be registered as a RecordingChannel
|
||||||
|
// callback so that SSH I/O is never blocked by database writes.
|
||||||
|
type EventRecorder struct {
|
||||||
|
sessionID string
|
||||||
|
store storage.Store
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
buf []storage.SessionEvent
|
||||||
|
cancel context.CancelFunc
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEventRecorder creates a recorder that will persist events for the given session.
|
||||||
|
func NewEventRecorder(sessionID string, store storage.Store, logger *slog.Logger) *EventRecorder {
|
||||||
|
return &EventRecorder{
|
||||||
|
sessionID: sessionID,
|
||||||
|
store: store,
|
||||||
|
logger: logger,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordEvent implements the EventCallback signature and appends an event to
|
||||||
|
// the in-memory buffer. It is safe to call concurrently.
|
||||||
|
func (er *EventRecorder) RecordEvent(ts time.Time, direction int, data []byte) {
|
||||||
|
er.mu.Lock()
|
||||||
|
defer er.mu.Unlock()
|
||||||
|
er.buf = append(er.buf, storage.SessionEvent{
|
||||||
|
SessionID: er.sessionID,
|
||||||
|
Timestamp: ts,
|
||||||
|
Direction: direction,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the background flush goroutine that drains the buffer every 2 seconds.
|
||||||
|
func (er *EventRecorder) Start(ctx context.Context) {
|
||||||
|
ctx, er.cancel = context.WithCancel(ctx)
|
||||||
|
go er.run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close cancels the background goroutine and performs a final flush.
|
||||||
|
func (er *EventRecorder) Close() {
|
||||||
|
if er.cancel != nil {
|
||||||
|
er.cancel()
|
||||||
|
}
|
||||||
|
<-er.done
|
||||||
|
}
|
||||||
|
|
||||||
|
func (er *EventRecorder) run(ctx context.Context) {
|
||||||
|
defer close(er.done)
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
er.flush()
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
er.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (er *EventRecorder) flush() {
|
||||||
|
er.mu.Lock()
|
||||||
|
if len(er.buf) == 0 {
|
||||||
|
er.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
events := er.buf
|
||||||
|
er.buf = nil
|
||||||
|
er.mu.Unlock()
|
||||||
|
|
||||||
|
if err := er.store.AppendSessionEvents(context.Background(), events); err != nil {
|
||||||
|
er.logger.Error("failed to flush session events", "err", err, "session_id", er.sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
internal/shell/eventrecorder_test.go
Normal file
80
internal/shell/eventrecorder_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package shell
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventRecorderFlush(t *testing.T) {
|
||||||
|
store := storage.NewMemoryStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a session so events have a valid session ID.
|
||||||
|
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := NewEventRecorder(id, store, slog.Default())
|
||||||
|
rec.Start(ctx)
|
||||||
|
|
||||||
|
// Record some events.
|
||||||
|
now := time.Now()
|
||||||
|
rec.RecordEvent(now, 0, []byte("hello"))
|
||||||
|
rec.RecordEvent(now.Add(100*time.Millisecond), 1, []byte("world"))
|
||||||
|
|
||||||
|
// Close should trigger final flush.
|
||||||
|
rec.Close()
|
||||||
|
|
||||||
|
events, err := store.GetSessionEvents(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSessionEvents: %v", err)
|
||||||
|
}
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(events))
|
||||||
|
}
|
||||||
|
if string(events[0].Data) != "hello" {
|
||||||
|
t.Errorf("events[0].Data = %q, want %q", events[0].Data, "hello")
|
||||||
|
}
|
||||||
|
if events[0].Direction != 0 {
|
||||||
|
t.Errorf("events[0].Direction = %d, want 0", events[0].Direction)
|
||||||
|
}
|
||||||
|
if string(events[1].Data) != "world" {
|
||||||
|
t.Errorf("events[1].Data = %q, want %q", events[1].Data, "world")
|
||||||
|
}
|
||||||
|
if events[1].Direction != 1 {
|
||||||
|
t.Errorf("events[1].Direction = %d, want 1", events[1].Direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventRecorderPeriodicFlush(t *testing.T) {
|
||||||
|
store := storage.NewMemoryStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := NewEventRecorder(id, store, slog.Default())
|
||||||
|
rec.Start(ctx)
|
||||||
|
|
||||||
|
// Record an event and wait for the periodic flush (2s + some margin).
|
||||||
|
rec.RecordEvent(time.Now(), 1, []byte("periodic"))
|
||||||
|
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
|
||||||
|
events, err := store.GetSessionEvents(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSessionEvents: %v", err)
|
||||||
|
}
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Errorf("expected periodic flush, got %d events", len(events))
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.Close()
|
||||||
|
}
|
||||||
@@ -9,12 +9,11 @@ import (
|
|||||||
// direction is 0 for input (client→server) and 1 for output (server→client).
|
// direction is 0 for input (client→server) and 1 for output (server→client).
|
||||||
type EventCallback func(ts time.Time, direction int, data []byte)
|
type EventCallback func(ts time.Time, direction int, data []byte)
|
||||||
|
|
||||||
// RecordingChannel wraps an io.ReadWriteCloser and optionally invokes a callback
|
// RecordingChannel wraps an io.ReadWriteCloser and optionally invokes callbacks
|
||||||
// on every Read (input) and Write (output). Phase 2.3 will add byte-level
|
// on every Read (input) and Write (output).
|
||||||
// keystroke recording here without changing any shell code.
|
|
||||||
type RecordingChannel struct {
|
type RecordingChannel struct {
|
||||||
inner io.ReadWriteCloser
|
inner io.ReadWriteCloser
|
||||||
callback EventCallback
|
callbacks []EventCallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRecordingChannel returns a RecordingChannel wrapping rw.
|
// NewRecordingChannel returns a RecordingChannel wrapping rw.
|
||||||
@@ -22,28 +21,40 @@ func NewRecordingChannel(rw io.ReadWriteCloser) *RecordingChannel {
|
|||||||
return &RecordingChannel{inner: rw}
|
return &RecordingChannel{inner: rw}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithCallback sets the event callback and returns the RecordingChannel for chaining.
|
// WithCallback clears existing callbacks, sets the given one, and returns the
|
||||||
|
// RecordingChannel for chaining. Kept for backward compatibility.
|
||||||
func (r *RecordingChannel) WithCallback(cb EventCallback) *RecordingChannel {
|
func (r *RecordingChannel) WithCallback(cb EventCallback) *RecordingChannel {
|
||||||
r.callback = cb
|
r.callbacks = []EventCallback{cb}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddCallback appends an additional event callback.
|
||||||
|
func (r *RecordingChannel) AddCallback(cb EventCallback) {
|
||||||
|
r.callbacks = append(r.callbacks, cb)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *RecordingChannel) Read(p []byte) (int, error) {
|
func (r *RecordingChannel) Read(p []byte) (int, error) {
|
||||||
n, err := r.inner.Read(p)
|
n, err := r.inner.Read(p)
|
||||||
if n > 0 && r.callback != nil {
|
if n > 0 && len(r.callbacks) > 0 {
|
||||||
|
ts := time.Now()
|
||||||
cp := make([]byte, n)
|
cp := make([]byte, n)
|
||||||
copy(cp, p[:n])
|
copy(cp, p[:n])
|
||||||
r.callback(time.Now(), 0, cp)
|
for _, cb := range r.callbacks {
|
||||||
|
cb(ts, 0, cp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RecordingChannel) Write(p []byte) (int, error) {
|
func (r *RecordingChannel) Write(p []byte) (int, error) {
|
||||||
n, err := r.inner.Write(p)
|
n, err := r.inner.Write(p)
|
||||||
if n > 0 && r.callback != nil {
|
if n > 0 && len(r.callbacks) > 0 {
|
||||||
|
ts := time.Now()
|
||||||
cp := make([]byte, n)
|
cp := make([]byte, n)
|
||||||
copy(cp, p[:n])
|
copy(cp, p[:n])
|
||||||
r.callback(time.Now(), 1, cp)
|
for _, cb := range r.callbacks {
|
||||||
|
cb(ts, 1, cp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package shell
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// nopCloser wraps a ReadWriter with a no-op Close.
|
// nopCloser wraps a ReadWriter with a no-op Close.
|
||||||
@@ -41,3 +43,80 @@ func TestRecordingChannelPassthrough(t *testing.T) {
|
|||||||
t.Fatalf("Close: %v", err)
|
t.Fatalf("Close: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecordingChannelMultiCallback(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rc := NewRecordingChannel(nopCloser{&buf})
|
||||||
|
|
||||||
|
type event struct {
|
||||||
|
ts time.Time
|
||||||
|
direction int
|
||||||
|
data string
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var events1, events2 []event
|
||||||
|
|
||||||
|
rc.AddCallback(func(ts time.Time, direction int, data []byte) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
events1 = append(events1, event{ts, direction, string(data)})
|
||||||
|
})
|
||||||
|
rc.AddCallback(func(ts time.Time, direction int, data []byte) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
events2 = append(events2, event{ts, direction, string(data)})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Write triggers both callbacks with direction=1.
|
||||||
|
rc.Write([]byte("hello"))
|
||||||
|
|
||||||
|
// Read triggers both callbacks with direction=0.
|
||||||
|
out := make([]byte, 16)
|
||||||
|
rc.Read(out)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if len(events1) != 2 {
|
||||||
|
t.Fatalf("callback1 got %d events, want 2", len(events1))
|
||||||
|
}
|
||||||
|
if len(events2) != 2 {
|
||||||
|
t.Fatalf("callback2 got %d events, want 2", len(events2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write event should be direction=1.
|
||||||
|
if events1[0].direction != 1 {
|
||||||
|
t.Errorf("write direction = %d, want 1", events1[0].direction)
|
||||||
|
}
|
||||||
|
// Read event should be direction=0.
|
||||||
|
if events1[1].direction != 0 {
|
||||||
|
t.Errorf("read direction = %d, want 0", events1[1].direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both callbacks should get the same timestamp for a single operation.
|
||||||
|
if events1[0].ts != events2[0].ts {
|
||||||
|
t.Error("callbacks should receive the same timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordingChannelWithCallbackClearsExisting(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rc := NewRecordingChannel(nopCloser{&buf})
|
||||||
|
|
||||||
|
called1 := false
|
||||||
|
called2 := false
|
||||||
|
|
||||||
|
rc.AddCallback(func(_ time.Time, _ int, _ []byte) { called1 = true })
|
||||||
|
// WithCallback should clear existing and set new.
|
||||||
|
rc.WithCallback(func(_ time.Time, _ int, _ []byte) { called2 = true })
|
||||||
|
|
||||||
|
rc.Write([]byte("x"))
|
||||||
|
|
||||||
|
if called1 {
|
||||||
|
t.Error("first callback should not be called after WithCallback")
|
||||||
|
}
|
||||||
|
if !called2 {
|
||||||
|
t.Error("second callback should be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type MemoryStore struct {
|
|||||||
LoginAttempts []LoginAttempt
|
LoginAttempts []LoginAttempt
|
||||||
Sessions map[string]*Session
|
Sessions map[string]*Session
|
||||||
SessionLogs []SessionLog
|
SessionLogs []SessionLog
|
||||||
|
SessionEvents []SessionEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMemoryStore returns a new empty MemoryStore.
|
// NewMemoryStore returns a new empty MemoryStore.
|
||||||
@@ -101,6 +102,55 @@ func (m *MemoryStore) AppendSessionLog(_ context.Context, sessionID, input, outp
|
|||||||
return nil
|
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) {
|
func (m *MemoryStore) DeleteRecordsBefore(_ context.Context, cutoff time.Time) (int64, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -136,6 +186,16 @@ func (m *MemoryStore) DeleteRecordsBefore(_ context.Context, cutoff time.Time) (
|
|||||||
}
|
}
|
||||||
m.SessionLogs = keptLogs
|
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
|
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 {
|
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
|
||||||
t.Fatalf("query version: %v", err)
|
t.Fatalf("query version: %v", err)
|
||||||
}
|
}
|
||||||
if version != 1 {
|
if version != 2 {
|
||||||
t.Errorf("version = %d, want 1", version)
|
t.Errorf("version = %d, want 2", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify tables exist by inserting into them.
|
// 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 {
|
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
|
||||||
t.Fatalf("query version: %v", err)
|
t.Fatalf("query version: %v", err)
|
||||||
}
|
}
|
||||||
if version != 1 {
|
if version != 2 {
|
||||||
t.Errorf("version = %d after double migrate, want 1", version)
|
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
|
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) {
|
func (s *SQLiteStore) DeleteRecordsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||||
cutoffStr := cutoff.UTC().Format(time.RFC3339)
|
cutoffStr := cutoff.UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
@@ -105,15 +210,26 @@ func (s *SQLiteStore) DeleteRecordsBefore(ctx context.Context, cutoff time.Time)
|
|||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
// Delete session logs for old sessions.
|
// Delete session events for old sessions.
|
||||||
res, err := tx.ExecContext(ctx, `
|
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 (
|
DELETE FROM session_logs WHERE session_id IN (
|
||||||
SELECT id FROM sessions WHERE connected_at < ?
|
SELECT id FROM sessions WHERE connected_at < ?
|
||||||
)`, cutoffStr)
|
)`, cutoffStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("deleting session logs: %w", err)
|
return 0, fmt.Errorf("deleting session logs: %w", err)
|
||||||
}
|
}
|
||||||
n, _ := res.RowsAffected()
|
n, _ = res.RowsAffected()
|
||||||
total += n
|
total += n
|
||||||
|
|
||||||
// Delete old sessions.
|
// Delete old sessions.
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ type SessionLog struct {
|
|||||||
Output string
|
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.
|
// DashboardStats holds aggregate counts for the web dashboard.
|
||||||
type DashboardStats struct {
|
type DashboardStats struct {
|
||||||
TotalAttempts int64
|
TotalAttempts int64
|
||||||
@@ -88,6 +96,18 @@ type Store interface {
|
|||||||
// If activeOnly is true, only sessions with no disconnected_at are returned.
|
// If activeOnly is true, only sessions with no disconnected_at are returned.
|
||||||
GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error)
|
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 releases any resources held by the store.
|
||||||
Close() error
|
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) {
|
func TestGetRecentSessions(t *testing.T) {
|
||||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||||
t.Run("empty", func(t *testing.T) {
|
t.Run("empty", func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
@@ -70,7 +72,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := s.tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
|
if err := s.tmpl.dashboard.ExecuteTemplate(w, "layout.html", data); err != nil {
|
||||||
s.logger.Error("failed to render dashboard", "err", err)
|
s.logger.Error("failed to render dashboard", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +86,7 @@ func (s *Server) handleFragmentStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := s.tmpl.ExecuteTemplate(w, "stats", stats); err != nil {
|
if err := s.tmpl.dashboard.ExecuteTemplate(w, "stats", stats); err != nil {
|
||||||
s.logger.Error("failed to render stats fragment", "err", err)
|
s.logger.Error("failed to render stats fragment", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +100,95 @@ func (s *Server) handleFragmentActiveSessions(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := s.tmpl.ExecuteTemplate(w, "active_sessions", sessions); err != nil {
|
if err := s.tmpl.dashboard.ExecuteTemplate(w, "active_sessions", sessions); err != nil {
|
||||||
s.logger.Error("failed to render active sessions fragment", "err", err)
|
s.logger.Error("failed to render active sessions fragment", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sessionDetailData struct {
|
||||||
|
Session *storage.Session
|
||||||
|
Logs []storage.SessionLog
|
||||||
|
EventCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSessionDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
sessionID := r.PathValue("id")
|
||||||
|
|
||||||
|
session, err := s.store.GetSession(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get session", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := s.store.GetSessionLogs(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get session logs", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := s.store.GetSessionEvents(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get session events", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := sessionDetailData{
|
||||||
|
Session: session,
|
||||||
|
Logs: logs,
|
||||||
|
EventCount: len(events),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := s.tmpl.sessionDetail.ExecuteTemplate(w, "layout.html", data); err != nil {
|
||||||
|
s.logger.Error("failed to render session detail", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiEvent struct {
|
||||||
|
T int64 `json:"t"`
|
||||||
|
D int `json:"d"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiEventsResponse struct {
|
||||||
|
Events []apiEvent `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAPISessionEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
sessionID := r.PathValue("id")
|
||||||
|
|
||||||
|
events, err := s.store.GetSessionEvents(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get session events", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := apiEventsResponse{Events: make([]apiEvent, len(events))}
|
||||||
|
var baseTime int64
|
||||||
|
for i, e := range events {
|
||||||
|
ms := e.Timestamp.UnixMilli()
|
||||||
|
if i == 0 {
|
||||||
|
baseTime = ms
|
||||||
|
}
|
||||||
|
resp.Events[i] = apiEvent{
|
||||||
|
T: ms - baseTime,
|
||||||
|
D: e.Direction,
|
||||||
|
Data: base64.StdEncoding.EncodeToString(e.Data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
|
s.logger.Error("failed to encode session events", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
83
internal/web/static/replay.js
Normal file
83
internal/web/static/replay.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// ReplayPlayer drives xterm.js playback of recorded session events.
|
||||||
|
function ReplayPlayer(containerId, sessionId) {
|
||||||
|
this.terminal = new Terminal({
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
convertEol: true,
|
||||||
|
disableStdin: true,
|
||||||
|
theme: {
|
||||||
|
background: '#000000',
|
||||||
|
foreground: '#ffffff'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.terminal.open(document.getElementById(containerId));
|
||||||
|
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
this.events = [];
|
||||||
|
this.index = 0;
|
||||||
|
this.speed = 1;
|
||||||
|
this.timers = [];
|
||||||
|
this.playing = false;
|
||||||
|
|
||||||
|
// Fetch events immediately.
|
||||||
|
var self = this;
|
||||||
|
fetch('/api/sessions/' + sessionId + '/events')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
self.events = data.events || [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ReplayPlayer.prototype.play = function() {
|
||||||
|
if (this.playing) return;
|
||||||
|
if (this.events.length === 0) return;
|
||||||
|
this.playing = true;
|
||||||
|
this._schedule();
|
||||||
|
};
|
||||||
|
|
||||||
|
ReplayPlayer.prototype.pause = function() {
|
||||||
|
this.playing = false;
|
||||||
|
for (var i = 0; i < this.timers.length; i++) {
|
||||||
|
clearTimeout(this.timers[i]);
|
||||||
|
}
|
||||||
|
this.timers = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
ReplayPlayer.prototype.reset = function() {
|
||||||
|
this.pause();
|
||||||
|
this.index = 0;
|
||||||
|
this.terminal.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
ReplayPlayer.prototype.setSpeed = function(speed) {
|
||||||
|
this.speed = speed;
|
||||||
|
if (this.playing) {
|
||||||
|
this.pause();
|
||||||
|
this.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ReplayPlayer.prototype._schedule = function() {
|
||||||
|
var self = this;
|
||||||
|
var baseT = this.index < this.events.length ? this.events[this.index].t : 0;
|
||||||
|
|
||||||
|
for (var i = this.index; i < this.events.length; i++) {
|
||||||
|
(function(idx) {
|
||||||
|
var evt = self.events[idx];
|
||||||
|
var delay = (evt.t - baseT) / self.speed;
|
||||||
|
var timer = setTimeout(function() {
|
||||||
|
if (!self.playing) return;
|
||||||
|
// Only write output events (d=1) to terminal; input is echoed in output.
|
||||||
|
if (evt.d === 1) {
|
||||||
|
var raw = atob(evt.data);
|
||||||
|
self.terminal.write(raw);
|
||||||
|
}
|
||||||
|
self.index = idx + 1;
|
||||||
|
if (self.index >= self.events.length) {
|
||||||
|
self.playing = false;
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
self.timers.push(timer);
|
||||||
|
})(i);
|
||||||
|
}
|
||||||
|
};
|
||||||
209
internal/web/static/xterm.css
Normal file
209
internal/web/static/xterm.css
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
|
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||||
|
* https://github.com/chjj/term.js
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* Originally forked from (with the author's permission):
|
||||||
|
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||||
|
* http://bellard.org/jslinux/
|
||||||
|
* Copyright (c) 2011 Fabrice Bellard
|
||||||
|
* The original design remains. The terminal itself
|
||||||
|
* has been extended to include xterm CSI codes, among
|
||||||
|
* other features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default styles for xterm.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
cursor: text;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.focus,
|
||||||
|
.xterm:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helpers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
/**
|
||||||
|
* The z-index of the helpers must be higher than the canvases in order for
|
||||||
|
* IMEs to appear on top.
|
||||||
|
*/
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helper-textarea {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
left: -9999em;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: -5;
|
||||||
|
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view {
|
||||||
|
/* TODO: Composition position got messed up somewhere */
|
||||||
|
background: #000;
|
||||||
|
color: #FFF;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||||
|
background-color: #000;
|
||||||
|
overflow-y: scroll;
|
||||||
|
cursor: default;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-scroll-area {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-char-measure-element {
|
||||||
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -9999em;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.enable-mouse-events {
|
||||||
|
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.xterm-cursor-pointer,
|
||||||
|
.xterm .xterm-cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.column-select.focus {
|
||||||
|
/* Column selection mode */
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility,
|
||||||
|
.xterm .xterm-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .live-region {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-dim {
|
||||||
|
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||||
|
* explicitly in the generated class and reset to 1 here */
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-underline-1 { text-decoration: underline; }
|
||||||
|
.xterm-underline-2 { text-decoration: double underline; }
|
||||||
|
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||||
|
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||||
|
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||||
|
|
||||||
|
.xterm-overline {
|
||||||
|
text-decoration: overline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||||
|
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||||
|
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||||
|
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||||
|
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||||
|
|
||||||
|
.xterm-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||||
|
z-index: 6;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-overview-ruler {
|
||||||
|
z-index: 8;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-top {
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
8
internal/web/static/xterm.min.js
vendored
Normal file
8
internal/web/static/xterm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -10,8 +10,13 @@ import (
|
|||||||
//go:embed templates/*.html templates/fragments/*.html
|
//go:embed templates/*.html templates/fragments/*.html
|
||||||
var templateFS embed.FS
|
var templateFS embed.FS
|
||||||
|
|
||||||
func loadTemplates() (*template.Template, error) {
|
type templateSet struct {
|
||||||
funcMap := template.FuncMap{
|
dashboard *template.Template
|
||||||
|
sessionDetail *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateFuncMap() template.FuncMap {
|
||||||
|
return template.FuncMap{
|
||||||
"formatTime": func(t time.Time) string {
|
"formatTime": func(t time.Time) string {
|
||||||
return t.Format("2006-01-02 15:04:05 UTC")
|
return t.Format("2006-01-02 15:04:05 UTC")
|
||||||
},
|
},
|
||||||
@@ -40,11 +45,31 @@ func loadTemplates() (*template.Template, error) {
|
|||||||
return fmt.Sprintf("%.0f%%", *f*100)
|
return fmt.Sprintf("%.0f%%", *f*100)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return template.New("").Funcs(funcMap).ParseFS(templateFS,
|
func loadTemplates() (*templateSet, error) {
|
||||||
|
funcMap := templateFuncMap()
|
||||||
|
|
||||||
|
dashboard, err := template.New("").Funcs(funcMap).ParseFS(templateFS,
|
||||||
"templates/layout.html",
|
"templates/layout.html",
|
||||||
"templates/dashboard.html",
|
"templates/dashboard.html",
|
||||||
"templates/fragments/stats.html",
|
"templates/fragments/stats.html",
|
||||||
"templates/fragments/active_sessions.html",
|
"templates/fragments/active_sessions.html",
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing dashboard templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionDetail, err := template.New("").Funcs(funcMap).ParseFS(templateFS,
|
||||||
|
"templates/layout.html",
|
||||||
|
"templates/session_detail.html",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing session detail templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &templateSet{
|
||||||
|
dashboard: dashboard,
|
||||||
|
sessionDetail: sessionDetail,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .RecentSessions}}
|
{{range .RecentSessions}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{truncateID .ID}}</code></td>
|
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a></td>
|
||||||
<td>{{.IP}}</td>
|
<td>{{.IP}}</td>
|
||||||
<td>{{.Username}}</td>
|
<td>{{.Username}}</td>
|
||||||
<td>{{.ShellName}}</td>
|
<td>{{.ShellName}}</td>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .}}
|
{{range .}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{truncateID .ID}}</code></td>
|
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a></td>
|
||||||
<td>{{.IP}}</td>
|
<td>{{.IP}}</td>
|
||||||
<td>{{.Username}}</td>
|
<td>{{.Username}}</td>
|
||||||
<td>{{.ShellName}}</td>
|
<td>{{.ShellName}}</td>
|
||||||
|
|||||||
79
internal/web/templates/session_detail.html
Normal file
79
internal/web/templates/session_detail.html
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<section>
|
||||||
|
<h3>Session {{.Session.ID}}</h3>
|
||||||
|
<div class="top-grid">
|
||||||
|
<article>
|
||||||
|
<header>Session Info</header>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>IP</strong></td><td>{{.Session.IP}}</td></tr>
|
||||||
|
<tr><td><strong>Username</strong></td><td>{{.Session.Username}}</td></tr>
|
||||||
|
<tr><td><strong>Shell</strong></td><td>{{.Session.ShellName}}</td></tr>
|
||||||
|
<tr><td><strong>Score</strong></td><td>{{formatScore .Session.HumanScore}}</td></tr>
|
||||||
|
<tr><td><strong>Connected</strong></td><td>{{formatTime .Session.ConnectedAt}}</td></tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Disconnected</strong></td>
|
||||||
|
<td>{{if .Session.DisconnectedAt}}{{formatTime (derefTime .Session.DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if gt .EventCount 0}}
|
||||||
|
<section>
|
||||||
|
<h3>Session Replay</h3>
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<button id="btn-play" onclick="replayPlayer.play()">Play</button>
|
||||||
|
<button id="btn-pause" onclick="replayPlayer.pause()">Pause</button>
|
||||||
|
<button id="btn-reset" onclick="replayPlayer.reset()">Reset</button>
|
||||||
|
<label for="speed-select" style="margin-left: 1rem;">Speed:</label>
|
||||||
|
<select id="speed-select" onchange="replayPlayer.setSpeed(parseFloat(this.value))">
|
||||||
|
<option value="0.5">0.5x</option>
|
||||||
|
<option value="1" selected>1x</option>
|
||||||
|
<option value="2">2x</option>
|
||||||
|
<option value="5">5x</option>
|
||||||
|
<option value="10">10x</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="terminal" style="background: #000; padding: 4px; border-radius: 4px;"></div>
|
||||||
|
</section>
|
||||||
|
<link rel="stylesheet" href="/static/xterm.css">
|
||||||
|
<script src="/static/xterm.min.js"></script>
|
||||||
|
<script src="/static/replay.js"></script>
|
||||||
|
<script>
|
||||||
|
var replayPlayer = new ReplayPlayer("terminal", "{{.Session.ID}}");
|
||||||
|
</script>
|
||||||
|
{{else}}
|
||||||
|
<section>
|
||||||
|
<p>No recorded events for this session.</p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Logs}}
|
||||||
|
<section>
|
||||||
|
<h3>Command Log</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Input</th>
|
||||||
|
<th>Output</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Logs}}
|
||||||
|
<tr>
|
||||||
|
<td>{{formatTime .Timestamp}}</td>
|
||||||
|
<td><code>{{.Input}}</code></td>
|
||||||
|
<td><pre style="margin:0; white-space:pre-wrap;">{{.Output}}</pre></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p><a href="/">← Back to dashboard</a></p>
|
||||||
|
{{end}}
|
||||||
@@ -2,7 +2,6 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"html/template"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ type Server struct {
|
|||||||
store storage.Store
|
store storage.Store
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
tmpl *template.Template
|
tmpl *templateSet
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new web Server with routes registered.
|
// NewServer creates a new web Server with routes registered.
|
||||||
@@ -35,6 +34,8 @@ func NewServer(store storage.Store, logger *slog.Logger) (*Server, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
s.mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||||
|
s.mux.HandleFunc("GET /sessions/{id}", s.handleSessionDetail)
|
||||||
|
s.mux.HandleFunc("GET /api/sessions/{id}/events", s.handleAPISessionEvents)
|
||||||
s.mux.HandleFunc("GET /", s.handleDashboard)
|
s.mux.HandleFunc("GET /", s.handleDashboard)
|
||||||
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
|
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
|
||||||
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
|
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -131,6 +133,109 @@ func TestFragmentActiveSessions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionDetailHandler(t *testing.T) {
|
||||||
|
t.Run("not found", func(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/sessions/nonexistent", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("found", func(t *testing.T) {
|
||||||
|
store := storage.NewMemoryStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := NewServer(store, slog.Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewServer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/sessions/"+id, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "10.0.0.1") {
|
||||||
|
t.Error("response should contain IP")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "root") {
|
||||||
|
t.Error("response should contain username")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISessionEvents(t *testing.T) {
|
||||||
|
store := storage.NewMemoryStore()
|
||||||
|
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 := []storage.SessionEvent{
|
||||||
|
{SessionID: id, Timestamp: now, Direction: 0, Data: []byte("ls\n")},
|
||||||
|
{SessionID: id, Timestamp: now.Add(500 * time.Millisecond), Direction: 1, Data: []byte("file1\n")},
|
||||||
|
}
|
||||||
|
if err := store.AppendSessionEvents(ctx, events); err != nil {
|
||||||
|
t.Fatalf("AppendSessionEvents: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := NewServer(store, slog.Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewServer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+id+"/events", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "application/json") {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp apiEventsResponse
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Events) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(resp.Events))
|
||||||
|
}
|
||||||
|
// First event should have t=0 (relative).
|
||||||
|
if resp.Events[0].T != 0 {
|
||||||
|
t.Errorf("events[0].T = %d, want 0", resp.Events[0].T)
|
||||||
|
}
|
||||||
|
// Second event should have t=500 (500ms later).
|
||||||
|
if resp.Events[1].T != 500 {
|
||||||
|
t.Errorf("events[1].T = %d, want 500", resp.Events[1].T)
|
||||||
|
}
|
||||||
|
if resp.Events[0].D != 0 {
|
||||||
|
t.Errorf("events[0].D = %d, want 0", resp.Events[0].D)
|
||||||
|
}
|
||||||
|
if resp.Events[1].D != 1 {
|
||||||
|
t.Errorf("events[1].D = %d, want 1", resp.Events[1].D)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStaticAssets(t *testing.T) {
|
func TestStaticAssets(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user