feat: capture SSH exec commands (PLAN.md 4.4)

Bots often send commands via `ssh user@host <command>` (exec request)
rather than requesting an interactive shell. These were previously
rejected silently. Now exec commands are captured, stored on the session
record, and displayed in the web UI session detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 17:43:11 +01:00
parent 3c20e854aa
commit 0133d956a5
14 changed files with 206 additions and 10 deletions

View File

@@ -23,6 +23,7 @@ type Metrics struct {
SessionsTotal *prometheus.CounterVec
SessionsActive prometheus.Gauge
SessionDuration prometheus.Histogram
ExecCommandsTotal prometheus.Counter
BuildInfo *prometheus.GaugeVec
}
@@ -70,6 +71,10 @@ func New(version string) *Metrics {
Help: "Session duration in seconds.",
Buckets: []float64{1, 5, 10, 30, 60, 120, 300, 600, 1800, 3600},
}),
ExecCommandsTotal: prometheus.NewCounter(prometheus.CounterOpts{
Name: "oubliette_exec_commands_total",
Help: "Total SSH exec commands received.",
}),
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "oubliette_build_info",
Help: "Build information. Always 1.",
@@ -88,6 +93,7 @@ func New(version string) *Metrics {
m.SessionsTotal,
m.SessionsActive,
m.SessionDuration,
m.ExecCommandsTotal,
m.BuildInfo,
)

View File

