feat: agents connect by WebSocket, falling back to long polling and trying again each minute; auto, ws or lp per machine (0.1.0)
This commit is contained in:
1 parent
6dd1120a41
commit
73bb720ec6
15 files changed
+676
-22
No files matched your search
@@ -18,6 +18,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
lpra "github.com/x-grk/longpolling-remote-access"
|
||||
@@ -53,6 +54,8 @@ type Agent struct {
|
||||
pollCount int
|
||||
|
||||
// Direct tunnel currently running (see setTunnelAddr).
|
||||
transport atomic.Value // string: auto, ws or lp, as the server says
|
||||
link atomic.Pointer[wsLink]
|
||||
tunMu sync.Mutex
|
||||
tunAddr string
|
||||
tunCancel context.CancelFunc
|
||||
@@ -107,10 +110,22 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
a.setTunnelAddr(ctx, a.cfg.TunnelAddr)
|
||||
}
|
||||
|
||||
// The control channel: a WebSocket, or long polls. In "auto" a failed or
|
||||
// dropped WebSocket gives way to long polling for a minute, then is tried
|
||||
// again; "ws" keeps trying the WebSocket; "lp" only polls.
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.pollOnce(ctx); err != nil {
|
||||
log.Printf("poll: %v (retry in %s)", err, backoff)
|
||||
if a.mode() == proto.TransportLP {
|
||||
a.longPoll(ctx, 0)
|
||||
continue
|
||||
}
|
||||
err := a.runWS(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
switch a.mode() {
|
||||
case proto.TransportWS:
|
||||
log.Printf("websocket: %v (retry in %s)", err, backoff)
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
@@ -120,10 +135,71 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
backoff *= 2
|
||||
}
|
||||
continue
|
||||
case proto.TransportAuto:
|
||||
log.Printf("websocket: %v - long polling, websocket again in a minute", err)
|
||||
}
|
||||
backoff = time.Second
|
||||
a.longPoll(ctx, time.Minute)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mode is the transport the server set for this agent.
|
||||
func (a *Agent) mode() string {
|
||||
if v, ok := a.transport.Load().(string); ok {
|
||||
return v
|
||||
}
|
||||
return proto.TransportAuto
|
||||
}
|
||||
|
||||
func (a *Agent) applyTransport(v string) {
|
||||
switch v {
|
||||
case proto.TransportAuto, proto.TransportWS, proto.TransportLP:
|
||||
if v != a.mode() {
|
||||
log.Printf("transport: %s", v)
|
||||
a.transport.Store(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// longPoll runs control polls: for d, or with d zero until the transport is
|
||||
// no longer long polling.
|
||||
func (a *Agent) longPoll(ctx context.Context, d time.Duration) {
|
||||
until := time.Now().Add(d)
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if d > 0 && (time.Now().After(until) || a.mode() == proto.TransportWS) {
|
||||
return
|
||||
}
|
||||
if d == 0 && a.mode() != proto.TransportLP {
|
||||
return
|
||||
}
|
||||
// In "auto" the minute is a minute: a poll still held then is cut
|
||||
// short (the server keeps its job for the next one).
|
||||
reqCtx, cancel := ctx, context.CancelFunc(func() {})
|
||||
if d > 0 {
|
||||
reqCtx, cancel = context.WithDeadline(ctx, until)
|
||||
}
|
||||
err := a.pollOnce(ctx, reqCtx)
|
||||
cut := reqCtx.Err() == context.DeadlineExceeded
|
||||
cancel()
|
||||
if err != nil && cut && ctx.Err() == nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("poll: %v (retry in %s)", err, backoff)
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) enroll(ctx context.Context) error {
|
||||
@@ -157,8 +233,10 @@ func (a *Agent) enroll(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// pollOnce performs one control long-poll and dispatches any instruction.
|
||||
func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
|
||||
// The poll itself runs on reqCtx, which may end early; what it starts runs on
|
||||
// ctx.
|
||||
func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
|
||||
req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
|
||||
a.authHeaders(req)
|
||||
// Report the version and our OS user occasionally (not every poll), so the
|
||||
// panel can flag outdated agents without extra traffic.
|
||||
@@ -183,7 +261,14 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
a.applyTunnelHeader(ctx, resp.Header)
|
||||
// Dispatch in the background so we keep polling for more work.
|
||||
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||
a.dispatch(ctx, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatch starts what a control message asks for, in the background, so the
|
||||
// control channel keeps listening.
|
||||
func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) {
|
||||
switch msg.Type {
|
||||
case proto.CtrlOpenTerminal:
|
||||
go a.runTerminal(ctx, msg.SessionID, msg.User)
|
||||
@@ -192,7 +277,6 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
case proto.CtrlTask:
|
||||
go a.runTask(ctx, msg.TaskID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchConfig asks the server for the settings it hands to agents and reports
|
||||
@@ -207,13 +291,17 @@ func (a *Agent) fetchConfig(ctx context.Context) bool {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||
return a.applyTunnelHeader(ctx, resp.Header)
|
||||
}
|
||||
|
||||
// applyTunnelHeader follows the tunnel address the server advertises as
|
||||
// "host:port"; an empty host means the host we already reach the server on.
|
||||
func (a *Agent) applyTunnelHeader(ctx context.Context, h http.Header) bool {
|
||||
v := h.Get("X-Tunnel-Addr")
|
||||
return a.applyTunnelAddr(ctx, h.Get("X-Tunnel-Addr"))
|
||||
}
|
||||
|
||||
func (a *Agent) applyTunnelAddr(ctx context.Context, v string) bool {
|
||||
if v == "" || a.cfg.TunnelPinned {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
defer rw.Close()
|
||||
defer func() {
|
||||
if l := a.link.Load(); l != nil {
|
||||
l.unsubscribe(sid)
|
||||
}
|
||||
}()
|
||||
// A session closed from the panel (a tab reloaded, a window shut) ends
|
||||
// the stream at once, not on its next output: an idle shell would live
|
||||
// on until the agent stopped.
|
||||
@@ -88,6 +93,21 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
|
||||
// pollIn performs one downstream long-poll and applies the frames.
|
||||
// Returns false when the session is gone and the loop should stop.
|
||||
func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize func(cols, rows uint16)) bool {
|
||||
// On the WebSocket the input comes pushed; if it drops, by a long poll.
|
||||
if l := a.link.Load(); l != nil {
|
||||
if ch := l.subscribe(sid); ch != nil {
|
||||
select {
|
||||
case in := <-ch:
|
||||
if in.gone {
|
||||
return false
|
||||
}
|
||||
return applyFrames(in.frames, w, onResize)
|
||||
case <-l.done:
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/term/"+sid+"/in", nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
@@ -105,7 +125,13 @@ func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize fu
|
||||
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
|
||||
return true
|
||||
}
|
||||
for _, f := range batch.Frames {
|
||||
return applyFrames(batch.Frames, w, onResize)
|
||||
}
|
||||
|
||||
// applyFrames writes a session's input to its stream; false once the panel
|
||||
// closed it.
|
||||
func applyFrames(frames []proto.InFrame, w io.Writer, onResize func(cols, rows uint16)) bool {
|
||||
for _, f := range frames {
|
||||
switch f.Type {
|
||||
case proto.FrameStdin:
|
||||
if data, err := b64.DecodeString(f.Data); err == nil {
|
||||
@@ -124,6 +150,11 @@ func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize fu
|
||||
|
||||
// postOut sends one upstream message. Returns false if the session is gone.
|
||||
func (a *Agent) postOut(ctx context.Context, sid string, msg proto.OutMessage) bool {
|
||||
if l := a.link.Load(); l != nil {
|
||||
if l.send(proto.WSMsg{T: "out", SID: sid, Out: &msg}) == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(msg)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/term/"+sid+"/out", bytes.NewReader(body))
|
||||
a.authHeaders(req)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// The WebSocket to the server: control messages arrive on it as soon as they
|
||||
// are queued, and terminal and http-forward sessions send and take their
|
||||
// bytes over it instead of a request each. When it drops, sessions carry on
|
||||
// over plain requests.
|
||||
|
||||
const wsPing = 20 * time.Second
|
||||
|
||||
type wsLink struct {
|
||||
c *websocket.Conn
|
||||
ctx context.Context
|
||||
mu sync.Mutex
|
||||
subs map[string]chan wsIn
|
||||
asked map[string]bool // sessions whose input was asked for
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// wsIn is what the server sent for a session: its input, or that it is over.
|
||||
type wsIn struct {
|
||||
frames []proto.InFrame
|
||||
gone bool
|
||||
}
|
||||
|
||||
func (l *wsLink) send(m proto.WSMsg) error {
|
||||
ctx, cancel := context.WithTimeout(l.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
return wsjson.Write(ctx, l.c, m)
|
||||
}
|
||||
|
||||
// subscribe asks the server for a session's input on this link, once, and
|
||||
// returns where it arrives; nil when the link cannot take it.
|
||||
func (l *wsLink) subscribe(sid string) chan wsIn {
|
||||
l.mu.Lock()
|
||||
ch := l.subs[sid]
|
||||
if ch == nil {
|
||||
ch = make(chan wsIn, 64)
|
||||
l.subs[sid] = ch
|
||||
}
|
||||
fresh := !l.subscribed(sid)
|
||||
l.mu.Unlock()
|
||||
if fresh && l.send(proto.WSMsg{T: "sub", SID: sid}) != nil {
|
||||
return nil
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// subscribed marks a session as asked for; l.mu is held.
|
||||
func (l *wsLink) subscribed(sid string) bool {
|
||||
if l.asked == nil {
|
||||
l.asked = map[string]bool{}
|
||||
}
|
||||
if l.asked[sid] {
|
||||
return true
|
||||
}
|
||||
l.asked[sid] = true
|
||||
return false
|
||||
}
|
||||
|
||||
func (l *wsLink) unsubscribe(sid string) {
|
||||
l.mu.Lock()
|
||||
delete(l.subs, sid)
|
||||
delete(l.asked, sid)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *wsLink) deliver(sid string, in wsIn) {
|
||||
l.mu.Lock()
|
||||
ch := l.subs[sid]
|
||||
l.mu.Unlock()
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case ch <- in:
|
||||
case <-l.done:
|
||||
}
|
||||
}
|
||||
|
||||
// runWS holds the WebSocket until it ends, dispatching what comes on it.
|
||||
func (a *Agent) runWS(ctx context.Context) error {
|
||||
u := a.cfg.Host + "/api/agent/ws"
|
||||
u = "ws" + strings.TrimPrefix(u, "http")
|
||||
hdr := http.Header{}
|
||||
hdr.Set("X-Agent-Id", a.state.AgentID)
|
||||
hdr.Set("Authorization", "Bearer "+a.state.Token)
|
||||
hdr.Set("X-Agent-Version", Version)
|
||||
hdr.Set("X-Agent-User", currentUser())
|
||||
dctx, dcancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
c, resp, err := websocket.Dial(dctx, u, &websocket.DialOptions{
|
||||
HTTPClient: a.stream, HTTPHeader: hdr, CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
dcancel()
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||
return fmt.Errorf("server answered %s", resp.Status)
|
||||
}
|
||||
return err
|
||||
}
|
||||
c.SetReadLimit(8 << 20)
|
||||
lctx, lcancel := context.WithCancel(ctx)
|
||||
l := &wsLink{c: c, ctx: lctx, subs: map[string]chan wsIn{}, done: make(chan struct{})}
|
||||
a.link.Store(l)
|
||||
defer func() {
|
||||
a.link.CompareAndSwap(l, nil)
|
||||
close(l.done)
|
||||
lcancel()
|
||||
c.CloseNow()
|
||||
}()
|
||||
log.Printf("connected by websocket")
|
||||
go func() {
|
||||
t := time.NewTicker(wsPing)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-lctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
pctx, pcancel := context.WithTimeout(lctx, 15*time.Second)
|
||||
err := c.Ping(pctx)
|
||||
pcancel()
|
||||
if err != nil {
|
||||
lcancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
for {
|
||||
var m proto.WSMsg
|
||||
if err := wsjson.Read(lctx, c, &m); err != nil {
|
||||
if errors.Is(err, context.Canceled) && ctx.Err() == nil {
|
||||
return errors.New("no answer to ping")
|
||||
}
|
||||
return err
|
||||
}
|
||||
switch m.T {
|
||||
case "ctrl":
|
||||
if m.Ctrl != nil {
|
||||
// Sessions live on the agent's context: they outlast the link.
|
||||
a.dispatch(ctx, *m.Ctrl)
|
||||
}
|
||||
case "cfg":
|
||||
a.applyTunnelAddr(ctx, m.Tunnel)
|
||||
a.applyTransport(m.Transport)
|
||||
if a.mode() == proto.TransportLP {
|
||||
return errors.New("set to long polling")
|
||||
}
|
||||
case "in":
|
||||
l.deliver(m.SID, wsIn{frames: m.Frames})
|
||||
case "gone":
|
||||
l.deliver(m.SID, wsIn{gone: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,32 @@ type ControlMessage struct {
|
||||
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
|
||||
}
|
||||
|
||||
// Transports an agent can reach the server by.
|
||||
const (
|
||||
TransportAuto = "auto" // WebSocket, long polling while it cannot connect
|
||||
TransportWS = "ws" // WebSocket only
|
||||
TransportLP = "lp" // long polling only
|
||||
)
|
||||
|
||||
// TransportHeader carries the agent's transport setting on poll and config
|
||||
// answers.
|
||||
const TransportHeader = "X-Transport"
|
||||
|
||||
// WSMsg is one message on an agent's WebSocket, either way. Server to agent:
|
||||
// "ctrl" (Ctrl, as a control poll would answer), "cfg" (Tunnel and
|
||||
// Transport, also a keep-alive), "in" (Frames for session SID), "gone"
|
||||
// (session SID is over). Agent to server: "sub" (send the input of session
|
||||
// SID here), "out" (Out of session SID).
|
||||
type WSMsg struct {
|
||||
T string `json:"t"`
|
||||
Ctrl *ControlMessage `json:"ctrl,omitempty"`
|
||||
SID string `json:"sid,omitempty"`
|
||||
Frames []InFrame `json:"frames,omitempty"`
|
||||
Out *OutMessage `json:"out,omitempty"`
|
||||
Tunnel string `json:"tunnel,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
}
|
||||
|
||||
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
||||
// Reused for both PTY terminals and raw TCP port-forward streams.
|
||||
const (
|
||||
|
||||
@@ -118,6 +118,7 @@ CREATE TABLE IF NOT EXISTS forward_rules (
|
||||
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admins ADD COLUMN password_off INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE agents ADD COLUMN transport TEXT NOT NULL DEFAULT 'auto'`,
|
||||
} {
|
||||
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||
return err
|
||||
@@ -354,6 +355,7 @@ type Agent struct {
|
||||
Version string
|
||||
RunUser string // OS user the agent process runs as (reported by the agent)
|
||||
LoginUser string // OS user terminals are opened as; "" = RunUser
|
||||
Transport string // auto, ws or lp: how the agent is to reach us
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
@@ -383,8 +385,8 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
|
||||
a := &Agent{}
|
||||
var last int64
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT id, name, hostname, os, arch, token_hash, version, run_user, login_user, last_seen FROM agents WHERE id = ?`, id).
|
||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &last)
|
||||
`SELECT id, name, hostname, os, arch, token_hash, version, run_user, login_user, transport, last_seen FROM agents WHERE id = ?`, id).
|
||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &a.Transport, &last)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -415,6 +417,11 @@ func (db *DB) ListAgents() ([]Agent, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) SetAgentTransport(id, transport string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET transport = ? WHERE id = ?`, transport, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) TouchAgent(id string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ? WHERE id = ?`, time.Now().Unix(), id)
|
||||
return err
|
||||
|
||||
@@ -53,8 +53,9 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.db.TouchAgent(a.ID)
|
||||
}
|
||||
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
|
||||
// Set after the hold so a port changed meanwhile reaches the agent now.
|
||||
// Set after the hold so a setting changed meanwhile reaches the agent now.
|
||||
s.setTunnelHeader(w)
|
||||
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
||||
writeJSON(w, msg)
|
||||
}
|
||||
|
||||
@@ -71,11 +72,13 @@ func (s *Server) setTunnelHeader(w http.ResponseWriter) {
|
||||
// GET /api/agent/config - settings an agent picks up when it connects, without
|
||||
// waiting out a poll hold.
|
||||
func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authenticateAgent(r) == nil {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
s.setTunnelHeader(w)
|
||||
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,9 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
||||
"Updated": r.URL.Query().Get("updated") == "1",
|
||||
"UserOK": r.URL.Query().Get("uok"),
|
||||
"UserErr": r.URL.Query().Get("uerr"),
|
||||
"Transport": firstNonEmpty(a.Transport, proto.TransportAuto),
|
||||
"OnWS": s.wsAgents.On(a.ID),
|
||||
"Online": s.online(a),
|
||||
}
|
||||
// The user list lives on the machine, so it is only known while it is online.
|
||||
if s.online(a) {
|
||||
@@ -225,6 +228,21 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "edit.html", data)
|
||||
}
|
||||
|
||||
// POST /agents/{id}/transport - how the agent is to reach the server: auto,
|
||||
// ws or lp. A connected agent picks it up within a poll round.
|
||||
func (s *Server) handleAgentTransport(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id, t := r.PathValue("id"), r.FormValue("transport")
|
||||
switch t {
|
||||
case proto.TransportAuto, proto.TransportWS, proto.TransportLP:
|
||||
_ = s.db.SetAgentTransport(id, t)
|
||||
}
|
||||
http.Redirect(w, r, "/agents/"+id+"/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// usersTaskTimeout bounds how long the edit page waits for the agent.
|
||||
const usersTaskTimeout = 8 * time.Second
|
||||
|
||||
|
||||
@@ -275,6 +275,18 @@ func (s *Session) PushIn(f proto.InFrame) {
|
||||
}
|
||||
}
|
||||
|
||||
// Unpoll puts frames taken by PollIn back in front, for frames that could not
|
||||
// be delivered.
|
||||
func (s *Session) Unpoll(frames []proto.InFrame) {
|
||||
s.mu.Lock()
|
||||
s.inBuf = append(append([]proto.InFrame{}, frames...), s.inBuf...)
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case s.inSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// PollIn drains queued downstream frames, blocking up to timeout for the first.
|
||||
func (s *Session) PollIn(timeout time.Duration) ([]proto.InFrame, bool) {
|
||||
deadline := time.After(timeout)
|
||||
|
||||
@@ -60,6 +60,7 @@ type Server struct {
|
||||
meters speedMeters // byte counts of the speed tests under way
|
||||
xfers xfers // files on their way between a browser and an agent
|
||||
challenges challenges // passkey challenges handed out
|
||||
wsAgents wsAgents // agents on their WebSocket now
|
||||
assets map[string]string // static file -> hash of its content
|
||||
}
|
||||
|
||||
@@ -224,6 +225,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename))
|
||||
mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate))
|
||||
mux.Handle("POST /agents/{id}/user", s.requireAdmin(s.handleAgentSetUser))
|
||||
mux.Handle("POST /agents/{id}/transport", s.requireAdmin(s.handleAgentTransport))
|
||||
mux.Handle("POST /agents/{id}/users/add", s.requireAdmin(s.handleAgentAddUser))
|
||||
mux.Handle("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete))
|
||||
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
|
||||
@@ -274,6 +276,7 @@ func (s *Server) Handler() http.Handler {
|
||||
// Agent API (token/secret auth inside handlers).
|
||||
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
|
||||
mux.HandleFunc("GET /api/agent/poll", s.handlePoll)
|
||||
mux.HandleFunc("GET /api/agent/ws", s.handleAgentWS)
|
||||
mux.HandleFunc("GET /api/agent/config", s.handleAgentConfig)
|
||||
mux.HandleFunc("GET /api/agent/term/{sid}/in", s.handleTermIn)
|
||||
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
|
||||
|
||||
@@ -72,6 +72,25 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Связь с сервером</h2>
|
||||
<p class="muted">
|
||||
Сейчас: {{if not .Online}}<span>офлайн</span>{{else if .OnWS}}<span style="color:var(--ok)">WebSocket</span>{{else}}<span>long polling</span>{{end}}
|
||||
</p>
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/transport" class="row">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<select name="transport">
|
||||
<option value="auto" {{if eq .Transport "auto"}}selected{{end}}>Авто: WebSocket, а если не выходит - long polling</option>
|
||||
<option value="ws" {{if eq .Transport "ws"}}selected{{end}}>Только WebSocket</option>
|
||||
<option value="lp" {{if eq .Transport "lp"}}selected{{end}}>Только long polling</option>
|
||||
</select>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
<p class="muted" style="font-size:13px; margin-top:10px">WebSocket держит одно соединение: команды и терминал идут по нему без отдельного запроса на каждое нажатие.
|
||||
В режиме «Авто» клиент, потеряв WebSocket, переходит на long polling и раз в минуту пробует вернуться.
|
||||
Нужен клиент 0.1.0+ и прокси, пропускающий WebSocket (nginx: заголовки Upgrade и Connection).</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Версия клиента</h2>
|
||||
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user