From 4c604023636de728ae902b9824c3fb5658243bc2 Mon Sep 17 00:00:00 2001 From: "Luis Gustavo S. Barreto" Date: Fri, 5 Jun 2026 11:01:38 -0300 Subject: [PATCH 1/4] feat(ssh): add shareable terminal hub and endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a tmate/upterm-style shareable terminal: a public, read-only (or collaborative) web link that mirrors a live terminal session to guests without requiring them to sign in. The ssh service gains an in-memory share registry and a fan-out hub (one producer, N consumers, with a scrollback ring buffer), exposed via: - POST /ssh/shares create a share (device-authenticated) - GET /ssh/shares list a namespace's active shares - DELETE /ssh/shares/:token revoke a share - GET /ssh/shares/:token/stream agent producer stream - GET /ws/share/:token public guest viewer The console web terminal can also share its own live session in-process (CreateLocal), teeing PTY output to the hub and, in collaborative mode, feeding guest input back into the session. CE-compatible — it does not depend on the Enterprise session recorder. --- gateway/nginx/conf.d/shellhub.conf | 26 +++ pkg/models/share.go | 50 ++++++ ssh/main.go | 9 +- ssh/web/conn.go | 8 + ssh/web/messages.go | 13 ++ ssh/web/session.go | 102 +++++++++++- ssh/web/session_test.go | 6 +- ssh/web/share/handlers.go | 248 +++++++++++++++++++++++++++++ ssh/web/share/hub.go | 192 ++++++++++++++++++++++ ssh/web/share/hub_test.go | 100 ++++++++++++ ssh/web/share/protocol.go | 37 +++++ ssh/web/share/register.go | 23 +++ ssh/web/share/registry.go | 140 ++++++++++++++++ ssh/web/web.go | 4 +- ssh/web/web_test.go | 4 +- 15 files changed, 952 insertions(+), 10 deletions(-) create mode 100644 pkg/models/share.go create mode 100644 ssh/web/share/handlers.go create mode 100644 ssh/web/share/hub.go create mode 100644 ssh/web/share/hub_test.go create mode 100644 ssh/web/share/protocol.go create mode 100644 ssh/web/share/register.go create mode 100644 ssh/web/share/registry.go diff --git a/gateway/nginx/conf.d/shellhub.conf b/gateway/nginx/conf.d/shellhub.conf index cb8f63bc0dd..b19221d2c39 100644 --- a/gateway/nginx/conf.d/shellhub.conf +++ b/gateway/nginx/conf.d/shellhub.conf @@ -481,6 +481,32 @@ server { proxy_redirect off; } + location /ssh/shares { + set $upstream ssh:8080; + + auth_request /auth; + auth_request_set $tenant_id $upstream_http_x_tenant_id; + auth_request_set $device_uid $upstream_http_x_device_uid; + proxy_pass http://$upstream; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + {{ if $cfg.EnableProxyProtocol -}} + proxy_set_header X-Real-IP $proxy_protocol_addr; + {{ else -}} + proxy_set_header X-Real-IP $x_real_ip; + {{ end -}} + proxy_set_header X-Device-UID $device_uid; + proxy_set_header X-Tenant-ID $tenant_id; + proxy_set_header X-Request-ID $request_id; + proxy_http_version 1.1; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + proxy_socket_keepalive on; + proxy_redirect off; + } + location /ssh/auth { set $upstream {{ $cfg.APIBackend }}; diff --git a/pkg/models/share.go b/pkg/models/share.go new file mode 100644 index 00000000000..79cf053007b --- /dev/null +++ b/pkg/models/share.go @@ -0,0 +1,50 @@ +package models + +import "time" + +// ShareCreateRequest is the payload sent by the agent to register a new shareable terminal session. +type ShareCreateRequest struct { + // Name is an optional human-friendly label for the share, shown in the namespace's list. + Name string `json:"name"` + // Command is the command being shared (informational; e.g. "bash" or "claude"). + Command string `json:"command"` + // Writable, when true, lets guests type into the session (collaborative mode). Defaults to + // read-only. + Writable bool `json:"writable"` + // TTLSeconds controls when the share token expires: 0 uses the server default, a negative value + // means no expiry (the share only ends when the command exits), and a positive value sets a + // custom lifetime in seconds. + TTLSeconds int `json:"ttl_seconds"` + // Term is the terminal type reported by the host (e.g. "xterm-256color"). + Term string `json:"term"` + // Cols and Rows are the initial terminal dimensions. + Cols int `json:"cols"` + Rows int `json:"rows"` +} + +// ShareCreateResponse is returned to the agent after a shareable terminal session is created. +type ShareCreateResponse struct { + // Token is the opaque, unguessable identifier used both to push the stream and to view it. + Token string `json:"token"` + // URL is the public, read-only address a guest can open to watch the session. + URL string `json:"url"` + // ExpiresAt is the moment the share token stops being valid. + ExpiresAt time.Time `json:"expires_at"` +} + +// ShareInfo describes an active shareable terminal session, listed for the namespace owner so they +// can see what is being shared and how many people are currently watching. +type ShareInfo struct { + Token string `json:"token"` + URL string `json:"url"` + Name string `json:"name"` + Command string `json:"command"` + Writable bool `json:"writable"` + DeviceUID string `json:"device_uid"` + DeviceName string `json:"device_name"` + DeviceOnline bool `json:"device_online"` + DeviceOS string `json:"device_os"` + Viewers int `json:"viewers"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} diff --git a/ssh/main.go b/ssh/main.go index 91f4436eed4..76a60670f73 100644 --- a/ssh/main.go +++ b/ssh/main.go @@ -14,6 +14,7 @@ import ( "github.com/shellhub-io/shellhub/ssh/pkg/dialer" ssh "github.com/shellhub-io/shellhub/ssh/server" "github.com/shellhub-io/shellhub/ssh/web" + "github.com/shellhub-io/shellhub/ssh/web/share" log "github.com/sirupsen/logrus" ) @@ -40,6 +41,8 @@ type Envs struct { // Domain is the base domain for this ShellHub instance. The env key must // stay SHELLHUB_DOMAIN (not SSH_SHELLHUB_DOMAIN) for the same reason. Domain string `env:"SHELLHUB_DOMAIN"` + // ShareTTL is how long a shareable terminal session (tmate-style) link stays valid. + ShareTTL time.Duration `env:"SHARE_TTL,default=4h"` } func main() { @@ -71,7 +74,11 @@ func main() { router := h.Router - web.NewSSHServerBridge(router, cache) + shareRegistry := share.NewRegistry(env.ShareTTL) + + web.NewSSHServerBridge(router, cache, shareRegistry) + + share.Register(router, shareRegistry, cli) if envs.IsDevelopment() { runtime.SetBlockProfileRate(1) diff --git a/ssh/web/conn.go b/ssh/web/conn.go index 09483ad3399..ab74e1a5742 100644 --- a/ssh/web/conn.go +++ b/ssh/web/conn.go @@ -109,6 +109,14 @@ func (c *Conn) ReadMessage(message *Message) (int, error) { } message.Data = sig + case messageKindShare: + var req ShareRequest + + if err := json.Unmarshal(data, &req); err != nil { + return 0, errors.Join(ErrConnReadMessageJSONInvalid) + } + + message.Data = req default: return 0, errors.Join(ErrConnReadMessageKindInvalid) } diff --git a/ssh/web/messages.go b/ssh/web/messages.go index b8de3950400..311c663c7ec 100644 --- a/ssh/web/messages.go +++ b/ssh/web/messages.go @@ -14,8 +14,21 @@ const ( // messageKindError is the identifier to output an erro rmessage. This kind of message contains data to be show // in terminal for information propose. messageKindError + // messageKindShare is sent by the client to ask the server to expose this live console session as + // a public shareable terminal, and sent back by the server carrying the generated share token. + messageKindShare ) +// ShareRequest is the payload of a client -> server [messageKindShare] message: it asks the server +// to share the current console session. The server replies with a [messageKindShare] message whose +// data is the generated share token (the client builds the public URL from it). +type ShareRequest struct { + Name string `json:"name"` + Writable bool `json:"writable"` + // TTL is the lifetime in seconds: 0 = server default, negative = no expiry, positive = custom. + TTL int `json:"ttl"` +} + // MessageMinSize is the minimum size of a message in bytes. This is used to validate if the message is valid. const MessageMinSize = 20 diff --git a/ssh/web/session.go b/ssh/web/session.go index 3a2413e7cd4..9748210272d 100644 --- a/ssh/web/session.go +++ b/ssh/web/session.go @@ -8,12 +8,15 @@ import ( "fmt" "io" "strings" + "sync/atomic" "time" "unicode/utf8" "github.com/shellhub-io/shellhub/pkg/api/internalclient" "github.com/shellhub-io/shellhub/pkg/cache" + "github.com/shellhub-io/shellhub/pkg/models" "github.com/shellhub-io/shellhub/pkg/uuid" + "github.com/shellhub-io/shellhub/ssh/web/share" log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" ) @@ -114,7 +117,33 @@ func (s *Signer) Sign(rand io.Reader, data []byte) (*ssh.Signature, error) { }, nil } -func newSession(ctx context.Context, cache cache.Cache, conn *Conn, creds *Credentials, dim Dimensions, info Info) error { +// startShare exposes the current console session as a public shareable terminal. It resolves the +// device's tenant, registers an in-process share whose producer is this session, and returns the +// hub (to feed output / drain guest input), a close function and the share token. +func startShare(ctx context.Context, shares *share.Registry, deviceUID string, dim Dimensions, req ShareRequest) (*share.Hub, func(), string, error) { + cli, err := internalclient.NewClient(nil) + if err != nil { + return nil, nil, "", err + } + + device, err := cli.GetDevice(ctx, deviceUID) + if err != nil { + return nil, nil, "", err + } + + token, hub, closeFn := shares.CreateLocal(deviceUID, device.TenantID, models.ShareCreateRequest{ + Name: req.Name, + Command: "console session", + Writable: req.Writable, + TTLSeconds: req.TTL, + }) + + hub.Resize(share.Dimensions{Cols: int(dim.Cols), Rows: int(dim.Rows)}) + + return hub, closeFn, token, nil +} + +func newSession(ctx context.Context, cache cache.Cache, conn *Conn, creds *Credentials, dim Dimensions, info Info, shares *share.Registry) error { logger := log.WithFields(log.Fields{ "user": creds.Username, "device": creds.Device, @@ -230,9 +259,24 @@ func newSession(ctx context.Context, cache cache.Cache, conn *Conn, creds *Crede return ErrShell } + // shareHub holds the share's hub once the user shares this session (nil until then). The output + // goroutine reads it to tee output; the input goroutine writes it. + var shareHub atomic.Pointer[share.Hub] + go func() { defer agent.Close() + // currentDim tracks the live terminal size so a share started mid-session matches geometry. + currentDim := dim + + // shareClose tears the share down when this session ends. Only this goroutine touches it. + var shareClose func() + defer func() { + if shareClose != nil { + shareClose() + } + }() + for { var message Message @@ -257,18 +301,59 @@ func newSession(ctx context.Context, cache cache.Cache, conn *Conn, creds *Crede } case messageKindResize: dim := message.Data.(Dimensions) + currentDim = dim if err := agent.WindowChange(int(dim.Rows), int(dim.Cols)); err != nil { logger.WithError(err).Error("failed to change the size of window for terminal session") return } + + if h := shareHub.Load(); h != nil { + h.Resize(share.Dimensions{Cols: int(dim.Cols), Rows: int(dim.Rows)}) + } + case messageKindShare: + if shareHub.Load() != nil { + continue // already shared + } + + req := message.Data.(ShareRequest) + + hub, closeFn, token, err := startShare(ctx, shares, creds.Device, currentDim, req) + if err != nil { + logger.WithError(err).Error("failed to start the share") + + continue + } + + shareClose = closeFn + shareHub.Store(hub) + + // In collaborative mode, guest keystrokes flow into the same PTY stdin as the local user. + if req.Writable { + go func() { + for { + select { + case <-hub.Done(): + return + case data := <-hub.Input(): + if _, err := stdin.Write(data); err != nil { + return + } + } + } + }() + } + + if _, err := conn.WriteMessage(&Message{Kind: messageKindShare, Data: token}); err != nil { + logger.WithError(err).Error("failed to send the share token to the client") + } } } }() - go redirToWs(stdout, conn) // nolint:errcheck - go io.Copy(conn, stderr) //nolint:errcheck + go redirToWs(stdout, conn, &shareHub) // nolint:errcheck + go io.Copy(conn, stderr) //nolint:errcheck if err := agent.Wait(); err != nil { logger.WithError(err).Warning("client remote command returned a error") @@ -277,7 +362,7 @@ func newSession(ctx context.Context, cache cache.Cache, conn *Conn, creds *Crede return nil } -func redirToWs(rd io.Reader, ws *Conn) error { +func redirToWs(rd io.Reader, ws *Conn, shareHub *atomic.Pointer[share.Hub]) error { // TODO: Evaluate refactoring this function to improve its readability. var buf [32 * 1024]byte var start, end, buflen int @@ -334,10 +419,17 @@ func redirToWs(rd io.Reader, ws *Conn) error { end = 0 } - if _, err = ws.WriteBinary([]byte(string(bytes.Runes(buf[0:end])))); err != nil { + chunk := []byte(string(bytes.Runes(buf[0:end]))) + + if _, err = ws.WriteBinary(chunk); err != nil { return err } + // Tee the same output to the share hub when this session is being shared. + if h := shareHub.Load(); h != nil { + h.Output(chunk) + } + start = buflen - end if start > 0 { diff --git a/ssh/web/session_test.go b/ssh/web/session_test.go index 558bb6f1a1f..fe2e5d30905 100644 --- a/ssh/web/session_test.go +++ b/ssh/web/session_test.go @@ -2,10 +2,12 @@ package web import ( "io" + "sync/atomic" "testing" "testing/iotest" "github.com/shellhub-io/shellhub/ssh/web/mocks" + "github.com/shellhub-io/shellhub/ssh/web/share" "github.com/stretchr/testify/assert" ) @@ -46,7 +48,7 @@ func TestRedirToWs_Regression_EndNegative(t *testing.T) { reader := &singleRead{data: []byte{0x80, 0x81, 0x82}} assert.NotPanics(t, func() { - _ = redirToWs(reader, conn) + _ = redirToWs(reader, conn, new(atomic.Pointer[share.Hub])) }, "expected redirToWs to panic when end is -1 and negative slice is attempted") } @@ -58,6 +60,6 @@ func TestRedirToWs_Regression_ZeroReadThenEOF(t *testing.T) { reader := iotest.TimeoutReader(&zeroReadNoEOFReader{}) assert.NotPanics(t, func() { - _ = redirToWs(reader, conn) + _ = redirToWs(reader, conn, new(atomic.Pointer[share.Hub])) }, "expected redirToWs to handle zero read without panicking") } diff --git a/ssh/web/share/handlers.go b/ssh/web/share/handlers.go new file mode 100644 index 00000000000..5e85f7d41bc --- /dev/null +++ b/ssh/web/share/handlers.go @@ -0,0 +1,248 @@ +package share + +import ( + "net/http" + "sort" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/shellhub-io/shellhub/pkg/api/internalclient" + "github.com/shellhub-io/shellhub/pkg/models" + log "github.com/sirupsen/logrus" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + Subprotocols: []string{"binary"}, + CheckOrigin: func(_ *http.Request) bool { + return true + }, +} + +// Handlers exposes the HTTP handlers for the shareable terminal feature. +type Handlers struct { + registry *Registry + cli internalclient.Client +} + +// HandleCreate registers a new shareable terminal session for an authenticated agent/device. +// +// The gateway authenticates the request and injects the X-Device-UID and X-Tenant-ID headers, +// which this handler trusts (same model as the reverse-tunnel connection handlers). +func (h *Handlers) HandleCreate(c echo.Context) error { + deviceUID := c.Request().Header.Get("X-Device-UID") + tenantID := c.Request().Header.Get("X-Tenant-ID") + + if deviceUID == "" || tenantID == "" { + return c.NoContent(http.StatusUnauthorized) + } + + var req models.ShareCreateRequest + if err := c.Bind(&req); err != nil { + return c.NoContent(http.StatusBadRequest) + } + + token, e := h.registry.create(deviceUID, tenantID, req) + + if req.Cols > 0 && req.Rows > 0 { + e.hub.Resize(Dimensions{Cols: req.Cols, Rows: req.Rows}) + } + + url := c.Scheme() + "://" + c.Request().Host + "/share/" + token + + return c.JSON(http.StatusOK, models.ShareCreateResponse{ + Token: token, + URL: url, + ExpiresAt: e.expiresAt, + }) +} + +// HandleList returns the active shares owned by the authenticated namespace, including how many +// guests are currently watching each one. The gateway authenticates the user and injects the +// X-Tenant-ID header. +func (h *Handlers) HandleList(c echo.Context) error { + tenantID := c.Request().Header.Get("X-Tenant-ID") + if tenantID == "" { + return c.NoContent(http.StatusUnauthorized) + } + + entries := h.registry.list(tenantID) + + shares := make([]models.ShareInfo, 0, len(entries)) + for token, e := range entries { + info := models.ShareInfo{ + Token: token, + URL: c.Scheme() + "://" + c.Request().Host + "/share/" + token, + Name: e.name, + Command: e.command, + Writable: e.writable, + DeviceUID: e.deviceUID, + DeviceName: e.deviceUID, + Viewers: e.hub.Viewers(), + CreatedAt: e.createdAt, + ExpiresAt: e.expiresAt, + } + + if device, err := h.cli.GetDevice(c.Request().Context(), e.deviceUID); err == nil && device != nil { + if device.Name != "" { + info.DeviceName = device.Name + } + info.DeviceOnline = device.Online + if device.Info != nil { + info.DeviceOS = device.Info.ID + } + } + + shares = append(shares, info) + } + + sort.Slice(shares, func(i, j int) bool { + return shares[i].CreatedAt.After(shares[j].CreatedAt) + }) + + return c.JSON(http.StatusOK, shares) +} + +// HandleStream binds the producer (the agent) to a share's hub. It reads binary frames as raw +// PTY output and JSON text frames as control events (resize), broadcasting both to guests. When +// the producer disconnects, the share is torn down. +func (h *Handlers) HandleStream(c echo.Context) error { + token := c.Param("token") + + e, ok := h.registry.get(token) + if !ok { + return c.NoContent(http.StatusNotFound) + } + + // Only the device that created the share may push its stream. + if uid := c.Request().Header.Get("X-Device-UID"); uid == "" || uid != e.deviceUID { + return c.NoContent(http.StatusForbidden) + } + + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer conn.Close() + + defer h.registry.remove(token) + + // In collaborative mode, forward guest keystrokes (drained from the hub) down to the agent as + // binary frames. This goroutine is the only writer on the producer connection. + if e.writable { + go func() { + for { + select { + case <-e.hub.Done(): + return + case data := <-e.hub.Input(): + if err := conn.WriteMessage(websocket.BinaryMessage, data); err != nil { + return + } + } + } + }() + } + + for { + typ, data, err := conn.ReadMessage() + if err != nil { + return nil + } + + switch typ { + case websocket.BinaryMessage: + e.hub.Output(data) + case websocket.TextMessage: + ctrl, err := decodeControl(data) + if err != nil { + continue + } + + if ctrl.Kind == controlKindResize { + e.hub.Resize(Dimensions{Cols: ctrl.Cols, Rows: ctrl.Rows}) + } + } + } +} + +// HandleDelete revokes a share, immediately disconnecting all guests. The gateway authenticates the +// namespace owner; only shares belonging to their tenant can be revoked. +func (h *Handlers) HandleDelete(c echo.Context) error { + tenantID := c.Request().Header.Get("X-Tenant-ID") + if tenantID == "" { + return c.NoContent(http.StatusUnauthorized) + } + + token := c.Param("token") + + e, ok := h.registry.get(token) + if !ok || e.tenantID != tenantID { + return c.NoContent(http.StatusNotFound) + } + + h.registry.remove(token) + + return c.NoContent(http.StatusNoContent) +} + +// HandleView serves a public, read-only guest viewer. It subscribes to the share's hub and writes +// every broadcast frame to the websocket. Inbound frames from the guest are discarded (read-only). +func (h *Handlers) HandleView(c echo.Context) error { + token := c.Param("token") + + e, ok := h.registry.get(token) + if !ok { + return c.NoContent(http.StatusNotFound) + } + + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer conn.Close() + + // Handshake: tell the guest whether the share is collaborative so it can enable input. This is + // written before the subscribe loop starts, so it is the only writer at this point. + if init, err := encodeInit(e.name, e.writable); err == nil { + _ = conn.WriteMessage(websocket.TextMessage, init) + } + + sub := e.hub.Subscribe() + defer e.hub.Unsubscribe(sub) + + // Read frames from the guest: in collaborative mode binary frames are keystrokes forwarded to + // the host; otherwise everything is discarded (read-only). + go func() { + for { + typ, data, err := conn.ReadMessage() + if err != nil { + conn.Close() + + return + } + + if e.writable && typ == websocket.BinaryMessage { + e.hub.SendInput(data) + } + } + }() + + for { + select { + case <-e.hub.Done(): + return nil + case msg, open := <-sub.out: + if !open { + return nil + } + + if err := conn.WriteMessage(msg.typ, msg.data); err != nil { + log.WithError(err).Debug("failed to write share frame to guest") + + return nil + } + } + } +} diff --git a/ssh/web/share/hub.go b/ssh/web/share/hub.go new file mode 100644 index 00000000000..4665ae8929b --- /dev/null +++ b/ssh/web/share/hub.go @@ -0,0 +1,192 @@ +package share + +import ( + "sync" + + "github.com/gorilla/websocket" +) + +// ringCapacity is the number of recent output bytes kept so a guest joining an in-progress +// session is sent the current screen contents before live output starts flowing. +const ringCapacity = 128 * 1024 + +// subscriberBuffer is the number of pending frames a single guest may lag behind before it is +// dropped. A read-only viewer that cannot keep up is disconnected rather than back-pressuring +// the producer (the agent). +const subscriberBuffer = 256 + +// Dimensions holds terminal geometry forwarded from the host so guests can mirror its size. +type Dimensions struct { + Cols int `json:"cols"` + Rows int `json:"rows"` +} + +// message is a single frame queued to a guest. typ is a gorilla websocket message type +// (websocket.BinaryMessage for raw PTY output, websocket.TextMessage for JSON control frames). +type message struct { + typ int + data []byte +} + +// subscriber represents a single connected guest. +type subscriber struct { + out chan message +} + +// ringBuffer keeps the last ringCapacity bytes of output as a flat slice. +type ringBuffer struct { + buf []byte +} + +func (r *ringBuffer) write(p []byte) { + r.buf = append(r.buf, p...) + if len(r.buf) > ringCapacity { + r.buf = r.buf[len(r.buf)-ringCapacity:] + } +} + +func (r *ringBuffer) snapshot() []byte { + if len(r.buf) == 0 { + return nil + } + + return append([]byte(nil), r.buf...) +} + +// inputBuffer bounds how much pending guest input may queue before keystrokes are dropped. +const inputBuffer = 256 + +// Hub fans out a single producer's terminal output (the agent) to N consumers (guests), and — in +// collaborative mode — fans guest input back in to the producer. It is independent of the +// Enterprise session recorder, so it works on the Community Edition. +type Hub struct { + mu sync.Mutex + subscribers map[*subscriber]struct{} + ring *ringBuffer + lastResize *Dimensions + input chan []byte + done chan struct{} + closeOnce sync.Once +} + +func newHub() *Hub { + return &Hub{ + subscribers: make(map[*subscriber]struct{}), + ring: &ringBuffer{}, + input: make(chan []byte, inputBuffer), + done: make(chan struct{}), + } +} + +// SendInput queues guest keystrokes to be forwarded to the producer (collaborative mode). It never +// blocks: if the producer is slow, excess input is dropped rather than stalling the guest. +func (h *Hub) SendInput(data []byte) { + select { + case h.input <- data: + case <-h.done: + default: + } +} + +// Input is drained by the producer handler to write guest keystrokes into the host PTY. +func (h *Hub) Input() <-chan []byte { + return h.input +} + +// Subscribe registers a new guest and seeds it with the last known terminal size and the current +// screen contents, so a late joiner immediately sees the session as it stands. +func (h *Hub) Subscribe() *subscriber { + s := &subscriber{out: make(chan message, subscriberBuffer)} + + h.mu.Lock() + defer h.mu.Unlock() + + if h.lastResize != nil { + if data, err := encodeResize(*h.lastResize); err == nil { + s.out <- message{typ: websocket.TextMessage, data: data} + } + } + + if snapshot := h.ring.snapshot(); snapshot != nil { + s.out <- message{typ: websocket.BinaryMessage, data: snapshot} + } + + h.subscribers[s] = struct{}{} + + return s +} + +// Unsubscribe removes a guest. It is safe to call even if the subscriber was already dropped. +func (h *Hub) Unsubscribe(s *subscriber) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, ok := h.subscribers[s]; ok { + delete(h.subscribers, s) + close(s.out) + } +} + +// Output records and broadcasts raw PTY output to every guest. +func (h *Hub) Output(data []byte) { + h.mu.Lock() + defer h.mu.Unlock() + + h.ring.write(data) + h.broadcast(message{typ: websocket.BinaryMessage, data: append([]byte(nil), data...)}) +} + +// Resize stores and broadcasts a terminal size change. +func (h *Hub) Resize(dim Dimensions) { + encoded, err := encodeResize(dim) + if err != nil { + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + h.lastResize = &dim + h.broadcast(message{typ: websocket.TextMessage, data: encoded}) +} + +// broadcast pushes a frame to every subscriber. A subscriber whose buffer is full is dropped +// (closed) so a single slow guest never stalls the producer. Must be called with h.mu held. +func (h *Hub) broadcast(msg message) { + for s := range h.subscribers { + select { + case s.out <- msg: + default: + delete(h.subscribers, s) + close(s.out) + } + } +} + +// Close tears down the hub when the producer disconnects, closing all guest channels. +func (h *Hub) Close() { + h.closeOnce.Do(func() { + h.mu.Lock() + defer h.mu.Unlock() + + for s := range h.subscribers { + delete(h.subscribers, s) + close(s.out) + } + + close(h.done) + }) +} + +// Done is closed once the hub is torn down. +func (h *Hub) Done() <-chan struct{} { + return h.done +} + +// Viewers returns the number of guests currently watching. +func (h *Hub) Viewers() int { + h.mu.Lock() + defer h.mu.Unlock() + + return len(h.subscribers) +} diff --git a/ssh/web/share/hub_test.go b/ssh/web/share/hub_test.go new file mode 100644 index 00000000000..da06af8d97a --- /dev/null +++ b/ssh/web/share/hub_test.go @@ -0,0 +1,100 @@ +package share + +import ( + "testing" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func drain(t *testing.T, s *subscriber) []message { + t.Helper() + + var msgs []message + for { + select { + case m, ok := <-s.out: + if !ok { + return msgs + } + msgs = append(msgs, m) + default: + return msgs + } + } +} + +func TestHubBroadcastsToAllSubscribers(t *testing.T) { + hub := newHub() + + a := hub.Subscribe() + b := hub.Subscribe() + + hub.Output([]byte("hello")) + + for _, sub := range []*subscriber{a, b} { + msgs := drain(t, sub) + require.Len(t, msgs, 1) + assert.Equal(t, websocket.BinaryMessage, msgs[0].typ) + assert.Equal(t, []byte("hello"), msgs[0].data) + } +} + +func TestHubLateJoinerReceivesRingAndResize(t *testing.T) { + hub := newHub() + + hub.Resize(Dimensions{Cols: 120, Rows: 40}) + hub.Output([]byte("scrollback")) + + late := hub.Subscribe() + msgs := drain(t, late) + + require.Len(t, msgs, 2) + assert.Equal(t, websocket.TextMessage, msgs[0].typ) + assert.Equal(t, websocket.BinaryMessage, msgs[1].typ) + assert.Equal(t, []byte("scrollback"), msgs[1].data) +} + +func TestHubDropsSlowConsumer(t *testing.T) { + hub := newHub() + + slow := hub.Subscribe() + + // Overflow the bounded buffer; the slow consumer must be dropped, never blocking the producer. + for i := 0; i < subscriberBuffer+10; i++ { + hub.Output([]byte("x")) + } + + hub.mu.Lock() + _, present := hub.subscribers[slow] + hub.mu.Unlock() + + assert.False(t, present, "slow consumer should have been dropped") + + _, open := <-slow.out + // Channel is closed for a dropped consumer; eventually a receive returns !ok. + for open { + _, open = <-slow.out + } +} + +func TestHubCloseClosesSubscribers(t *testing.T) { + hub := newHub() + + sub := hub.Subscribe() + hub.Close() + + // Done channel is closed. + select { + case <-hub.Done(): + default: + t.Fatal("expected Done to be closed") + } + + // Drain any buffered frames, then the channel must be closed. + open := true + for open { + _, open = <-sub.out + } +} diff --git a/ssh/web/share/protocol.go b/ssh/web/share/protocol.go new file mode 100644 index 00000000000..701c11c4120 --- /dev/null +++ b/ssh/web/share/protocol.go @@ -0,0 +1,37 @@ +package share + +import "encoding/json" + +// control is the envelope for non-output (text) frames exchanged over the share websocket. +// Output (PTY bytes) travels as raw binary frames; everything else is a JSON control frame. +type control struct { + Kind string `json:"kind"` + Cols int `json:"cols,omitempty"` + Rows int `json:"rows,omitempty"` + Writable bool `json:"writable,omitempty"` + Name string `json:"name,omitempty"` +} + +const ( + controlKindResize = "resize" + controlKindInit = "init" +) + +// encodeResize encodes a terminal size change as a JSON control frame. +func encodeResize(dim Dimensions) ([]byte, error) { + return json.Marshal(control{Kind: controlKindResize, Cols: dim.Cols, Rows: dim.Rows}) +} + +// encodeInit encodes the handshake frame sent to a guest on connect, carrying the share's label +// and whether it is collaborative (writable). +func encodeInit(name string, writable bool) ([]byte, error) { + return json.Marshal(control{Kind: controlKindInit, Writable: writable, Name: name}) +} + +// decodeControl parses a JSON control frame received from the producer (agent). +func decodeControl(data []byte) (control, error) { + var ctrl control + err := json.Unmarshal(data, &ctrl) + + return ctrl, err +} diff --git a/ssh/web/share/register.go b/ssh/web/share/register.go new file mode 100644 index 00000000000..4529abadb89 --- /dev/null +++ b/ssh/web/share/register.go @@ -0,0 +1,23 @@ +package share + +import ( + "github.com/labstack/echo/v4" + "github.com/shellhub-io/shellhub/pkg/api/internalclient" +) + +// Register wires the shareable-terminal routes into the given Echo router. +// +// - GET /ssh/shares list active shares for the namespace (user-authenticated) +// - POST /ssh/shares create a share (agent-authenticated via the gateway) +// - DELETE /ssh/shares/:token revoke a share (namespace owner, user-authenticated) +// - GET /ssh/shares/:token/stream producer stream pushed by the agent (authenticated) +// - GET /ws/share/:token public guest viewer (no authentication) +func Register(router *echo.Echo, registry *Registry, cli internalclient.Client) { + h := &Handlers{registry: registry, cli: cli} + + router.GET("/ssh/shares", h.HandleList) + router.POST("/ssh/shares", h.HandleCreate) + router.DELETE("/ssh/shares/:token", h.HandleDelete) + router.GET("/ssh/shares/:token/stream", h.HandleStream) + router.GET("/ws/share/:token", h.HandleView) +} diff --git a/ssh/web/share/registry.go b/ssh/web/share/registry.go new file mode 100644 index 00000000000..40d1555301a --- /dev/null +++ b/ssh/web/share/registry.go @@ -0,0 +1,140 @@ +package share + +import ( + "sync" + "time" + + "github.com/shellhub-io/shellhub/pkg/clock" + "github.com/shellhub-io/shellhub/pkg/models" + "github.com/shellhub-io/shellhub/pkg/uuid" +) + +// entry holds a live share together with the device that owns it and its expiry. +type entry struct { + hub *Hub + deviceUID string + tenantID string + name string + command string + writable bool + createdAt time.Time + expiresAt time.Time +} + +// registry tracks live shares in memory. Each share's hub is in-memory by nature (it holds live +// websocket subscribers), so the registry itself is the source of truth and an expiry timer +// cleans up abandoned shares — mirroring the TTL approach used by the web-terminal manager. +type Registry struct { + mu sync.RWMutex + entries map[string]*entry + ttl time.Duration +} + +func NewRegistry(ttl time.Duration) *Registry { + return &Registry{ + entries: make(map[string]*entry), + ttl: ttl, + } +} + +// create allocates a new share for the given device and returns its token, hub and expiry. +func (r *Registry) create(deviceUID, tenantID string, req models.ShareCreateRequest) (string, *entry) { + token := uuid.Generate() + + now := clock.Now() + + // Resolve the requested lifetime: a negative TTL means never expire (the share only ends when + // the producer disconnects); zero falls back to the server default; positive is a custom span. + ttl := r.ttl + noExpiry := req.TTLSeconds < 0 + if req.TTLSeconds > 0 { + ttl = time.Duration(req.TTLSeconds) * time.Second + } + + var expiresAt time.Time + if !noExpiry { + expiresAt = now.Add(ttl) + } + + e := &entry{ + hub: newHub(), + deviceUID: deviceUID, + tenantID: tenantID, + name: req.Name, + command: req.Command, + writable: req.Writable, + createdAt: now, + expiresAt: expiresAt, + } + + r.mu.Lock() + r.entries[token] = e + r.mu.Unlock() + + if !noExpiry { + go time.AfterFunc(ttl, func() { + r.remove(token) + }) + } + + return token, e +} + +// CreateLocal registers a share whose producer is an in-process terminal session (e.g. the web +// console) instead of an external agent stream. It returns the token, the hub to feed output into +// and drain guest input from, and a close function to tear the share down when the session ends. +func (r *Registry) CreateLocal(deviceUID, tenantID string, req models.ShareCreateRequest) (string, *Hub, func()) { + token, e := r.create(deviceUID, tenantID, req) + + return token, e.hub, func() { r.remove(token) } +} + +// get returns the share entry for a token if it exists and has not expired. +func (r *Registry) get(token string) (*entry, bool) { + r.mu.RLock() + e, ok := r.entries[token] + r.mu.RUnlock() + + if !ok { + return nil, false + } + + if !e.expiresAt.IsZero() && clock.Now().After(e.expiresAt) { + r.remove(token) + + return nil, false + } + + return e, true +} + +// list returns all live (non-expired) shares belonging to the given tenant. +func (r *Registry) list(tenantID string) map[string]*entry { + now := clock.Now() + + r.mu.RLock() + defer r.mu.RUnlock() + + out := make(map[string]*entry) + for token, e := range r.entries { + if e.tenantID == tenantID && (e.expiresAt.IsZero() || !now.After(e.expiresAt)) { + out[token] = e + } + } + + return out +} + +// remove deletes a share and tears down its hub. +func (r *Registry) remove(token string) { + r.mu.Lock() + e, ok := r.entries[token] + if ok { + delete(r.entries, token) + } + r.mu.Unlock() + + if ok { + e.hub.Close() + } +} diff --git a/ssh/web/web.go b/ssh/web/web.go index 8ff36ce57f7..85768b287a1 100644 --- a/ssh/web/web.go +++ b/ssh/web/web.go @@ -9,12 +9,13 @@ import ( "github.com/shellhub-io/shellhub/pkg/cache" "github.com/shellhub-io/shellhub/ssh/pkg/magickey" "github.com/shellhub-io/shellhub/ssh/web/pkg/token" + "github.com/shellhub-io/shellhub/ssh/web/share" log "github.com/sirupsen/logrus" "golang.org/x/net/websocket" ) // NewSSHServerBridge creates routes into a [echo.Router] to connect a webscoket to SSH using Shell session. -func NewSSHServerBridge(router *echo.Echo, cache cache.Cache) { +func NewSSHServerBridge(router *echo.Echo, cache cache.Cache, shares *share.Registry) { const WebsocketSSHBridgeRoute = "/ws/ssh" manager := newManager(30 * time.Second) @@ -127,6 +128,7 @@ func NewSSHServerBridge(router *echo.Echo, cache cache.Cache) { creds, Dimensions{cols, rows}, Info{IP: ip}, + shares, ); err != nil { exit(wsconn, err) diff --git a/ssh/web/web_test.go b/ssh/web/web_test.go index 21f64f0ee95..838eeab47d0 100644 --- a/ssh/web/web_test.go +++ b/ssh/web/web_test.go @@ -5,9 +5,11 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/labstack/echo/v4" cachemock "github.com/shellhub-io/shellhub/pkg/cache/mocks" + "github.com/shellhub-io/shellhub/ssh/web/share" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/net/websocket" @@ -17,7 +19,7 @@ func TestNewSSHServerBridge_CredentialsNotFound(t *testing.T) { e := echo.New() cache := new(cachemock.Cache) - NewSSHServerBridge(e, cache) + NewSSHServerBridge(e, cache, share.NewRegistry(time.Hour)) server := httptest.NewServer(e) defer server.Close() From ad15c8eee709a37305ca5243b15faca09f65f7b3 Mon Sep 17 00:00:00 2001 From: "Luis Gustavo S. Barreto" Date: Fri, 5 Jun 2026 11:01:49 -0300 Subject: [PATCH 2/4] feat(agent): add share subcommand and host wrapper `shellhub-agent share [-- command]` hosts a command in a PTY and exposes it as a public shareable terminal. Flags: --name, --write (collaborative), --duration (time limit) and --user. The command runs on the host using the same path as SSH sessions (command.NewCmd, i.e. nsenter+setpriv in Docker mode) as the resolved user. Adds a host wrapper (scripts/shellhub-agent) to install at /usr/local/bin: it auto-detects the agent container and binary and forwards the invoking host user via SHELLHUB_SHARE_USER, so the shared command runs on the host as that user with no configuration. --- agent/go.mod | 1 + agent/main.go | 60 +++++++ agent/scripts/shellhub-agent | 59 +++++++ agent/share.go | 297 +++++++++++++++++++++++++++++++++++ 4 files changed, 417 insertions(+) create mode 100755 agent/scripts/shellhub-agent create mode 100644 agent/share.go diff --git a/agent/go.mod b/agent/go.mod index f5728b3271f..64ad6c100cc 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -22,6 +22,7 @@ require ( github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.53.0 golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 ) require ( diff --git a/agent/main.go b/agent/main.go index d7605e6888d..cf3d634df73 100644 --- a/agent/main.go +++ b/agent/main.go @@ -313,6 +313,66 @@ func main() { }, }) + shareCmd := &cobra.Command{ // nolint: exhaustruct + Use: "share [-- command...]", + Short: "Share a local terminal over a public link", + Long: `Spawn a command (or your login shell) inside a PTY and expose it as a public shared +terminal — like tmate or upterm. Open the link in a browser to watch the session live, without +signing in. The share is read-only by default; use --write to let guests type. The share always +ends when the command exits; --duration sets an additional time limit (--duration 0 disables it). +You keep using the terminal normally.`, + Args: cobra.ArbitraryArgs, + Run: func(cmd *cobra.Command, args []string) { + loglevel.SetLogLevel() + + cfg, _, err := LoadConfigFromEnv() + if err != nil { + log.WithError(err).Fatal("Failed to load the configuration from the environmental variables") + } + + name, _ := cmd.Flags().GetString("name") + writable, _ := cmd.Flags().GetBool("write") + + // User precedence: explicit --user flag, else the SHELLHUB_SHARE_USER env (set by the + // host wrapper to the invoking user), else the flag default. + user, _ := cmd.Flags().GetString("user") + if !cmd.Flags().Changed("user") { + if envUser := os.Getenv("SHELLHUB_SHARE_USER"); envUser != "" { + user = envUser + } + } + + // Resolve the lifetime: flag not set -> server default (0); set to 0 -> no expiry (-1); + // set to a positive duration -> that many seconds. + ttlSeconds := 0 + if cmd.Flags().Changed("duration") { + duration, _ := cmd.Flags().GetDuration("duration") + if duration <= 0 { + ttlSeconds = -1 + } else { + ttlSeconds = int(duration.Seconds()) + } + } + + opts := ShareOptions{ + Command: args, + Name: name, + Writable: writable, + TTLSeconds: ttlSeconds, + User: user, + } + + if err := NewShareSession(cfg, opts).Run(cmd.Context()); err != nil { + log.WithError(err).Fatal("Failed to share the terminal") + } + }, + } + shareCmd.Flags().String("name", "", "Optional label for the share, shown in the namespace's list") + shareCmd.Flags().Bool("write", false, "Allow guests to type into the session (collaborative mode)") + shareCmd.Flags().Duration("duration", 0, "Time limit for the share (e.g. 30m, 2h); 0 means no time limit") + shareCmd.Flags().String("user", "root", "Host user to run the command as") + rootCmd.AddCommand(shareCmd) + registerInstallerCommands(rootCmd) rootCmd.AddCommand(&cobra.Command{ // nolint: exhaustruct diff --git a/agent/scripts/shellhub-agent b/agent/scripts/shellhub-agent new file mode 100755 index 00000000000..3bae2d59456 --- /dev/null +++ b/agent/scripts/shellhub-agent @@ -0,0 +1,59 @@ +#!/bin/sh +# +# ShellHub agent host wrapper. +# +# Install this at /usr/local/bin/shellhub-agent on the host where the agent +# container runs, so host users can invoke the agent CLI directly: +# +# shellhub-agent share -- bash +# +# The agent itself runs inside a container, so a plain `share` would spawn the +# command inside that container. This wrapper forwards the invocation into the +# agent container with `docker exec`, and passes the invoking host user via +# SHELLHUB_SHARE_USER so the command runs on the host as that user (the agent +# resolves it through the host's /etc/passwd, the same way SSH sessions do). +# +# Everything is auto-detected — no configuration needed in either development or +# production. The env vars below are optional overrides: +# +# SHELLHUB_AGENT_CONTAINER force a specific container/name +# SHELLHUB_AGENT_BIN force the agent binary path inside the container +# +set -eu + +container="${SHELLHUB_AGENT_CONTAINER:-}" +if [ -z "$container" ]; then + # Prefer a Compose-managed `agent` service (development stack and most deployments), + # then a plainly-named container, then anything from the official agent image. + for filter in \ + 'label=com.docker.compose.service=agent' \ + 'name=shellhub-agent' \ + 'ancestor=shellhubio/agent'; do + container=$(docker ps --filter "$filter" --format '{{.Names}}' 2>/dev/null | head -n1) + [ -n "$container" ] && break + done +fi + +if [ -z "$container" ]; then + echo "shellhub-agent: could not find a running agent container." >&2 + echo "Set SHELLHUB_AGENT_CONTAINER to its name." >&2 + exit 1 +fi + +bin="${SHELLHUB_AGENT_BIN:-}" +if [ -z "$bin" ]; then + # Development runs under air with the binary at /tmp/air/main; production has it on PATH. + if docker exec "$container" test -x /tmp/air/main 2>/dev/null; then + bin=/tmp/air/main + else + bin=shellhub-agent + fi +fi + +# Allocate a TTY only when one is attached, so the wrapper also works in pipelines/scripts. +tty="-i" +[ -t 0 ] && tty="-it" + +exec docker exec "$tty" \ + -e "SHELLHUB_SHARE_USER=$(id -un)" \ + "$container" "$bin" "$@" diff --git a/agent/share.go b/agent/share.go new file mode 100644 index 00000000000..19906b1ccf3 --- /dev/null +++ b/agent/share.go @@ -0,0 +1,297 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "os/signal" + "strings" + "sync" + "syscall" + + creackpty "github.com/creack/pty" + "github.com/gorilla/websocket" + "github.com/shellhub-io/shellhub/agent/pkg/osauth" + "github.com/shellhub-io/shellhub/agent/server/modes/host/command" + "github.com/shellhub-io/shellhub/pkg/api/client" + "github.com/shellhub-io/shellhub/pkg/models" + log "github.com/sirupsen/logrus" + "golang.org/x/term" +) + +// ShareSession hosts a command inside a PTY and exposes its output as a public shareable terminal +// (tmate/upterm style). The local user keeps using the terminal normally while remote guests watch +// live through a web link; in collaborative mode they can also type. +// +// ShareOptions holds the tunables for a share session. +type ShareOptions struct { + // Command to run; empty means the user's login shell. + Command []string + // Name is an optional label shown in the namespace's list. + Name string + // Writable enables collaborative input (guests can type). + Writable bool + // TTLSeconds controls the share lifetime: 0 = server default, <0 = no expiry, >0 = custom. + TTLSeconds int + // User is the host account the command runs as (resolved via the OS, like an SSH login). + User string +} + +type ShareSession struct { + config *Config + opts ShareOptions +} + +// NewShareSession creates a share session with the given options. +func NewShareSession(config *Config, opts ShareOptions) *ShareSession { + return &ShareSession{config: config, opts: opts} +} + +// wsWriter serializes writes to the upstream websocket so the output and resize goroutines never +// write to the connection concurrently (gorilla forbids concurrent writers). +type wsWriter struct { + mu sync.Mutex + conn *websocket.Conn +} + +func (w *wsWriter) output(p []byte) error { + w.mu.Lock() + defer w.mu.Unlock() + + return w.conn.WriteMessage(websocket.BinaryMessage, p) +} + +func (w *wsWriter) resize(cols, rows int) error { + data, err := json.Marshal(map[string]any{"kind": "resize", "cols": cols, "rows": rows}) + if err != nil { + return err + } + + w.mu.Lock() + defer w.mu.Unlock() + + return w.conn.WriteMessage(websocket.TextMessage, data) +} + +// buildCmd resolves the host user and builds the command to run inside the PTY. It uses the same +// host-execution path as SSH sessions (command.NewCmd), so in Docker mode the command runs on the +// host via nsenter/setpriv as the resolved user, rather than inside the agent container. +func (s *ShareSession) buildCmd(host string) (*exec.Cmd, error) { + username := s.opts.User + if username == "" { + username = "root" + } + + user, err := osauth.LookupUser(username) + if err != nil { + return nil, fmt.Errorf("failed to look up user %q: %w", username, err) + } + + shell := user.Shell + if shell == "" { + shell = "/bin/sh" + } + + term := os.Getenv("TERM") + if term == "" { + term = "xterm" + } + + argv := s.opts.Command + if len(argv) == 0 { + argv = []string{shell, "--login"} + } + + return command.NewCmd(user, shell, term, host, nil, argv...), nil +} + +// createShare registers the share on the server and returns the public link. +func (s *ShareSession) createShare(ctx context.Context, token string, cols, rows int) (*models.ShareCreateResponse, error) { + label := strings.Join(s.opts.Command, " ") + if label == "" { + label = "login shell" + } + + payload, err := json.Marshal(models.ShareCreateRequest{ + Name: s.opts.Name, + Command: label, + Writable: s.opts.Writable, + TTLSeconds: s.opts.TTLSeconds, + Term: os.Getenv("TERM"), + Cols: cols, + Rows: rows, + }) + if err != nil { + return nil, err + } + + url := strings.TrimRight(s.config.ServerAddress, "/") + "/ssh/shares" + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to reach the server: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server rejected the share request with status %d", res.StatusCode) + } + + var share models.ShareCreateResponse + if err := json.NewDecoder(res.Body).Decode(&share); err != nil { + return nil, fmt.Errorf("failed to decode the share response: %w", err) + } + + return &share, nil +} + +// dialStream opens the producer websocket used to push PTY output to the server. +func (s *ShareSession) dialStream(ctx context.Context, token, authToken string) (*websocket.Conn, error) { + url := fmt.Sprintf("%s/ssh/shares/%s/stream", strings.TrimRight(s.config.ServerAddress, "/"), token) + + conn, _, err := client.DialContext(ctx, url, http.Header{ + "Authorization": []string{"Bearer " + authToken}, + }) + if err != nil { + return nil, fmt.Errorf("failed to open the share stream: %w", err) + } + + return conn, nil +} + +// Run authenticates the device, spawns the command in a PTY and streams it to the server until the +// command exits. +func (s *ShareSession) Run(ctx context.Context) error { + ag, err := NewAgentWithConfig(s.config, new(HostMode)) + if err != nil { + return fmt.Errorf("failed to create agent: %w", err) + } + + if err := ag.Initialize(); err != nil { + return fmt.Errorf("failed to authenticate device: %w", err) + } + + authToken := ag.authData.Token + + cols, rows := 80, 24 + if w, h, err := term.GetSize(int(os.Stdin.Fd())); err == nil { + cols, rows = w, h + } + + // Start the PTY before registering the share, so a bad command never leaves an orphaned share + // dangling in the namespace's list. + cmd, err := s.buildCmd(ag.authData.Name) + if err != nil { + return err + } + + ptmx, err := creackpty.Start(cmd) + if err != nil { + return fmt.Errorf("failed to start pty: %w", err) + } + defer func() { _ = ptmx.Close() }() + + _ = creackpty.Setsize(ptmx, &creackpty.Winsize{Rows: uint16(rows), Cols: uint16(cols)}) //nolint:gosec + + share, err := s.createShare(ctx, authToken, cols, rows) + if err != nil { + return err + } + + conn, err := s.dialStream(ctx, share.Token, authToken) + if err != nil { + return err + } + defer conn.Close() + + upstream := &wsWriter{conn: conn} + _ = upstream.resize(cols, rows) + + access := "public, read-only" + if s.opts.Writable { + access = "public, collaborative — guests can type" + } + + fmt.Printf("\r\nSharing this terminal (%s). Anyone with this link can watch live:\r\n\r\n %s\r\n\r\nThe share ends when this command exits (press Ctrl-D).\r\n\r\n", access, share.URL) + + // In collaborative mode, guest keystrokes arrive as binary frames on the producer connection; + // write them straight into the PTY so they reach the running command. + if s.opts.Writable { + go func() { + for { + typ, data, err := conn.ReadMessage() + if err != nil { + return + } + + if typ == websocket.BinaryMessage { + _, _ = ptmx.Write(data) + } + } + }() + } + + // Put the local terminal in raw mode so the spawned command behaves interactively. + if oldState, err := term.MakeRaw(int(os.Stdin.Fd())); err == nil { + defer term.Restore(int(os.Stdin.Fd()), oldState) //nolint:errcheck + } + + // Forward local window resizes to the PTY and to the guests. + winch := make(chan os.Signal, 1) + signal.Notify(winch, syscall.SIGWINCH) + defer signal.Stop(winch) + + go func() { + for range winch { + w, h, err := term.GetSize(int(os.Stdin.Fd())) + if err != nil { + continue + } + + _ = creackpty.Setsize(ptmx, &creackpty.Winsize{Rows: uint16(h), Cols: uint16(w)}) //nolint:gosec + _ = upstream.resize(w, h) + } + }() + + // Local user input always goes to the PTY. Remote guest input is additionally forwarded above + // when the share is collaborative. + go func() { _, _ = io.Copy(ptmx, os.Stdin) }() + + // PTY output goes to the local terminal AND, best-effort, to the upstream stream. An upstream + // failure must never tear down the local session, so it is handled independently of stdout. + buf := make([]byte, 32*1024) + upstreamAlive := true + + for { + n, readErr := ptmx.Read(buf) + if n > 0 { + _, _ = os.Stdout.Write(buf[:n]) + + if upstreamAlive { + if err := upstream.output(buf[:n]); err != nil { + upstreamAlive = false + log.WithError(err).Debug("share stream closed; continuing local session") + } + } + } + + if readErr != nil { + break + } + } + + return nil +} From e5faaaeed145b4d32ba131711eeeb06e54b053a2 Mon Sep 17 00:00:00 2001 From: "Luis Gustavo S. Barreto" Date: Fri, 5 Jun 2026 11:02:04 -0300 Subject: [PATCH 3/4] feat(ui-react): add shareable terminal UI Adds a "Share" action to the console terminal that creates a public link to the live session (name, time limit, collaborative toggle); a public, no-login viewer page at /share/:token (xterm, themed, mirrors host geometry, read-only or collaborative, with clear ended/expired states); and a "Shared Terminals" page listing a namespace's active shares with live viewer counts, duration, mode and a revoke action. --- ui/apps/console/src/App.tsx | 5 + .../console/src/components/layout/Sidebar.tsx | 6 + .../components/terminal/TerminalControls.tsx | 18 ++ .../components/terminal/TerminalInstance.tsx | 20 ++ .../terminal/TerminalShareDialog.tsx | 214 ++++++++++++++++ .../src/components/terminal/terminalErrors.ts | 8 +- ui/apps/console/src/hooks/useShares.ts | 36 +++ ui/apps/console/src/pages/SharedTerminal.tsx | 231 +++++++++++++++++ ui/apps/console/src/pages/SharedTerminals.tsx | 235 ++++++++++++++++++ ui/apps/console/src/stores/terminalStore.ts | 38 +++ .../console/src/stores/terminalThemeStore.ts | 2 +- ui/apps/console/src/types/share.ts | 14 ++ 12 files changed, 825 insertions(+), 2 deletions(-) create mode 100644 ui/apps/console/src/components/terminal/TerminalShareDialog.tsx create mode 100644 ui/apps/console/src/hooks/useShares.ts create mode 100644 ui/apps/console/src/pages/SharedTerminal.tsx create mode 100644 ui/apps/console/src/pages/SharedTerminals.tsx create mode 100644 ui/apps/console/src/types/share.ts diff --git a/ui/apps/console/src/App.tsx b/ui/apps/console/src/App.tsx index 3010345ebb5..c842990a64a 100644 --- a/ui/apps/console/src/App.tsx +++ b/ui/apps/console/src/App.tsx @@ -31,6 +31,8 @@ const ContainerDetails = lazy(() => import("./pages/ContainerDetails")); const Sessions = lazy(() => import("./pages/sessions")); const SessionDetails = lazy(() => import("./pages/SessionDetails")); const NotFound = lazy(() => import("./pages/NotFound")); +const SharedTerminal = lazy(() => import("./pages/SharedTerminal")); +const SharedTerminals = lazy(() => import("./pages/SharedTerminals")); const PublicKeys = lazy(() => import("./pages/public-keys")); const DeviceDetails = lazy(() => import("./pages/DeviceDetails")); const AddDevice = lazy(() => import("./pages/AddDevice")); @@ -200,6 +202,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> + {/* Public, read-only shared terminal — intentionally outside every auth/setup guard. */} + } /> } /> diff --git a/ui/apps/console/src/components/layout/Sidebar.tsx b/ui/apps/console/src/components/layout/Sidebar.tsx index 3ba9486137f..a6a6b6e37d5 100644 --- a/ui/apps/console/src/components/layout/Sidebar.tsx +++ b/ui/apps/console/src/components/layout/Sidebar.tsx @@ -12,6 +12,7 @@ import { CubeIcon, GlobeAltIcon, ShieldExclamationIcon, + ShareIcon, } from "@heroicons/react/24/outline"; import SidebarShell, { NavItemLink, navIcon } from "./SidebarShell"; import CommandPaletteTrigger from "./CommandPaletteTrigger"; @@ -48,6 +49,11 @@ function buildSections(): NavSection[] { label: "Sessions", icon: , }, + { + to: "/shared-terminals", + label: "Shared Terminals", + icon: , + }, ]; if (config.webEndpoints && (config.cloud || config.enterprise)) { diff --git a/ui/apps/console/src/components/terminal/TerminalControls.tsx b/ui/apps/console/src/components/terminal/TerminalControls.tsx index 802dcfd41dc..bd732f91d17 100644 --- a/ui/apps/console/src/components/terminal/TerminalControls.tsx +++ b/ui/apps/console/src/components/terminal/TerminalControls.tsx @@ -4,10 +4,12 @@ import { XMarkIcon, Cog6ToothIcon, MinusIcon, + ShareIcon, } from "@heroicons/react/24/outline"; import { useTerminalStore } from "@/stores/terminalStore"; import type { TerminalSession } from "@/stores/terminalStore"; import TerminalSettingsDrawer from "./TerminalSettingsDrawer"; +import TerminalShareDialog from "./TerminalShareDialog"; /** Terminal info shown on the left side of the AppBar */ export function TerminalInfo({ session }: { session: TerminalSession }) { @@ -43,6 +45,7 @@ export function TerminalInfo({ session }: { session: TerminalSession }) { export function TerminalActions({ session }: { session: TerminalSession }) { const { minimize, toggleFullscreen, close } = useTerminalStore(); const [settingsOpen, setSettingsOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); const isFullscreen = session.state === "fullscreen"; return ( @@ -93,6 +96,15 @@ export function TerminalActions({ session }: { session: TerminalSession }) { + {/* Share */} + + {/* Settings */} + setShareOpen(false)} + /> + {createPortal( s.sessions.find((ss) => ss.id === session.id)?.pendingShare, + ); + const updateStatus = useCallback( (s: "connecting" | "connected" | "disconnected") => { useTerminalStore.getState().setConnectionStatus(session.id, s); @@ -186,6 +191,11 @@ export default function TerminalInstance({ setError(resolveError(msg.data, session.deviceUid)); break; } + case WS_KIND.SHARE: { + // Server returned the share token for this session. + useTerminalStore.getState().setShareToken(session.id, msg.data); + break; + } default: break; } @@ -267,6 +277,16 @@ export default function TerminalInstance({ fitRef.current?.fit(); }, [fontSize]); + // Forward a pending share request over this session's websocket. + useEffect(() => { + if (!pendingShare) return; + const ws = wsRef.current; + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ kind: WS_KIND.SHARE, data: pendingShare })); + useTerminalStore.getState().clearPendingShare(session.id); + } + }, [pendingShare, session.id]); + // Hide cursor on error useEffect(() => { const term = termRef.current; diff --git a/ui/apps/console/src/components/terminal/TerminalShareDialog.tsx b/ui/apps/console/src/components/terminal/TerminalShareDialog.tsx new file mode 100644 index 00000000000..1894e1db81e --- /dev/null +++ b/ui/apps/console/src/components/terminal/TerminalShareDialog.tsx @@ -0,0 +1,214 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { + XMarkIcon, + ShareIcon, + ClipboardDocumentIcon, + CheckIcon, + ArrowTopRightOnSquareIcon, +} from "@heroicons/react/24/outline"; +import { useTerminalStore } from "@/stores/terminalStore"; +import type { TerminalSession } from "@/stores/terminalStore"; + +const DURATIONS: { label: string; ttl: number }[] = [ + { label: "Default", ttl: 0 }, + { label: "30 minutes", ttl: 30 * 60 }, + { label: "1 hour", ttl: 60 * 60 }, + { label: "4 hours", ttl: 4 * 60 * 60 }, + { label: "No limit", ttl: -1 }, +]; + +export default function TerminalShareDialog({ + session, + open, + onClose, +}: { + session: TerminalSession; + open: boolean; + onClose: () => void; +}) { + const requestShare = useTerminalStore((s) => s.requestShare); + const token = useTerminalStore( + (s) => s.sessions.find((ss) => ss.id === session.id)?.shareToken, + ); + + const [name, setName] = useState(""); + const [writable, setWritable] = useState(false); + const [ttl, setTtl] = useState(0); + const [copied, setCopied] = useState(false); + + // Reset the form each time the dialog opens. + useEffect(() => { + if (open) { + setName(""); + setWritable(false); + setTtl(0); + setCopied(false); + } + }, [open]); + + if (!open) return null; + + const url = token ? `${window.location.origin}/share/${token}` : ""; + + const handleCreate = () => { + requestShare(session.id, { name: name.trim(), writable, ttl }); + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard unavailable — ignore. + } + }; + + return createPortal( +
+
e.stopPropagation()} + > +
+
+ + Share terminal +
+ +
+ + {!token ? ( +
+

+ Create a public link to this live session. Anyone with the link can watch — no sign-in + required. +

+ +
+ + setName(e.target.value)} + placeholder="e.g. Debugging the deploy" + className="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-text-primary placeholder:text-text-muted/60 focus:border-primary focus:outline-none" + /> +
+ +
+ +
+ {DURATIONS.map((d) => ( + + ))} +
+
+ + + + +
+ ) : ( +
+
+ + + Sharing live{" "} + + ({writable ? "collaborative" : "read-only"}) + + +
+ +
+ + {url} + + + + + +
+ +

+ The share ends when you close this terminal. Manage active shares under{" "} + Shared Terminals. +

+
+ )} +
+
, + document.body, + ); +} diff --git a/ui/apps/console/src/components/terminal/terminalErrors.ts b/ui/apps/console/src/components/terminal/terminalErrors.ts index f9015fa552d..c2f7ece691a 100644 --- a/ui/apps/console/src/components/terminal/terminalErrors.ts +++ b/ui/apps/console/src/components/terminal/terminalErrors.ts @@ -96,7 +96,13 @@ const errorMap: Record = { }; // Values match ssh/web/messages.go messageKind iota. -export const WS_KIND = { INPUT: 1, RESIZE: 2, SIGNATURE: 3, ERROR: 4 } as const; +export const WS_KIND = { + INPUT: 1, + RESIZE: 2, + SIGNATURE: 3, + ERROR: 4, + SHARE: 5, +} as const; export const HTTP_CONNECT_ERROR: TerminalError = { title: "Connection failed", diff --git a/ui/apps/console/src/hooks/useShares.ts b/ui/apps/console/src/hooks/useShares.ts new file mode 100644 index 00000000000..88041a4e9c6 --- /dev/null +++ b/ui/apps/console/src/hooks/useShares.ts @@ -0,0 +1,36 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import apiClient from "@/api/client"; +import type { Share } from "@/types/share"; + +// Active shares live in-memory in the ssh service, so we poll for fresh viewer counts. +const REFETCH_INTERVAL_MS = 4000; + +export function useShares() { + const result = useQuery({ + queryKey: ["shares"], + queryFn: async () => { + const res = await apiClient.get("/ssh/shares"); + return res.data ?? []; + }, + refetchInterval: REFETCH_INTERVAL_MS, + }); + + return { + shares: result.data ?? [], + isLoading: result.isLoading, + error: result.error, + }; +} + +export function useRevokeShare() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (token: string) => { + await apiClient.delete(`/ssh/shares/${token}`); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["shares"] }); + }, + }); +} diff --git a/ui/apps/console/src/pages/SharedTerminal.tsx b/ui/apps/console/src/pages/SharedTerminal.tsx new file mode 100644 index 00000000000..5e679a7fb47 --- /dev/null +++ b/ui/apps/console/src/pages/SharedTerminal.tsx @@ -0,0 +1,231 @@ +import { useEffect, useRef, useState } from "react"; +import { useParams } from "react-router-dom"; +import { Terminal } from "@xterm/xterm"; +import { WebglAddon } from "@xterm/addon-webgl"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { CommandLineIcon, Cog6ToothIcon } from "@heroicons/react/24/outline"; +import { useTerminalThemeStore } from "@/stores/terminalThemeStore"; +import TerminalSettingsDrawer from "@/components/terminal/TerminalSettingsDrawer"; + +type Status = "connecting" | "watching" | "ended" | "invalid"; + +// SharedTerminal is a public, read-only viewer for a terminal session shared by an agent +// (tmate/upterm style). It requires no authentication: the unguessable token in the URL is the +// only capability. The viewer mirrors the host's geometry and never sends input. +export default function SharedTerminal() { + const { token } = useParams<{ token: string }>(); + const containerRef = useRef(null); + const termRef = useRef(null); + const writableRef = useRef(false); + const [status, setStatus] = useState("connecting"); + const [writable, setWritable] = useState(false); + const [name, setName] = useState(""); + const [settingsOpen, setSettingsOpen] = useState(false); + + const { theme, fontFamilyWithFallback, fontSize, loadThemes } = + useTerminalThemeStore(); + + useEffect(() => { + document.title = "ShellHub — Shared terminal"; + void loadThemes(); + }, [loadThemes]); + + useEffect(() => { + if (!token) return; + + const { + theme: initTheme, + fontFamilyWithFallback: initFont, + fontSize: initSize, + } = useTerminalThemeStore.getState(); + + const term = new Terminal({ + theme: initTheme.colors, + fontFamily: initFont, + fontSize: initSize, + cursorBlink: false, + disableStdin: true, + allowProposedApi: true, + }); + termRef.current = term; + + term.loadAddon(new WebLinksAddon()); + + // The viewer mirrors the host's exact geometry (sent as resize control frames), so we don't + // fit to the container — the fixed-size grid is centered and letterboxed instead, like tmate. + if (containerRef.current) { + term.open(containerRef.current); + try { + term.loadAddon(new WebglAddon()); + } catch { + // DOM renderer fallback + } + } + + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${window.location.host}/ws/share/${token}`); + let opened = false; + + ws.onopen = () => { + opened = true; + setStatus("watching"); + }; + + // In collaborative mode, forward the viewer's keystrokes to the host as binary frames. + term.onData((data) => { + if (writableRef.current && ws.readyState === WebSocket.OPEN) { + ws.send(new TextEncoder().encode(data)); + } + }); + + ws.onmessage = async (event) => { + if (event.data instanceof Blob) { + // Binary frame = raw terminal output. + term.write(new Uint8Array(await event.data.arrayBuffer())); + return; + } + + // Text frame = JSON control message (init handshake or resize). + try { + const msg = JSON.parse(String(event.data)) as { + kind?: string; + cols?: number; + rows?: number; + writable?: boolean; + name?: string; + }; + if (msg.kind === "init") { + const w = Boolean(msg.writable); + writableRef.current = w; + setWritable(w); + term.options.disableStdin = !w; + term.options.cursorBlink = w; + if (w) term.focus(); + if (msg.name) { + setName(msg.name); + document.title = `ShellHub — ${msg.name}`; + } + } else if (msg.kind === "resize" && msg.cols && msg.rows) { + term.resize(msg.cols, msg.rows); + } + } catch { + // Ignore malformed control frames. + } + }; + + // A socket that closes without ever opening means the link is invalid or expired; one that + // opened and then closed means the host ended the session. + ws.onclose = () => setStatus(opened ? "ended" : "invalid"); + ws.onerror = () => setStatus(opened ? "ended" : "invalid"); + + return () => { + ws.onopen = null; + ws.onmessage = null; + ws.onclose = null; + ws.onerror = null; + ws.close(); + term.dispose(); + termRef.current = null; + }; + }, [token]); + + // Live theme/font updates, mirroring the authenticated terminal. + useEffect(() => { + if (termRef.current) termRef.current.options.theme = theme.colors; + }, [theme]); + + useEffect(() => { + if (termRef.current) termRef.current.options.fontFamily = fontFamilyWithFallback; + }, [fontFamilyWithFallback]); + + useEffect(() => { + if (termRef.current) termRef.current.options.fontSize = fontSize; + }, [fontSize]); + + const dot = + status === "watching" + ? "bg-accent-green shadow-[0_0_6px_rgba(130,165,104,0.4)]" + : status === "connecting" + ? "bg-accent-yellow animate-pulse-subtle" + : "bg-text-muted/50"; + + const label = + status === "watching" + ? writable + ? "Live (you can type)" + : "Live (read-only)" + : status === "connecting" + ? "Connecting…" + : status === "invalid" + ? "Link invalid or expired" + : "Session ended"; + + return ( +
+
+
+ ShellHub + / + + {name || "Shared terminal"} + +
+
+ {writable && status === "watching" && ( + + Collaborative + + )} + + + {label} + + +
+
+ +
+ {/* Framed terminal window so the host-sized grid reads as a deliberate panel rather than + floating loose in the viewport. */} +
+
+
+ + {(status === "ended" || status === "invalid") && ( +
+
+ +

+ {status === "invalid" + ? "This share link is invalid or has expired." + : "This shared terminal has ended."} +

+

+ {status === "invalid" + ? "Ask for a fresh link to watch." + : "The host closed the session."} +

+
+
+ )} +
+ + setSettingsOpen(false)} + /> +
+ ); +} diff --git a/ui/apps/console/src/pages/SharedTerminals.tsx b/ui/apps/console/src/pages/SharedTerminals.tsx new file mode 100644 index 00000000000..9b0c240113f --- /dev/null +++ b/ui/apps/console/src/pages/SharedTerminals.tsx @@ -0,0 +1,235 @@ +import { useState } from "react"; +import { + ShareIcon, + EyeIcon, + ClipboardDocumentIcon, + CheckIcon, + ArrowTopRightOnSquareIcon, + XCircleIcon, +} from "@heroicons/react/24/outline"; +import { useShares, useRevokeShare } from "@/hooks/useShares"; +import type { Share } from "@/types/share"; +import PageHeader from "@/components/common/PageHeader"; +import DataTable, { type Column } from "@/components/common/DataTable"; +import DeviceChip from "@/components/common/DeviceChip"; +import Spinner from "@/components/common/Spinner"; +import { formatDuration } from "@/utils/date"; + +function CopyLinkButton({ url }: { url: string }) { + const [copied, setCopied] = useState(false); + + const handleClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard unavailable — ignore. + } + }; + + return ( + + ); +} + +function EndButton({ onEnd }: { onEnd: () => Promise }) { + const [ending, setEnding] = useState(false); + + const handleClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + setEnding(true); + try { + await onEnd(); + } finally { + setEnding(false); + } + }; + + return ( + + ); +} + +export default function SharedTerminals() { + const { shares, isLoading } = useShares(); + const revokeShare = useRevokeShare(); + + const columns: Column[] = [ + { + key: "name", + header: "Name", + render: (s) => ( +
+ + {s.name ? ( + {s.name} + ) : ( + Untitled + )} +
+ ), + }, + { + key: "command", + header: "Command", + render: (s) => + s.command ? ( + + {s.command} + + ) : ( + login shell + ), + }, + { + key: "mode", + header: "Mode", + render: (s) => + s.writable ? ( + + Collaborative + + ) : ( + + Read-only + + ), + }, + { + key: "device", + header: "Device", + render: (s) => ( + e.stopPropagation()} + /> + ), + }, + { + key: "viewers", + header: "Viewers", + render: (s) => ( + 0 ? "text-text-primary" : "text-text-muted" + }`} + > + + {s.viewers} + + ), + }, + { + key: "duration", + header: "Duration", + render: (s) => { + const noLimit = new Date(s.expires_at).getFullYear() < 2000; + return ( +
+ + {formatDuration(s.created_at, s.created_at, true)} + + + {noLimit + ? "no limit" + : `${formatDuration(new Date().toISOString(), s.expires_at, false)} left`} + +
+ ); + }, + }, + { + key: "actions", + header: "", + headerClassName: "w-52", + render: (s) => ( + + ), + }, + ]; + + return ( +
+ } + overline="Terminals" + title="Shared Terminals" + description="Live terminals shared from your devices and the number of viewers watching each one." + /> + + s.token} + isLoading={isLoading} + itemLabel="shared terminal" + emptyState={ +
+ +

+ No active shared terminals +

+

+ Run{" "} + + shellhub-agent share + {" "} + on a device to start one +

+
+ } + /> +
+ ); +} diff --git a/ui/apps/console/src/stores/terminalStore.ts b/ui/apps/console/src/stores/terminalStore.ts index 4c209d41acc..9e7d56ae77e 100644 --- a/ui/apps/console/src/stores/terminalStore.ts +++ b/ui/apps/console/src/stores/terminalStore.ts @@ -16,6 +16,17 @@ export interface TerminalSession { passphrase?: string; state: TerminalWindowState; connectionStatus: ConnectionStatus; + // pendingShare carries a share request from the UI to the live TerminalInstance, which sends it + // over the session websocket. shareToken holds the token returned once the share is active. + pendingShare?: ShareOptions | null; + shareToken?: string | null; +} + +export interface ShareOptions { + name: string; + writable: boolean; + // ttl in seconds: 0 = server default, negative = no expiry, positive = custom. + ttl: number; } export interface ReconnectTarget { @@ -44,6 +55,9 @@ interface TerminalState { clearReconnect: () => void; setConnectionStatus: (id: string, status: ConnectionStatus) => void; clearSensitiveData: (id: string) => void; + requestShare: (id: string, opts: ShareOptions) => void; + clearPendingShare: (id: string) => void; + setShareToken: (id: string, token: string) => void; } function demoteOthers( @@ -161,4 +175,28 @@ export const useTerminalStore = create((set) => ({ ), })); }, + + requestShare: (id, opts) => { + set((state) => ({ + sessions: state.sessions.map((s) => + s.id === id ? { ...s, pendingShare: opts } : s, + ), + })); + }, + + clearPendingShare: (id) => { + set((state) => ({ + sessions: state.sessions.map((s) => + s.id === id ? { ...s, pendingShare: null } : s, + ), + })); + }, + + setShareToken: (id, token) => { + set((state) => ({ + sessions: state.sessions.map((s) => + s.id === id ? { ...s, shareToken: token } : s, + ), + })); + }, })); diff --git a/ui/apps/console/src/stores/terminalThemeStore.ts b/ui/apps/console/src/stores/terminalThemeStore.ts index 09cd6969dca..c0969610acb 100644 --- a/ui/apps/console/src/stores/terminalThemeStore.ts +++ b/ui/apps/console/src/stores/terminalThemeStore.ts @@ -59,7 +59,7 @@ const STORAGE_KEYS = { fontSize: "terminalFontSize", }; -const FALLBACK_THEME: TerminalTheme = { +export const FALLBACK_THEME: TerminalTheme = { name: "ShellHub Dark", dark: true, preview: { background: "#18191B", foreground: "#667ACC" }, diff --git a/ui/apps/console/src/types/share.ts b/ui/apps/console/src/types/share.ts new file mode 100644 index 00000000000..14777e069d1 --- /dev/null +++ b/ui/apps/console/src/types/share.ts @@ -0,0 +1,14 @@ +export interface Share { + token: string; + url: string; + name: string; + writable: boolean; + command: string; + device_uid: string; + device_name: string; + device_online: boolean; + device_os: string; + viewers: number; + created_at: string; + expires_at: string; +} From 15c312560decb50952570165a7b5de9fa5e81513 Mon Sep 17 00:00:00 2001 From: "Luis Gustavo S. Barreto" Date: Fri, 5 Jun 2026 11:59:10 -0300 Subject: [PATCH 4/4] feat(ssh): show joining viewers the current screen of a shared session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guest opening a share link mid-session saw a blank terminal until new output arrived, because the shared console session only captured output from the moment the share was created. Capture the console session's output from the start and seed the share hub with it when the share begins, so a late joiner is replayed the screen that was already there. The hub's replay buffer is also reset whenever the screen is cleared (erase screen / scrollback, alternate-screen enter/leave, full reset), so the replay starts from a clean screen and stays bounded at 128KB per share. Viewers reconstruct the current screen at full fidelity from the raw replayed output — no server-side terminal emulator needed. --- ssh/web/session.go | 90 ++++++++++++++++++++++++++++++++------- ssh/web/session_test.go | 6 +-- ssh/web/share/hub.go | 59 ++++++++++++++++++++----- ssh/web/share/hub_test.go | 15 +++++++ 4 files changed, 139 insertions(+), 31 deletions(-) diff --git a/ssh/web/session.go b/ssh/web/session.go index 9748210272d..c4eb89df017 100644 --- a/ssh/web/session.go +++ b/ssh/web/session.go @@ -8,7 +8,7 @@ import ( "fmt" "io" "strings" - "sync/atomic" + "sync" "time" "unicode/utf8" @@ -259,9 +259,8 @@ func newSession(ctx context.Context, cache cache.Cache, conn *Conn, creds *Crede return ErrShell } - // shareHub holds the share's hub once the user shares this session (nil until then). The output - // goroutine reads it to tee output; the input goroutine writes it. - var shareHub atomic.Pointer[share.Hub] + // output accumulates console output for scrollback and tees it to the share hub once shared. + output := &shareOutput{} go func() { defer agent.Close() @@ -309,11 +308,9 @@ func newSession(ctx context.Context, cache cache.Cache, conn *Conn, creds *Crede return } - if h := shareHub.Load(); h != nil { - h.Resize(share.Dimensions{Cols: int(dim.Cols), Rows: int(dim.Rows)}) - } + output.resize(share.Dimensions{Cols: int(dim.Cols), Rows: int(dim.Rows)}) case messageKindShare: - if shareHub.Load() != nil { + if output.shared() { continue // already shared } @@ -327,7 +324,10 @@ func newSession(ctx context.Context, cache cache.Cache, conn *Conn, creds *Crede } shareClose = closeFn - shareHub.Store(hub) + + // Seed the hub with the screen captured so far, then tee new output. A guest joining + // later is replayed this from the hub, so they see the current screen. + output.activate(hub) // In collaborative mode, guest keystrokes flow into the same PTY stdin as the local user. if req.Writable { @@ -352,8 +352,8 @@ func newSession(ctx context.Context, cache cache.Cache, conn *Conn, creds *Crede } }() - go redirToWs(stdout, conn, &shareHub) // nolint:errcheck - go io.Copy(conn, stderr) //nolint:errcheck + go redirToWs(stdout, conn, output) // nolint:errcheck + go io.Copy(conn, stderr) //nolint:errcheck if err := agent.Wait(); err != nil { logger.WithError(err).Warning("client remote command returned a error") @@ -362,7 +362,67 @@ func newSession(ctx context.Context, cache cache.Cache, conn *Conn, creds *Crede return nil } -func redirToWs(rd io.Reader, ws *Conn, shareHub *atomic.Pointer[share.Hub]) error { +// shareCaptureCap bounds the console output retained before a session is shared, so the eventual +// guest can be seeded with the current screen. It matches the hub's ring capacity. +const shareCaptureCap = 128 * 1024 + +// shareOutput accumulates the console session's output from the start and, once the session is +// shared, seeds the hub with it (so a guest sees the screen that was already there) and tees new +// output. The hub's bounded ring buffer — not this accumulator — is what's retained long-term. +type shareOutput struct { + mu sync.Mutex + buf []byte + hub *share.Hub +} + +func (s *shareOutput) write(p []byte) { + s.mu.Lock() + defer s.mu.Unlock() + + // Once shared, output flows straight to the hub (which owns retention); before that, keep a + // bounded buffer so the pre-share screen can seed the first guest. + if s.hub != nil { + s.hub.Output(p) + + return + } + + s.buf = append(s.buf, p...) + if len(s.buf) > shareCaptureCap { + s.buf = s.buf[len(s.buf)-shareCaptureCap:] + } +} + +func (s *shareOutput) activate(hub *share.Hub) { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.buf) > 0 { + hub.Output(s.buf) + } + + s.hub = hub + s.buf = nil +} + +func (s *shareOutput) shared() bool { + s.mu.Lock() + defer s.mu.Unlock() + + return s.hub != nil +} + +func (s *shareOutput) resize(dim share.Dimensions) { + s.mu.Lock() + hub := s.hub + s.mu.Unlock() + + if hub != nil { + hub.Resize(dim) + } +} + +func redirToWs(rd io.Reader, ws *Conn, output *shareOutput) error { // TODO: Evaluate refactoring this function to improve its readability. var buf [32 * 1024]byte var start, end, buflen int @@ -425,10 +485,8 @@ func redirToWs(rd io.Reader, ws *Conn, shareHub *atomic.Pointer[share.Hub]) erro return err } - // Tee the same output to the share hub when this session is being shared. - if h := shareHub.Load(); h != nil { - h.Output(chunk) - } + // Record the output for scrollback and, when sharing, tee it to the share hub. + output.write(chunk) start = buflen - end diff --git a/ssh/web/session_test.go b/ssh/web/session_test.go index fe2e5d30905..051d753aaeb 100644 --- a/ssh/web/session_test.go +++ b/ssh/web/session_test.go @@ -2,12 +2,10 @@ package web import ( "io" - "sync/atomic" "testing" "testing/iotest" "github.com/shellhub-io/shellhub/ssh/web/mocks" - "github.com/shellhub-io/shellhub/ssh/web/share" "github.com/stretchr/testify/assert" ) @@ -48,7 +46,7 @@ func TestRedirToWs_Regression_EndNegative(t *testing.T) { reader := &singleRead{data: []byte{0x80, 0x81, 0x82}} assert.NotPanics(t, func() { - _ = redirToWs(reader, conn, new(atomic.Pointer[share.Hub])) + _ = redirToWs(reader, conn, &shareOutput{}) }, "expected redirToWs to panic when end is -1 and negative slice is attempted") } @@ -60,6 +58,6 @@ func TestRedirToWs_Regression_ZeroReadThenEOF(t *testing.T) { reader := iotest.TimeoutReader(&zeroReadNoEOFReader{}) assert.NotPanics(t, func() { - _ = redirToWs(reader, conn, new(atomic.Pointer[share.Hub])) + _ = redirToWs(reader, conn, &shareOutput{}) }, "expected redirToWs to handle zero read without panicking") } diff --git a/ssh/web/share/hub.go b/ssh/web/share/hub.go index 4665ae8929b..11bde4b148d 100644 --- a/ssh/web/share/hub.go +++ b/ssh/web/share/hub.go @@ -1,13 +1,16 @@ package share import ( + "bytes" "sync" "github.com/gorilla/websocket" ) -// ringCapacity is the number of recent output bytes kept so a guest joining an in-progress -// session is sent the current screen contents before live output starts flowing. +// ringCapacity bounds the recent output replayed to a guest joining an in-progress session. The +// buffer is also reset whenever the screen is cleared (see clearSequences), so for full-screen apps +// it naturally holds just the current screen, and for shells it holds the most recent output. It is +// a per-share ceiling (only shared terminals allocate it), kept small to bound memory at scale. const ringCapacity = 128 * 1024 // subscriberBuffer is the number of pending frames a single guest may lag behind before it is @@ -15,6 +18,20 @@ const ringCapacity = 128 * 1024 // the producer (the agent). const subscriberBuffer = 256 +// inputBuffer bounds how much pending guest input may queue before keystrokes are dropped. +const inputBuffer = 256 + +// clearSequences are escape sequences that repaint the whole screen. When one appears, the ring +// buffer is reset to start from it, so a replayed snapshot begins from a clean screen and stays +// bounded. (Erase screen, erase scrollback, enter/leave alternate screen, full reset.) +var clearSequences = [][]byte{ + []byte("\x1b[2J"), + []byte("\x1b[3J"), + []byte("\x1b[?1049h"), + []byte("\x1b[?1049l"), + []byte("\x1bc"), +} + // Dimensions holds terminal geometry forwarded from the host so guests can mirror its size. type Dimensions struct { Cols int `json:"cols"` @@ -33,12 +50,20 @@ type subscriber struct { out chan message } -// ringBuffer keeps the last ringCapacity bytes of output as a flat slice. +// ringBuffer keeps recent raw output so a late joiner can be replayed the current screen. It resets +// on a screen-clear sequence and is otherwise capped at ringCapacity bytes. type ringBuffer struct { buf []byte } func (r *ringBuffer) write(p []byte) { + // Start fresh from the last screen-clear in this chunk, if any, so the replay begins clean. + if idx := lastClearIndex(p); idx >= 0 { + r.buf = append(r.buf[:0], p[idx:]...) + + return + } + r.buf = append(r.buf, p...) if len(r.buf) > ringCapacity { r.buf = r.buf[len(r.buf)-ringCapacity:] @@ -53,12 +78,24 @@ func (r *ringBuffer) snapshot() []byte { return append([]byte(nil), r.buf...) } -// inputBuffer bounds how much pending guest input may queue before keystrokes are dropped. -const inputBuffer = 256 +// lastClearIndex returns the start index of the last screen-clear sequence in p, or -1. +func lastClearIndex(p []byte) int { + best := -1 + for _, seq := range clearSequences { + if i := bytes.LastIndex(p, seq); i > best { + best = i + } + } + + return best +} // Hub fans out a single producer's terminal output (the agent) to N consumers (guests), and — in -// collaborative mode — fans guest input back in to the producer. It is independent of the -// Enterprise session recorder, so it works on the Community Edition. +// collaborative mode — fans guest input back in to the producer. +// +// Output is replayed (raw) to a joining guest so they see the current screen reconstructed at full +// fidelity by their own terminal, rather than a blank one. It is independent of the Enterprise +// session recorder, so it works on the Community Edition. type Hub struct { mu sync.Mutex subscribers map[*subscriber]struct{} @@ -93,8 +130,8 @@ func (h *Hub) Input() <-chan []byte { return h.input } -// Subscribe registers a new guest and seeds it with the last known terminal size and the current -// screen contents, so a late joiner immediately sees the session as it stands. +// Subscribe registers a new guest and seeds it with the current terminal size and a replay of the +// recent output, so a late joiner immediately sees the session as it stands. func (h *Hub) Subscribe() *subscriber { s := &subscriber{out: make(chan message, subscriberBuffer)} @@ -107,8 +144,8 @@ func (h *Hub) Subscribe() *subscriber { } } - if snapshot := h.ring.snapshot(); snapshot != nil { - s.out <- message{typ: websocket.BinaryMessage, data: snapshot} + if snap := h.ring.snapshot(); snap != nil { + s.out <- message{typ: websocket.BinaryMessage, data: snap} } h.subscribers[s] = struct{}{} diff --git a/ssh/web/share/hub_test.go b/ssh/web/share/hub_test.go index da06af8d97a..4fb31bc8602 100644 --- a/ssh/web/share/hub_test.go +++ b/ssh/web/share/hub_test.go @@ -56,6 +56,21 @@ func TestHubLateJoinerReceivesRingAndResize(t *testing.T) { assert.Equal(t, []byte("scrollback"), msgs[1].data) } +func TestHubRingResetsOnScreenClear(t *testing.T) { + hub := newHub() + + hub.Output([]byte("stale content from before the clear")) + hub.Output([]byte("\x1b[2Jcurrent screen")) + + late := hub.Subscribe() + msgs := drain(t, late) + + // The snapshot must start at the clear, dropping the stale pre-clear content. + require.Len(t, msgs, 1) + assert.Equal(t, websocket.BinaryMessage, msgs[0].typ) + assert.Equal(t, []byte("\x1b[2Jcurrent screen"), msgs[0].data) +} + func TestHubDropsSlowConsumer(t *testing.T) { hub := newHub()