Files
lpra/internal/server/handlers_panel_term.go
T

161 lines
4.4 KiB
Go

package server
import (
"context"
"encoding/json"
"log"
"net/http"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// The browser terminal talks to the server over plain HTTP long-polling (the
// same transport the agent uses), so it works through any reverse proxy without
// WebSocket upgrade support.
// POST /api/panel/term/{agentID}/open - create a terminal session.
func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("agentID")
agent, err := s.db.GetAgent(agentID)
if err != nil || agent == nil {
log.Printf("panel open: agent %q not found (err=%v)", agentID, err)
http.NotFound(w, r)
return
}
sess := s.hub.CreateSession(agentID, agent.LoginUser)
if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
}
writeJSON(w, map[string]string{"session_id": sess.ID})
}
// GET /api/panel/term/{sid}/out - long-poll for PTY output.
func (s *Server) handlePanelOut(w http.ResponseWriter, r *http.Request) {
sess := s.hub.Session(r.PathValue("sid"))
if sess == nil {
writeJSON(w, map[string]any{"closed": true})
return
}
msgs, alive := sess.PollOut(pollHold)
if !alive {
writeJSON(w, map[string]any{"closed": true})
return
}
writeJSON(w, map[string]any{"frames": msgs})
}
// panelInMsg is what the browser posts for keystrokes/resize.
type panelInMsg struct {
T string `json:"t"` // "i" input, "r" resize
D string `json:"d,omitempty"`
Cols uint16 `json:"cols,omitempty"`
Rows uint16 `json:"rows,omitempty"`
// Seq numbers the messages of a session from 1; 0 from a page that
// predates it.
Seq uint64 `json:"seq,omitempty"`
}
// POST /api/panel/term/{sid}/in - send keystrokes/resize to the agent PTY.
func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
sess := s.hub.Session(r.PathValue("sid"))
if sess == nil {
http.Error(w, "closed", http.StatusGone)
return
}
var m panelInMsg
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&m); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
takePanelIn(sess, m)
w.WriteHeader(http.StatusNoContent)
}
// takePanelIn passes the browser's keystrokes or resize on to the agent; a
// repeat of one already taken is dropped.
func takePanelIn(sess *Session, m panelInMsg) {
if m.Seq > 0 && !sess.TakeSeq(m.Seq) {
return
}
switch m.T {
case "i":
sess.PushIn(proto.InFrame{Type: proto.FrameStdin, Data: m.D})
case "r":
sess.PushIn(proto.InFrame{Type: proto.FrameResize, Cols: m.Cols, Rows: m.Rows})
}
}
// GET /api/panel/term/{sid}/ws - the browser's terminal on a WebSocket: what
// the "in" requests send comes as messages, and output goes out as soon as
// it is there, in the "out" answers' shape. The page falls back to those
// requests when this does not connect.
func (s *Server) handlePanelTermWS(w http.ResponseWriter, r *http.Request) {
sess := s.hub.Session(r.PathValue("sid"))
if sess == nil {
http.Error(w, "closed", http.StatusGone)
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
if err != nil {
return
}
conn.SetReadLimit(1 << 20)
defer conn.CloseNow()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go func() {
defer cancel()
for ctx.Err() == nil {
msgs, alive := sess.PollOutUntil(ctx.Done(), pollHold)
if ctx.Err() != nil {
return
}
out := map[string]any{"frames": msgs}
if !alive {
out = map[string]any{"closed": true}
}
wctx, wcancel := context.WithTimeout(ctx, 30*time.Second)
err := wsjson.Write(wctx, conn, out)
wcancel()
if err != nil || !alive {
return
}
}
}()
go func() {
t := time.NewTicker(wsPing)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
pctx, pcancel := context.WithTimeout(ctx, 15*time.Second)
err := conn.Ping(pctx)
pcancel()
if err != nil {
cancel()
return
}
}
}
}()
for {
var m panelInMsg
if err := wsjson.Read(ctx, conn, &m); err != nil {
return
}
takePanelIn(sess, m)
}
}
// POST /api/panel/term/{sid}/close - tear the session down.
func (s *Server) handlePanelClose(w http.ResponseWriter, r *http.Request) {
s.hub.CloseSession(r.PathValue("sid"))
w.WriteHeader(http.StatusNoContent)
}