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:
166
internal/shell/bash/filesystem.go
Normal file
166
internal/shell/bash/filesystem.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package bash
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type fsNode struct {
|
||||
name string
|
||||
isDir bool
|
||||
content string
|
||||
children map[string]*fsNode
|
||||
}
|
||||
|
||||
type filesystem struct {
|
||||
root *fsNode
|
||||
}
|
||||
|
||||
func newFilesystem(hostname string) *filesystem {
|
||||
fs := &filesystem{
|
||||
root: &fsNode{name: "/", isDir: true, children: make(map[string]*fsNode)},
|
||||
}
|
||||
|
||||
fs.mkdirAll("/etc")
|
||||
fs.mkdirAll("/root")
|
||||
fs.mkdirAll("/home")
|
||||
fs.mkdirAll("/var/log")
|
||||
fs.mkdirAll("/tmp")
|
||||
fs.mkdirAll("/usr/bin")
|
||||
fs.mkdirAll("/usr/local")
|
||||
|
||||
fs.writeFile("/etc/passwd", "root:x:0:0:root:/root:/bin/bash\n"+
|
||||
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n"+
|
||||
"www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\n"+
|
||||
"mysql:x:27:27:MySQL Server:/var/lib/mysql:/bin/false\n")
|
||||
|
||||
fs.writeFile("/etc/hostname", hostname+"\n")
|
||||
|
||||
fs.writeFile("/etc/hosts", "127.0.0.1\tlocalhost\n"+
|
||||
"127.0.1.1\t"+hostname+"\n"+
|
||||
"::1\t\tlocalhost ip6-localhost ip6-loopback\n")
|
||||
|
||||
fs.writeFile("/root/.bash_history",
|
||||
"apt update\n"+
|
||||
"apt upgrade -y\n"+
|
||||
"systemctl restart nginx\n"+
|
||||
"tail -f /var/log/syslog\n"+
|
||||
"df -h\n"+
|
||||
"free -m\n"+
|
||||
"netstat -tlnp\n"+
|
||||
"cat /etc/passwd\n")
|
||||
|
||||
fs.writeFile("/root/.bashrc",
|
||||
"# ~/.bashrc: executed by bash(1) for non-login shells.\n"+
|
||||
"export PS1='\\u@\\h:\\w\\$ '\n"+
|
||||
"alias ll='ls -alF'\n"+
|
||||
"alias la='ls -A'\n")
|
||||
|
||||
fs.writeFile("/root/README.txt", "Production server - DO NOT MODIFY\n")
|
||||
|
||||
fs.writeFile("/var/log/syslog",
|
||||
"Jan 12 03:14:22 "+hostname+" systemd[1]: Started Daily apt download activities.\n"+
|
||||
"Jan 12 03:14:23 "+hostname+" systemd[1]: Started Daily Cleanup of Temporary Directories.\n"+
|
||||
"Jan 12 04:00:01 "+hostname+" CRON[12345]: (root) CMD (/usr/local/bin/backup.sh)\n"+
|
||||
"Jan 12 04:00:03 "+hostname+" kernel: [UFW BLOCK] IN=eth0 OUT= SRC=203.0.113.42 DST=10.0.0.5 PROTO=TCP DPT=22\n")
|
||||
|
||||
fs.writeFile("/tmp/notes.txt", "TODO: Update SSL certificates\n")
|
||||
|
||||
return fs
|
||||
}
|
||||
|
||||
// resolvePath converts a potentially relative path to an absolute one.
|
||||
func resolvePath(cwd, p string) string {
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = cwd + "/" + p
|
||||
}
|
||||
return path.Clean(p)
|
||||
}
|
||||
|
||||
func (fs *filesystem) lookup(p string) *fsNode {
|
||||
p = path.Clean(p)
|
||||
if p == "/" {
|
||||
return fs.root
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.TrimPrefix(p, "/"), "/")
|
||||
node := fs.root
|
||||
for _, part := range parts {
|
||||
if node.children == nil {
|
||||
return nil
|
||||
}
|
||||
child, ok := node.children[part]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
node = child
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func (fs *filesystem) exists(p string) bool {
|
||||
return fs.lookup(p) != nil
|
||||
}
|
||||
|
||||
func (fs *filesystem) isDirectory(p string) bool {
|
||||
n := fs.lookup(p)
|
||||
return n != nil && n.isDir
|
||||
}
|
||||
|
||||
func (fs *filesystem) list(p string) ([]string, error) {
|
||||
n := fs.lookup(p)
|
||||
if n == nil {
|
||||
return nil, fmt.Errorf("ls: cannot access '%s': No such file or directory", p)
|
||||
}
|
||||
if !n.isDir {
|
||||
return nil, fmt.Errorf("ls: cannot access '%s': Not a directory", p)
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(n.children))
|
||||
for name, child := range n.children {
|
||||
if child.isDir {
|
||||
name += "/"
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (fs *filesystem) read(p string) (string, error) {
|
||||
n := fs.lookup(p)
|
||||
if n == nil {
|
||||
return "", fmt.Errorf("cat: %s: No such file or directory", p)
|
||||
}
|
||||
if n.isDir {
|
||||
return "", fmt.Errorf("cat: %s: Is a directory", p)
|
||||
}
|
||||
return n.content, nil
|
||||
}
|
||||
|
||||
func (fs *filesystem) mkdirAll(p string) {
|
||||
p = path.Clean(p)
|
||||
parts := strings.Split(strings.TrimPrefix(p, "/"), "/")
|
||||
node := fs.root
|
||||
for _, part := range parts {
|
||||
if node.children == nil {
|
||||
node.children = make(map[string]*fsNode)
|
||||
}
|
||||
child, ok := node.children[part]
|
||||
if !ok {
|
||||
child = &fsNode{name: part, isDir: true, children: make(map[string]*fsNode)}
|
||||
node.children[part] = child
|
||||
}
|
||||
node = child
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *filesystem) writeFile(p string, content string) {
|
||||
p = path.Clean(p)
|
||||
dir := path.Dir(p)
|
||||
base := path.Base(p)
|
||||
|
||||
fs.mkdirAll(dir)
|
||||
parent := fs.lookup(dir)
|
||||
parent.children[base] = &fsNode{name: base, content: content}
|
||||
}
|
||||
Reference in New Issue
Block a user