package terminal import ( "context" "encoding/json" "net/http" "os/exec" "strings" "sync/atomic" "time" "nadir/internal/auth" "nadir/internal/module" "nadir/internal/rbac" "github.com/coder/websocket" "github.com/creack/pty" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" ) // tagTerminal is the OpenAPI tag for this module (registered in server.go), // keeping tags 1:1 with modules per the project convention. const tagTerminal = "Terminal" const ( // maxTerminals caps concurrent shells so a buggy frontend or careless admin // can't pile up PTYs (guideline: limit everything). Raise if it's ever a real // limit in practice. maxTerminals = 10 // idleTimeout closes a session after this long with no I/O in either // direction, reclaiming abandoned shells. idleTimeout = 15 * time.Minute ) // terminalSem is the concurrency limiter; a slot is held for the life of a session. var terminalSem = make(chan struct{}, maxTerminals) type terminalModule struct { sessions *auth.SessionStore } // New creates a new Terminal module that allows interactive shell access. func New(sessions *auth.SessionStore) module.Module { return &terminalModule{sessions: sessions} } func (m *terminalModule) ID() string { return "terminal" } func (m *terminalModule) Permissions() []rbac.Permission { return []rbac.Permission{rbac.Root} } type TerminalInput struct { Ctx huma.Context `json:"-"` } // Resolve extracts the huma.Context into the input struct. func (i *TerminalInput) Resolve(ctx huma.Context) []error { i.Ctx = ctx return nil } type resizeMessage struct { Cols uint16 `json:"cols"` Rows uint16 `json:"rows"` } func (m *terminalModule) Register(api huma.API) { huma.Register(api, huma.Operation{ OperationID: "terminal-connect", Method: "GET", Path: "/api/terminal", Summary: "Connect to an interactive terminal", Description: "Upgrades the connection to a WebSocket and spawns a PTY shell as the logged-in user. Send JSON `{cols, rows}` text messages to resize, and raw binary/text messages for stdin. This is a raw WebSocket endpoint — it cannot be exercised from the API docs \"Try it\" panel; use a WebSocket client.", Tags: []string{tagTerminal}, Metadata: map[string]any{"module": m.ID(), "permission": string(rbac.Root)}, Errors: []int{401, 403, 426, 500}, }, func(ctx context.Context, in *TerminalInput) (*struct{}, error) { // The RBAC middleware already authenticated this request and enforced the // "root" permission before we got here. We re-read the session only to get // the username for `su`; the 401 below is the fallback for when the module // is mounted without the middleware (e.g. in unit tests). cookie, err := huma.ReadCookie(in.Ctx, "nadir_session_id") if err != nil || cookie == nil { return nil, huma.Error401Unauthorized("unauthorized") } sess, ok := m.sessions.GetByToken(cookie.Value) if !ok { return nil, huma.Error401Unauthorized("unauthorized") } req, res := humago.Unwrap(in.Ctx) if req == nil || res == nil { return nil, huma.Error500InternalServerError("missing http context") } // Reject plain GETs (e.g. the docs "Try it" button) with a clear 426 rather // than letting websocket.Accept emit a raw protocol-violation error. if !strings.EqualFold(req.Header.Get("Upgrade"), "websocket") { return nil, huma.NewError(http.StatusUpgradeRequired, "this endpoint requires a WebSocket connection; connect with a WebSocket client") } // InsecureSkipVerify is deliberately NOT set: coder/websocket then enforces // that the Origin host matches the request Host, rejecting cross-site upgrade // attempts. Defense-in-depth on top of the SameSite=Strict session cookie — // important because this endpoint hands out an interactive shell. conn, err := websocket.Accept(res, req, nil) if err != nil { // websocket.Accept already wrote the error response (e.g. 403 on an // Origin mismatch). Just stop; writing again would corrupt the response. return nil, nil } defer conn.CloseNow() // Bound concurrent shells: take a slot or reject (don't pile up PTYs). select { case terminalSem <- struct{}{}: defer func() { <-terminalSem }() default: conn.Close(websocket.StatusTryAgainLater, "too many terminal sessions") return nil, nil } // Launch the user's login shell via su. // "su - " ensures we get their actual environment and shell. cmd := exec.CommandContext(req.Context(), "su", "-", "--", sess.Username) // Start the command with a PTY. ptmx, err := pty.Start(cmd) if err != nil { conn.Close(websocket.StatusInternalError, "failed to start pty") return nil, nil } defer ptmx.Close() // lastActive is bumped by both pumps; the watchdog uses it to close idle // sessions. Output activity (e.g. `top`) counts, so it isn't killed. var lastActive atomic.Int64 lastActive.Store(time.Now().UnixNano()) go func() { tick := time.NewTicker(idleTimeout / 4) defer tick.Stop() for { select { case <-req.Context().Done(): return case <-tick.C: if time.Since(time.Unix(0, lastActive.Load())) > idleTimeout { conn.Close(websocket.StatusGoingAway, "idle timeout") return } } } }() // Pump stdout/stderr from PTY to WebSocket. go func() { buf := make([]byte, 8192) for { n, err := ptmx.Read(buf) if err != nil { break } lastActive.Store(time.Now().UnixNano()) // We write PTY output as binary messages. The frontend (e.g., xterm.js) // can handle UTF-8 binary or text transparently. err = conn.Write(req.Context(), websocket.MessageBinary, buf[:n]) if err != nil { break } } conn.Close(websocket.StatusNormalClosure, "") }() // Pump stdin and resize commands from WebSocket to PTY. for { typ, b, err := conn.Read(req.Context()) if err != nil { break } lastActive.Store(time.Now().UnixNano()) if typ == websocket.MessageText { var resize resizeMessage if err := json.Unmarshal(b, &resize); err == nil && resize.Cols > 0 && resize.Rows > 0 { // Handle resize _ = pty.Setsize(ptmx, &pty.Winsize{ Cols: resize.Cols, Rows: resize.Rows, }) continue } } // If not a valid resize message, or if it's MessageBinary, pass to PTY stdin. _, _ = ptmx.Write(b) } // The read loop has ended (client gone or PTY closed). Tear down the shell // and reap it: closing the PTY sends EOF, Kill covers shells that ignore it. _ = ptmx.Close() _ = cmd.Process.Kill() _ = cmd.Wait() return nil, nil }) }