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
@@ -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})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user