feat: add Smart Fridge shell and per-credential shell routing
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 <noreply@anthropic.com>
This commit is contained in:
4
PLAN.md
4
PLAN.md
@@ -155,12 +155,12 @@ Goal: Add the entertaining shell implementations.
|
|||||||
- Common commands: `show running-config`, `show interfaces`, `enable`, `configure terminal`
|
- Common commands: `show running-config`, `show interfaces`, `enable`, `configure terminal`
|
||||||
- Fake device info that looks like a real router
|
- Fake device info that looks like a real router
|
||||||
|
|
||||||
### 3.3 Smart Fridge Shell
|
### 3.3 Smart Fridge Shell ✅
|
||||||
- Samsung FridgeOS boot banner
|
- Samsung FridgeOS boot banner
|
||||||
- Inventory management commands
|
- Inventory management commands
|
||||||
- Temperature warnings
|
- Temperature warnings
|
||||||
- "WARNING: milk expires in 2 days"
|
- "WARNING: milk expires in 2 days"
|
||||||
- Easter eggs
|
- Per-credential shell routing via `shell` field in static credentials
|
||||||
|
|
||||||
### 3.4 Text Adventure
|
### 3.4 Text Adventure
|
||||||
- Zork-style dungeon crawler
|
- Zork-style dungeon crawler
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ Key settings:
|
|||||||
- `ssh.host_key_path` — Ed25519 host key, auto-generated if missing
|
- `ssh.host_key_path` — Ed25519 host key, auto-generated if missing
|
||||||
- `auth.accept_after` — accept login after N failures per IP (default `10`)
|
- `auth.accept_after` — accept login after N failures per IP (default `10`)
|
||||||
- `auth.credential_ttl` — how long to remember accepted credentials (default `24h`)
|
- `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.db_path` — SQLite database path (default `oubliette.db`)
|
||||||
- `storage.retention_days` — auto-prune records older than N days (default `90`)
|
- `storage.retention_days` — auto-prune records older than N days (default `90`)
|
||||||
- `storage.retention_interval` — how often to run retention (default `1h`)
|
- `storage.retention_interval` — how often to run retention (default `1h`)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type credKey struct {
|
|||||||
type Decision struct {
|
type Decision struct {
|
||||||
Accepted bool
|
Accepted bool
|
||||||
Reason string // "static_credential", "threshold_reached", "remembered_credential", "rejected"
|
Reason string // "static_credential", "threshold_reached", "remembered_credential", "rejected"
|
||||||
|
Shell string // optional: route to specific shell (only set for static credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Authenticator struct {
|
type Authenticator struct {
|
||||||
@@ -50,7 +51,7 @@ func (a *Authenticator) Authenticate(ip, username, password string) Decision {
|
|||||||
pMatch := subtle.ConstantTimeCompare([]byte(cred.Password), []byte(password))
|
pMatch := subtle.ConstantTimeCompare([]byte(cred.Password), []byte(password))
|
||||||
if uMatch == 1 && pMatch == 1 {
|
if uMatch == 1 && pMatch == 1 {
|
||||||
a.failCounts[ip] = 0
|
a.failCounts[ip] = 0
|
||||||
return Decision{Accepted: true, Reason: "static_credential"}
|
return Decision{Accepted: true, Reason: "static_credential", Shell: cred.Shell}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestConcurrentAccess(t *testing.T) {
|
||||||
a := newTestAuth(5, time.Hour)
|
a := newTestAuth(5, time.Hour)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type AuthConfig struct {
|
|||||||
type Credential struct {
|
type Credential struct {
|
||||||
Username string `toml:"username"`
|
Username string `toml:"username"`
|
||||||
Password string `toml:"password"`
|
Password string `toml:"password"`
|
||||||
|
Shell string `toml:"shell"` // optional: route to specific shell (empty = random)
|
||||||
}
|
}
|
||||||
|
|
||||||
type DetectionConfig struct {
|
type DetectionConfig struct {
|
||||||
|
|||||||
@@ -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) {
|
func TestLoadMissingFile(t *testing.T) {
|
||||||
_, err := Load("/nonexistent/path/config.toml")
|
_, err := Load("/nonexistent/path/config.toml")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"git.t-juice.club/torjus/oubliette/internal/notify"
|
"git.t-juice.club/torjus/oubliette/internal/notify"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/shell"
|
"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/bash"
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/shell/fridge"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
"golang.org/x/crypto/ssh"
|
"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 {
|
if err := registry.Register(bash.NewBashShell(), 1); err != nil {
|
||||||
return nil, fmt.Errorf("registering bash shell: %w", err)
|
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{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@@ -138,11 +142,25 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request
|
|||||||
defer channel.Close()
|
defer channel.Close()
|
||||||
|
|
||||||
// Select a shell from the registry.
|
// Select a shell from the registry.
|
||||||
selectedShell, err := s.shellRegistry.Select()
|
// 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 {
|
if err != nil {
|
||||||
s.logger.Error("failed to select shell", "err", err)
|
s.logger.Error("failed to select shell", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ip := extractIP(conn.RemoteAddr())
|
ip := extractIP(conn.RemoteAddr())
|
||||||
sessionID, err := s.store.CreateSession(context.Background(), ip, conn.User(), selectedShell.Name())
|
sessionID, err := s.store.CreateSession(context.Background(), ip, conn.User(), selectedShell.Name())
|
||||||
@@ -304,7 +322,13 @@ func (s *Server) passwordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.
|
|||||||
}
|
}
|
||||||
|
|
||||||
if d.Accepted {
|
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")
|
return nil, fmt.Errorf("rejected")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func TestIntegrationSSHConnect(t *testing.T) {
|
|||||||
AcceptAfter: 2,
|
AcceptAfter: 2,
|
||||||
CredentialTTLDuration: time.Hour,
|
CredentialTTLDuration: time.Hour,
|
||||||
StaticCredentials: []config.Credential{
|
StaticCredentials: []config.Credential{
|
||||||
{Username: "root", Password: "toor"},
|
{Username: "root", Password: "toor", Shell: "bash"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Shell: config.ShellConfig{
|
Shell: config.ShellConfig{
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func (b *BashShell) Handle(ctx context.Context, sess *shell.SessionContext, rw i
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
line, err := readLine(ctx, rw)
|
line, err := shell.ReadLine(ctx, rw)
|
||||||
if errors.Is(err, io.EOF) {
|
if errors.Is(err, io.EOF) {
|
||||||
fmt.Fprint(rw, "logout\r\n")
|
fmt.Fprint(rw, "logout\r\n")
|
||||||
return nil
|
return nil
|
||||||
@@ -103,58 +103,3 @@ func formatPrompt(state *shellState) string {
|
|||||||
return fmt.Sprintf("%s@%s:%s# ", state.username, state.hostname, cwd)
|
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})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func TestReadLineEnter(t *testing.T) {
|
|||||||
}{input, &output}
|
}{input, &output}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
line, err := readLine(ctx, rw)
|
line, err := shell.ReadLine(ctx, rw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("readLine: %v", err)
|
t.Fatalf("readLine: %v", err)
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ func TestReadLineBackspace(t *testing.T) {
|
|||||||
}{input, &output}
|
}{input, &output}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
line, err := readLine(ctx, rw)
|
line, err := shell.ReadLine(ctx, rw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("readLine: %v", err)
|
t.Fatalf("readLine: %v", err)
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ func TestReadLineCtrlC(t *testing.T) {
|
|||||||
}{input, &output}
|
}{input, &output}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
line, err := readLine(ctx, rw)
|
line, err := shell.ReadLine(ctx, rw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("readLine: %v", err)
|
t.Fatalf("readLine: %v", err)
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ func TestReadLineCtrlD(t *testing.T) {
|
|||||||
}{input, &output}
|
}{input, &output}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, err := readLine(ctx, rw)
|
_, err := shell.ReadLine(ctx, rw)
|
||||||
if !errors.Is(err, io.EOF) {
|
if !errors.Is(err, io.EOF) {
|
||||||
t.Fatalf("expected io.EOF, got %v", err)
|
t.Fatalf("expected io.EOF, got %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
348
internal/shell/fridge/fridge.go
Normal file
348
internal/shell/fridge/fridge.go
Normal file
@@ -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 <item> - Add item to inventory
|
||||||
|
inventory remove <item> - Remove item from inventory
|
||||||
|
temp - Show current temperatures
|
||||||
|
temp set <zone> <value> - 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>"}
|
||||||
|
}
|
||||||
|
item := strings.Join(args[1:], " ")
|
||||||
|
return s.inventoryAdd(item)
|
||||||
|
case "remove":
|
||||||
|
if len(args) < 2 {
|
||||||
|
return commandResult{output: "Usage: inventory remove <item>"}
|
||||||
|
}
|
||||||
|
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 <fridge|freezer> <value_in_F>"}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
}
|
||||||
233
internal/shell/fridge/fridge_test.go
Normal file
233
internal/shell/fridge/fridge_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package shell
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
@@ -31,3 +32,59 @@ type ShellCommonConfig struct {
|
|||||||
Banner string
|
Banner string
|
||||||
FakeUser string // override username in prompt; empty = use authenticated user
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ password = "toor"
|
|||||||
username = "admin"
|
username = "admin"
|
||||||
password = "admin"
|
password = "admin"
|
||||||
|
|
||||||
|
# Route specific credentials to a named shell (optional).
|
||||||
|
# [[auth.static_credentials]]
|
||||||
|
# username = "samsung"
|
||||||
|
# password = "fridge"
|
||||||
|
# shell = "fridge"
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
db_path = "oubliette.db"
|
db_path = "oubliette.db"
|
||||||
retention_days = 90
|
retention_days = 90
|
||||||
|
|||||||
Reference in New Issue
Block a user