2026-06-22 16:06:57 +02:00
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.
2026-06-22 20:03:27 +02:00
cmd := exec . CommandContext ( req . Context (), "su" , "-" , "--" , sess . Username )
2026-06-22 16:06:57 +02:00
// 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
})
}