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) }