From 82c11dd2f82b62b02419ab4256d118461b5dadb3 Mon Sep 17 00:00:00 2001 From: Honza Novak Date: Mon, 13 Apr 2026 10:53:41 +0000 Subject: [PATCH] feat: add web terminal UI with WebSocket console and clone management API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a browser-based terminal interface backed by xterm.js, served directly from the binary via an embedded HTML asset. New HTTP server (`serve [addr]`, default :8080): GET / — xterm.js terminal UI; ?id=N selects a clone GET /clones — JSON list of running clone IDs POST /clones — spawn a new clone; returns {"id": N} DELETE /clones/{id} — destroy a clone by ID GET /ws/{id} — WebSocket console for clone {id} binary frames = raw PTY I/O text frames = JSON resize {"rows":N,"cols":M} Supporting changes: - orchestrator: add SpawnSingle() and KillClone(id) for per-clone lifecycle management from the HTTP layer - console: add a resize sideband Unix socket (console-resize.sock) that accepts newline-delimited JSON {"rows","cols"} messages and applies them to the PTY master via pty.Setsize; the WebSocket handler writes to this socket on text frames so browser window resizes propagate into the VM - deps: add gorilla/websocket v1.5.3 Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 1 + go.sum | 2 + main.go | 7 + orchestrator/console.go | 60 +++++++ orchestrator/orchestrator.go | 44 +++++ orchestrator/serve.go | 218 ++++++++++++++++++++++++ orchestrator/web/terminal.html | 298 +++++++++++++++++++++++++++++++++ 7 files changed, 630 insertions(+) create mode 100644 orchestrator/serve.go create mode 100644 orchestrator/web/terminal.html diff --git a/go.mod b/go.mod index 8ccf9a7..f20d9fb 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/go-openapi/validate v0.22.0 // indirect github.com/go-stack/stack v1.8.1 // indirect github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/go.sum b/go.sum index 5137e5e..c79e207 100644 --- a/go.sum +++ b/go.sum @@ -408,6 +408,8 @@ github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= diff --git a/main.go b/main.go index 86e4165..786ac7b 100644 --- a/main.go +++ b/main.go @@ -80,6 +80,12 @@ func main() { fatal(orch.Kill()) case "cleanup": fatal(orch.Cleanup()) + case "serve": + addr := ":8080" + if len(os.Args) > 2 { + addr = os.Args[2] + } + fatal(orchestrator.Serve(orch, addr)) case "console": if len(os.Args) < 3 { fmt.Fprintf(os.Stderr, "usage: %s console \n", os.Args[0]) @@ -116,6 +122,7 @@ Commands: init Download kernel + create Alpine rootfs golden Boot golden VM → pause → snapshot spawn [N] Restore N clones from golden snapshot (default: 1) + serve [addr] Start terminal web UI (default: :8080) console Attach to the serial console of a running clone (Ctrl+] to detach) status Show running clones kill Kill all running VMs diff --git a/orchestrator/console.go b/orchestrator/console.go index dd2429a..82780d7 100644 --- a/orchestrator/console.go +++ b/orchestrator/console.go @@ -1,7 +1,9 @@ package orchestrator import ( + "bufio" "context" + "encoding/json" "fmt" "io" "net" @@ -150,6 +152,15 @@ func RunConsoleProxy(cfg Config, id int, tapName string) error { return fmt.Errorf("listen on console socket: %w", err) } + // --- Create resize sideband socket --- + resizeSockPath := filepath.Join(cloneDir, "console-resize.sock") + os.Remove(resizeSockPath) //nolint:errcheck + resizeListener, err := net.Listen("unix", resizeSockPath) + if err != nil { + logger.Warnf("could not create resize socket, terminal resize will be unavailable: %v", err) + resizeListener = nil + } + // --- Serve until VM exits --- vmDone := make(chan struct{}) go func() { @@ -157,11 +168,18 @@ func RunConsoleProxy(cfg Config, id int, tapName string) error { close(vmDone) }() + if resizeListener != nil { + go serveResize(resizeListener, ptm, vmDone, logger) + } serveConsole(listener, ptm, vmDone, logger) listener.Close() + if resizeListener != nil { + resizeListener.Close() + } ptm.Close() os.Remove(consoleSockPath) //nolint:errcheck + os.Remove(resizeSockPath) //nolint:errcheck if tapName != "" { destroyTap(tapName) @@ -171,6 +189,48 @@ func RunConsoleProxy(cfg Config, id int, tapName string) error { return nil } +// resizeMsg is the JSON payload sent over the resize sideband socket. +type resizeMsg struct { + Rows uint16 `json:"rows"` + Cols uint16 `json:"cols"` +} + +// serveResize accepts connections on the resize sideband listener and applies +// PTY window size changes as JSON resize messages arrive. +func serveResize(listener net.Listener, ptm *os.File, vmDone <-chan struct{}, logger *log.Entry) { + go func() { + <-vmDone + listener.Close() + }() + for { + conn, err := listener.Accept() + if err != nil { + return + } + go handleResize(conn, ptm, logger) + } +} + +// handleResize reads newline-delimited JSON resize messages from conn and +// applies each one to the PTY master. +func handleResize(conn net.Conn, ptm *os.File, logger *log.Entry) { + defer conn.Close() + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + var msg resizeMsg + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + logger.Warnf("resize: bad message: %v", err) + continue + } + if msg.Rows == 0 || msg.Cols == 0 { + continue + } + if err := pty.Setsize(ptm, &pty.Winsize{Rows: msg.Rows, Cols: msg.Cols}); err != nil { + logger.Warnf("resize pty: %v", err) + } + } +} + // atomicWriter wraps an io.Writer behind a read/write lock so it can be // swapped between connections without holding the lock during writes. type atomicWriter struct { diff --git a/orchestrator/orchestrator.go b/orchestrator/orchestrator.go index 88da6ef..c17ed9f 100644 --- a/orchestrator/orchestrator.go +++ b/orchestrator/orchestrator.go @@ -305,6 +305,50 @@ func (o *Orchestrator) Spawn(count int) error { return nil } +// SpawnSingle spawns exactly one new clone and returns its ID. +// It is safe to call from multiple goroutines (nextCloneID is serialised by the +// filesystem scan, and each clone gets its own directory/tap). +func (o *Orchestrator) SpawnSingle() (int, error) { + goldenDir := o.goldenDir() + for _, f := range []string{"vmstate", "mem"} { + if _, err := os.Stat(filepath.Join(goldenDir, f)); err != nil { + return 0, fmt.Errorf("golden %s not found — run golden first", f) + } + } + os.MkdirAll(o.clonesDir(), 0o755) + os.MkdirAll(o.pidsDir(), 0o755) + if o.cfg.Bridge != "none" { + if err := o.setupBridge(); err != nil { + return 0, err + } + } + id := o.nextCloneID() + if err := o.spawnOne(id); err != nil { + return 0, err + } + return id, nil +} + +// KillClone kills a single clone by ID: terminates its proxy process, +// destroys its tap device, and removes its working directory. +func (o *Orchestrator) KillClone(id int) error { + pidFile := filepath.Join(o.pidsDir(), fmt.Sprintf("clone-%d.proxy.pid", id)) + if data, err := os.ReadFile(pidFile); err == nil { + if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { + if p, err := os.FindProcess(pid); err == nil { + _ = p.Kill() + o.log.Infof("clone %d: killed proxy pid %d", id, pid) + } + } + os.Remove(pidFile) //nolint:errcheck + } + tapName := fmt.Sprintf("fctap%d", id) + destroyTap(tapName) + os.RemoveAll(filepath.Join(o.clonesDir(), strconv.Itoa(id))) //nolint:errcheck + o.log.Infof("clone %d: destroyed", id) + return nil +} + func (o *Orchestrator) spawnOne(id int) error { goldenDir := o.goldenDir() cloneDir := filepath.Join(o.clonesDir(), strconv.Itoa(id)) diff --git a/orchestrator/serve.go b/orchestrator/serve.go new file mode 100644 index 0000000..5610513 --- /dev/null +++ b/orchestrator/serve.go @@ -0,0 +1,218 @@ +package orchestrator + +import ( + "encoding/json" + "fmt" + _ "embed" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gorilla/websocket" + log "github.com/sirupsen/logrus" +) + +//go:embed web/terminal.html +var terminalHTML []byte + +var upgrader = websocket.Upgrader{ + // Allow all origins for local/dev use. Add an origin check for production. + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Serve starts an HTTP server that exposes: +// +// GET / — terminal UI (xterm.js); ?id=N selects a clone +// GET /clones — JSON list of running clone IDs +// POST /clones — spawn a new clone; returns {"id": N} +// DELETE /clones/{id} — destroy clone {id} +// GET /ws/{id} — WebSocket console for clone {id} +// +// Binary WebSocket frames carry raw terminal bytes (stdin/stdout). +// Text WebSocket frames carry JSON resize commands: {"rows":N,"cols":M}. +func Serve(orch *Orchestrator, addr string) error { + mux := http.NewServeMux() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(terminalHTML) //nolint:errcheck + }) + + // /clones — list (GET) or spawn (POST) + mux.HandleFunc("/clones", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, "": + ids := runningCloneIDs(orch.cfg) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ids) //nolint:errcheck + case http.MethodPost: + id, err := orch.SpawnSingle() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]int{"id": id}) //nolint:errcheck + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + + // /clones/{id} — destroy (DELETE) + mux.HandleFunc("/clones/", func(w http.ResponseWriter, r *http.Request) { + idStr := strings.TrimPrefix(r.URL.Path, "/clones/") + if idStr == "" { + // redirect bare /clones/ to /clones + http.Redirect(w, r, "/clones", http.StatusMovedPermanently) + return + } + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "invalid clone id", http.StatusBadRequest) + return + } + if err := orch.KillClone(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + }) + + mux.HandleFunc("/ws/", func(w http.ResponseWriter, r *http.Request) { + idStr := strings.TrimPrefix(r.URL.Path, "/ws/") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "invalid clone id", http.StatusBadRequest) + return + } + handleConsoleWS(orch.cfg, id, w, r) + }) + + log.Infof("terminal UI: http://%s", addr) + return http.ListenAndServe(addr, mux) +} + +// handleConsoleWS upgrades the request to a WebSocket and bridges it to the +// clone's console.sock (I/O) and console-resize.sock (terminal resize). +func handleConsoleWS(cfg Config, id int, w http.ResponseWriter, r *http.Request) { + cloneDir := filepath.Join(cfg.BaseDir, "clones", strconv.Itoa(id)) + consoleSock := filepath.Join(cloneDir, "console.sock") + resizeSock := filepath.Join(cloneDir, "console-resize.sock") + + if _, err := os.Stat(consoleSock); err != nil { + http.Error(w, fmt.Sprintf("clone %d is not running", id), http.StatusNotFound) + return + } + + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer ws.Close() + + consoleConn, err := net.Dial("unix", consoleSock) + if err != nil { + writeWSError(ws, fmt.Sprintf("console unavailable: %v", err)) + return + } + defer consoleConn.Close() + + resizeConn, err := net.Dial("unix", resizeSock) + if err != nil { + log.Warnf("clone %d: could not connect to resize socket, resize disabled: %v", id, err) + resizeConn = nil + } + if resizeConn != nil { + defer resizeConn.Close() + } + + log.Infof("ws: clone %d: client connected from %s", id, r.RemoteAddr) + bridgeWS(ws, consoleConn, resizeConn) + log.Infof("ws: clone %d: client disconnected", id) +} + +// bridgeWS proxies between a WebSocket connection and a console Unix socket. +// +// - Binary WS frames → consoleConn (terminal input) +// - Text WS frames → resizeConn as a JSON line (resize command) +// - consoleConn reads → binary WS frames (terminal output) +func bridgeWS(ws *websocket.Conn, consoleConn net.Conn, resizeConn net.Conn) { + // console → WebSocket + sockDone := make(chan struct{}) + go func() { + defer close(sockDone) + buf := make([]byte, 4096) + for { + n, err := consoleConn.Read(buf) + if n > 0 { + if werr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil { + return + } + } + if err != nil { + return + } + } + }() + + // WebSocket → console / resize + for { + msgType, data, err := ws.ReadMessage() + if err != nil { + break + } + switch msgType { + case websocket.BinaryMessage: + consoleConn.Write(data) //nolint:errcheck + case websocket.TextMessage: + if resizeConn != nil { + // Append newline so the scanner in handleResize sees a complete line. + resizeConn.Write(append(data, '\n')) //nolint:errcheck + } + } + } + + consoleConn.Close() + <-sockDone +} + +// runningCloneIDs returns clone IDs that have a live console socket. +func runningCloneIDs(cfg Config) []int { + clonesDir := filepath.Join(cfg.BaseDir, "clones") + entries, err := os.ReadDir(clonesDir) + if err != nil { + return nil + } + var ids []int + for _, e := range entries { + if !e.IsDir() { + continue + } + id, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + sock := filepath.Join(clonesDir, e.Name(), "console.sock") + if _, err := os.Stat(sock); err == nil { + ids = append(ids, id) + } + } + return ids +} + +func writeWSError(ws *websocket.Conn, msg string) { + ws.WriteMessage(websocket.TextMessage, //nolint:errcheck + []byte("\r\n\x1b[31m["+msg+"]\x1b[0m\r\n")) +} diff --git a/orchestrator/web/terminal.html b/orchestrator/web/terminal.html new file mode 100644 index 0000000..5db7e60 --- /dev/null +++ b/orchestrator/web/terminal.html @@ -0,0 +1,298 @@ + + + + + + fc-orch console + + + + + + + + +
+

fc-orch console

+
    + + +

    +
    + + +
    +
    + + connecting… + ← all clones +
    +
    +
    + + + +