feat: add shell interface, registry, and bash shell emulator
Implement Phase 1.4: replaces the hardcoded banner/timeout stub with a proper shell system. Adds a Shell interface with weighted registry for shell selection, a RecordingChannel wrapper (pass-through for now, prep for Phase 2.3 replay), and a bash-like shell with fake filesystem, terminal line reader, and command handling (pwd, ls, cd, cat, whoami, hostname, id, uname, exit). Sessions now log command/output pairs to the store and record the shell name. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,28 +14,35 @@ import (
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/auth"
|
||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||
"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"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const sessionTimeout = 30 * time.Second
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
func New(cfg config.Config, store storage.Store, logger *slog.Logger) (*Server, error) {
|
||||
registry := shell.NewRegistry()
|
||||
if err := registry.Register(bash.NewBashShell(), 1); err != nil {
|
||||
return nil, fmt.Errorf("registering bash shell: %w", err)
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
authenticator: auth.NewAuthenticator(cfg.Auth),
|
||||
logger: logger,
|
||||
connSem: make(chan struct{}, cfg.SSH.MaxConnections),
|
||||
shellRegistry: registry,
|
||||
}
|
||||
|
||||
hostKey, err := loadOrGenerateHostKey(cfg.SSH.HostKeyPath)
|
||||
@@ -126,8 +133,15 @@ func (s *Server) handleConn(conn net.Conn) {
|
||||
func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request, conn *ssh.ServerConn) {
|
||||
defer channel.Close()
|
||||
|
||||
// Select a shell from the registry.
|
||||
selectedShell, err := s.shellRegistry.Select()
|
||||
if err != nil {
|
||||
s.logger.Error("failed to select shell", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
ip := extractIP(conn.RemoteAddr())
|
||||
sessionID, err := s.store.CreateSession(context.Background(), ip, conn.User(), "")
|
||||
sessionID, err := s.store.CreateSession(context.Background(), ip, conn.User(), selectedShell.Name())
|
||||
if err != nil {
|
||||
s.logger.Error("failed to create session", "err", err)
|
||||
} else {
|
||||
@@ -138,6 +152,13 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request
|
||||
}()
|
||||
}
|
||||
|
||||
s.logger.Info("session started",
|
||||
"remote_addr", conn.RemoteAddr(),
|
||||
"user", conn.User(),
|
||||
"shell", selectedShell.Name(),
|
||||
"session_id", sessionID,
|
||||
)
|
||||
|
||||
// Handle session requests (pty-req, shell, etc.)
|
||||
go func() {
|
||||
for req := range requests {
|
||||
@@ -154,33 +175,37 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request
|
||||
}
|
||||
}()
|
||||
|
||||
// Write a fake banner.
|
||||
fmt.Fprint(channel, "Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-89-generic x86_64)\r\n\r\n")
|
||||
fmt.Fprintf(channel, "Last login: %s from 10.0.0.1\r\n", time.Now().Add(-2*time.Hour).Format("Mon Jan 2 15:04:05 2006"))
|
||||
fmt.Fprintf(channel, "%s@ubuntu:~$ ", conn.User())
|
||||
|
||||
// Hold connection open until timeout or client disconnect.
|
||||
timer := time.NewTimer(sessionTimeout)
|
||||
defer timer.Stop()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
buf := make([]byte, 256)
|
||||
for {
|
||||
_, err := channel.Read(buf)
|
||||
if err != nil {
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
s.logger.Info("session timed out", "remote_addr", conn.RemoteAddr(), "user", conn.User())
|
||||
case <-done:
|
||||
s.logger.Info("session closed by client", "remote_addr", conn.RemoteAddr(), "user", conn.User())
|
||||
// Build session context.
|
||||
var shellCfg map[string]any
|
||||
if s.cfg.Shell.Shells != nil {
|
||||
shellCfg = s.cfg.Shell.Shells[selectedShell.Name()]
|
||||
}
|
||||
sessCtx := &shell.SessionContext{
|
||||
SessionID: sessionID,
|
||||
Username: conn.User(),
|
||||
RemoteAddr: ip,
|
||||
ClientVersion: string(conn.ClientVersion()),
|
||||
Store: s.store,
|
||||
ShellConfig: shellCfg,
|
||||
CommonConfig: shell.ShellCommonConfig{
|
||||
Hostname: s.cfg.Shell.Hostname,
|
||||
Banner: s.cfg.Shell.Banner,
|
||||
FakeUser: s.cfg.Shell.FakeUser,
|
||||
},
|
||||
}
|
||||
|
||||
// Wrap channel in RecordingChannel for future byte-level recording.
|
||||
recorder := shell.NewRecordingChannel(channel)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) passwordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
|
||||
Reference in New Issue
Block a user