From 8e90f21d91a2b86a2977728db4fa6bce4ab6a029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Sat, 14 Feb 2026 22:34:29 +0100 Subject: [PATCH] feat: add Smart Fridge shell and per-credential shell routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Samsung FridgeOS-themed shell (PLAN.md §3.3) with inventory management, temperature controls, diagnostics, alerts, and other appliance commands. Add per-credential shell routing so static credentials can specify which shell to use via the `shell` config field, passed through ssh.Permissions.Extensions. Also extract shared ReadLine helper from bash to the shell package so both shells can reuse terminal input handling. Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 4 +- README.md | 3 +- internal/auth/auth.go | 3 +- internal/auth/auth_test.go | 33 +++ internal/config/config.go | 1 + internal/config/config_test.go | 27 +++ internal/server/server.go | 34 ++- internal/server/server_test.go | 2 +- internal/shell/bash/bash.go | 57 +---- internal/shell/bash/bash_test.go | 8 +- internal/shell/fridge/fridge.go | 348 +++++++++++++++++++++++++++ internal/shell/fridge/fridge_test.go | 233 ++++++++++++++++++ internal/shell/shell.go | 57 +++++ oubliette.toml.example | 6 + 14 files changed, 746 insertions(+), 70 deletions(-) create mode 100644 internal/shell/fridge/fridge.go create mode 100644 internal/shell/fridge/fridge_test.go diff --git a/PLAN.md b/PLAN.md index a881a1d..f75c992 100644 --- a/PLAN.md +++ b/PLAN.md @@ -155,12 +155,12 @@ Goal: Add the entertaining shell implementations. - Common commands: `show running-config`, `show interfaces`, `enable`, `configure terminal` - Fake device info that looks like a real router -### 3.3 Smart Fridge Shell +### 3.3 Smart Fridge Shell ✅ - Samsung FridgeOS boot banner - Inventory management commands - Temperature warnings - "WARNING: milk expires in 2 days" -- Easter eggs +- Per-credential shell routing via `shell` field in static credentials ### 3.4 Text Adventure - Zork-style dungeon crawler diff --git a/README.md b/README.md index c8d714e..aeab1c4 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ Key settings: - `ssh.host_key_path` — Ed25519 host key, auto-generated if missing - `auth.accept_after` — accept login after N failures per IP (default `10`) - `auth.credential_ttl` — how long to remember accepted credentials (default `24h`) -- `auth.static_credentials` — always-accepted username/password pairs +- `auth.static_credentials` — always-accepted username/password pairs (optional `shell` field routes to a specific shell) +- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS) - `storage.db_path` — SQLite database path (default `oubliette.db`) - `storage.retention_days` — auto-prune records older than N days (default `90`) - `storage.retention_interval` — how often to run retention (default `1h`) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f47b49a..0b9fc31 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -21,6 +21,7 @@ type credKey struct { type Decision struct { Accepted bool Reason string // "static_credential", "threshold_reached", "remembered_credential", "rejected" + Shell string // optional: route to specific shell (only set for static credentials) } type Authenticator struct { @@ -50,7 +51,7 @@ func (a *Authenticator) Authenticate(ip, username, password string) Decision { pMatch := subtle.ConstantTimeCompare([]byte(cred.Password), []byte(password)) if uMatch == 1 && pMatch == 1 { a.failCounts[ip] = 0 - return Decision{Accepted: true, Reason: "static_credential"} + return Decision{Accepted: true, Reason: "static_credential", Shell: cred.Shell} } } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index c74f036..d61d305 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -153,6 +153,39 @@ func TestExpiredCredentialsSweep(t *testing.T) { } } +func TestStaticCredentialShellPropagation(t *testing.T) { + a := newTestAuth(10, time.Hour, + config.Credential{Username: "samsung", Password: "fridge", Shell: "fridge"}, + config.Credential{Username: "root", Password: "toor"}, + ) + + // Static credential with shell set should propagate it. + d := a.Authenticate("1.2.3.4", "samsung", "fridge") + if !d.Accepted || d.Reason != "static_credential" { + t.Fatalf("got %+v, want accepted with static_credential", d) + } + if d.Shell != "fridge" { + t.Errorf("Shell = %q, want %q", d.Shell, "fridge") + } + + // Static credential without shell should leave it empty. + d = a.Authenticate("1.2.3.4", "root", "toor") + if !d.Accepted || d.Reason != "static_credential" { + t.Fatalf("got %+v, want accepted with static_credential", d) + } + if d.Shell != "" { + t.Errorf("Shell = %q, want empty", d.Shell) + } + + // Threshold-reached decision should not have a shell set. + a2 := newTestAuth(2, time.Hour) + a2.Authenticate("5.5.5.5", "user", "pass") + d = a2.Authenticate("5.5.5.5", "user", "pass") + if d.Shell != "" { + t.Errorf("threshold decision Shell = %q, want empty", d.Shell) + } +} + func TestConcurrentAccess(t *testing.T) { a := newTestAuth(5, time.Hour) var wg sync.WaitGroup diff --git a/internal/config/config.go b/internal/config/config.go index 8104609..8372289 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,6 +59,7 @@ type AuthConfig struct { type Credential struct { Username string `toml:"username"` Password string `toml:"password"` + Shell string `toml:"shell"` // optional: route to specific shell (empty = random) } type DetectionConfig struct { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f13bf14..01a5813 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -255,6 +255,33 @@ listen_addr = ":9090" } } +func TestLoadCredentialWithShell(t *testing.T) { + content := ` +[[auth.static_credentials]] +username = "samsung" +password = "fridge" +shell = "fridge" + +[[auth.static_credentials]] +username = "root" +password = "toor" +` + path := writeTemp(t, content) + cfg, err := Load(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.Auth.StaticCredentials) != 2 { + t.Fatalf("static_credentials len = %d, want 2", len(cfg.Auth.StaticCredentials)) + } + if cfg.Auth.StaticCredentials[0].Shell != "fridge" { + t.Errorf("cred[0].Shell = %q, want %q", cfg.Auth.StaticCredentials[0].Shell, "fridge") + } + if cfg.Auth.StaticCredentials[1].Shell != "" { + t.Errorf("cred[1].Shell = %q, want empty", cfg.Auth.StaticCredentials[1].Shell) + } +} + func TestLoadMissingFile(t *testing.T) { _, err := Load("/nonexistent/path/config.toml") if err == nil { diff --git a/internal/server/server.go b/internal/server/server.go index cc854dc..bd76650 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -18,6 +18,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/notify" "git.t-juice.club/torjus/oubliette/internal/shell" "git.t-juice.club/torjus/oubliette/internal/shell/bash" + "git.t-juice.club/torjus/oubliette/internal/shell/fridge" "git.t-juice.club/torjus/oubliette/internal/storage" "golang.org/x/crypto/ssh" ) @@ -38,6 +39,9 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger) (*Server, if err := registry.Register(bash.NewBashShell(), 1); err != nil { return nil, fmt.Errorf("registering bash shell: %w", err) } + if err := registry.Register(fridge.NewFridgeShell(), 1); err != nil { + return nil, fmt.Errorf("registering fridge shell: %w", err) + } s := &Server{ cfg: cfg, @@ -138,10 +142,24 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request 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 + // If the auth layer specified a shell preference, use it; otherwise random. + var selectedShell shell.Shell + if conn.Permissions != nil && conn.Permissions.Extensions["shell"] != "" { + shellName := conn.Permissions.Extensions["shell"] + sh, ok := s.shellRegistry.Get(shellName) + if ok { + selectedShell = sh + } else { + s.logger.Warn("configured shell not found, falling back to random", "shell", shellName) + } + } + if selectedShell == nil { + var err error + selectedShell, err = s.shellRegistry.Select() + if err != nil { + s.logger.Error("failed to select shell", "err", err) + return + } } ip := extractIP(conn.RemoteAddr()) @@ -304,7 +322,13 @@ func (s *Server) passwordCallback(conn ssh.ConnMetadata, password []byte) (*ssh. } if d.Accepted { - return nil, nil + var perms *ssh.Permissions + if d.Shell != "" { + perms = &ssh.Permissions{ + Extensions: map[string]string{"shell": d.Shell}, + } + } + return perms, nil } return nil, fmt.Errorf("rejected") } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index ee29086..6da0f09 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -108,7 +108,7 @@ func TestIntegrationSSHConnect(t *testing.T) { AcceptAfter: 2, CredentialTTLDuration: time.Hour, StaticCredentials: []config.Credential{ - {Username: "root", Password: "toor"}, + {Username: "root", Password: "toor", Shell: "bash"}, }, }, Shell: config.ShellConfig{ diff --git a/internal/shell/bash/bash.go b/internal/shell/bash/bash.go index 0688af9..bad169e 100644 --- a/internal/shell/bash/bash.go +++ b/internal/shell/bash/bash.go @@ -55,7 +55,7 @@ func (b *BashShell) Handle(ctx context.Context, sess *shell.SessionContext, rw i return nil } - line, err := readLine(ctx, rw) + line, err := shell.ReadLine(ctx, rw) if errors.Is(err, io.EOF) { fmt.Fprint(rw, "logout\r\n") return nil @@ -103,58 +103,3 @@ func formatPrompt(state *shellState) string { 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) - if n, _ := rw.Read(next); n > 0 && next[0] == '[' { - rw.Read(next) // read the final byte - } - - case ch >= 32 && ch < 127: // printable ASCII - buf = append(buf, ch) - rw.Write([]byte{ch}) - } - } -} diff --git a/internal/shell/bash/bash_test.go b/internal/shell/bash/bash_test.go index c845731..c56f3ff 100644 --- a/internal/shell/bash/bash_test.go +++ b/internal/shell/bash/bash_test.go @@ -53,7 +53,7 @@ func TestReadLineEnter(t *testing.T) { }{input, &output} ctx := context.Background() - line, err := readLine(ctx, rw) + line, err := shell.ReadLine(ctx, rw) if err != nil { t.Fatalf("readLine: %v", err) } @@ -72,7 +72,7 @@ func TestReadLineBackspace(t *testing.T) { }{input, &output} ctx := context.Background() - line, err := readLine(ctx, rw) + line, err := shell.ReadLine(ctx, rw) if err != nil { t.Fatalf("readLine: %v", err) } @@ -90,7 +90,7 @@ func TestReadLineCtrlC(t *testing.T) { }{input, &output} ctx := context.Background() - line, err := readLine(ctx, rw) + line, err := shell.ReadLine(ctx, rw) if err != nil { t.Fatalf("readLine: %v", err) } @@ -108,7 +108,7 @@ func TestReadLineCtrlD(t *testing.T) { }{input, &output} ctx := context.Background() - _, err := readLine(ctx, rw) + _, err := shell.ReadLine(ctx, rw) if !errors.Is(err, io.EOF) { t.Fatalf("expected io.EOF, got %v", err) } diff --git a/internal/shell/fridge/fridge.go b/internal/shell/fridge/fridge.go new file mode 100644 index 0000000..2078578 --- /dev/null +++ b/internal/shell/fridge/fridge.go @@ -0,0 +1,348 @@ +package fridge + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "git.t-juice.club/torjus/oubliette/internal/shell" +) + +const sessionTimeout = 5 * time.Minute + +// FridgeShell emulates a Samsung Smart Fridge OS interface. +type FridgeShell struct{} + +// NewFridgeShell returns a new FridgeShell instance. +func NewFridgeShell() *FridgeShell { + return &FridgeShell{} +} + +func (f *FridgeShell) Name() string { return "fridge" } +func (f *FridgeShell) Description() string { return "Samsung Smart Fridge shell emulator" } + +func (f *FridgeShell) Handle(ctx context.Context, sess *shell.SessionContext, rw io.ReadWriteCloser) error { + ctx, cancel := context.WithTimeout(ctx, sessionTimeout) + defer cancel() + + state := newFridgeState() + + // Boot banner. + fmt.Fprint(rw, bootBanner()) + + for { + if _, err := fmt.Fprint(rw, "FridgeOS> "); err != nil { + return nil + } + + line, err := shell.ReadLine(ctx, rw) + if errors.Is(err, io.EOF) { + fmt.Fprint(rw, "logout\r\n") + return nil + } + if err != nil { + return nil + } + + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + result := state.dispatch(trimmed) + + var output string + if result.output != "" { + output = result.output + 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 { + if err := sess.Store.AppendSessionLog(ctx, sess.SessionID, trimmed, output); err != nil { + return fmt.Errorf("append session log: %w", err) + } + } + + if result.exit { + return nil + } + } +} + +func bootBanner() string { + now := time.Now() + defrost := now.Add(-3*time.Hour - 22*time.Minute).Format("2006-01-02 15:04") + return fmt.Sprintf(` + _____ ____ ___ ____ ____ _____ ___ ____ + | ___| _ \|_ _| _ \ / ___| ____/ _ \/ ___| + | |_ | |_) || || | | | | _| _|| | | \___ \ + | _| | _ < | || |_| | |_| | |__| |_| |___) | + |_| |_| \_\___|____/ \____|_____\___/|____/ + +Samsung Smart Fridge OS v3.2.1 (FridgeOS-ARM) +Model: RF28R7351SR | Serial: SN-2847-FRDG-9182 +Firmware: 3.2.1-stable | Last defrost: %s + +Type 'help' for available commands. + +`, defrost) +} + +type fridgeState struct { + inventory []inventoryItem + fridgeF int // fridge temp in °F + freezerF int // freezer temp in °F +} + +type inventoryItem struct { + name string + expiry string +} + +type commandResult struct { + output string + exit bool +} + +func newFridgeState() *fridgeState { + return &fridgeState{ + inventory: []inventoryItem{ + {"Whole Milk (1 gal)", time.Now().Add(48 * time.Hour).Format("2006-01-02")}, + {"Eggs (dozen)", time.Now().Add(7 * 24 * time.Hour).Format("2006-01-02")}, + {"Leftover Pizza (3 slices)", time.Now().Add(24 * time.Hour).Format("2006-01-02")}, + {"Orange Juice", time.Now().Add(5 * 24 * time.Hour).Format("2006-01-02")}, + {"Butter (unsalted)", time.Now().Add(30 * 24 * time.Hour).Format("2006-01-02")}, + {"Mystery Tupperware", time.Now().Add(-14 * 24 * time.Hour).Format("2006-01-02")}, + }, + fridgeF: 37, + freezerF: 0, + } +} + +func (s *fridgeState) dispatch(input string) commandResult { + parts := strings.Fields(input) + if len(parts) == 0 { + return commandResult{} + } + + cmd := strings.ToLower(parts[0]) + args := parts[1:] + + switch cmd { + case "help": + return s.cmdHelp() + case "inventory": + return s.cmdInventory(args) + case "temp", "temperature": + return s.cmdTemp(args) + case "status": + return s.cmdStatus() + case "diagnostics": + return s.cmdDiagnostics() + case "alerts": + return s.cmdAlerts() + case "reboot": + return s.cmdReboot() + case "exit", "logout": + return commandResult{output: "Goodbye! Keep your food fresh!", exit: true} + default: + return commandResult{output: fmt.Sprintf("FridgeOS: unknown command '%s'. Type 'help' for available commands.", cmd)} + } +} + +func (s *fridgeState) cmdHelp() commandResult { + help := `Available commands: + help - Show this help message + inventory - List fridge contents + inventory add - Add item to inventory + inventory remove - Remove item from inventory + temp - Show current temperatures + temp set - Set temperature (zone: fridge|freezer) + status - Show system status + diagnostics - Run system diagnostics + alerts - Show active alerts + reboot - Reboot FridgeOS + exit / logout - Disconnect` + return commandResult{output: help} +} + +func (s *fridgeState) cmdInventory(args []string) commandResult { + if len(args) == 0 || strings.ToLower(args[0]) == "list" { + return s.inventoryList() + } + + sub := strings.ToLower(args[0]) + switch sub { + case "add": + if len(args) < 2 { + return commandResult{output: "Usage: inventory add "} + } + item := strings.Join(args[1:], " ") + return s.inventoryAdd(item) + case "remove": + if len(args) < 2 { + return commandResult{output: "Usage: inventory remove "} + } + item := strings.Join(args[1:], " ") + return s.inventoryRemove(item) + default: + return commandResult{output: fmt.Sprintf("Unknown inventory subcommand '%s'. Try: list, add, remove", sub)} + } +} + +func (s *fridgeState) inventoryList() commandResult { + if len(s.inventory) == 0 { + return commandResult{output: "Inventory is empty."} + } + var b strings.Builder + b.WriteString("=== Fridge Inventory ===\n") + b.WriteString(fmt.Sprintf("%-30s %s\n", "ITEM", "EXPIRES")) + b.WriteString(fmt.Sprintf("%-30s %s\n", "----", "-------")) + for _, item := range s.inventory { + b.WriteString(fmt.Sprintf("%-30s %s\n", item.name, item.expiry)) + } + b.WriteString(fmt.Sprintf("\nTotal items: %d", len(s.inventory))) + return commandResult{output: b.String()} +} + +func (s *fridgeState) inventoryAdd(item string) commandResult { + expiry := time.Now().Add(7 * 24 * time.Hour).Format("2006-01-02") + s.inventory = append(s.inventory, inventoryItem{name: item, expiry: expiry}) + return commandResult{output: fmt.Sprintf("Added '%s' to inventory (expires: %s).", item, expiry)} +} + +func (s *fridgeState) inventoryRemove(item string) commandResult { + lower := strings.ToLower(item) + for i, inv := range s.inventory { + if strings.ToLower(inv.name) == lower || strings.Contains(strings.ToLower(inv.name), lower) { + s.inventory = append(s.inventory[:i], s.inventory[i+1:]...) + return commandResult{output: fmt.Sprintf("Removed '%s' from inventory.", inv.name)} + } + } + return commandResult{output: fmt.Sprintf("Item '%s' not found in inventory.", item)} +} + +func (s *fridgeState) cmdTemp(args []string) commandResult { + if len(args) == 0 { + return commandResult{output: fmt.Sprintf( + "=== Temperature Status ===\nFridge: %d°F (%.1f°C)\nFreezer: %d°F (%.1f°C)", + s.fridgeF, fToC(s.fridgeF), s.freezerF, fToC(s.freezerF), + )} + } + + if strings.ToLower(args[0]) != "set" || len(args) < 3 { + return commandResult{output: "Usage: temp set "} + } + + zone := strings.ToLower(args[1]) + var val int + if _, err := fmt.Sscanf(args[2], "%d", &val); err != nil { + return commandResult{output: "Invalid temperature value. Must be an integer."} + } + + switch zone { + case "fridge": + if val < 33 || val > 45 { + return commandResult{output: fmt.Sprintf("WARNING: Temperature %d°F is out of safe range (33-45°F). Setting rejected.", val)} + } + s.fridgeF = val + return commandResult{output: fmt.Sprintf("Fridge temperature set to %d°F (%.1f°C).", val, fToC(val))} + case "freezer": + if val < -10 || val > 10 { + return commandResult{output: fmt.Sprintf("WARNING: Temperature %d°F is out of safe range (-10 to 10°F). Setting rejected.", val)} + } + s.freezerF = val + return commandResult{output: fmt.Sprintf("Freezer temperature set to %d°F (%.1f°C).", val, fToC(val))} + default: + return commandResult{output: fmt.Sprintf("Unknown zone '%s'. Use 'fridge' or 'freezer'.", zone)} + } +} + +func fToC(f int) float64 { + return float64(f-32) * 5.0 / 9.0 +} + +func (s *fridgeState) cmdStatus() commandResult { + status := `=== FridgeOS System Status === +Compressor: Running +Door seal: OK +Ice maker: Active +Water filter: 82% remaining + +WiFi: Connected (SmartHome-5G) +Signal: -42 dBm +Internal camera: Online (3 objects detected) +Voice assistant: Standby +TikTok recipes: Enabled +Spotify: "Chill Vibes" playlist paused + +Energy rating: A++ +Power: 127W +SmartHome Hub: Connected (12 devices) + +Firmware: v3.2.1-stable +Update available: v3.3.0-beta` + return commandResult{output: status} +} + +func (s *fridgeState) cmdDiagnostics() commandResult { + diag := `Running FridgeOS diagnostics... + +[1/6] Compressor.............. OK +[2/6] Temperature sensors..... OK +[3/6] Door seal integrity..... OK +[4/6] Ice maker............... OK +[5/6] Network connectivity.... OK +[6/6] Internal camera......... OK + +ALL SYSTEMS NOMINAL` + return commandResult{output: diag} +} + +func (s *fridgeState) cmdAlerts() commandResult { + // Build dynamic alerts based on inventory. + var alerts []string + for _, item := range s.inventory { + expiry, err := time.Parse("2006-01-02", item.expiry) + if err != nil { + continue + } + days := int(time.Until(expiry).Hours() / 24) + if days < 0 { + alerts = append(alerts, fmt.Sprintf("CRITICAL: %s expired %d day(s) ago!", item.name, -days)) + } else if days <= 2 { + alerts = append(alerts, fmt.Sprintf("WARNING: %s expires in %d day(s)", item.name, days)) + } + } + alerts = append(alerts, + "INFO: Ice maker: low water pressure detected", + "INFO: Firmware update available: v3.3.0-beta", + "INFO: TikTok recipe sync overdue (last sync: 3 days ago)", + ) + + var b strings.Builder + b.WriteString("=== Active Alerts ===\n") + for _, a := range alerts { + b.WriteString(a + "\n") + } + b.WriteString(fmt.Sprintf("\n%d alert(s) active", len(alerts))) + return commandResult{output: b.String()} +} + +func (s *fridgeState) cmdReboot() commandResult { + reboot := `FridgeOS is rebooting... + +Stopping services........... done +Saving inventory data....... done +Flushing temperature log.... done +Unmounting partitions....... done + +Rebooting now. Goodbye!` + return commandResult{output: reboot, exit: true} +} diff --git a/internal/shell/fridge/fridge_test.go b/internal/shell/fridge/fridge_test.go new file mode 100644 index 0000000..15f775b --- /dev/null +++ b/internal/shell/fridge/fridge_test.go @@ -0,0 +1,233 @@ +package fridge + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + "time" + + "git.t-juice.club/torjus/oubliette/internal/shell" + "git.t-juice.club/torjus/oubliette/internal/storage" +) + +type rwCloser struct { + io.Reader + io.Writer +} + +func (r *rwCloser) Close() error { return nil } + +func runShell(t *testing.T, commands string) string { + t.Helper() + store := storage.NewMemoryStore() + sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "fridge") + + sess := &shell.SessionContext{ + SessionID: sessID, + Username: "root", + Store: store, + CommonConfig: shell.ShellCommonConfig{ + Hostname: "testhost", + }, + } + + rw := &rwCloser{ + Reader: bytes.NewBufferString(commands), + Writer: &bytes.Buffer{}, + } + + sh := NewFridgeShell() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := sh.Handle(ctx, sess, rw); err != nil { + t.Fatalf("Handle: %v", err) + } + + return rw.Writer.(*bytes.Buffer).String() +} + +func TestFridgeShellName(t *testing.T) { + sh := NewFridgeShell() + if sh.Name() != "fridge" { + t.Errorf("Name() = %q, want %q", sh.Name(), "fridge") + } + if sh.Description() == "" { + t.Error("Description() should not be empty") + } +} + +func TestBootBanner(t *testing.T) { + output := runShell(t, "exit\r") + if !strings.Contains(output, "FridgeOS-ARM") { + t.Error("output should contain FridgeOS-ARM in banner") + } + if !strings.Contains(output, "Samsung Smart Fridge OS") { + t.Error("output should contain Samsung Smart Fridge OS") + } + if !strings.Contains(output, "FridgeOS>") { + t.Error("output should contain FridgeOS> prompt") + } +} + +func TestHelpCommand(t *testing.T) { + output := runShell(t, "help\rexit\r") + for _, keyword := range []string{"inventory", "temp", "status", "diagnostics", "alerts", "reboot", "exit"} { + if !strings.Contains(output, keyword) { + t.Errorf("help output should mention %q", keyword) + } + } +} + +func TestInventoryList(t *testing.T) { + output := runShell(t, "inventory\rexit\r") + if !strings.Contains(output, "Fridge Inventory") { + t.Error("should show inventory header") + } + if !strings.Contains(output, "Whole Milk") { + t.Error("should list milk") + } + if !strings.Contains(output, "Eggs") { + t.Error("should list eggs") + } +} + +func TestInventoryAdd(t *testing.T) { + output := runShell(t, "inventory add Cheese\rinventory\rexit\r") + if !strings.Contains(output, "Added 'Cheese'") { + t.Error("should confirm adding cheese") + } + if !strings.Contains(output, "Cheese") { + t.Error("inventory list should contain cheese") + } +} + +func TestInventoryRemove(t *testing.T) { + output := runShell(t, "inventory remove milk\rinventory\rexit\r") + if !strings.Contains(output, "Removed") { + t.Error("should confirm removal") + } +} + +func TestTemperature(t *testing.T) { + output := runShell(t, "temp\rexit\r") + if !strings.Contains(output, "37") { + t.Error("should show fridge temp 37°F") + } + if !strings.Contains(output, "Fridge") { + t.Error("should label fridge zone") + } + if !strings.Contains(output, "Freezer") { + t.Error("should label freezer zone") + } +} + +func TestTempSetValid(t *testing.T) { + output := runShell(t, "temp set fridge 40\rtemp\rexit\r") + if !strings.Contains(output, "set to 40") { + t.Errorf("should confirm temp set, got: %s", output) + } + // Second temp call should show 40. + if !strings.Contains(output, "40") { + t.Error("temperature should now be 40") + } +} + +func TestTempSetOutOfRange(t *testing.T) { + output := runShell(t, "temp set fridge 100\rexit\r") + if !strings.Contains(output, "WARNING") { + t.Error("should warn about out-of-range temp") + } +} + +func TestTempSetFreezerOutOfRange(t *testing.T) { + output := runShell(t, "temp set freezer 50\rexit\r") + if !strings.Contains(output, "WARNING") { + t.Error("should warn about out-of-range freezer temp") + } +} + +func TestStatus(t *testing.T) { + output := runShell(t, "status\rexit\r") + for _, keyword := range []string{"Compressor", "WiFi", "Ice maker", "TikTok", "Spotify", "SmartHome"} { + if !strings.Contains(output, keyword) { + t.Errorf("status should contain %q", keyword) + } + } +} + +func TestDiagnostics(t *testing.T) { + output := runShell(t, "diagnostics\rexit\r") + if !strings.Contains(output, "ALL SYSTEMS NOMINAL") { + t.Error("diagnostics should end with ALL SYSTEMS NOMINAL") + } +} + +func TestAlerts(t *testing.T) { + output := runShell(t, "alerts\rexit\r") + if !strings.Contains(output, "Active Alerts") { + t.Error("should show alerts header") + } + if !strings.Contains(output, "Firmware update") { + t.Error("should mention firmware update") + } +} + +func TestReboot(t *testing.T) { + output := runShell(t, "reboot\r") + if !strings.Contains(output, "rebooting") || !strings.Contains(output, "Rebooting") { + t.Error("should show reboot message") + } +} + +func TestUnknownCommand(t *testing.T) { + output := runShell(t, "foobar\rexit\r") + if !strings.Contains(output, "unknown command") { + t.Error("should show unknown command message") + } +} + +func TestExitCommand(t *testing.T) { + output := runShell(t, "exit\r") + if !strings.Contains(output, "Goodbye") { + t.Error("exit should show goodbye message") + } +} + +func TestLogoutCommand(t *testing.T) { + output := runShell(t, "logout\r") + if !strings.Contains(output, "Goodbye") { + t.Error("logout should show goodbye message") + } +} + +func TestSessionLogs(t *testing.T) { + store := storage.NewMemoryStore() + sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "fridge") + + sess := &shell.SessionContext{ + SessionID: sessID, + Username: "root", + Store: store, + CommonConfig: shell.ShellCommonConfig{ + Hostname: "testhost", + }, + } + + rw := &rwCloser{ + Reader: bytes.NewBufferString("help\rexit\r"), + Writer: &bytes.Buffer{}, + } + + sh := NewFridgeShell() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + sh.Handle(ctx, sess, rw) + + if len(store.SessionLogs) < 2 { + t.Errorf("expected at least 2 session logs, got %d", len(store.SessionLogs)) + } +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 8e8029c..3c29a0f 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -2,6 +2,7 @@ package shell import ( "context" + "fmt" "io" "git.t-juice.club/torjus/oubliette/internal/storage" @@ -31,3 +32,59 @@ type ShellCommonConfig struct { Banner string FakeUser string // override username in prompt; empty = use authenticated user } + +// 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) + if n, _ := rw.Read(next); n > 0 && next[0] == '[' { + rw.Read(next) // read the final byte + } + + case ch >= 32 && ch < 127: // printable ASCII + buf = append(buf, ch) + rw.Write([]byte{ch}) + } + } +} diff --git a/oubliette.toml.example b/oubliette.toml.example index fd93b35..10d3ef1 100644 --- a/oubliette.toml.example +++ b/oubliette.toml.example @@ -18,6 +18,12 @@ password = "toor" username = "admin" password = "admin" +# Route specific credentials to a named shell (optional). +# [[auth.static_credentials]] +# username = "samsung" +# password = "fridge" +# shell = "fridge" + [storage] db_path = "oubliette.db" retention_days = 90