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
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
Files:
-
Create:
go.mod -
Create:
cmd/shellfb/main.go -
Create:
Makefile -
Step 1: Create Go module
cd C:/Users/bbrod/Projets/ShellFromBrowser
go mod init github.com/valorisa/ShellFromBrowser- Step 2: Create entry point
Create cmd/shellfb/main.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:
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
cd C:/Users/bbrod/Projets/ShellFromBrowser
go build ./cmd/shellfbExpected: builds without error.
- Step 5: Commit
git init
git add go.mod cmd/shellfb/main.go Makefile
git commit -m "feat: initialize project scaffold with entry point and Makefile"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:
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
go test ./internal/server/ -v -run TestWebSocketEchoExpected: FAIL — package doesn't exist yet.
- Step 3: Add gorilla/websocket dependency
go get github.com/gorilla/websocket- Step 4: Implement server
Create internal/server/server.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:
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
go test ./internal/server/ -v -run TestWebSocketEchoExpected: PASS
- Step 6: Wire server into main.go
Replace cmd/shellfb/main.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
git add internal/server/ cmd/shellfb/main.go go.mod go.sum
git commit -m "feat: add WebSocket server with echo handler"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:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ShellFromBrowser</title>
<link rel="stylesheet" href="/static/css/xterm.css">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<div id="terminal-container">
<div id="terminal"></div>
</div>
<script src="/static/js/xterm.js"></script>
<script src="/static/js/xterm-addon-fit.js"></script>
<script src="/static/js/xterm-addon-web-links.js"></script>
<script src="/static/js/terminal.js"></script>
</body>
</html>- Step 2: Create terminal.js WebSocket bridge
Create web/static/js/terminal.js:
(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:
* {
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:
package web
import "embed"
//go:embed static
var StaticFiles embed.FS- Step 5: Download xterm.js assets
cd C:/Users/bbrod/Projets/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:
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
cd C:/Users/bbrod/Projets/ShellFromBrowser
go build -o bin/shellfb.exe ./cmd/shellfb
./bin/shellfb.exeOpen http://localhost:8080 — should see xterm.js terminal with "Connected" message. Typing echoes back (echo mode from Task 2).
- Step 8: Commit
git add web/ internal/server/server.go
git commit -m "feat: add xterm.js frontend with WebSocket bridge and embedded static files"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:
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
go test ./internal/terminal/ -v -run TestSessionSpawnAndWriteExpected: FAIL — package doesn't exist.
- Step 3: Add PTY dependency
go get github.com/creack/pty- Step 4: Implement session.go
Create internal/terminal/session.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: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: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)
go test ./internal/terminal/ -v -run TestSessionSpawnAndWriteExpected: PASS on Unix/WSL. On Windows native, skip for now (ConPTY needs refinement).
- Step 8: Connect PTY to WebSocket
Modify internal/server/websocket.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
cd C:/Users/bbrod/Projets/ShellFromBrowser
go build -o bin/shellfb.exe ./cmd/shellfb && ./bin/shellfb.exeOpen http://localhost:8080 — should show a working interactive terminal (cmd.exe or shell).
- Step 10: Commit
git add internal/terminal/ internal/server/websocket.go go.mod go.sum
git commit -m "feat: add cross-platform PTY spawning connected to WebSocket"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:
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
go get gopkg.in/yaml.v3
go test ./internal/config/ -vExpected: FAIL
- Step 3: Implement config.go
Create internal/config/config.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
go test ./internal/config/ -vExpected: PASS
- Step 5: Create example config
Create config.example.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
git add internal/config/ config.example.yaml go.mod go.sum
git commit -m "feat: add YAML configuration system with defaults"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:
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
go get github.com/golang-jwt/jwt/v5
go get golang.org/x/crypto/bcrypt
go test ./internal/auth/ -vExpected: FAIL
- Step 3: Implement auth.go
Create internal/auth/auth.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:
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
go test ./internal/auth/ -vExpected: PASS
- Step 6: Add login endpoint and auth middleware to server
Modify internal/server/server.go — add login route and middleware:
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
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
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"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:
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
go test ./internal/terminal/ -v -run TestSessionManagerExpected: FAIL
- Step 3: Implement manager.go
Create internal/terminal/manager.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
go test ./internal/terminal/ -v -run TestSessionManagerExpected: PASS
- Step 5: Update WebSocket handler for multi-session
Modify internal/server/websocket.go to support session create/attach/list via query params:
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:
// In New():
s.sessions = terminal.NewManager(cfg.Sessions.MaxPerUser)
s.mux.HandleFunc("/api/sessions", s.authMiddleware(s.handleSessions))Add field to struct:
type Server struct {
addr string
mux *http.ServeMux
cfg *config.Config
authProv auth.Provider
sessions *terminal.Manager
}Add import:
"github.com/valorisa/ShellFromBrowser/internal/terminal"- Step 7: Commit
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"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:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ShellFromBrowser</title>
<link rel="stylesheet" href="/static/css/xterm.css">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<div id="login-screen" style="display:none;">
<div class="login-box">
<h1>ShellFromBrowser</h1>
<form id="login-form">
<input type="text" id="username" placeholder="Username" autocomplete="username">
<input type="password" id="password" placeholder="Password" autocomplete="current-password">
<button type="submit">Connect</button>
<div id="login-error" class="error"></div>
</form>
</div>
</div>
<div id="app" style="display:none;">
<div id="tab-bar">
<div id="tabs"></div>
<button id="new-tab" title="New session">+</button>
</div>
<div id="terminal-container">
<div id="terminal"></div>
</div>
<div id="status-bar">
<span id="session-info"></span>
<span id="connection-status">Disconnected</span>
</div>
</div>
<script src="/static/js/xterm.js"></script>
<script src="/static/js/xterm-addon-fit.js"></script>
<script src="/static/js/xterm-addon-web-links.js"></script>
<script src="/static/js/sessions.js"></script>
<script src="/static/js/terminal.js"></script>
</body>
</html>- Step 2: Create sessions.js
Create web/static/js/sessions.js:
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:
(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:
* {
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
cd C:/Users/bbrod/Projets/ShellFromBrowser
go build -o bin/shellfb.exe ./cmd/shellfb && ./bin/shellfb.exeOpen http://localhost:8080 — verify tabs work, new sessions open, close works.
- Step 6: Commit
git add web/
git commit -m "feat: add multi-tab session UI with login screen"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:
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
go get golang.org/x/crypto/ssh
go test ./internal/ssh/ -v -run TestParseTargetExpected: FAIL
- Step 3: Implement client.go
Create internal/ssh/client.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:
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
go test ./internal/ssh/ -v -run TestParseTargetExpected: PASS
- Step 5: Add SSH WebSocket endpoint
Add to internal/server/server.go a new route:
s.mux.HandleFunc("/ws/ssh", s.authMiddleware(s.handleSSHWebSocket))Create handler in internal/server/websocket.go (add to file):
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
git add internal/ssh/ internal/server/
git commit -m "feat: add SSH client integration with WebSocket bridge"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:
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
go test ./internal/transfer/ -vExpected: FAIL
- Step 3: Implement upload.go
Create internal/transfer/upload.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:
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
go test ./internal/transfer/ -vExpected: PASS
- Step 6: Wire into server
Add to internal/server/server.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
git add internal/transfer/ internal/server/
git commit -m "feat: add file upload/download with path traversal protection"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:
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
go test ./internal/recording/ -vExpected: FAIL
- Step 3: Implement recorder.go
Create internal/recording/recorder.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:
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
go test ./internal/recording/ -vExpected: PASS
- Step 6: Commit
git add internal/recording/
git commit -m "feat: add session recording in asciicast v2 format with playback"Files:
-
Create:
Dockerfile -
Create:
docker-compose.yml -
Create:
.dockerignore -
Step 1: Create Dockerfile
Create 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:
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
cd C:/Users/bbrod/Projets/ShellFromBrowser
docker build -t shellfb:dev .Expected: builds successfully.
- Step 5: Commit
git add Dockerfile docker-compose.yml .dockerignore
git commit -m "feat: add Dockerfile (multi-stage) and docker-compose"Files:
-
Modify:
internal/server/server.go -
Create:
internal/server/middleware.go -
Step 1: Create security middleware
Create internal/server/middleware.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:
func (s *Server) Handler() http.Handler {
return securityHeaders(s.mux)
}Apply rate limiter to login:
loginLimiter := newRateLimiter(5, time.Minute)
s.mux.HandleFunc("/api/login", loginLimiter.Middleware(s.handleLogin))- Step 3: Commit
git add internal/server/middleware.go internal/server/server.go
git commit -m "feat: add security headers, CSP, and rate limiting on login"Files:
-
Create:
README.md -
Create:
LICENSE -
Step 1: Create README
Create README.md:
# 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.yamldocker compose up -dCopy config.example.yaml and customize:
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"shellfb hash-passwordgit clone https://github.com/valorisa/ShellFromBrowser.git
cd ShellFromBrowser
make build
./bin/shellfb- 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
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"
Files:
-
Modify:
cmd/shellfb/main.go -
Step 1: Add hash-password command
Add to cmd/shellfb/main.go before the server startup logic:
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
echo "test123" | go run ./cmd/shellfb hash-passwordExpected: outputs a bcrypt hash starting with $2a$
- Step 3: Commit
git add cmd/shellfb/main.go
git commit -m "feat: add hash-password CLI subcommand"- Step 1: Initialize git and push
cd C:/Users/bbrod/Projets/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:
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
git add .github/
git commit -m "ci: add GitHub Actions workflow for test and cross-platform build"
git push| 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.