@@ -231,14 +231,24 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request
s.notifier.Notify(context.Background(), notify.EventSessionStarted, sessionInfo)
defer s.notifier.CleanupSession(sessionID)
// Handle session requests (pty-req, shell, etc.)
// Handle session requests (pty-req, shell, exec, etc.)
execCh := make(chan string, 1)
go func() {
defer close(execCh)
for req := range requests {
switch req.Type {
case "pty-req", "shell":
if req.WantReply {
req.Reply(true, nil)
}
case "exec":
if req.WantReply {
req.Reply(true, nil)
}
var payload struct{ Command string }
if err := ssh.Unmarshal(req.Payload, &payload); err == nil {
execCh <- payload.Command
}
default:
if req.WantReply {
req.Reply(false, nil)
@@ -247,6 +257,29 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request
}
}()
// Check for exec request before proceeding to interactive shell.
select {
case cmd, ok := <-execCh:
if ok && cmd != "" {
s.logger.Info("exec command received",
"remote_addr", conn.RemoteAddr(),
"user", conn.User(),
"session_id", sessionID,
"command", cmd,
)
if err := s.store.SetExecCommand(context.Background(), sessionID, cmd); err != nil {
s.logger.Error("failed to set exec command", "err", err, "session_id", sessionID)
}
s.metrics.ExecCommandsTotal.Inc()
// Send exit-status 0 and close channel.
exitPayload := make([]byte, 4) // uint32(0)
_, _ = channel.SendRequest("exit-status", false, exitPayload)
return
}
case <-time.After(500 * time.Millisecond):
// No exec request within timeout — proceed with interactive shell.
}
// Build session context.
var shellCfg map[string]any
if s.cfg.Shell.Shells != nil {

View File

@@ -252,6 +252,54 @@ func TestIntegrationSSHConnect(t *testing.T) {
}
})
// Test exec command capture.
t.Run("exec_command", func(t *testing.T) {
clientCfg := &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{ssh.Password("toor")},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 5 * time.Second,
}
client, err := ssh.Dial("tcp", addr, clientCfg)
if err != nil {
t.Fatalf("SSH dial: %v", err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
t.Fatalf("new session: %v", err)
}
defer session.Close()
// Run a command via exec (no PTY, no shell).
if err := session.Run("uname -a"); err != nil {
// Run returns an error because the server closes the channel,
// but that's expected.
_ = err
}
// Give the server a moment to store the command.
time.Sleep(200 * time.Millisecond)
// Verify the exec command was captured.
sessions, err := store.GetRecentSessions(context.Background(), 50, false)
if err != nil {
t.Fatalf("GetRecentSessions: %v", err)
}
var foundExec bool
for _, s := range sessions {
if s.ExecCommand != nil && *s.ExecCommand == "uname -a" {
foundExec = true
break
}
}
if !foundExec {
t.Error("expected a session with exec_command='uname -a'")
}
})
// Test threshold acceptance: after enough failed dials, a subsequent
// dial with the same credentials should succeed via threshold or
// remembered credential.

View File

@@ -91,6 +91,16 @@ func (m *MemoryStore) UpdateHumanScore(_ context.Context, sessionID string, scor
return nil
}
func (m *MemoryStore) SetExecCommand(_ context.Context, sessionID string, command string) error {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.Sessions[sessionID]; ok {
s.ExecCommand = &command
}
return nil
}
func (m *MemoryStore) AppendSessionLog(_ context.Context, sessionID, input, output string) error {
m.mu.Lock()
defer m.mu.Unlock()

View File

@@ -0,0 +1 @@
ALTER TABLE sessions ADD COLUMN exec_command TEXT;

View File

@@ -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 != 3 {
t.Errorf("version = %d, want 3", version)
if version != 4 {
t.Errorf("version = %d, want 4", 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 != 3 {
t.Errorf("version = %d after double migrate, want 3", version)
if version != 4 {
t.Errorf("version = %d after double migrate, want 4", version)
}
}

View File

@@ -83,6 +83,16 @@ func (s *SQLiteStore) UpdateHumanScore(ctx context.Context, sessionID string, sc
return nil
}
func (s *SQLiteStore) SetExecCommand(ctx context.Context, sessionID string, command string) error {
_, err := s.db.ExecContext(ctx, `
UPDATE sessions SET exec_command = ? WHERE id = ?`,
command, sessionID)
if err != nil {
return fmt.Errorf("setting exec command: %w", err)
}
return nil
}
func (s *SQLiteStore) AppendSessionLog(ctx context.Context, sessionID, input, output string) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.ExecContext(ctx, `
@@ -100,12 +110,13 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
var connectedAt string
var disconnectedAt sql.NullString
var humanScore sql.NullFloat64
var execCommand sql.NullString
err := s.db.QueryRowContext(ctx, `
SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score
SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score, exec_command
FROM sessions WHERE id = ?`, sessionID).Scan(
&sess.ID, &sess.IP, &sess.Country, &sess.Username, &sess.ShellName,
&connectedAt, &disconnectedAt, &humanScore,
&connectedAt, &disconnectedAt, &humanScore, &execCommand,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -122,6 +133,9 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
if humanScore.Valid {
sess.HumanScore = &humanScore.Float64
}
if execCommand.Valid {
sess.ExecCommand = &execCommand.String
}
return &sess, nil
}
@@ -368,7 +382,7 @@ func (s *SQLiteStore) queryTopN(ctx context.Context, column string, limit int) (
}
func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) {
query := `SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score FROM sessions`
query := `SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score, exec_command FROM sessions`
if activeOnly {
query += ` WHERE disconnected_at IS NULL`
}
@@ -386,7 +400,8 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
var connectedAt string
var disconnectedAt sql.NullString
var humanScore sql.NullFloat64
if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore); err != nil {
var execCommand sql.NullString
if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore, &execCommand); err != nil {
return nil, fmt.Errorf("scanning session: %w", err)
}
s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
@@ -397,6 +412,9 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
if humanScore.Valid {
s.HumanScore = &humanScore.Float64
}
if execCommand.Valid {
s.ExecCommand = &execCommand.String
}
sessions = append(sessions, s)
}
return sessions, rows.Err()

View File

@@ -27,6 +27,7 @@ type Session struct {
ConnectedAt time.Time
DisconnectedAt *time.Time
HumanScore *float64
ExecCommand *string
}
// SessionLog represents a single log entry for a session.
@@ -76,6 +77,9 @@ type Store interface {
// UpdateHumanScore sets the human detection score for a session.
UpdateHumanScore(ctx context.Context, sessionID string, score float64) error
// SetExecCommand sets the exec command for a session.
SetExecCommand(ctx context.Context, sessionID string, command string) error
// AppendSessionLog adds a log entry to a session.
AppendSessionLog(ctx context.Context, sessionID, input, output string) error

View File

@@ -361,6 +361,69 @@ func TestCloseActiveSessions(t *testing.T) {
})
}
func TestSetExecCommand(t *testing.T) {
testStores(t, func(t *testing.T, newStore storeFactory) {
t.Run("set 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)
}
// Initially nil.
s, err := store.GetSession(ctx, id)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if s.ExecCommand != nil {
t.Errorf("expected nil ExecCommand, got %q", *s.ExecCommand)
}
// Set exec command.
if err := store.SetExecCommand(ctx, id, "uname -a"); err != nil {
t.Fatalf("SetExecCommand: %v", err)
}
s, err = store.GetSession(ctx, id)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if s.ExecCommand == nil {
t.Fatal("expected non-nil ExecCommand")
}
if *s.ExecCommand != "uname -a" {
t.Errorf("ExecCommand = %q, want %q", *s.ExecCommand, "uname -a")
}
})
t.Run("appears in recent sessions", 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)
}
if err := store.SetExecCommand(ctx, id, "id"); err != nil {
t.Fatalf("SetExecCommand: %v", err)
}
sessions, err := store.GetRecentSessions(ctx, 10, false)
if err != nil {
t.Fatalf("GetRecentSessions: %v", err)
}
if len(sessions) != 1 {
t.Fatalf("len = %d, want 1", len(sessions))
}
if sessions[0].ExecCommand == nil || *sessions[0].ExecCommand != "id" {
t.Errorf("ExecCommand = %v, want \"id\"", sessions[0].ExecCommand)
}
})
})
}
func TestGetRecentSessions(t *testing.T) {
testStores(t, func(t *testing.T, newStore storeFactory) {
t.Run("empty", func(t *testing.T) {

View File

@@ -44,6 +44,12 @@ func templateFuncMap() template.FuncMap {
}
return fmt.Sprintf("%.0f%%", *f*100)
},
"derefString": func(s *string) string {
if s == nil {
return ""
}
return *s
},
}
}

View File

@@ -10,6 +10,7 @@
<tr><td><strong>Country</strong></td><td>{{.Session.Country}}</td></tr>
<tr><td><strong>Username</strong></td><td>{{.Session.Username}}</td></tr>
<tr><td><strong>Shell</strong></td><td>{{.Session.ShellName}}</td></tr>
{{if .Session.ExecCommand}}<tr><td><strong>Exec Command</strong></td><td><code>{{derefString .Session.ExecCommand}}</code></td></tr>{{end}}
<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>