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:
3
PLAN.md
3
PLAN.md
@@ -210,3 +210,6 @@ Many bots send a command directly via `ssh user@host <command>` (an SSH "exec" r
|
|||||||
- Store the command on the session record before closing the channel
|
- Store the command on the session record before closing the channel
|
||||||
- Optionally return plausible fake output for common commands (e.g. `uname`, `id`, `cat /etc/passwd`) to encourage further interaction
|
- Optionally return plausible fake output for common commands (e.g. `uname`, `id`, `cat /etc/passwd`) to encourage further interaction
|
||||||
- Surface exec commands in the web UI (session detail view)
|
- Surface exec commands in the web UI (session detail view)
|
||||||
|
|
||||||
|
#### 4.4.1 Fake Exec Output
|
||||||
|
Return plausible fake output for common exec commands (e.g. `uname`, `id`, `cat /etc/passwd`) to encourage bots to interact further. Implement after collecting data on what bots commonly try to run.
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ Test with:
|
|||||||
ssh -o StrictHostKeyChecking=no -p 2222 root@localhost
|
ssh -o StrictHostKeyChecking=no -p 2222 root@localhost
|
||||||
```
|
```
|
||||||
|
|
||||||
|
SSH exec commands (`ssh user@host <command>`) are also captured and stored on the session record.
|
||||||
|
|
||||||
|
|
||||||
### NixOS Module
|
### NixOS Module
|
||||||
|
|
||||||
Add the flake as an input and enable the service:
|
Add the flake as an input and enable the service:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"git.t-juice.club/torjus/oubliette/internal/web"
|
"git.t-juice.club/torjus/oubliette/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.10.0"
|
const Version = "0.11.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Metrics struct {
|
|||||||
SessionsTotal *prometheus.CounterVec
|
SessionsTotal *prometheus.CounterVec
|
||||||
SessionsActive prometheus.Gauge
|
SessionsActive prometheus.Gauge
|
||||||
SessionDuration prometheus.Histogram
|
SessionDuration prometheus.Histogram
|
||||||
|
ExecCommandsTotal prometheus.Counter
|
||||||
BuildInfo *prometheus.GaugeVec
|
BuildInfo *prometheus.GaugeVec
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +71,10 @@ func New(version string) *Metrics {
|
|||||||
Help: "Session duration in seconds.",
|
Help: "Session duration in seconds.",
|
||||||
Buckets: []float64{1, 5, 10, 30, 60, 120, 300, 600, 1800, 3600},
|
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{
|
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||||
Name: "oubliette_build_info",
|
Name: "oubliette_build_info",
|
||||||
Help: "Build information. Always 1.",
|
Help: "Build information. Always 1.",
|
||||||
@@ -88,6 +93,7 @@ func New(version string) *Metrics {
|
|||||||
m.SessionsTotal,
|
m.SessionsTotal,
|
||||||
m.SessionsActive,
|
m.SessionsActive,
|
||||||
m.SessionDuration,
|
m.SessionDuration,
|
||||||
|
m.ExecCommandsTotal,
|
||||||
m.BuildInfo,
|
m.BuildInfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -231,14 +231,24 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request
|
|||||||
s.notifier.Notify(context.Background(), notify.EventSessionStarted, sessionInfo)
|
s.notifier.Notify(context.Background(), notify.EventSessionStarted, sessionInfo)
|
||||||
defer s.notifier.CleanupSession(sessionID)
|
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() {
|
go func() {
|
||||||
|
defer close(execCh)
|
||||||
for req := range requests {
|
for req := range requests {
|
||||||
switch req.Type {
|
switch req.Type {
|
||||||
case "pty-req", "shell":
|
case "pty-req", "shell":
|
||||||
if req.WantReply {
|
if req.WantReply {
|
||||||
req.Reply(true, nil)
|
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:
|
default:
|
||||||
if req.WantReply {
|
if req.WantReply {
|
||||||
req.Reply(false, nil)
|
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.
|
// Build session context.
|
||||||
var shellCfg map[string]any
|
var shellCfg map[string]any
|
||||||
if s.cfg.Shell.Shells != nil {
|
if s.cfg.Shell.Shells != nil {
|
||||||
|
|||||||
@@ -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
|
// Test threshold acceptance: after enough failed dials, a subsequent
|
||||||
// dial with the same credentials should succeed via threshold or
|
// dial with the same credentials should succeed via threshold or
|
||||||
// remembered credential.
|
// remembered credential.
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ func (m *MemoryStore) UpdateHumanScore(_ context.Context, sessionID string, scor
|
|||||||
return nil
|
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 {
|
func (m *MemoryStore) AppendSessionLog(_ context.Context, sessionID, input, output string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
1
internal/storage/migrations/004_add_exec_command.sql
Normal file
1
internal/storage/migrations/004_add_exec_command.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE sessions ADD COLUMN exec_command TEXT;
|
||||||
@@ -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 != 3 {
|
if version != 4 {
|
||||||
t.Errorf("version = %d, want 3", version)
|
t.Errorf("version = %d, want 4", 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 != 3 {
|
if version != 4 {
|
||||||
t.Errorf("version = %d after double migrate, want 3", version)
|
t.Errorf("version = %d after double migrate, want 4", version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ func (s *SQLiteStore) UpdateHumanScore(ctx context.Context, sessionID string, sc
|
|||||||
return nil
|
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 {
|
func (s *SQLiteStore) AppendSessionLog(ctx context.Context, sessionID, input, output string) error {
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
_, err := s.db.ExecContext(ctx, `
|
_, err := s.db.ExecContext(ctx, `
|
||||||
@@ -100,12 +110,13 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
|
|||||||
var connectedAt string
|
var connectedAt string
|
||||||
var disconnectedAt sql.NullString
|
var disconnectedAt sql.NullString
|
||||||
var humanScore sql.NullFloat64
|
var humanScore sql.NullFloat64
|
||||||
|
var execCommand sql.NullString
|
||||||
|
|
||||||
err := s.db.QueryRowContext(ctx, `
|
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(
|
FROM sessions WHERE id = ?`, sessionID).Scan(
|
||||||
&sess.ID, &sess.IP, &sess.Country, &sess.Username, &sess.ShellName,
|
&sess.ID, &sess.IP, &sess.Country, &sess.Username, &sess.ShellName,
|
||||||
&connectedAt, &disconnectedAt, &humanScore,
|
&connectedAt, &disconnectedAt, &humanScore, &execCommand,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -122,6 +133,9 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
|
|||||||
if humanScore.Valid {
|
if humanScore.Valid {
|
||||||
sess.HumanScore = &humanScore.Float64
|
sess.HumanScore = &humanScore.Float64
|
||||||
}
|
}
|
||||||
|
if execCommand.Valid {
|
||||||
|
sess.ExecCommand = &execCommand.String
|
||||||
|
}
|
||||||
return &sess, nil
|
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) {
|
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 {
|
if activeOnly {
|
||||||
query += ` WHERE disconnected_at IS NULL`
|
query += ` WHERE disconnected_at IS NULL`
|
||||||
}
|
}
|
||||||
@@ -386,7 +400,8 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
|
|||||||
var connectedAt string
|
var connectedAt string
|
||||||
var disconnectedAt sql.NullString
|
var disconnectedAt sql.NullString
|
||||||
var humanScore sql.NullFloat64
|
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)
|
return nil, fmt.Errorf("scanning session: %w", err)
|
||||||
}
|
}
|
||||||
s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
|
s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
|
||||||
@@ -397,6 +412,9 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
|
|||||||
if humanScore.Valid {
|
if humanScore.Valid {
|
||||||
s.HumanScore = &humanScore.Float64
|
s.HumanScore = &humanScore.Float64
|
||||||
}
|
}
|
||||||
|
if execCommand.Valid {
|
||||||
|
s.ExecCommand = &execCommand.String
|
||||||
|
}
|
||||||
sessions = append(sessions, s)
|
sessions = append(sessions, s)
|
||||||
}
|
}
|
||||||
return sessions, rows.Err()
|
return sessions, rows.Err()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type Session struct {
|
|||||||
ConnectedAt time.Time
|
ConnectedAt time.Time
|
||||||
DisconnectedAt *time.Time
|
DisconnectedAt *time.Time
|
||||||
HumanScore *float64
|
HumanScore *float64
|
||||||
|
ExecCommand *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionLog represents a single log entry for a session.
|
// 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 sets the human detection score for a session.
|
||||||
UpdateHumanScore(ctx context.Context, sessionID string, score float64) error
|
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 adds a log entry to a session.
|
||||||
AppendSessionLog(ctx context.Context, sessionID, input, output string) error
|
AppendSessionLog(ctx context.Context, sessionID, input, output string) error
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
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) {
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ func templateFuncMap() template.FuncMap {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%.0f%%", *f*100)
|
return fmt.Sprintf("%.0f%%", *f*100)
|
||||||
},
|
},
|
||||||
|
"derefString": func(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<tr><td><strong>Country</strong></td><td>{{.Session.Country}}</td></tr>
|
<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>Username</strong></td><td>{{.Session.Username}}</td></tr>
|
||||||
<tr><td><strong>Shell</strong></td><td>{{.Session.ShellName}}</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>Score</strong></td><td>{{formatScore .Session.HumanScore}}</td></tr>
|
||||||
<tr><td><strong>Connected</strong></td><td>{{formatTime .Session.ConnectedAt}}</td></tr>
|
<tr><td><strong>Connected</strong></td><td>{{formatTime .Session.ConnectedAt}}</td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user