Files
lpra/internal/server/wsagent.go
T

203 lines
5.0 KiB
Go

package server
import (
"context"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// An agent's WebSocket carries what its long polls would: control messages
// pushed the moment they are queued, and the input and output of its
// terminal and http-forward sessions, without a request per keystroke. Tasks,
// file transfers and the speed test stay on plain requests.
// wsPing keeps the connection alive through proxies that drop quiet ones
// (Cloudflare after 100 s).
const wsPing = 20 * time.Second
// wsAgents are the agents connected by WebSocket now, for the panel to show.
type wsAgents struct {
mu sync.Mutex
m map[string]*websocket.Conn
}
// set records the agent's connection, closing one it had before.
func (c *wsAgents) set(id string, conn *websocket.Conn) {
c.mu.Lock()
if c.m == nil {
c.m = map[string]*websocket.Conn{}
}
old := c.m[id]
c.m[id] = conn
c.mu.Unlock()
if old != nil {
old.Close(websocket.StatusGoingAway, "replaced")
}
}
func (c *wsAgents) drop(id string, conn *websocket.Conn) {
c.mu.Lock()
if c.m[id] == conn {
delete(c.m, id)
}
c.mu.Unlock()
}
// On reports whether the agent is on its WebSocket now.
func (c *wsAgents) On(id string) bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.m[id] != nil
}
// transportOf is the agent's setting as stored, "auto" when unset.
func (s *Server) transportOf(agentID string) string {
if a, err := s.db.GetAgent(agentID); err == nil && a != nil && a.Transport != "" {
return a.Transport
}
return proto.TransportAuto
}
// GET /api/agent/ws - the agent's WebSocket. Refused (409) when the agent is
// set to long polling.
func (s *Server) handleAgentWS(w http.ResponseWriter, r *http.Request) {
a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if a.Transport == proto.TransportLP {
w.Header().Set(proto.TransportHeader, proto.TransportLP)
http.Error(w, "long polling only", http.StatusConflict)
return
}
if v := r.Header.Get("X-Agent-Version"); v != "" {
_ = s.db.TouchAgentInfo(a.ID, v, firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser))
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
if err != nil {
return
}
conn.SetReadLimit(4 << 20)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
s.wsAgents.set(a.ID, conn)
defer s.wsAgents.drop(a.ID, conn)
defer conn.CloseNow()
send := func(m proto.WSMsg) error {
wctx, wcancel := context.WithTimeout(ctx, 30*time.Second)
defer wcancel()
return wsjson.Write(wctx, conn, m)
}
cfg := func() proto.WSMsg {
return proto.WSMsg{T: "cfg", Tunnel: s.tunnelAdvert(), Transport: s.transportOf(a.ID)}
}
if send(cfg()) != nil {
return
}
// Control: what a poll would get, as soon as it is queued. An idle round
// sends the settings, which doubles as a sign of life.
go func() {
defer cancel()
for ctx.Err() == nil {
msg := s.hub.WaitControl(ctx.Done(), a.ID, pollHold)
if ctx.Err() != nil {
return
}
_ = s.db.TouchAgent(a.ID)
out := proto.WSMsg{T: "ctrl", Ctrl: &msg}
if msg.Type == proto.CtrlIdle {
out = cfg()
}
if send(out) != nil {
return
}
if out.Transport == proto.TransportLP {
conn.Close(websocket.StatusNormalClosure, "long polling only")
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
}
}
}
}()
subs := map[string]bool{}
for {
var m proto.WSMsg
if err := wsjson.Read(ctx, conn, &m); err != nil {
return
}
switch m.T {
case "sub":
if subs[m.SID] {
continue
}
sess := s.hub.Session(m.SID)
if sess == nil || sess.AgentID != a.ID {
_ = send(proto.WSMsg{T: "gone", SID: m.SID})
continue
}
subs[m.SID] = true
go s.pumpSession(ctx, sess, send)
case "out":
sess := s.hub.Session(m.SID)
if sess == nil || sess.AgentID != a.ID || m.Out == nil {
_ = send(proto.WSMsg{T: "gone", SID: m.SID})
continue
}
if !sess.PushOut(*m.Out) {
_ = send(proto.WSMsg{T: "gone", SID: m.SID})
continue
}
if m.Out.Exit {
sess.close()
time.AfterFunc(pollHold, func() { s.hub.CloseSession(sess.ID) })
}
}
}
}
// pumpSession sends a session's input down the WebSocket as it comes. Input
// taken just as the connection ends goes back to the session, for the agent's
// long poll to collect.
func (s *Server) pumpSession(ctx context.Context, sess *Session, send func(proto.WSMsg) error) {
for ctx.Err() == nil {
frames, alive := sess.PollIn(pollHold)
if !alive {
_ = send(proto.WSMsg{T: "gone", SID: sess.ID})
return
}
if len(frames) == 0 {
continue
}
if ctx.Err() != nil || send(proto.WSMsg{T: "in", SID: sess.ID, Frames: frames}) != nil {
sess.Unpoll(frames)
return
}
}
}