From 6f5fa3d9e934edc44e82d09a944e8c34c850a297 Mon Sep 17 00:00:00 2001 From: valorisa Date: Sun, 25 Oct 2026 18:21:05 +0100 Subject: [PATCH] docs: add superpowers plans and specs; ignore local Claude settings --- .gitignore | 3 + .../plans/2026-05-14-shellfrom-browser.md | 3869 +++++++++++++++++ .../plans/2026-05-15-docker-local-test.md | 158 + .../2026-05-15-docker-local-test-design.md | 84 + 4 files changed, 4114 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-14-shellfrom-browser.md create mode 100644 docs/superpowers/plans/2026-05-15-docker-local-test.md create mode 100644 docs/superpowers/specs/2026-05-15-docker-local-test-design.md diff --git a/.gitignore b/.gitignore index 29eb66c..1a80634 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ config.yaml # Local test artifacts and secrets certs/ config.test.yaml + +# Local Claude Code settings +.claude/ diff --git a/docs/superpowers/plans/2026-05-14-shellfrom-browser.md b/docs/superpowers/plans/2026-05-14-shellfrom-browser.md new file mode 100644 index 0000000..05d4882 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-shellfrom-browser.md @@ -0,0 +1,3869 @@ +# ShellFromBrowser Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a modern, cross-platform web-based terminal emulator in Go that replaces the obsolete ShellInBox — with SSH client, multi-sessions, file transfer, session recording, and Docker support. + +**Architecture:** Go HTTP server with WebSocket upgrade for bidirectional terminal I/O. Frontend uses xterm.js for accurate terminal emulation. Sessions are multiplexed server-side with reconnection support. Authentication via JWT tokens with configurable backends (local users, PAM on Linux). SSH client mode allows connecting to remote hosts through the browser. + +**Tech Stack:** +- Backend: Go 1.22+ +- WebSocket: `github.com/gorilla/websocket` +- PTY: `github.com/creack/pty` (Unix) + ConPTY wrapper (Windows) +- SSH: `golang.org/x/crypto/ssh` +- Frontend: xterm.js 5.x + xterm-addon-fit + xterm-addon-web-links +- Config: `gopkg.in/yaml.v3` +- Auth: JWT (`github.com/golang-jwt/jwt/v5`) + bcrypt +- Build: Go modules, embedded frontend via `embed` + +--- + +## File Structure + +``` +ShellFromBrowser/ +├── cmd/ +│ └── shellfb/ +│ └── main.go # Entry point, CLI flags, server startup +├── internal/ +│ ├── config/ +│ │ ├── config.go # Config struct + YAML loading +│ │ └── config_test.go +│ ├── auth/ +│ │ ├── auth.go # Auth interface + JWT middleware +│ │ ├── local.go # Local user/password backend +│ │ ├── pam.go # PAM backend (Linux only, build tag) +│ │ └── auth_test.go +│ ├── terminal/ +│ │ ├── session.go # Session lifecycle (create, attach, detach, destroy) +│ │ ├── pty_unix.go # PTY spawning for Unix (build tag) +│ │ ├── pty_windows.go # ConPTY for Windows (build tag) +│ │ ├── manager.go # Session manager (multi-session registry) +│ │ └── session_test.go +│ ├── ssh/ +│ │ ├── client.go # SSH client wrapper +│ │ ├── known_hosts.go # Known hosts management +│ │ └── client_test.go +│ ├── transfer/ +│ │ ├── upload.go # File upload handler +│ │ ├── download.go # File download handler +│ │ └── transfer_test.go +│ ├── recording/ +│ │ ├── recorder.go # Session recording (asciicast v2 format) +│ │ ├── player.go # Playback handler +│ │ └── recorder_test.go +│ └── server/ +│ ├── server.go # HTTP server, routes, WebSocket upgrade +│ ├── websocket.go # WebSocket handler + message protocol +│ └── server_test.go +├── web/ +│ ├── static/ +│ │ ├── index.html # Main SPA page +│ │ ├── css/ +│ │ │ └── app.css # Custom styles +│ │ └── js/ +│ │ ├── terminal.js # xterm.js initialization + WebSocket bridge +│ │ ├── sessions.js # Multi-tab/session UI logic +│ │ └── transfer.js # File upload/download UI +│ └── embed.go # go:embed directive for static files +├── config.example.yaml # Example configuration +├── Dockerfile # Multi-stage build +├── docker-compose.yml # Ready-to-use compose file +├── Makefile # Build, test, lint targets +├── go.mod +├── go.sum +├── README.md +└── LICENSE +``` + +--- + +## Phase 1: Project Scaffold + Basic Terminal + +### Task 1: Initialize Go module and project structure + +**Files:** +- Create: `go.mod` +- Create: `cmd/shellfb/main.go` +- Create: `Makefile` + +- [ ] **Step 1: Create Go module** + +```bash +cd ShellFromBrowser +go mod init github.com/valorisa/ShellFromBrowser +``` + +- [ ] **Step 2: Create entry point** + +Create `cmd/shellfb/main.go`: +```go +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" +) + +var ( + version = "dev" + commit = "none" +) + +func main() { + addr := flag.String("addr", ":8080", "listen address (host:port)") + configPath := flag.String("config", "", "path to config file") + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + + if *showVersion { + fmt.Printf("ShellFromBrowser %s (%s)\n", version, commit) + os.Exit(0) + } + + _ = configPath // used in Phase 4 + log.Printf("ShellFromBrowser %s starting on %s", version, *addr) + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("shutting down") +} +``` + +- [ ] **Step 3: Create Makefile** + +Create `Makefile`: +```makefile +BINARY=shellfb +VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "none") +LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" + +.PHONY: build test run clean + +build: + go build $(LDFLAGS) -o bin/$(BINARY) ./cmd/shellfb + +test: + go test ./... -v -race + +run: build + ./bin/$(BINARY) + +clean: + rm -rf bin/ +``` + +- [ ] **Step 4: Verify build** + +```bash +cd ShellFromBrowser +go build ./cmd/shellfb +``` + +Expected: builds without error. + +- [ ] **Step 5: Commit** + +```bash +git init +git add go.mod cmd/shellfb/main.go Makefile +git commit -m "feat: initialize project scaffold with entry point and Makefile" +``` + +--- + +### Task 2: WebSocket server with echo + +**Files:** +- Modify: `cmd/shellfb/main.go` +- Create: `internal/server/server.go` +- Create: `internal/server/websocket.go` +- Create: `internal/server/server_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/server/server_test.go`: +```go +package server_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/websocket" + "github.com/valorisa/ShellFromBrowser/internal/server" +) + +func TestWebSocketEcho(t *testing.T) { + srv := server.New(":0") + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws" + ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer ws.Close() + + msg := []byte("hello") + err = ws.WriteMessage(websocket.TextMessage, msg) + if err != nil { + t.Fatalf("write: %v", err) + } + + _, got, err := ws.ReadMessage() + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != string(msg) { + t.Errorf("got %q, want %q", got, msg) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/server/ -v -run TestWebSocketEcho +``` + +Expected: FAIL — package doesn't exist yet. + +- [ ] **Step 3: Add gorilla/websocket dependency** + +```bash +go get github.com/gorilla/websocket +``` + +- [ ] **Step 4: Implement server** + +Create `internal/server/server.go`: +```go +package server + +import ( + "net/http" +) + +type Server struct { + addr string + mux *http.ServeMux +} + +func New(addr string) *Server { + s := &Server{addr: addr, mux: http.NewServeMux()} + s.mux.HandleFunc("/ws", s.handleWebSocket) + return s +} + +func (s *Server) Handler() http.Handler { + return s.mux +} + +func (s *Server) ListenAndServe() error { + return http.ListenAndServe(s.addr, s.mux) +} +``` + +Create `internal/server/websocket.go`: +```go +package server + +import ( + "log" + "net/http" + + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("websocket upgrade: %v", err) + return + } + defer conn.Close() + + for { + msgType, msg, err := conn.ReadMessage() + if err != nil { + break + } + if err := conn.WriteMessage(msgType, msg); err != nil { + break + } + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +go test ./internal/server/ -v -run TestWebSocketEcho +``` + +Expected: PASS + +- [ ] **Step 6: Wire server into main.go** + +Replace `cmd/shellfb/main.go`: +```go +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/valorisa/ShellFromBrowser/internal/server" +) + +var ( + version = "dev" + commit = "none" +) + +func main() { + addr := flag.String("addr", ":8080", "listen address (host:port)") + configPath := flag.String("config", "", "path to config file") + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + + if *showVersion { + fmt.Printf("ShellFromBrowser %s (%s)\n", version, commit) + os.Exit(0) + } + + _ = configPath + + srv := server.New(*addr) + log.Printf("ShellFromBrowser %s starting on %s", version, *addr) + + go func() { + if err := srv.ListenAndServe(); err != nil { + log.Fatalf("server: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("shutting down") +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add internal/server/ cmd/shellfb/main.go go.mod go.sum +git commit -m "feat: add WebSocket server with echo handler" +``` + +--- + +### Task 3: xterm.js frontend with WebSocket bridge + +**Files:** +- Create: `web/static/index.html` +- Create: `web/static/js/terminal.js` +- Create: `web/static/css/app.css` +- Create: `web/embed.go` +- Modify: `internal/server/server.go` + +- [ ] **Step 1: Create frontend HTML** + +Create `web/static/index.html`: +```html + + + + + + ShellFromBrowser + + + + +
+
+
+ + + + + + +``` + +- [ ] **Step 2: Create terminal.js WebSocket bridge** + +Create `web/static/js/terminal.js`: +```javascript +(function () { + "use strict"; + + const term = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: "'JetBrains Mono', 'Fira Code', monospace", + theme: { + background: "#1e1e2e", + foreground: "#cdd6f4", + cursor: "#f5e0dc", + }, + }); + + const fitAddon = new FitAddon.FitAddon(); + const webLinksAddon = new WebLinksAddon.WebLinksAddon(); + + term.loadAddon(fitAddon); + term.loadAddon(webLinksAddon); + term.open(document.getElementById("terminal")); + fitAddon.fit(); + + window.addEventListener("resize", () => fitAddon.fit()); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/ws`; + let ws = null; + + function connect() { + ws = new WebSocket(wsUrl); + ws.binaryType = "arraybuffer"; + + ws.onopen = function () { + term.writeln("\x1b[32mConnected to ShellFromBrowser\x1b[0m"); + const dims = { type: "resize", cols: term.cols, rows: term.rows }; + ws.send(JSON.stringify(dims)); + }; + + ws.onmessage = function (event) { + if (event.data instanceof ArrayBuffer) { + term.write(new Uint8Array(event.data)); + } else { + term.write(event.data); + } + }; + + ws.onclose = function () { + term.writeln("\r\n\x1b[31mDisconnected\x1b[0m"); + }; + + ws.onerror = function () { + term.writeln("\r\n\x1b[31mConnection error\x1b[0m"); + }; + } + + term.onData(function (data) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + }); + + term.onResize(function (size) { + if (ws && ws.readyState === WebSocket.OPEN) { + const dims = { type: "resize", cols: size.cols, rows: size.rows }; + ws.send(JSON.stringify(dims)); + } + }); + + connect(); +})(); +``` + +- [ ] **Step 3: Create CSS** + +Create `web/static/css/app.css`: +```css +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + background: #1e1e2e; + overflow: hidden; +} + +#terminal-container { + height: 100%; + padding: 4px; +} + +#terminal { + height: 100%; +} +``` + +- [ ] **Step 4: Create embed.go for static files** + +Create `web/embed.go`: +```go +package web + +import "embed" + +//go:embed static +var StaticFiles embed.FS +``` + +- [ ] **Step 5: Download xterm.js assets** + +```bash +cd ShellFromBrowser/web/static +mkdir -p js css +curl -sL https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js -o js/xterm.js +curl -sL https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css -o css/xterm.css +curl -sL https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js -o js/xterm-addon-fit.js +curl -sL https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js -o js/xterm-addon-web-links.js +``` + +- [ ] **Step 6: Serve static files from server** + +Modify `internal/server/server.go`: +```go +package server + +import ( + "io/fs" + "net/http" + + "github.com/valorisa/ShellFromBrowser/web" +) + +type Server struct { + addr string + mux *http.ServeMux +} + +func New(addr string) *Server { + s := &Server{addr: addr, mux: http.NewServeMux()} + s.mux.HandleFunc("/ws", s.handleWebSocket) + + staticFS, _ := fs.Sub(web.StaticFiles, "static") + s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + data, _ := web.StaticFiles.ReadFile("static/index.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) + }) + + return s +} + +func (s *Server) Handler() http.Handler { + return s.mux +} + +func (s *Server) ListenAndServe() error { + return http.ListenAndServe(s.addr, s.mux) +} +``` + +- [ ] **Step 7: Build and test manually in browser** + +```bash +cd ShellFromBrowser +go build -o bin/shellfb.exe ./cmd/shellfb +./bin/shellfb.exe +``` + +Open `http://localhost:8080` — should see xterm.js terminal with "Connected" message. Typing echoes back (echo mode from Task 2). + +- [ ] **Step 8: Commit** + +```bash +git add web/ internal/server/server.go +git commit -m "feat: add xterm.js frontend with WebSocket bridge and embedded static files" +``` + +--- + +### Task 4: PTY spawning (cross-platform) + +**Files:** +- Create: `internal/terminal/session.go` +- Create: `internal/terminal/pty_unix.go` +- Create: `internal/terminal/pty_windows.go` +- Create: `internal/terminal/session_test.go` +- Modify: `internal/server/websocket.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/terminal/session_test.go`: +```go +package terminal_test + +import ( + "bytes" + "testing" + "time" + + "github.com/valorisa/ShellFromBrowser/internal/terminal" +) + +func TestSessionSpawnAndWrite(t *testing.T) { + sess, err := terminal.NewSession(80, 24) + if err != nil { + t.Fatalf("NewSession: %v", err) + } + defer sess.Close() + + // Write a command + _, err = sess.Write([]byte("echo hello\n")) + if err != nil { + t.Fatalf("Write: %v", err) + } + + // Read output + buf := make([]byte, 4096) + var output bytes.Buffer + deadline := time.After(3 * time.Second) + + for { + select { + case <-deadline: + if !bytes.Contains(output.Bytes(), []byte("hello")) { + t.Fatalf("timeout: output was %q", output.String()) + } + return + default: + sess.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, err := sess.Read(buf) + if n > 0 { + output.Write(buf[:n]) + if bytes.Contains(output.Bytes(), []byte("hello")) { + return + } + } + if err != nil { + continue + } + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/terminal/ -v -run TestSessionSpawnAndWrite +``` + +Expected: FAIL — package doesn't exist. + +- [ ] **Step 3: Add PTY dependency** + +```bash +go get github.com/creack/pty +``` + +- [ ] **Step 4: Implement session.go** + +Create `internal/terminal/session.go`: +```go +package terminal + +import ( + "io" + "os" + "sync" + "time" +) + +type Session struct { + id string + pty *os.File + cols uint16 + rows uint16 + mu sync.Mutex + closed bool + deadline time.Time +} + +func NewSession(cols, rows uint16) (*Session, error) { + return newPlatformSession(cols, rows) +} + +func (s *Session) ID() string { + return s.id +} + +func (s *Session) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return 0, io.ErrClosedPipe + } + return s.pty.Write(p) +} + +func (s *Session) Read(p []byte) (int, error) { + return s.pty.Read(p) +} + +func (s *Session) SetReadDeadline(t time.Time) { + s.deadline = t + s.pty.SetReadDeadline(t) +} + +func (s *Session) Resize(cols, rows uint16) error { + s.mu.Lock() + defer s.mu.Unlock() + s.cols = cols + s.rows = rows + return s.resizePty(cols, rows) +} + +func (s *Session) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil + } + s.closed = true + return s.pty.Close() +} +``` + +- [ ] **Step 5: Implement pty_unix.go** + +Create `internal/terminal/pty_unix.go`: +```go +//go:build !windows + +package terminal + +import ( + "crypto/rand" + "encoding/hex" + "os" + "os/exec" + + "github.com/creack/pty" +) + +func newPlatformSession(cols, rows uint16) (*Session, error) { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + + cmd := exec.Command(shell) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{ + Cols: cols, + Rows: rows, + }) + if err != nil { + return nil, err + } + + id := make([]byte, 16) + rand.Read(id) + + return &Session{ + id: hex.EncodeToString(id), + pty: ptmx, + cols: cols, + rows: rows, + }, nil +} + +func (s *Session) resizePty(cols, rows uint16) error { + return pty.Setsize(s.pty, &pty.Winsize{Cols: cols, Rows: rows}) +} +``` + +- [ ] **Step 6: Implement pty_windows.go** + +Create `internal/terminal/pty_windows.go`: +```go +//go:build windows + +package terminal + +import ( + "crypto/rand" + "encoding/hex" + "os" + "os/exec" + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procCreatePseudoConsole = kernel32.NewProc("CreatePseudoConsole") + procResizePseudoConsole = kernel32.NewProc("ResizePseudoConsole") + procClosePseudoConsole = kernel32.NewProc("ClosePseudoConsole") +) + +type conPTY struct { + handle syscall.Handle + inPipe *os.File + outPipe *os.File + cmd *exec.Cmd +} + +func newPlatformSession(cols, rows uint16) (*Session, error) { + // Create pipes for ConPTY + inRead, inWrite, err := os.Pipe() + if err != nil { + return nil, err + } + outRead, outWrite, err := os.Pipe() + if err != nil { + inRead.Close() + inWrite.Close() + return nil, err + } + + // Create pseudo console + coord := uintptr(cols) | (uintptr(rows) << 16) + var hPC syscall.Handle + ret, _, err := procCreatePseudoConsole.Call( + coord, + uintptr(inRead.Fd()), + uintptr(outWrite.Fd()), + 0, + uintptr(unsafe.Pointer(&hPC)), + ) + if ret != 0 { + inRead.Close() + inWrite.Close() + outRead.Close() + outWrite.Close() + return nil, err + } + + // Close the ends we gave to ConPTY + inRead.Close() + outWrite.Close() + + // Start process attached to ConPTY + shell := os.Getenv("COMSPEC") + if shell == "" { + shell = "cmd.exe" + } + + cmd := exec.Command(shell) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_UNICODE_ENVIRONMENT, + } + + // For Windows ConPTY, we use the pipes directly + // The outRead pipe gives us terminal output + // The inWrite pipe accepts terminal input + + id := make([]byte, 16) + rand.Read(id) + + sess := &Session{ + id: hex.EncodeToString(id), + pty: outRead, // read terminal output from here + cols: cols, + rows: rows, + } + + // Store inWrite for writing (we'll use a wrapper) + // For simplicity in the cross-platform interface, we use outRead as the "pty" + // and override Write to use inWrite + sess.pty = &winPtyFile{ + in: inWrite, + out: outRead, + handle: hPC, + cmd: cmd, + } + + return sess, nil +} + +// winPtyFile wraps ConPTY pipes to implement *os.File-like interface +type winPtyFile struct { + in *os.File + out *os.File + handle syscall.Handle + cmd *exec.Cmd +} + +func (s *Session) resizePty(cols, rows uint16) error { + coord := uintptr(cols) | (uintptr(rows) << 16) + ret, _, err := procResizePseudoConsole.Call( + uintptr(s.pty.(*winPtyFile).handle), + coord, + ) + if ret != 0 { + return err + } + return nil +} +``` + +**Note:** The Windows implementation above is a simplified skeleton. For production, we'll use `github.com/UserExitworktree/conpty` or refactor to use `golang.org/x/sys/windows`. This will be refined in Phase 9 (polish). + +- [ ] **Step 7: Run test (Unix only for now)** + +```bash +go test ./internal/terminal/ -v -run TestSessionSpawnAndWrite +``` + +Expected: PASS on Unix/WSL. On Windows native, skip for now (ConPTY needs refinement). + +- [ ] **Step 8: Connect PTY to WebSocket** + +Modify `internal/server/websocket.go`: +```go +package server + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/gorilla/websocket" + "github.com/valorisa/ShellFromBrowser/internal/terminal" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +type resizeMsg struct { + Type string `json:"type"` + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` +} + +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("websocket upgrade: %v", err) + return + } + defer conn.Close() + + sess, err := terminal.NewSession(80, 24) + if err != nil { + log.Printf("session create: %v", err) + conn.WriteMessage(websocket.TextMessage, []byte("Failed to create session: "+err.Error())) + return + } + defer sess.Close() + + // PTY -> WebSocket + go func() { + buf := make([]byte, 4096) + for { + n, err := sess.Read(buf) + if err != nil { + conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(1000, "session closed")) + return + } + if err := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { + return + } + } + }() + + // WebSocket -> PTY + for { + msgType, msg, err := conn.ReadMessage() + if err != nil { + break + } + + if msgType == websocket.TextMessage { + var resize resizeMsg + if json.Unmarshal(msg, &resize) == nil && resize.Type == "resize" { + sess.Resize(resize.Cols, resize.Rows) + continue + } + } + + sess.Write(msg) + } +} +``` + +- [ ] **Step 9: Build and test in browser** + +```bash +cd ShellFromBrowser +go build -o bin/shellfb.exe ./cmd/shellfb && ./bin/shellfb.exe +``` + +Open `http://localhost:8080` — should show a working interactive terminal (cmd.exe or shell). + +- [ ] **Step 10: Commit** + +```bash +git add internal/terminal/ internal/server/websocket.go go.mod go.sum +git commit -m "feat: add cross-platform PTY spawning connected to WebSocket" +``` + +--- + +## Phase 2: Authentication + TLS + +### Task 5: Configuration system (YAML) + +**Files:** +- Create: `internal/config/config.go` +- Create: `internal/config/config_test.go` +- Create: `config.example.yaml` + +- [ ] **Step 1: Write the failing test** + +Create `internal/config/config_test.go`: +```go +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/valorisa/ShellFromBrowser/internal/config" +) + +func TestLoadConfig(t *testing.T) { + yaml := ` +server: + addr: ":9090" + tls: + enabled: true + cert: "/etc/ssl/cert.pem" + key: "/etc/ssl/key.pem" +auth: + enabled: true + users: + - username: admin + password_hash: "$2a$10$abcdefghijklmnopqrstuuxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +shell: + command: "/bin/bash" + env: + - "TERM=xterm-256color" +sessions: + max_per_user: 5 + idle_timeout: "30m" +ssh: + enabled: true + known_hosts: "~/.ssh/known_hosts" +recording: + enabled: true + dir: "./recordings" +` + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + os.WriteFile(path, []byte(yaml), 0644) + + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.Addr != ":9090" { + t.Errorf("addr = %q, want :9090", cfg.Server.Addr) + } + if !cfg.Server.TLS.Enabled { + t.Error("TLS should be enabled") + } + if !cfg.Auth.Enabled { + t.Error("Auth should be enabled") + } + if len(cfg.Auth.Users) != 1 { + t.Errorf("users = %d, want 1", len(cfg.Auth.Users)) + } + if cfg.Sessions.MaxPerUser != 5 { + t.Errorf("max_per_user = %d, want 5", cfg.Sessions.MaxPerUser) + } +} + +func TestLoadConfigDefaults(t *testing.T) { + cfg := config.Default() + if cfg.Server.Addr != ":8080" { + t.Errorf("default addr = %q, want :8080", cfg.Server.Addr) + } + if cfg.Auth.Enabled { + t.Error("auth should be disabled by default") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go get gopkg.in/yaml.v3 +go test ./internal/config/ -v +``` + +Expected: FAIL + +- [ ] **Step 3: Implement config.go** + +Create `internal/config/config.go`: +```go +package config + +import ( + "os" + "time" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server ServerConfig `yaml:"server"` + Auth AuthConfig `yaml:"auth"` + Shell ShellConfig `yaml:"shell"` + Sessions SessionsConfig `yaml:"sessions"` + SSH SSHConfig `yaml:"ssh"` + Recording RecordingConfig `yaml:"recording"` +} + +type ServerConfig struct { + Addr string `yaml:"addr"` + TLS TLSConfig `yaml:"tls"` +} + +type TLSConfig struct { + Enabled bool `yaml:"enabled"` + Cert string `yaml:"cert"` + Key string `yaml:"key"` +} + +type AuthConfig struct { + Enabled bool `yaml:"enabled"` + Users []UserDef `yaml:"users"` + JWTSecret string `yaml:"jwt_secret"` +} + +type UserDef struct { + Username string `yaml:"username"` + PasswordHash string `yaml:"password_hash"` +} + +type ShellConfig struct { + Command string `yaml:"command"` + Env []string `yaml:"env"` +} + +type SessionsConfig struct { + MaxPerUser int `yaml:"max_per_user"` + IdleTimeout time.Duration `yaml:"idle_timeout"` +} + +type SSHConfig struct { + Enabled bool `yaml:"enabled"` + KnownHosts string `yaml:"known_hosts"` +} + +type RecordingConfig struct { + Enabled bool `yaml:"enabled"` + Dir string `yaml:"dir"` +} + +func Default() *Config { + return &Config{ + Server: ServerConfig{Addr: ":8080"}, + Shell: ShellConfig{ + Env: []string{"TERM=xterm-256color"}, + }, + Sessions: SessionsConfig{ + MaxPerUser: 10, + IdleTimeout: 30 * time.Minute, + }, + } +} + +func Load(path string) (*Config, error) { + cfg := Default() + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, err + } + return cfg, nil +} +``` + +- [ ] **Step 4: Run tests** + +```bash +go test ./internal/config/ -v +``` + +Expected: PASS + +- [ ] **Step 5: Create example config** + +Create `config.example.yaml`: +```yaml +# ShellFromBrowser configuration +server: + addr: ":8080" + tls: + enabled: false + cert: "" + key: "" + +auth: + enabled: false + jwt_secret: "change-me-to-a-random-string" + users: + - username: admin + # Generate with: shellfb hash-password + password_hash: "" + +shell: + # Leave empty for system default (SHELL on Unix, COMSPEC on Windows) + command: "" + env: + - "TERM=xterm-256color" + +sessions: + max_per_user: 10 + idle_timeout: "30m" + +ssh: + enabled: true + known_hosts: "~/.ssh/known_hosts" + +recording: + enabled: false + dir: "./recordings" +``` + +- [ ] **Step 6: Commit** + +```bash +git add internal/config/ config.example.yaml go.mod go.sum +git commit -m "feat: add YAML configuration system with defaults" +``` + +--- + +### Task 6: Authentication (JWT + bcrypt) + +**Files:** +- Create: `internal/auth/auth.go` +- Create: `internal/auth/local.go` +- Create: `internal/auth/auth_test.go` +- Modify: `internal/server/server.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/auth/auth_test.go`: +```go +package auth_test + +import ( + "testing" + "time" + + "github.com/valorisa/ShellFromBrowser/internal/auth" + "github.com/valorisa/ShellFromBrowser/internal/config" +) + +func TestLocalAuth(t *testing.T) { + hash, err := auth.HashPassword("secret123") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + + cfg := &config.AuthConfig{ + Enabled: true, + JWTSecret: "test-secret-key", + Users: []config.UserDef{ + {Username: "admin", PasswordHash: hash}, + }, + } + + provider := auth.NewLocalProvider(cfg) + + // Valid credentials + token, err := provider.Authenticate("admin", "secret123") + if err != nil { + t.Fatalf("Authenticate valid: %v", err) + } + if token == "" { + t.Fatal("token is empty") + } + + // Validate token + claims, err := provider.ValidateToken(token) + if err != nil { + t.Fatalf("ValidateToken: %v", err) + } + if claims.Username != "admin" { + t.Errorf("username = %q, want admin", claims.Username) + } + + // Invalid password + _, err = provider.Authenticate("admin", "wrong") + if err == nil { + t.Fatal("expected error for wrong password") + } + + // Invalid username + _, err = provider.Authenticate("nobody", "secret123") + if err == nil { + t.Fatal("expected error for unknown user") + } +} + +func TestTokenExpiry(t *testing.T) { + cfg := &config.AuthConfig{ + Enabled: true, + JWTSecret: "test-secret", + Users: []config.UserDef{{Username: "u", PasswordHash: ""}}, + } + provider := auth.NewLocalProvider(cfg) + provider.SetTokenDuration(1 * time.Millisecond) + + token, _ := provider.Authenticate("u", "") + time.Sleep(10 * time.Millisecond) + + _, err := provider.ValidateToken(token) + if err == nil { + t.Fatal("expected expired token error") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go get github.com/golang-jwt/jwt/v5 +go get golang.org/x/crypto/bcrypt +go test ./internal/auth/ -v +``` + +Expected: FAIL + +- [ ] **Step 3: Implement auth.go** + +Create `internal/auth/auth.go`: +```go +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrTokenExpired = errors.New("token expired") + ErrTokenInvalid = errors.New("token invalid") +) + +type Claims struct { + Username string `json:"username"` + jwt.RegisteredClaims +} + +type Provider interface { + Authenticate(username, password string) (token string, err error) + ValidateToken(token string) (*Claims, error) +} + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} +``` + +- [ ] **Step 4: Implement local.go** + +Create `internal/auth/local.go`: +```go +package auth + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/valorisa/ShellFromBrowser/internal/config" +) + +type LocalProvider struct { + users map[string]string // username -> password_hash + jwtSecret []byte + tokenDuration time.Duration +} + +func NewLocalProvider(cfg *config.AuthConfig) *LocalProvider { + users := make(map[string]string) + for _, u := range cfg.Users { + users[u.Username] = u.PasswordHash + } + return &LocalProvider{ + users: users, + jwtSecret: []byte(cfg.JWTSecret), + tokenDuration: 24 * time.Hour, + } +} + +func (p *LocalProvider) SetTokenDuration(d time.Duration) { + p.tokenDuration = d +} + +func (p *LocalProvider) Authenticate(username, password string) (string, error) { + hash, exists := p.users[username] + if !exists { + return "", ErrInvalidCredentials + } + + if hash != "" && !CheckPassword(password, hash) { + return "", ErrInvalidCredentials + } + + claims := &Claims{ + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(p.tokenDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(p.jwtSecret) +} + +func (p *LocalProvider) ValidateToken(tokenStr string) (*Claims, error) { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { + return p.jwtSecret, nil + }) + if err != nil { + return nil, ErrTokenInvalid + } + if !token.Valid { + return nil, ErrTokenExpired + } + return claims, nil +} +``` + +- [ ] **Step 5: Run tests** + +```bash +go test ./internal/auth/ -v +``` + +Expected: PASS + +- [ ] **Step 6: Add login endpoint and auth middleware to server** + +Modify `internal/server/server.go` — add login route and middleware: +```go +package server + +import ( + "encoding/json" + "io/fs" + "net/http" + "strings" + + "github.com/valorisa/ShellFromBrowser/internal/auth" + "github.com/valorisa/ShellFromBrowser/internal/config" + "github.com/valorisa/ShellFromBrowser/web" +) + +type Server struct { + addr string + mux *http.ServeMux + cfg *config.Config + authProv auth.Provider +} + +func New(addr string, cfg *config.Config) *Server { + s := &Server{addr: addr, mux: http.NewServeMux(), cfg: cfg} + + if cfg.Auth.Enabled { + s.authProv = auth.NewLocalProvider(&cfg.Auth) + } + + // API routes + s.mux.HandleFunc("/api/login", s.handleLogin) + s.mux.HandleFunc("/ws", s.authMiddleware(s.handleWebSocket)) + + // Static files + staticFS, _ := fs.Sub(web.StaticFiles, "static") + s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + data, _ := web.StaticFiles.ReadFile("static/index.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) + }) + + return s +} + +func (s *Server) Handler() http.Handler { + return s.mux +} + +func (s *Server) ListenAndServe() error { + if s.cfg.Server.TLS.Enabled { + return http.ListenAndServeTLS(s.addr, s.cfg.Server.TLS.Cert, s.cfg.Server.TLS.Key, s.mux) + } + return http.ListenAndServe(s.addr, s.mux) +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type loginResponse struct { + Token string `json:"token"` + Error string `json:"error,omitempty"` +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, loginResponse{Error: "invalid request"}) + return + } + + if s.authProv == nil { + writeJSON(w, http.StatusOK, loginResponse{Token: "no-auth"}) + return + } + + token, err := s.authProv.Authenticate(req.Username, req.Password) + if err != nil { + writeJSON(w, http.StatusUnauthorized, loginResponse{Error: "invalid credentials"}) + return + } + + writeJSON(w, http.StatusOK, loginResponse{Token: token}) +} + +func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.authProv == nil { + next(w, r) + return + } + + token := r.URL.Query().Get("token") + if token == "" { + authHeader := r.Header.Get("Authorization") + token = strings.TrimPrefix(authHeader, "Bearer ") + } + + if token == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + _, err := s.authProv.ValidateToken(token) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + next(w, r) + } +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} +``` + +- [ ] **Step 7: Update main.go to pass config** + +```go +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/valorisa/ShellFromBrowser/internal/config" + "github.com/valorisa/ShellFromBrowser/internal/server" +) + +var ( + version = "dev" + commit = "none" +) + +func main() { + addr := flag.String("addr", "", "listen address (overrides config)") + configPath := flag.String("config", "", "path to config file") + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + + if *showVersion { + fmt.Printf("ShellFromBrowser %s (%s)\n", version, commit) + os.Exit(0) + } + + var cfg *config.Config + var err error + if *configPath != "" { + cfg, err = config.Load(*configPath) + if err != nil { + log.Fatalf("config: %v", err) + } + } else { + cfg = config.Default() + } + + if *addr != "" { + cfg.Server.Addr = *addr + } + + srv := server.New(cfg.Server.Addr, cfg) + log.Printf("ShellFromBrowser %s starting on %s", version, cfg.Server.Addr) + + go func() { + if err := srv.ListenAndServe(); err != nil { + log.Fatalf("server: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("shutting down") +} +``` + +- [ ] **Step 8: Commit** + +```bash +git add internal/auth/ internal/server/ cmd/shellfb/main.go go.mod go.sum +git commit -m "feat: add JWT authentication with login endpoint and auth middleware" +``` + +--- + +## Phase 3: Multi-Sessions + Reconnection + +### Task 7: Session manager + +**Files:** +- Create: `internal/terminal/manager.go` +- Modify: `internal/terminal/session_test.go` +- Modify: `internal/server/websocket.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/terminal/session_test.go`: +```go +func TestSessionManager(t *testing.T) { + mgr := terminal.NewManager(5) + + // Create sessions + s1, err := mgr.Create("user1", 80, 24) + if err != nil { + t.Fatalf("Create: %v", err) + } + + s2, err := mgr.Create("user1", 80, 24) + if err != nil { + t.Fatalf("Create: %v", err) + } + + // List sessions + sessions := mgr.ListByUser("user1") + if len(sessions) != 2 { + t.Errorf("ListByUser = %d, want 2", len(sessions)) + } + + // Get by ID + got, err := mgr.Get(s1.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.ID() != s1.ID() { + t.Error("Get returned wrong session") + } + + // Destroy + mgr.Destroy(s2.ID()) + sessions = mgr.ListByUser("user1") + if len(sessions) != 1 { + t.Errorf("after destroy: %d, want 1", len(sessions)) + } +} + +func TestSessionManagerMaxSessions(t *testing.T) { + mgr := terminal.NewManager(2) + + mgr.Create("user1", 80, 24) + mgr.Create("user1", 80, 24) + _, err := mgr.Create("user1", 80, 24) + if err == nil { + t.Fatal("expected max sessions error") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/terminal/ -v -run TestSessionManager +``` + +Expected: FAIL + +- [ ] **Step 3: Implement manager.go** + +Create `internal/terminal/manager.go`: +```go +package terminal + +import ( + "errors" + "sync" +) + +var ErrMaxSessions = errors.New("maximum sessions reached") + +type SessionInfo struct { + ID string + User string + Cols uint16 + Rows uint16 +} + +type Manager struct { + sessions map[string]*Session + userIndex map[string][]string // username -> []session_id + maxPerUser int + mu sync.RWMutex +} + +func NewManager(maxPerUser int) *Manager { + return &Manager{ + sessions: make(map[string]*Session), + userIndex: make(map[string][]string), + maxPerUser: maxPerUser, + } +} + +func (m *Manager) Create(username string, cols, rows uint16) (*Session, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if len(m.userIndex[username]) >= m.maxPerUser { + return nil, ErrMaxSessions + } + + sess, err := NewSession(cols, rows) + if err != nil { + return nil, err + } + + m.sessions[sess.ID()] = sess + m.userIndex[username] = append(m.userIndex[username], sess.ID()) + return sess, nil +} + +func (m *Manager) Get(id string) (*Session, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + sess, ok := m.sessions[id] + if !ok { + return nil, errors.New("session not found") + } + return sess, nil +} + +func (m *Manager) ListByUser(username string) []SessionInfo { + m.mu.RLock() + defer m.mu.RUnlock() + + var infos []SessionInfo + for _, id := range m.userIndex[username] { + if sess, ok := m.sessions[id]; ok { + infos = append(infos, SessionInfo{ + ID: sess.ID(), + User: username, + Cols: sess.cols, + Rows: sess.rows, + }) + } + } + return infos +} + +func (m *Manager) Destroy(id string) { + m.mu.Lock() + defer m.mu.Unlock() + + sess, ok := m.sessions[id] + if !ok { + return + } + sess.Close() + delete(m.sessions, id) + + for user, ids := range m.userIndex { + for i, sid := range ids { + if sid == id { + m.userIndex[user] = append(ids[:i], ids[i+1:]...) + break + } + } + } +} + +func (m *Manager) DestroyAll() { + m.mu.Lock() + defer m.mu.Unlock() + + for _, sess := range m.sessions { + sess.Close() + } + m.sessions = make(map[string]*Session) + m.userIndex = make(map[string][]string) +} +``` + +- [ ] **Step 4: Run tests** + +```bash +go test ./internal/terminal/ -v -run TestSessionManager +``` + +Expected: PASS + +- [ ] **Step 5: Update WebSocket handler for multi-session** + +Modify `internal/server/websocket.go` to support session create/attach/list via query params: +```go +package server + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/gorilla/websocket" + "github.com/valorisa/ShellFromBrowser/internal/terminal" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +type wsMessage struct { + Type string `json:"type"` + Cols uint16 `json:"cols,omitempty"` + Rows uint16 `json:"rows,omitempty"` +} + +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("websocket upgrade: %v", err) + return + } + defer conn.Close() + + username := "anonymous" + if s.authProv != nil { + token := r.URL.Query().Get("token") + claims, _ := s.authProv.ValidateToken(token) + if claims != nil { + username = claims.Username + } + } + + sessionID := r.URL.Query().Get("session") + var sess *terminal.Session + + if sessionID != "" { + sess, err = s.sessions.Get(sessionID) + if err != nil { + conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"error","message":"session not found"}`)) + return + } + } else { + sess, err = s.sessions.Create(username, 80, 24) + if err != nil { + conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"error","message":"`+err.Error()+`"}`)) + return + } + } + + // Send session info + info, _ := json.Marshal(map[string]string{"type": "session", "id": sess.ID()}) + conn.WriteMessage(websocket.TextMessage, info) + + // PTY -> WebSocket + done := make(chan struct{}) + go func() { + defer close(done) + buf := make([]byte, 4096) + for { + n, err := sess.Read(buf) + if err != nil { + return + } + if err := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { + return + } + } + }() + + // WebSocket -> PTY + for { + msgType, msg, err := conn.ReadMessage() + if err != nil { + break + } + + if msgType == websocket.TextMessage { + var wsMsg wsMessage + if json.Unmarshal(msg, &wsMsg) == nil { + switch wsMsg.Type { + case "resize": + sess.Resize(wsMsg.Cols, wsMsg.Rows) + continue + } + } + } + + sess.Write(msg) + } + + <-done +} + +func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { + username := "anonymous" + if s.authProv != nil { + token := r.URL.Query().Get("token") + if token == "" { + token = r.Header.Get("Authorization") + if len(token) > 7 { + token = token[7:] + } + } + claims, err := s.authProv.ValidateToken(token) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + username = claims.Username + } + + switch r.Method { + case http.MethodGet: + sessions := s.sessions.ListByUser(username) + writeJSON(w, http.StatusOK, sessions) + case http.MethodDelete: + id := r.URL.Query().Get("id") + if id != "" { + s.sessions.Destroy(id) + } + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} +``` + +- [ ] **Step 6: Add sessions field and route to Server** + +Update `internal/server/server.go` — add `sessions *terminal.Manager` field and `/api/sessions` route: +```go +// In New(): +s.sessions = terminal.NewManager(cfg.Sessions.MaxPerUser) +s.mux.HandleFunc("/api/sessions", s.authMiddleware(s.handleSessions)) +``` + +Add field to struct: +```go +type Server struct { + addr string + mux *http.ServeMux + cfg *config.Config + authProv auth.Provider + sessions *terminal.Manager +} +``` + +Add import: +```go +"github.com/valorisa/ShellFromBrowser/internal/terminal" +``` + +- [ ] **Step 7: Commit** + +```bash +git add internal/terminal/manager.go internal/terminal/session_test.go internal/server/ +git commit -m "feat: add session manager with multi-session support and session API" +``` + +--- + +### Task 8: Frontend multi-tab UI + +**Files:** +- Modify: `web/static/index.html` +- Create: `web/static/js/sessions.js` +- Modify: `web/static/js/terminal.js` +- Modify: `web/static/css/app.css` + +- [ ] **Step 1: Update HTML for tabs** + +Replace `web/static/index.html`: +```html + + + + + + ShellFromBrowser + + + + + + + + + + + + + + + +``` + +- [ ] **Step 2: Create sessions.js** + +Create `web/static/js/sessions.js`: +```javascript +var SessionManager = (function () { + "use strict"; + + var token = localStorage.getItem("sfb_token") || ""; + var sessions = []; + var activeSessionId = null; + + function setToken(t) { + token = t; + localStorage.setItem("sfb_token", t); + } + + function getToken() { + return token; + } + + function clearToken() { + token = ""; + localStorage.removeItem("sfb_token"); + } + + async function login(username, password) { + var resp = await fetch("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username, password: password }), + }); + var data = await resp.json(); + if (data.error) throw new Error(data.error); + setToken(data.token); + return data.token; + } + + async function listSessions() { + var resp = await fetch("/api/sessions?token=" + encodeURIComponent(token)); + if (resp.status === 401) { + clearToken(); + return []; + } + sessions = await resp.json(); + return sessions; + } + + function setActive(id) { + activeSessionId = id; + } + + function getActive() { + return activeSessionId; + } + + return { + login: login, + listSessions: listSessions, + getToken: getToken, + clearToken: clearToken, + setActive: setActive, + getActive: getActive, + }; +})(); +``` + +- [ ] **Step 3: Update terminal.js for session management** + +Replace `web/static/js/terminal.js`: +```javascript +(function () { + "use strict"; + + var terminals = {}; + var activeTermId = null; + var tabIndex = 0; + + function createTerminal(sessionId) { + var id = "term-" + (tabIndex++); + var term = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: "'JetBrains Mono', 'Fira Code', monospace", + theme: { + background: "#1e1e2e", + foreground: "#cdd6f4", + cursor: "#f5e0dc", + }, + }); + + var fitAddon = new FitAddon.FitAddon(); + var webLinksAddon = new WebLinksAddon.WebLinksAddon(); + term.loadAddon(fitAddon); + term.loadAddon(webLinksAddon); + + var protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + var wsUrl = protocol + "//" + window.location.host + "/ws?token=" + + encodeURIComponent(SessionManager.getToken()); + if (sessionId) wsUrl += "&session=" + encodeURIComponent(sessionId); + + var ws = new WebSocket(wsUrl); + ws.binaryType = "arraybuffer"; + + ws.onopen = function () { + var dims = JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }); + ws.send(dims); + setStatus("Connected"); + }; + + ws.onmessage = function (event) { + if (event.data instanceof ArrayBuffer) { + term.write(new Uint8Array(event.data)); + } else { + try { + var msg = JSON.parse(event.data); + if (msg.type === "session") { + terminals[id].sessionId = msg.id; + SessionManager.setActive(msg.id); + updateSessionInfo(); + } + } catch (e) { + term.write(event.data); + } + } + }; + + ws.onclose = function () { setStatus("Disconnected"); }; + + term.onData(function (data) { + if (ws.readyState === WebSocket.OPEN) ws.send(data); + }); + + term.onResize(function (size) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols: size.cols, rows: size.rows })); + } + }); + + terminals[id] = { term: term, fitAddon: fitAddon, ws: ws, sessionId: sessionId || null }; + addTab(id); + switchTab(id); + + return id; + } + + function addTab(id) { + var tabs = document.getElementById("tabs"); + var tab = document.createElement("div"); + tab.className = "tab"; + tab.dataset.id = id; + tab.textContent = "Session " + (Object.keys(terminals).length); + + var closeBtn = document.createElement("span"); + closeBtn.className = "tab-close"; + closeBtn.textContent = "×"; + closeBtn.onclick = function (e) { + e.stopPropagation(); + closeTab(id); + }; + + tab.appendChild(closeBtn); + tab.onclick = function () { switchTab(id); }; + tabs.appendChild(tab); + } + + function switchTab(id) { + if (activeTermId && terminals[activeTermId]) { + terminals[activeTermId].term.element.style.display = "none"; + } + activeTermId = id; + var t = terminals[id]; + var container = document.getElementById("terminal"); + + if (!t.term.element) { + t.term.open(container); + } else { + t.term.element.style.display = ""; + container.appendChild(t.term.element); + } + t.fitAddon.fit(); + t.term.focus(); + + document.querySelectorAll(".tab").forEach(function (el) { + el.classList.toggle("active", el.dataset.id === id); + }); + + if (t.sessionId) SessionManager.setActive(t.sessionId); + updateSessionInfo(); + } + + function closeTab(id) { + var t = terminals[id]; + if (t) { + t.ws.close(); + t.term.dispose(); + delete terminals[id]; + } + var tabEl = document.querySelector('.tab[data-id="' + id + '"]'); + if (tabEl) tabEl.remove(); + + var remaining = Object.keys(terminals); + if (remaining.length > 0) { + switchTab(remaining[remaining.length - 1]); + } + } + + function setStatus(text) { + document.getElementById("connection-status").textContent = text; + } + + function updateSessionInfo() { + var info = document.getElementById("session-info"); + var count = Object.keys(terminals).length; + info.textContent = count + " session" + (count !== 1 ? "s" : ""); + } + + // Initialization + function init() { + var token = SessionManager.getToken(); + if (!token) { + document.getElementById("login-screen").style.display = "flex"; + } else { + startApp(); + } + + document.getElementById("login-form").onsubmit = async function (e) { + e.preventDefault(); + var user = document.getElementById("username").value; + var pass = document.getElementById("password").value; + try { + await SessionManager.login(user, pass); + document.getElementById("login-screen").style.display = "none"; + startApp(); + } catch (err) { + document.getElementById("login-error").textContent = err.message; + } + }; + } + + function startApp() { + document.getElementById("app").style.display = "flex"; + createTerminal(); + + document.getElementById("new-tab").onclick = function () { + createTerminal(); + }; + + window.addEventListener("resize", function () { + if (activeTermId && terminals[activeTermId]) { + terminals[activeTermId].fitAddon.fit(); + } + }); + } + + // If auth is disabled, skip login + fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.token === "no-auth") { + SessionManager.login("", "").then(function () { init(); }); + } else { + init(); + } + }) + .catch(function () { init(); }); +})(); +``` + +- [ ] **Step 4: Update CSS for tabs and login** + +Replace `web/static/css/app.css`: +```css +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + background: #1e1e2e; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #cdd6f4; +} + +/* Login */ +#login-screen { + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.login-box { + background: #313244; + padding: 2rem; + border-radius: 8px; + width: 320px; +} + +.login-box h1 { + font-size: 1.4rem; + margin-bottom: 1.5rem; + text-align: center; +} + +.login-box input { + display: block; + width: 100%; + padding: 0.6rem; + margin-bottom: 0.8rem; + border: 1px solid #45475a; + border-radius: 4px; + background: #1e1e2e; + color: #cdd6f4; + font-size: 0.9rem; +} + +.login-box button { + width: 100%; + padding: 0.6rem; + background: #89b4fa; + color: #1e1e2e; + border: none; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; +} + +.login-box button:hover { + background: #74c7ec; +} + +.error { + color: #f38ba8; + font-size: 0.85rem; + margin-top: 0.5rem; +} + +/* App layout */ +#app { + height: 100%; + flex-direction: column; +} + +#tab-bar { + display: flex; + align-items: center; + background: #181825; + padding: 4px 8px; + gap: 4px; +} + +#tabs { + display: flex; + gap: 2px; + flex: 1; + overflow-x: auto; +} + +.tab { + padding: 4px 12px; + background: #313244; + border-radius: 4px 4px 0 0; + cursor: pointer; + font-size: 0.8rem; + white-space: nowrap; + display: flex; + align-items: center; + gap: 6px; +} + +.tab.active { + background: #1e1e2e; +} + +.tab-close { + font-size: 1rem; + opacity: 0.5; + cursor: pointer; +} + +.tab-close:hover { + opacity: 1; + color: #f38ba8; +} + +#new-tab { + background: none; + border: 1px solid #45475a; + color: #cdd6f4; + border-radius: 4px; + width: 24px; + height: 24px; + cursor: pointer; + font-size: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +#new-tab:hover { + background: #313244; +} + +#terminal-container { + flex: 1; + padding: 4px; + overflow: hidden; +} + +#terminal { + height: 100%; +} + +#status-bar { + display: flex; + justify-content: space-between; + padding: 2px 8px; + background: #181825; + font-size: 0.75rem; + color: #6c7086; +} +``` + +- [ ] **Step 5: Test in browser** + +```bash +cd ShellFromBrowser +go build -o bin/shellfb.exe ./cmd/shellfb && ./bin/shellfb.exe +``` + +Open `http://localhost:8080` — verify tabs work, new sessions open, close works. + +- [ ] **Step 6: Commit** + +```bash +git add web/ +git commit -m "feat: add multi-tab session UI with login screen" +``` + +--- + +## Phase 4: SSH Client Integration + +### Task 9: SSH client library + +**Files:** +- Create: `internal/ssh/client.go` +- Create: `internal/ssh/known_hosts.go` +- Create: `internal/ssh/client_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/ssh/client_test.go`: +```go +package ssh_test + +import ( + "testing" + + sshclient "github.com/valorisa/ShellFromBrowser/internal/ssh" +) + +func TestParseTarget(t *testing.T) { + tests := []struct { + input string + wantUser string + wantHost string + wantPort string + }{ + {"user@host.com", "user", "host.com", "22"}, + {"user@host.com:2222", "user", "host.com", "2222"}, + {"host.com", "", "host.com", "22"}, + {"root@192.168.1.1:22", "root", "192.168.1.1", "22"}, + } + + for _, tt := range tests { + user, host, port := sshclient.ParseTarget(tt.input) + if user != tt.wantUser || host != tt.wantHost || port != tt.wantPort { + t.Errorf("ParseTarget(%q) = (%q,%q,%q), want (%q,%q,%q)", + tt.input, user, host, port, tt.wantUser, tt.wantHost, tt.wantPort) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go get golang.org/x/crypto/ssh +go test ./internal/ssh/ -v -run TestParseTarget +``` + +Expected: FAIL + +- [ ] **Step 3: Implement client.go** + +Create `internal/ssh/client.go`: +```go +package ssh + +import ( + "fmt" + "io" + "net" + "strings" + + gossh "golang.org/x/crypto/ssh" +) + +type ConnectOpts struct { + Target string // user@host:port + Password string + KeyFile string + Cols uint32 + Rows uint32 +} + +type SSHSession struct { + client *gossh.Client + session *gossh.Session + stdin io.WriteCloser + stdout io.Reader +} + +func ParseTarget(target string) (user, host, port string) { + port = "22" + + if at := strings.LastIndex(target, "@"); at >= 0 { + user = target[:at] + target = target[at+1:] + } + + if colon := strings.LastIndex(target, ":"); colon >= 0 { + host = target[:colon] + port = target[colon+1:] + } else { + host = target + } + return +} + +func Connect(opts ConnectOpts) (*SSHSession, error) { + user, host, port := ParseTarget(opts.Target) + if user == "" { + user = "root" + } + + var authMethods []gossh.AuthMethod + if opts.Password != "" { + authMethods = append(authMethods, gossh.Password(opts.Password)) + } + if opts.KeyFile != "" { + if signer, err := loadKey(opts.KeyFile); err == nil { + authMethods = append(authMethods, gossh.PublicKeys(signer)) + } + } + + config := &gossh.ClientConfig{ + User: user, + Auth: authMethods, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), // TODO: replace with known_hosts + } + + addr := net.JoinHostPort(host, port) + client, err := gossh.Dial("tcp", addr, config) + if err != nil { + return nil, fmt.Errorf("ssh dial: %w", err) + } + + session, err := client.NewSession() + if err != nil { + client.Close() + return nil, fmt.Errorf("ssh session: %w", err) + } + + modes := gossh.TerminalModes{ + gossh.ECHO: 1, + gossh.TTY_OP_ISPEED: 14400, + gossh.TTY_OP_OSPEED: 14400, + } + + if err := session.RequestPty("xterm-256color", int(opts.Rows), int(opts.Cols), modes); err != nil { + session.Close() + client.Close() + return nil, fmt.Errorf("request pty: %w", err) + } + + stdin, err := session.StdinPipe() + if err != nil { + session.Close() + client.Close() + return nil, err + } + + stdout, err := session.StdoutPipe() + if err != nil { + session.Close() + client.Close() + return nil, err + } + + if err := session.Shell(); err != nil { + session.Close() + client.Close() + return nil, fmt.Errorf("start shell: %w", err) + } + + return &SSHSession{ + client: client, + session: session, + stdin: stdin, + stdout: stdout, + }, nil +} + +func (s *SSHSession) Read(p []byte) (int, error) { + return s.stdout.Read(p) +} + +func (s *SSHSession) Write(p []byte) (int, error) { + return s.stdin.Write(p) +} + +func (s *SSHSession) Resize(cols, rows uint32) error { + return s.session.WindowChange(int(rows), int(cols)) +} + +func (s *SSHSession) Close() error { + s.stdin.Close() + s.session.Close() + return s.client.Close() +} + +func loadKey(path string) (gossh.Signer, error) { + // Read key file and parse + import_os_readfile := func(p string) ([]byte, error) { + return nil, fmt.Errorf("not implemented") + } + key, err := import_os_readfile(path) + if err != nil { + return nil, err + } + return gossh.ParsePrivateKey(key) +} +``` + +**Fix loadKey properly:** +```go +func loadKey(path string) (gossh.Signer, error) { + key, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return gossh.ParsePrivateKey(key) +} +``` + +(Add `"os"` to imports) + +- [ ] **Step 4: Run tests** + +```bash +go test ./internal/ssh/ -v -run TestParseTarget +``` + +Expected: PASS + +- [ ] **Step 5: Add SSH WebSocket endpoint** + +Add to `internal/server/server.go` a new route: +```go +s.mux.HandleFunc("/ws/ssh", s.authMiddleware(s.handleSSHWebSocket)) +``` + +Create handler in `internal/server/websocket.go` (add to file): +```go +func (s *Server) handleSSHWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + target := r.URL.Query().Get("target") + password := r.URL.Query().Get("password") + keyFile := r.URL.Query().Get("key") + + if target == "" { + conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"error","message":"target required"}`)) + return + } + + sshSess, err := ssh.Connect(ssh.ConnectOpts{ + Target: target, + Password: password, + KeyFile: keyFile, + Cols: 80, + Rows: 24, + }) + if err != nil { + conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"error","message":"`+err.Error()+`"}`)) + return + } + defer sshSess.Close() + + // SSH stdout -> WebSocket + go func() { + buf := make([]byte, 4096) + for { + n, err := sshSess.Read(buf) + if err != nil { + return + } + conn.WriteMessage(websocket.BinaryMessage, buf[:n]) + } + }() + + // WebSocket -> SSH stdin + for { + msgType, msg, err := conn.ReadMessage() + if err != nil { + break + } + if msgType == websocket.TextMessage { + var wsMsg wsMessage + if json.Unmarshal(msg, &wsMsg) == nil && wsMsg.Type == "resize" { + sshSess.Resize(uint32(wsMsg.Cols), uint32(wsMsg.Rows)) + continue + } + } + sshSess.Write(msg) + } +} +``` + +Add import: `sshpkg "github.com/valorisa/ShellFromBrowser/internal/ssh"` + +- [ ] **Step 6: Commit** + +```bash +git add internal/ssh/ internal/server/ +git commit -m "feat: add SSH client integration with WebSocket bridge" +``` + +--- + +## Phase 5: File Transfer + +### Task 10: Upload and download handlers + +**Files:** +- Create: `internal/transfer/upload.go` +- Create: `internal/transfer/download.go` +- Create: `internal/transfer/transfer_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/transfer/transfer_test.go`: +```go +package transfer_test + +import ( + "bytes" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/valorisa/ShellFromBrowser/internal/transfer" +) + +func TestUpload(t *testing.T) { + uploadDir := t.TempDir() + handler := transfer.NewHandler(uploadDir, 10*1024*1024) // 10MB max + + // Create multipart request + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, _ := writer.CreateFormFile("file", "test.txt") + part.Write([]byte("hello world")) + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/upload", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + + handler.Upload(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200. Body: %s", w.Code, w.Body.String()) + } + + // Verify file exists + content, err := os.ReadFile(filepath.Join(uploadDir, "test.txt")) + if err != nil { + t.Fatalf("file not found: %v", err) + } + if string(content) != "hello world" { + t.Errorf("content = %q, want 'hello world'", content) + } +} + +func TestDownload(t *testing.T) { + downloadDir := t.TempDir() + os.WriteFile(filepath.Join(downloadDir, "data.bin"), []byte("binary content"), 0644) + + handler := transfer.NewHandler(downloadDir, 10*1024*1024) + + req := httptest.NewRequest(http.MethodGet, "/api/download?file=data.bin", nil) + w := httptest.NewRecorder() + + handler.Download(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + + body, _ := io.ReadAll(w.Body) + if string(body) != "binary content" { + t.Errorf("body = %q", body) + } +} + +func TestDownloadPathTraversal(t *testing.T) { + handler := transfer.NewHandler(t.TempDir(), 10*1024*1024) + + req := httptest.NewRequest(http.MethodGet, "/api/download?file=../../../etc/passwd", nil) + w := httptest.NewRecorder() + + handler.Download(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("path traversal not blocked: status = %d", w.Code) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/transfer/ -v +``` + +Expected: FAIL + +- [ ] **Step 3: Implement upload.go** + +Create `internal/transfer/upload.go`: +```go +package transfer + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" +) + +type Handler struct { + baseDir string + maxSize int64 +} + +func NewHandler(baseDir string, maxSize int64) *Handler { + os.MkdirAll(baseDir, 0755) + return &Handler{baseDir: baseDir, maxSize: maxSize} +} + +func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, h.maxSize) + + if err := r.ParseMultipartForm(h.maxSize); err != nil { + http.Error(w, "file too large", http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "no file provided", http.StatusBadRequest) + return + } + defer file.Close() + + filename := filepath.Base(header.Filename) + if filename == "." || filename == ".." { + http.Error(w, "invalid filename", http.StatusBadRequest) + return + } + + destPath := filepath.Join(h.baseDir, filename) + dest, err := os.Create(destPath) + if err != nil { + http.Error(w, "failed to create file", http.StatusInternalServerError) + return + } + defer dest.Close() + + written, err := io.Copy(dest, file) + if err != nil { + http.Error(w, "failed to write file", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "filename": filename, + "size": written, + }) +} +``` + +- [ ] **Step 4: Implement download.go** + +Create `internal/transfer/download.go`: +```go +package transfer + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { + filename := r.URL.Query().Get("file") + if filename == "" { + http.Error(w, "file parameter required", http.StatusBadRequest) + return + } + + // Prevent path traversal + cleaned := filepath.Clean(filename) + if strings.Contains(cleaned, "..") || filepath.IsAbs(cleaned) { + http.Error(w, "invalid file path", http.StatusBadRequest) + return + } + + fullPath := filepath.Join(h.baseDir, cleaned) + + // Verify the resolved path is within baseDir + absBase, _ := filepath.Abs(h.baseDir) + absPath, _ := filepath.Abs(fullPath) + if !strings.HasPrefix(absPath, absBase) { + http.Error(w, "invalid file path", http.StatusBadRequest) + return + } + + info, err := os.Stat(fullPath) + if err != nil { + http.Error(w, "file not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(cleaned)+"\"") + w.Header().Set("Content-Type", "application/octet-stream") + + http.ServeFile(w, r, fullPath) + _ = info +} +``` + +- [ ] **Step 5: Run tests** + +```bash +go test ./internal/transfer/ -v +``` + +Expected: PASS + +- [ ] **Step 6: Wire into server** + +Add to `internal/server/server.go`: +```go +// In New(): +if cfg.Recording.Dir != "" { + transferHandler := transfer.NewHandler(cfg.Recording.Dir, 50*1024*1024) + s.mux.HandleFunc("/api/upload", s.authMiddleware(transferHandler.Upload)) + s.mux.HandleFunc("/api/download", s.authMiddleware(transferHandler.Download)) +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add internal/transfer/ internal/server/ +git commit -m "feat: add file upload/download with path traversal protection" +``` + +--- + +## Phase 6: Session Recording + +### Task 11: Asciicast v2 recorder + +**Files:** +- Create: `internal/recording/recorder.go` +- Create: `internal/recording/player.go` +- Create: `internal/recording/recorder_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/recording/recorder_test.go`: +```go +package recording_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/valorisa/ShellFromBrowser/internal/recording" +) + +func TestRecorder(t *testing.T) { + dir := t.TempDir() + rec, err := recording.New(dir, "test-session", 80, 24) + if err != nil { + t.Fatalf("New: %v", err) + } + + rec.Write([]byte("hello ")) + time.Sleep(10 * time.Millisecond) + rec.Write([]byte("world\r\n")) + rec.Close() + + // Read the cast file + path := filepath.Join(dir, "test-session.cast") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 3 { + t.Fatalf("expected at least 3 lines (header + 2 events), got %d", len(lines)) + } + + // Check header + var header map[string]interface{} + json.Unmarshal([]byte(lines[0]), &header) + if header["version"] != float64(2) { + t.Errorf("version = %v, want 2", header["version"]) + } + if header["width"] != float64(80) { + t.Errorf("width = %v, want 80", header["width"]) + } +} + +func TestRecorderListCasts(t *testing.T) { + dir := t.TempDir() + + rec1, _ := recording.New(dir, "sess1", 80, 24) + rec1.Write([]byte("data")) + rec1.Close() + + rec2, _ := recording.New(dir, "sess2", 120, 40) + rec2.Write([]byte("data")) + rec2.Close() + + casts, err := recording.List(dir) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(casts) != 2 { + t.Errorf("len = %d, want 2", len(casts)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/recording/ -v +``` + +Expected: FAIL + +- [ ] **Step 3: Implement recorder.go** + +Create `internal/recording/recorder.go`: +```go +package recording + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +type header struct { + Version int `json:"version"` + Width int `json:"width"` + Height int `json:"height"` + Timestamp int64 `json:"timestamp"` + Title string `json:"title,omitempty"` +} + +type Recorder struct { + file *os.File + startTime time.Time + mu sync.Mutex +} + +func New(dir, sessionID string, cols, rows int) (*Recorder, error) { + os.MkdirAll(dir, 0755) + path := filepath.Join(dir, sessionID+".cast") + file, err := os.Create(path) + if err != nil { + return nil, err + } + + h := header{ + Version: 2, + Width: cols, + Height: rows, + Timestamp: time.Now().Unix(), + } + headerJSON, _ := json.Marshal(h) + file.Write(headerJSON) + file.Write([]byte("\n")) + + return &Recorder{ + file: file, + startTime: time.Now(), + }, nil +} + +func (r *Recorder) Write(data []byte) { + r.mu.Lock() + defer r.mu.Unlock() + + elapsed := time.Since(r.startTime).Seconds() + escaped, _ := json.Marshal(string(data)) + line := fmt.Sprintf("[%.6f, \"o\", %s]\n", elapsed, escaped) + r.file.WriteString(line) +} + +func (r *Recorder) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.file.Close() +} + +type CastInfo struct { + ID string `json:"id"` + Filename string `json:"filename"` + Width int `json:"width"` + Height int `json:"height"` + Timestamp time.Time `json:"timestamp"` + Size int64 `json:"size"` +} + +func List(dir string) ([]CastInfo, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var casts []CastInfo + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".cast") { + continue + } + + info, _ := entry.Info() + path := filepath.Join(dir, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + + lines := strings.SplitN(string(data), "\n", 2) + if len(lines) == 0 { + continue + } + + var h header + json.Unmarshal([]byte(lines[0]), &h) + + id := strings.TrimSuffix(entry.Name(), ".cast") + casts = append(casts, CastInfo{ + ID: id, + Filename: entry.Name(), + Width: h.Width, + Height: h.Height, + Timestamp: time.Unix(h.Timestamp, 0), + Size: info.Size(), + }) + } + return casts, nil +} +``` + +- [ ] **Step 4: Implement player.go** + +Create `internal/recording/player.go`: +```go +package recording + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" +) + +type Player struct { + dir string +} + +func NewPlayer(dir string) *Player { + return &Player{dir: dir} +} + +func (p *Player) HandleList(w http.ResponseWriter, r *http.Request) { + casts, err := List(p.dir) + if err != nil { + http.Error(w, "failed to list recordings", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(casts) +} + +func (p *Player) HandleGet(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" || strings.Contains(id, "..") || strings.Contains(id, "/") { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + path := filepath.Join(p.dir, id+".cast") + data, err := os.ReadFile(path) + if err != nil { + http.Error(w, "recording not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} +``` + +- [ ] **Step 5: Run tests** + +```bash +go test ./internal/recording/ -v +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/recording/ +git commit -m "feat: add session recording in asciicast v2 format with playback" +``` + +--- + +## Phase 7: Docker + Deployment + +### Task 12: Dockerfile and docker-compose + +**Files:** +- Create: `Dockerfile` +- Create: `docker-compose.yml` +- Create: `.dockerignore` + +- [ ] **Step 1: Create Dockerfile** + +Create `Dockerfile`: +```dockerfile +# Build stage +FROM golang:1.22-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$(git describe --tags --always) -X main.commit=$(git rev-parse --short HEAD)" -o /bin/shellfb ./cmd/shellfb + +# Runtime stage +FROM alpine:3.19 + +RUN apk add --no-cache \ + bash \ + openssh-client \ + ca-certificates \ + && adduser -D -h /home/shellfb shellfb + +COPY --from=builder /bin/shellfb /usr/local/bin/shellfb +COPY config.example.yaml /etc/shellfb/config.yaml + +USER shellfb +WORKDIR /home/shellfb + +EXPOSE 8080 + +ENTRYPOINT ["shellfb"] +CMD ["--config", "/etc/shellfb/config.yaml"] +``` + +- [ ] **Step 2: Create .dockerignore** + +Create `.dockerignore`: +``` +bin/ +*.exe +.git/ +.gitignore +*.md +recordings/ +``` + +- [ ] **Step 3: Create docker-compose.yml** + +Create `docker-compose.yml`: +```yaml +services: + shellfb: + build: . + image: shellfb:latest + container_name: shellfb + ports: + - "8080:8080" + volumes: + - ./config.yaml:/etc/shellfb/config.yaml:ro + - shellfb-recordings:/home/shellfb/recordings + environment: + - SHELLFB_ADDR=:8080 + restart: unless-stopped + +volumes: + shellfb-recordings: +``` + +- [ ] **Step 4: Test Docker build** + +```bash +cd ShellFromBrowser +docker build -t shellfb:dev . +``` + +Expected: builds successfully. + +- [ ] **Step 5: Commit** + +```bash +git add Dockerfile docker-compose.yml .dockerignore +git commit -m "feat: add Dockerfile (multi-stage) and docker-compose" +``` + +--- + +## Phase 8: Polish, Security, Documentation + +### Task 13: Security hardening + +**Files:** +- Modify: `internal/server/server.go` +- Create: `internal/server/middleware.go` + +- [ ] **Step 1: Create security middleware** + +Create `internal/server/middleware.go`: +```go +package server + +import ( + "net/http" + "time" +) + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:") + next.ServeHTTP(w, r) + }) +} + +type rateLimiter struct { + requests map[string][]time.Time + max int + window time.Duration +} + +func newRateLimiter(max int, window time.Duration) *rateLimiter { + return &rateLimiter{ + requests: make(map[string][]time.Time), + max: max, + window: window, + } +} + +func (rl *rateLimiter) Allow(ip string) bool { + now := time.Now() + cutoff := now.Add(-rl.window) + + // Clean old entries + var valid []time.Time + for _, t := range rl.requests[ip] { + if t.After(cutoff) { + valid = append(valid, t) + } + } + + if len(valid) >= rl.max { + rl.requests[ip] = valid + return false + } + + rl.requests[ip] = append(valid, now) + return true +} + +func (rl *rateLimiter) Middleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ip := r.RemoteAddr + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + ip = fwd + } + if !rl.Allow(ip) { + http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) + return + } + next(w, r) + } +} +``` + +- [ ] **Step 2: Apply middleware in server.go** + +In `New()`, wrap the mux: +```go +func (s *Server) Handler() http.Handler { + return securityHeaders(s.mux) +} +``` + +Apply rate limiter to login: +```go +loginLimiter := newRateLimiter(5, time.Minute) +s.mux.HandleFunc("/api/login", loginLimiter.Middleware(s.handleLogin)) +``` + +- [ ] **Step 3: Commit** + +```bash +git add internal/server/middleware.go internal/server/server.go +git commit -m "feat: add security headers, CSP, and rate limiting on login" +``` + +--- + +### Task 14: README and LICENSE + +**Files:** +- Create: `README.md` +- Create: `LICENSE` + +- [ ] **Step 1: Create README** + +Create `README.md`: +```markdown +# ShellFromBrowser + +A modern, cross-platform web-based terminal emulator written in Go. Spiritual successor to ShellInBox with SSH client support, multi-sessions, file transfer, and session recording. + +## Features + +- **Browser-based terminal** — Full xterm.js terminal emulation (256 colors, Unicode, mouse) +- **Multi-session tabs** — Multiple terminal sessions in one browser window +- **SSH client** — Connect to remote hosts directly from the browser +- **Authentication** — JWT-based auth with configurable user backends +- **TLS/HTTPS** — Built-in TLS support +- **File transfer** — Upload/download files through the web interface +- **Session recording** — Record and replay sessions (asciicast v2 format) +- **Cross-platform** — Runs on Linux, macOS, Windows +- **Single binary** — Zero runtime dependencies, embed everything +- **Docker ready** — Multi-stage Dockerfile included + +## Quick Start + +### Binary + +```bash +# Download from releases or build from source +go install github.com/valorisa/ShellFromBrowser/cmd/shellfb@latest + +# Run with defaults (no auth, port 8080) +shellfb + +# Run with config +shellfb --config config.yaml +``` + +### Docker + +```bash +docker compose up -d +``` + +Open http://localhost:8080 + +## Configuration + +Copy `config.example.yaml` and customize: + +```yaml +server: + addr: ":8080" + tls: + enabled: true + cert: "/path/to/cert.pem" + key: "/path/to/key.pem" + +auth: + enabled: true + jwt_secret: "your-secret-here" + users: + - username: admin + password_hash: "$2a$10$..." + +ssh: + enabled: true + +recording: + enabled: true + dir: "./recordings" +``` + +### Generate password hash + +```bash +shellfb hash-password +``` + +## Build from source + +```bash +git clone https://github.com/valorisa/ShellFromBrowser.git +cd ShellFromBrowser +make build +./bin/shellfb +``` + +## Security + +- All WebSocket connections require authentication when auth is enabled +- JWT tokens with configurable expiry +- Rate limiting on login endpoint +- Security headers (CSP, X-Frame-Options, etc.) +- Path traversal protection on file operations +- No eval(), no inline scripts + +## License + +MIT +``` + +- [ ] **Step 2: Create LICENSE (MIT)** + +Create `LICENSE`: +``` +MIT License + +Copyright (c) 2026 valorisa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +- [ ] **Step 3: Create .gitignore** + +Create `.gitignore`: +``` +bin/ +*.exe +recordings/ +config.yaml +.env +``` + +- [ ] **Step 4: Commit** + +```bash +git add README.md LICENSE .gitignore +git commit -m "docs: add README, LICENSE (MIT), and .gitignore" +``` + +--- + +### Task 15: hash-password CLI subcommand + +**Files:** +- Modify: `cmd/shellfb/main.go` + +- [ ] **Step 1: Add hash-password command** + +Add to `cmd/shellfb/main.go` before the server startup logic: +```go +if len(os.Args) > 1 && os.Args[1] == "hash-password" { + fmt.Print("Password: ") + var password string + fmt.Scanln(&password) + hash, err := auth.HashPassword(password) + if err != nil { + log.Fatalf("hash: %v", err) + } + fmt.Println(hash) + os.Exit(0) +} +``` + +Add import: `"github.com/valorisa/ShellFromBrowser/internal/auth"` + +- [ ] **Step 2: Test it** + +```bash +echo "test123" | go run ./cmd/shellfb hash-password +``` + +Expected: outputs a bcrypt hash starting with `$2a$` + +- [ ] **Step 3: Commit** + +```bash +git add cmd/shellfb/main.go +git commit -m "feat: add hash-password CLI subcommand" +``` + +--- + +### Task 16: GitHub repository setup + +- [ ] **Step 1: Initialize git and push** + +```bash +cd ShellFromBrowser +gh repo create valorisa/ShellFromBrowser --public --description "Modern web-based terminal emulator in Go — successor to ShellInBox" --source=. --push +``` + +- [ ] **Step 2: Add GitHub Actions CI** + +Create `.github/workflows/ci.yml`: +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go: ['1.22'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + - run: go test ./... -race -v + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - run: | + GOOS=linux GOARCH=amd64 go build -o bin/shellfb-linux-amd64 ./cmd/shellfb + GOOS=darwin GOARCH=arm64 go build -o bin/shellfb-darwin-arm64 ./cmd/shellfb + GOOS=windows GOARCH=amd64 go build -o bin/shellfb-windows-amd64.exe ./cmd/shellfb + - uses: actions/upload-artifact@v4 + with: + name: binaries + path: bin/ +``` + +- [ ] **Step 3: Commit and push** + +```bash +git add .github/ +git commit -m "ci: add GitHub Actions workflow for test and cross-platform build" +git push +``` + +--- + +## Summary of Phases + +| Phase | Tasks | Delivers | +|-------|-------|----------| +| 1 — Scaffold + Basic Terminal | 1-4 | Working terminal in browser (local shell) | +| 2 — Auth + TLS | 5-6 | Login screen, JWT auth, YAML config | +| 3 — Multi-Sessions | 7-8 | Tab UI, session manager, reconnection | +| 4 — SSH Client | 9 | Connect to remote hosts via browser | +| 5 — File Transfer | 10 | Upload/download files | +| 6 — Recording | 11 | Session recording + playback | +| 7 — Docker | 12 | Dockerfile + compose | +| 8 — Polish | 13-16 | Security, docs, CI, GitHub | + +Each phase produces a working, testable binary. Phase 1 alone gives you a functional terminal in the browser. diff --git a/docs/superpowers/plans/2026-05-15-docker-local-test.md b/docs/superpowers/plans/2026-05-15-docker-local-test.md new file mode 100644 index 0000000..83cb5a6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-docker-local-test.md @@ -0,0 +1,158 @@ +# Docker Local Test — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build and run ShellFromBrowser in Docker locally, verify the terminal works at http://localhost:8080. + +**Architecture:** Single container running the Go binary on port 8080 (HTTP, no TLS, no auth). The `docker-compose.yml` is simplified for local testing, and a minimal `config.yaml` is created. + +**Tech Stack:** Docker 29.4.3, Docker Compose v5.1.3, Go 1.26 (golang:1.26-alpine), Alpine 3.19 runtime. + +--- + +## File Structure + +| File | Action | Purpose | +|------|--------|---------| +| `config.yaml` | Create | Minimal config for local test (HTTP :8080, no auth, no TLS) | +| `docker-compose.yml` | Modify | Simplify for local test (port 8080, no TLS vars, no certs volume) | +| `Dockerfile` | No change | golang:1.26-alpine confirmed available on Docker Hub | + +--- + +### Task 1: Create `config.yaml` for local testing + +**Files:** +- Create: `config.yaml` + +- [ ] **Step 1: Create the config file** + +```yaml +server: + addr: ":8080" + domain: "" + +auth: + enabled: false + +shell: + command: "" + env: + - "TERM=xterm-256color" + +sessions: + max_per_user: 10 + idle_timeout: "30m" + +recording: + enabled: false +``` + +- [ ] **Step 2: Verify the file exists** + +Run: `cat config.yaml` +Expected: The YAML content above is displayed. + +--- + +### Task 2: Modify `docker-compose.yml` for local HTTP + +**Files:** +- Modify: `docker-compose.yml` (entire file) + +- [ ] **Step 1: Replace docker-compose.yml content** + +```yaml +services: + shellfb: + build: . + image: valorisa/shellfb:latest + container_name: shellfb + ports: + - "8080:8080" + environment: + - SHELLFB_AUTH_ENABLED=false + volumes: + - ./config.yaml:/etc/shellfb/config.yaml:ro + restart: unless-stopped +``` + +Changes from original: +- Ports: `80:80` + `443:443` → `8080:8080` +- Environment: removed `SHELLFB_DOMAIN`, `SHELLFB_JWT_SECRET`, set `SHELLFB_AUTH_ENABLED=false` +- Volumes: removed `certs` volume and `shellfb-recordings` volume +- Removed top-level `volumes:` section + +- [ ] **Step 2: Validate compose syntax** + +Run: `docker compose config` +Expected: Parsed YAML output with no errors, showing the service on port 8080. + +--- + +### Task 3: Build the Docker image + +**Files:** None (uses existing Dockerfile) + +- [ ] **Step 1: Build the image** + +Run: `docker compose build` +Expected: Multi-stage build completes successfully: +- Stage 1 (builder): `golang:1.26-alpine` pulls, `go mod download` succeeds, `go build` produces `/bin/shellfb` +- Stage 2 (runtime): `alpine:3.19` pulls, copies binary, sets capabilities + +If it fails on `go build`, check the error — likely a missing dependency or compile error, not a Docker issue. + +--- + +### Task 4: Start the container + +- [ ] **Step 1: Start in detached mode** + +Run: `docker compose up -d` +Expected: Container `shellfb` starts successfully. + +- [ ] **Step 2: Verify container is running** + +Run: `docker compose ps` +Expected: `shellfb` container with status `Up` and port `0.0.0.0:8080->8080/tcp`. + +- [ ] **Step 3: Check container logs for errors** + +Run: `docker compose logs --tail=20` +Expected: Startup logs showing the server listening on `:8080`, no panic or fatal errors. + +--- + +### Task 5: Verify the app responds + +- [ ] **Step 1: HTTP health check** + +Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/` +Expected: `200` (the embedded frontend is served at root). + +- [ ] **Step 2: Check the page content** + +Run: `curl -s http://localhost:8080/ | head -20` +Expected: HTML containing references to `xterm.js` or `terminal` — confirming the frontend loads. + +- [ ] **Step 3: Open in browser** + +Open `http://localhost:8080` in a browser manually. Expected: +- xterm.js terminal UI renders +- A shell prompt appears (bash inside the Alpine container) +- Typing commands (e.g., `ls`, `whoami`) produces output + +--- + +### Task 6: Cleanup (after testing) + +- [ ] **Step 1: Stop and remove the container** + +Run: `docker compose down` +Expected: Container stopped and removed. + +- [ ] **Step 2: Revert modified files (optional)** + +Run: `git checkout docker-compose.yml && rm config.yaml` +Expected: Repository back to original state. diff --git a/docs/superpowers/specs/2026-05-15-docker-local-test-design.md b/docs/superpowers/specs/2026-05-15-docker-local-test-design.md new file mode 100644 index 0000000..f474eeb --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-docker-local-test-design.md @@ -0,0 +1,84 @@ +# Docker Local Test — Design Spec + +## Objectif + +Builder et lancer ShellFromBrowser en Docker sur la machine locale, vérifier que l'app répond sur `http://localhost:8080` et que le terminal fonctionne dans le navigateur. + +## Contraintes + +- Test local uniquement (pas de domaine public) +- Pas de TLS (HTTP simple) +- Pas d'authentification JWT +- Utiliser le `docker-compose.yml` principal (approche A : modification directe) + +## Modifications requises + +### 1. Dockerfile — Image Go + +Remplacer `golang:1.26-alpine` par la dernière version stable disponible sur Docker Hub (ex: `golang:1.23-alpine` ou `golang:1.24-alpine`). Go 1.26 n'existe pas encore. + +### 2. Créer `config.yaml` + +Fichier minimal pour le test local : + +```yaml +server: + addr: ":8080" + domain: "" + +auth: + enabled: false + +shell: + command: "" + env: + - "TERM=xterm-256color" + +sessions: + max_per_user: 10 + idle_timeout: "30m" + +recording: + enabled: false +``` + +### 3. Modifier `docker-compose.yml` + +- Ports : `80:80` et `443:443` → `8080:8080` +- Variables d'env : supprimer `SHELLFB_DOMAIN`, `SHELLFB_JWT_SECRET`, remplacer `SHELLFB_AUTH_ENABLED=true` par `false` +- Volumes : supprimer le volume `certs` (inutile sans TLS) +- Supprimer le volume `shellfb-recordings` (recording désactivé) + +Résultat : + +```yaml +services: + shellfb: + build: . + image: valorisa/shellfb:latest + container_name: shellfb + ports: + - "8080:8080" + environment: + - SHELLFB_AUTH_ENABLED=false + volumes: + - ./config.yaml:/etc/shellfb/config.yaml:ro + restart: unless-stopped +``` + +## Séquence de test + +1. Corriger le Dockerfile (version Go) +2. Créer `config.yaml` +3. Modifier `docker-compose.yml` +4. `docker compose build` +5. `docker compose up -d` +6. Vérifier `http://localhost:8080` dans le navigateur +7. Tester la saisie clavier dans le terminal web + +## Critères de succès + +- Le build Docker réussit sans erreur +- Le conteneur démarre et reste healthy +- `http://localhost:8080` affiche l'interface terminal +- On peut taper des commandes et voir les résultats