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:
@@ -9,11 +9,19 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SSH SSHConfig `toml:"ssh"`
|
||||
Auth AuthConfig `toml:"auth"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
LogLevel string `toml:"log_level"`
|
||||
LogFormat string `toml:"log_format"` // "text" (default) or "json"
|
||||
SSH SSHConfig `toml:"ssh"`
|
||||
Auth AuthConfig `toml:"auth"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Shell ShellConfig `toml:"shell"`
|
||||
LogLevel string `toml:"log_level"`
|
||||
LogFormat string `toml:"log_format"` // "text" (default) or "json"
|
||||
}
|
||||
|
||||
type ShellConfig struct {
|
||||
Hostname string `toml:"hostname"`
|
||||
Banner string `toml:"banner"`
|
||||
FakeUser string `toml:"fake_user"`
|
||||
Shells map[string]map[string]any `toml:"-"` // per-shell config extracted manually
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
@@ -56,6 +64,14 @@ func Load(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
|
||||
// Second pass: extract per-shell sub-tables (e.g. [shell.bash]).
|
||||
var raw map[string]any
|
||||
if err := toml.Unmarshal(data, &raw); err == nil {
|
||||
if shellSection, ok := raw["shell"].(map[string]any); ok {
|
||||
cfg.Shell.Shells = extractShellTables(shellSection)
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaults(cfg)
|
||||
|
||||
if err := validate(cfg); err != nil {
|
||||
@@ -96,6 +112,36 @@ func applyDefaults(cfg *Config) {
|
||||
if cfg.Storage.RetentionInterval == "" {
|
||||
cfg.Storage.RetentionInterval = "1h"
|
||||
}
|
||||
if cfg.Shell.Hostname == "" {
|
||||
cfg.Shell.Hostname = "ubuntu-server"
|
||||
}
|
||||
if cfg.Shell.Banner == "" {
|
||||
cfg.Shell.Banner = "Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-89-generic x86_64)\r\n\r\n"
|
||||
}
|
||||
}
|
||||
|
||||
// knownShellKeys are top-level keys in [shell] that are not per-shell sub-tables.
|
||||
var knownShellKeys = map[string]bool{
|
||||
"hostname": true,
|
||||
"banner": true,
|
||||
"fake_user": true,
|
||||
}
|
||||
|
||||
// extractShellTables pulls per-shell config sub-tables from the raw [shell] section.
|
||||
func extractShellTables(section map[string]any) map[string]map[string]any {
|
||||
result := make(map[string]map[string]any)
|
||||
for key, val := range section {
|
||||
if knownShellKeys[key] {
|
||||
continue
|
||||
}
|
||||
if sub, ok := val.(map[string]any); ok {
|
||||
result[key] = sub
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validate(cfg *Config) error {
|
||||
|
||||
@@ -169,6 +169,59 @@ retention_interval = "2h"
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadShellDefaults(t *testing.T) {
|
||||
path := writeTemp(t, "")
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Shell.Hostname != "ubuntu-server" {
|
||||
t.Errorf("default hostname = %q, want %q", cfg.Shell.Hostname, "ubuntu-server")
|
||||
}
|
||||
if cfg.Shell.Banner == "" {
|
||||
t.Error("default banner should not be empty")
|
||||
}
|
||||
if cfg.Shell.FakeUser != "" {
|
||||
t.Errorf("default fake_user = %q, want empty", cfg.Shell.FakeUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadShellConfig(t *testing.T) {
|
||||
content := `
|
||||
[shell]
|
||||
hostname = "myhost"
|
||||
banner = "Custom banner\r\n"
|
||||
fake_user = "admin"
|
||||
|
||||
[shell.bash]
|
||||
custom_key = "value"
|
||||
`
|
||||
path := writeTemp(t, content)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Shell.Hostname != "myhost" {
|
||||
t.Errorf("hostname = %q, want %q", cfg.Shell.Hostname, "myhost")
|
||||
}
|
||||
if cfg.Shell.Banner != "Custom banner\r\n" {
|
||||
t.Errorf("banner = %q, want %q", cfg.Shell.Banner, "Custom banner\r\n")
|
||||
}
|
||||
if cfg.Shell.FakeUser != "admin" {
|
||||
t.Errorf("fake_user = %q, want %q", cfg.Shell.FakeUser, "admin")
|
||||
}
|
||||
if cfg.Shell.Shells == nil {
|
||||
t.Fatal("Shells map should not be nil")
|
||||
}
|
||||
bashCfg, ok := cfg.Shell.Shells["bash"]
|
||||
if !ok {
|
||||
t.Fatal("Shells[\"bash\"] not found")
|
||||
}
|
||||
if bashCfg["custom_key"] != "value" {
|
||||
t.Errorf("Shells[\"bash\"][\"custom_key\"] = %v, want %q", bashCfg["custom_key"], "value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFile(t *testing.T) {
|
||||
_, err := Load("/nonexistent/path/config.toml")
|
||||
if err == nil {
|
||||
|
||||
Reference in New Issue
Block a user