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:
158
internal/shell/bash/bash.go
Normal file
158
internal/shell/bash/bash.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package bash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/shell"
|
||||
)
|
||||
|
||||
const sessionTimeout = 5 * time.Minute
|
||||
|
||||
// BashShell emulates a basic bash-like shell.
|
||||
type BashShell struct{}
|
||||
|
||||
// NewBashShell returns a new BashShell instance.
|
||||
func NewBashShell() *BashShell {
|
||||
return &BashShell{}
|
||||
}
|
||||
|
||||
func (b *BashShell) Name() string { return "bash" }
|
||||
func (b *BashShell) Description() string { return "Basic bash-like shell emulator" }
|
||||
|
||||
func (b *BashShell) Handle(ctx context.Context, sess *shell.SessionContext, rw io.ReadWriteCloser) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, sessionTimeout)
|
||||
defer cancel()
|
||||
|
||||
username := sess.Username
|
||||
if sess.CommonConfig.FakeUser != "" {
|
||||
username = sess.CommonConfig.FakeUser
|
||||
}
|
||||
hostname := sess.CommonConfig.Hostname
|
||||
|
||||
fs := newFilesystem(hostname)
|
||||
state := &shellState{
|
||||
cwd: "/root",
|
||||
username: username,
|
||||
hostname: hostname,
|
||||
fs: fs,
|
||||
}
|
||||
|
||||
// Send banner.
|
||||
if sess.CommonConfig.Banner != "" {
|
||||
fmt.Fprint(rw, sess.CommonConfig.Banner)
|
||||
}
|
||||
fmt.Fprintf(rw, "Last login: %s from 10.0.0.1\r\n",
|
||||
time.Now().Add(-2*time.Hour).Format("Mon Jan 2 15:04:05 2006"))
|
||||
|
||||
for {
|
||||
prompt := formatPrompt(state)
|
||||
if _, err := fmt.Fprint(rw, prompt); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
line, err := readLine(ctx, rw)
|
||||
if err == io.EOF {
|
||||
fmt.Fprint(rw, "logout\r\n")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
result := dispatch(state, trimmed)
|
||||
|
||||
var output string
|
||||
if result.output != "" {
|
||||
output = result.output
|
||||
// Convert newlines to \r\n for terminal display.
|
||||
output = strings.ReplaceAll(output, "\r\n", "\n")
|
||||
output = strings.ReplaceAll(output, "\n", "\r\n")
|
||||
fmt.Fprintf(rw, "%s\r\n", output)
|
||||
}
|
||||
|
||||
// Log command and output to store.
|
||||
if sess.Store != nil {
|
||||
sess.Store.AppendSessionLog(ctx, sess.SessionID, trimmed, output)
|
||||
}
|
||||
|
||||
if result.exit {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatPrompt(state *shellState) string {
|
||||
cwd := state.cwd
|
||||
if cwd == "/root" {
|
||||
cwd = "~"
|
||||
} else if strings.HasPrefix(cwd, "/root/") {
|
||||
cwd = "~" + cwd[5:]
|
||||
}
|
||||
return fmt.Sprintf("%s@%s:%s# ", state.username, state.hostname, cwd)
|
||||
}
|
||||
|
||||
// readLine reads a line of input byte-by-byte, handling backspace, Ctrl+C, and Ctrl+D.
|
||||
func readLine(ctx context.Context, rw io.ReadWriter) (string, error) {
|
||||
var buf []byte
|
||||
b := make([]byte, 1)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
n, err := rw.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
ch := b[0]
|
||||
switch {
|
||||
case ch == '\r' || ch == '\n':
|
||||
fmt.Fprint(rw, "\r\n")
|
||||
return string(buf), nil
|
||||
|
||||
case ch == 4: // Ctrl+D
|
||||
if len(buf) == 0 {
|
||||
return "", io.EOF
|
||||
}
|
||||
|
||||
case ch == 3: // Ctrl+C
|
||||
fmt.Fprint(rw, "^C\r\n")
|
||||
return "", nil
|
||||
|
||||
case ch == 127 || ch == 8: // DEL or Backspace
|
||||
if len(buf) > 0 {
|
||||
buf = buf[:len(buf)-1]
|
||||
fmt.Fprint(rw, "\b \b")
|
||||
}
|
||||
|
||||
case ch == 27: // ESC - start of escape sequence
|
||||
// Read and discard the rest of the escape sequence.
|
||||
// Most are 3 bytes: ESC [ X (arrow keys, etc.)
|
||||
next := make([]byte, 1)
|
||||
rw.Read(next)
|
||||
if next[0] == '[' {
|
||||
rw.Read(next) // read the final byte
|
||||
}
|
||||
|
||||
case ch >= 32 && ch < 127: // printable ASCII
|
||||
buf = append(buf, ch)
|
||||
rw.Write([]byte{ch})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user