feat: add human detection scoring and webhook notifications
Implement phase 2.1 (human detection) and 2.2 (notifications): - Detection scorer computes 0.0-1.0 human likelihood from keystroke timing variance, special key usage, typing speed, command diversity, and session duration - Webhook notifier sends JSON POST to configured endpoints with deduplication, custom headers, and event filtering - RecordingChannel gains an event callback for feeding keystrokes to the scorer without coupling shell and detection packages - Server wires scorer into session lifecycle with periodic updates and threshold-based notification triggers - Web UI shows human score in session tables with highlighting - New config sections: [detection] and [[notify.webhooks]] Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ import (
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/auth"
|
||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||
"git.t-juice.club/torjus/oubliette/internal/detection"
|
||||
"git.t-juice.club/torjus/oubliette/internal/notify"
|
||||
"git.t-juice.club/torjus/oubliette/internal/shell"
|
||||
"git.t-juice.club/torjus/oubliette/internal/shell/bash"
|
||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||
@@ -21,13 +23,14 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
store storage.Store
|
||||
authenticator *auth.Authenticator
|
||||
sshConfig *ssh.ServerConfig
|
||||
logger *slog.Logger
|
||||
connSem chan struct{} // semaphore limiting concurrent connections
|
||||
shellRegistry *shell.Registry
|
||||
cfg config.Config
|
||||
store storage.Store
|
||||
authenticator *auth.Authenticator
|
||||
sshConfig *ssh.ServerConfig
|
||||
logger *slog.Logger
|
||||
connSem chan struct{} // semaphore limiting concurrent connections
|
||||
shellRegistry *shell.Registry
|
||||
notifier notify.Sender
|
||||
}
|
||||
|
||||
func New(cfg config.Config, store storage.Store, logger *slog.Logger) (*Server, error) {
|
||||
@@ -43,6 +46,7 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger) (*Server,
|
||||
logger: logger,
|
||||
connSem: make(chan struct{}, cfg.SSH.MaxConnections),
|
||||
shellRegistry: registry,
|
||||
notifier: notify.NewSender(cfg.Notify.Webhooks, logger),
|
||||
}
|
||||
|
||||
hostKey, err := loadOrGenerateHostKey(cfg.SSH.HostKeyPath)
|
||||
@@ -159,6 +163,18 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request
|
||||
"session_id", sessionID,
|
||||
)
|
||||
|
||||
// Send session_started notification.
|
||||
connectedAt := time.Now()
|
||||
sessionInfo := notify.SessionInfo{
|
||||
ID: sessionID,
|
||||
IP: ip,
|
||||
Username: conn.User(),
|
||||
ShellName: selectedShell.Name(),
|
||||
ConnectedAt: notify.FormatConnectedAt(connectedAt),
|
||||
}
|
||||
s.notifier.Notify(context.Background(), notify.EventSessionStarted, sessionInfo)
|
||||
defer s.notifier.CleanupSession(sessionID)
|
||||
|
||||
// Handle session requests (pty-req, shell, etc.)
|
||||
go func() {
|
||||
for req := range requests {
|
||||
@@ -194,18 +210,76 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request
|
||||
},
|
||||
}
|
||||
|
||||
// Wrap channel in RecordingChannel for future byte-level recording.
|
||||
// Wrap channel in RecordingChannel.
|
||||
recorder := shell.NewRecordingChannel(channel)
|
||||
|
||||
// Set up detection scorer if enabled.
|
||||
var scorer *detection.Scorer
|
||||
var scoreCancel context.CancelFunc
|
||||
if s.cfg.Detection.Enabled {
|
||||
scorer = detection.NewScorer()
|
||||
recorder.WithCallback(func(ts time.Time, direction int, data []byte) {
|
||||
scorer.RecordEvent(ts, direction, data)
|
||||
})
|
||||
|
||||
var scoreCtx context.Context
|
||||
scoreCtx, scoreCancel = context.WithCancel(context.Background())
|
||||
go s.runScoreUpdater(scoreCtx, sessionID, scorer, sessionInfo)
|
||||
}
|
||||
|
||||
if err := selectedShell.Handle(context.Background(), sessCtx, recorder); err != nil {
|
||||
s.logger.Error("shell error", "err", err, "session_id", sessionID)
|
||||
}
|
||||
|
||||
s.logger.Info("session ended",
|
||||
"remote_addr", conn.RemoteAddr(),
|
||||
"user", conn.User(),
|
||||
"session_id", sessionID,
|
||||
)
|
||||
// Stop score updater and write final score.
|
||||
if scoreCancel != nil {
|
||||
scoreCancel()
|
||||
}
|
||||
if scorer != nil {
|
||||
finalScore := scorer.Score()
|
||||
if err := s.store.UpdateHumanScore(context.Background(), sessionID, finalScore); err != nil {
|
||||
s.logger.Error("failed to write final human score", "err", err, "session_id", sessionID)
|
||||
}
|
||||
s.logger.Info("session ended",
|
||||
"remote_addr", conn.RemoteAddr(),
|
||||
"user", conn.User(),
|
||||
"session_id", sessionID,
|
||||
"human_score", finalScore,
|
||||
)
|
||||
} else {
|
||||
s.logger.Info("session ended",
|
||||
"remote_addr", conn.RemoteAddr(),
|
||||
"user", conn.User(),
|
||||
"session_id", sessionID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// runScoreUpdater periodically computes the human score, writes it to the DB,
|
||||
// and triggers a notification if the threshold is crossed.
|
||||
func (s *Server) runScoreUpdater(ctx context.Context, sessionID string, scorer *detection.Scorer, sessionInfo notify.SessionInfo) {
|
||||
ticker := time.NewTicker(s.cfg.Detection.UpdateIntervalDuration)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
score := scorer.Score()
|
||||
if err := s.store.UpdateHumanScore(ctx, sessionID, score); err != nil {
|
||||
s.logger.Error("failed to update human score", "err", err, "session_id", sessionID)
|
||||
continue
|
||||
}
|
||||
s.logger.Debug("human score updated", "session_id", sessionID, "score", score)
|
||||
|
||||
if score >= s.cfg.Detection.Threshold {
|
||||
info := sessionInfo
|
||||
info.HumanScore = score
|
||||
s.notifier.Notify(ctx, notify.EventHumanDetected, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) passwordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
|
||||
Reference in New Issue
Block a user