198 lines
5.6 KiB
Go
198 lines
5.6 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"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: a shell
|
|
// on the agent, or with {"ssh": {...}} in the body a shell the agent opens
|
|
// by SSH on another host.
|
|
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
|
|
}
|
|
var body struct {
|
|
SSH *proto.SSHTarget `json:"ssh"`
|
|
}
|
|
if r.ContentLength != 0 {
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil && err != io.EOF {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
if err := s.fillSaved(s.currentAdmin(r).ID, body.SSH); err != nil {
|
|
writeJSON(w, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
if t := body.SSH; t != nil {
|
|
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
|
if t.Port == 0 {
|
|
t.Port = 22
|
|
}
|
|
if t.Host == "" || t.User == "" || t.Port < 1 || t.Port > 65535 || strings.ContainsAny(t.Host+t.User, " \t\r\n/") {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "нужны хост, порт 1-65535 и пользователь"})
|
|
return
|
|
}
|
|
sess := s.hub.CreateSSH(agentID, *t)
|
|
if admin := s.currentAdmin(r); admin != nil {
|
|
s.db.Audit(admin.ID, agentID, "open_ssh:"+t.User+"@"+net.JoinHostPort(t.Host, strconv.Itoa(t.Port)))
|
|
}
|
|
writeJSON(w, map[string]string{"session_id": sess.ID})
|
|
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)
|
|
}
|