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 +} 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..c4eb89df017 100644 --- a/ssh/web/session.go +++ b/ssh/web/session.go @@ -8,12 +8,15 @@ import ( "fmt" "io" "strings" + "sync" "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,23 @@ func newSession(ctx context.Context, cache cache.Cache, conn *Conn, creds *Crede return ErrShell } + // output accumulates console output for scrollback and tees it to the share hub once shared. + output := &shareOutput{} + 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 +300,60 @@ 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 } + + output.resize(share.Dimensions{Cols: int(dim.Cols), Rows: int(dim.Rows)}) + case messageKindShare: + if output.shared() { + 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 + + // 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 { + 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, 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") @@ -277,7 +362,67 @@ func newSession(ctx context.Context, cache cache.Cache, conn *Conn, creds *Crede return nil } -func redirToWs(rd io.Reader, ws *Conn) 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 @@ -334,10 +479,15 @@ 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 } + // Record the output for scrollback and, when sharing, tee it to the share hub. + output.write(chunk) + start = buflen - end if start > 0 { diff --git a/ssh/web/session_test.go b/ssh/web/session_test.go index 558bb6f1a1f..051d753aaeb 100644 --- a/ssh/web/session_test.go +++ b/ssh/web/session_test.go @@ -46,7 +46,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, &shareOutput{}) }, "expected redirToWs to panic when end is -1 and negative slice is attempted") } @@ -58,6 +58,6 @@ func TestRedirToWs_Regression_ZeroReadThenEOF(t *testing.T) { reader := iotest.TimeoutReader(&zeroReadNoEOFReader{}) assert.NotPanics(t, func() { - _ = redirToWs(reader, conn) + _ = redirToWs(reader, conn, &shareOutput{}) }, "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..11bde4b148d --- /dev/null +++ b/ssh/web/share/hub.go @@ -0,0 +1,229 @@ +package share + +import ( + "bytes" + "sync" + + "github.com/gorilla/websocket" +) + +// 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 +// dropped. A read-only viewer that cannot keep up is disconnected rather than back-pressuring +// 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"` + 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 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:] + } +} + +func (r *ringBuffer) snapshot() []byte { + if len(r.buf) == 0 { + return nil + } + + return append([]byte(nil), r.buf...) +} + +// 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. +// +// 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{} + 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 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)} + + 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 snap := h.ring.snapshot(); snap != nil { + s.out <- message{typ: websocket.BinaryMessage, data: snap} + } + + 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..4fb31bc8602 --- /dev/null +++ b/ssh/web/share/hub_test.go @@ -0,0 +1,115 @@ +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 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() + + 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() 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; +}