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:
redtea committed 2026-10-14 21:04:13 +03:00
1 parent 6dd1120a41
commit 73bb720ec6
15 files changed
+676 -22

No files matched your search

+96 -8
View File
@@ -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
}