210 lines
6.5 KiB
Go
210 lines
6.5 KiB
Go
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 - <username>" 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
|
|
})
|
|
}
|