diff --git a/README.md b/README.md index 3d4a725..9f8383e 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,23 @@ -# webterm (Go) +# webterm ![Icon](docs/icon-256.png) `webterm` serves terminal sessions over HTTP/WebSocket, with a dashboard mode for multiple sessions and Docker-aware tiles. +This repository is the Go port of the original Python implementation, which is preserved in the `python` branch. + ![Screenshot](docs/screenshot.png) ## Features - Web terminal with reconnect support +- Ghostty WebAssembly terminal engine for fast rendering - Session dashboard with live SVG screenshots - Docker watch mode (`webterm-command` / `webterm-theme` labels) - Docker compose manifest ingestion - CPU sparkline tiles for compose services - SSE activity updates for fast dashboard refresh +- Mobile/touch support with virtual keyboard + draggable keybar - Theme/font controls for terminal rendering ## Install diff --git a/go/webterm/server.go b/go/webterm/server.go index f0edda7..bd0920d 100644 --- a/go/webterm/server.go +++ b/go/webterm/server.go @@ -1,11 +1,14 @@ package webterm import ( + "bufio" "context" "crypto/sha1" "encoding/json" "fmt" "io" + "log" + "net" "net/http" "os" "path/filepath" @@ -58,6 +61,59 @@ type wsOutbound struct { payload []byte } +type loggingResponseWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (w *loggingResponseWriter) WriteHeader(statusCode int) { + w.status = statusCode + w.ResponseWriter.WriteHeader(statusCode) +} + +func (w *loggingResponseWriter) Write(payload []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + n, err := w.ResponseWriter.Write(payload) + w.bytes += n + return n, err +} + +func (w *loggingResponseWriter) ReadFrom(r io.Reader) (int64, error) { + if rf, ok := w.ResponseWriter.(io.ReaderFrom); ok { + if w.status == 0 { + w.status = http.StatusOK + } + n, err := rf.ReadFrom(r) + w.bytes += int(n) + return n, err + } + return io.Copy(w.ResponseWriter, r) +} + +func (w *loggingResponseWriter) Flush() { + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (w *loggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, fmt.Errorf("response writer does not support hijacking") + } + return hijacker.Hijack() +} + +func (w *loggingResponseWriter) Push(target string, opts *http.PushOptions) error { + if pusher, ok := w.ResponseWriter.(http.Pusher); ok { + return pusher.Push(target, opts) + } + return http.ErrNotSupported +} + type LocalServer struct { host string port int @@ -300,6 +356,19 @@ func parseResizePayload(value any) (int, int) { return clampInt(width, 1, 500), clampInt(height, 1, 500) } +func (s *LocalServer) loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + lw := &loggingResponseWriter{ResponseWriter: w} + next.ServeHTTP(lw, r) + status := lw.status + if status == 0 { + status = http.StatusOK + } + log.Printf("%s %s status=%d bytes=%d duration=%s remote=%s", r.Method, r.URL.RequestURI(), status, lw.bytes, time.Since(start), r.RemoteAddr) + }) +} + func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { routeKey := strings.TrimPrefix(r.URL.Path, "/ws/") if routeKey == "" { @@ -308,8 +377,11 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { } conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { + log.Printf("websocket upgrade failed route=%s remote=%s err=%v", routeKey, r.RemoteAddr, err) return } + log.Printf("websocket connected route=%s remote=%s", routeKey, r.RemoteAddr) + defer log.Printf("websocket disconnected route=%s remote=%s", routeKey, r.RemoteAddr) defer conn.Close() client := &wsClient{ @@ -361,6 +433,9 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { for { messageType, payload, err := conn.ReadMessage() if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + log.Printf("websocket read error route=%s remote=%s err=%v", routeKey, r.RemoteAddr, err) + } return } if messageType != websocket.TextMessage { @@ -428,11 +503,10 @@ func (s *LocalServer) chooseRouteForScreenshot(requested string) (string, Sessio if session != nil { return requested, session, true } + return requested, nil, false } - if requested == "" { - if routeKey, session, ok := s.sessionManager.GetFirstRunningSession(); ok { - return routeKey, session, true - } + if routeKey, session, ok := s.sessionManager.GetFirstRunningSession(); ok { + return routeKey, session, true } return "", nil, false } @@ -823,7 +897,7 @@ func (s *LocalServer) Handler() http.Handler { if strings.TrimSpace(s.staticPath) != "" { mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.staticPath)))) } - return mux + return s.loggingMiddleware(mux) } func (s *LocalServer) Run(ctx context.Context) error { diff --git a/go/webterm/server_test.go b/go/webterm/server_test.go index c5b87a7..99cfa1c 100644 --- a/go/webterm/server_test.go +++ b/go/webterm/server_test.go @@ -192,6 +192,22 @@ func TestScreenshotAndETag(t *testing.T) { } } +func TestScreenshotCreatesSessionFromRequestedRoute(t *testing.T) { + _, httpServer, _ := newServerForTests(t, false) + resp, err := http.Get(httpServer.URL + "/screenshot.svg?route_key=shell") + if err != nil { + t.Fatalf("screenshot request error = %v", err) + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d body=%q", resp.StatusCode, string(body)) + } + if !strings.Contains(string(body), "