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>
167 lines
3.9 KiB
Go
167 lines
3.9 KiB
Go
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}
|
|
}
|