|
|
@@ -4,48 +4,283 @@
|
|
|
package magicsock
|
|
|
|
|
|
import (
|
|
|
+ "bytes"
|
|
|
+ "context"
|
|
|
+ "encoding/json"
|
|
|
+ "io"
|
|
|
+ "net/http"
|
|
|
"net/netip"
|
|
|
"sync"
|
|
|
+ "time"
|
|
|
|
|
|
"tailscale.com/disco"
|
|
|
+ udprelay "tailscale.com/net/udprelay/endpoint"
|
|
|
"tailscale.com/types/key"
|
|
|
+ "tailscale.com/util/httpm"
|
|
|
+ "tailscale.com/util/set"
|
|
|
)
|
|
|
|
|
|
// relayManager manages allocation and handshaking of
|
|
|
// [tailscale.com/net/udprelay.Server] endpoints. The zero value is ready for
|
|
|
// use.
|
|
|
type relayManager struct {
|
|
|
- mu sync.Mutex // guards the following fields
|
|
|
+ initOnce sync.Once
|
|
|
+
|
|
|
+ // ===================================================================
|
|
|
+ // The following fields are owned by a single goroutine, runLoop().
|
|
|
+ serversByAddrPort set.Set[netip.AddrPort]
|
|
|
+ allocWorkByEndpoint map[*endpoint]*relayEndpointAllocWork
|
|
|
+
|
|
|
+ // ===================================================================
|
|
|
+ // The following chan fields serve event inputs to a single goroutine,
|
|
|
+ // runLoop().
|
|
|
+ allocateHandshakeCh chan *endpoint
|
|
|
+ allocateWorkDoneCh chan relayEndpointAllocWorkDoneEvent
|
|
|
+ cancelWorkCh chan *endpoint
|
|
|
+ newServerEndpointCh chan newRelayServerEndpointEvent
|
|
|
+ rxChallengeCh chan relayHandshakeChallengeEvent
|
|
|
+ rxCallMeMaybeViaCh chan *disco.CallMeMaybeVia
|
|
|
+
|
|
|
+ discoInfoMu sync.Mutex // guards the following field
|
|
|
discoInfoByServerDisco map[key.DiscoPublic]*discoInfo
|
|
|
+
|
|
|
+ // runLoopStoppedCh is written to by runLoop() upon return, enabling event
|
|
|
+ // writers to restart it when they are blocked (see
|
|
|
+ // relayManagerInputEvent()).
|
|
|
+ runLoopStoppedCh chan struct{}
|
|
|
}
|
|
|
|
|
|
-func (h *relayManager) initLocked() {
|
|
|
- if h.discoInfoByServerDisco != nil {
|
|
|
- return
|
|
|
+type newRelayServerEndpointEvent struct {
|
|
|
+ ep *endpoint
|
|
|
+ se udprelay.ServerEndpoint
|
|
|
+}
|
|
|
+
|
|
|
+type relayEndpointAllocWorkDoneEvent struct {
|
|
|
+ ep *endpoint
|
|
|
+ work *relayEndpointAllocWork
|
|
|
+}
|
|
|
+
|
|
|
+// activeWork returns true if there is outstanding allocation or handshaking
|
|
|
+// work, otherwise it returns false.
|
|
|
+func (r *relayManager) activeWork() bool {
|
|
|
+ return len(r.allocWorkByEndpoint) > 0
|
|
|
+ // TODO(jwhited): consider handshaking work
|
|
|
+}
|
|
|
+
|
|
|
+// runLoop is a form of event loop. It ensures exclusive access to most of
|
|
|
+// [relayManager] state.
|
|
|
+func (r *relayManager) runLoop() {
|
|
|
+ defer func() {
|
|
|
+ r.runLoopStoppedCh <- struct{}{}
|
|
|
+ }()
|
|
|
+
|
|
|
+ for {
|
|
|
+ select {
|
|
|
+ case ep := <-r.allocateHandshakeCh:
|
|
|
+ r.cancelAndClearWork(ep)
|
|
|
+ r.allocateAllServersForEndpoint(ep)
|
|
|
+ if !r.activeWork() {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ case msg := <-r.allocateWorkDoneCh:
|
|
|
+ work, ok := r.allocWorkByEndpoint[msg.ep]
|
|
|
+ if ok && work == msg.work {
|
|
|
+ // Verify the work in the map is the same as the one that we're
|
|
|
+ // cleaning up. New events on r.allocateHandshakeCh can
|
|
|
+ // overwrite pre-existing keys.
|
|
|
+ delete(r.allocWorkByEndpoint, msg.ep)
|
|
|
+ }
|
|
|
+ if !r.activeWork() {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ case ep := <-r.cancelWorkCh:
|
|
|
+ r.cancelAndClearWork(ep)
|
|
|
+ if !r.activeWork() {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ case newEndpoint := <-r.newServerEndpointCh:
|
|
|
+ _ = newEndpoint
|
|
|
+ // TODO(jwhited): implement
|
|
|
+ if !r.activeWork() {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ case challenge := <-r.rxChallengeCh:
|
|
|
+ _ = challenge
|
|
|
+ // TODO(jwhited): implement
|
|
|
+ if !r.activeWork() {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ case via := <-r.rxCallMeMaybeViaCh:
|
|
|
+ _ = via
|
|
|
+ // TODO(jwhited): implement
|
|
|
+ if !r.activeWork() {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
- h.discoInfoByServerDisco = make(map[key.DiscoPublic]*discoInfo)
|
|
|
+}
|
|
|
+
|
|
|
+type relayHandshakeChallengeEvent struct {
|
|
|
+ challenge [32]byte
|
|
|
+ disco key.DiscoPublic
|
|
|
+ from netip.AddrPort
|
|
|
+ vni uint32
|
|
|
+ at time.Time
|
|
|
+}
|
|
|
+
|
|
|
+// relayEndpointAllocWork serves to track in-progress relay endpoint allocation
|
|
|
+// for an [*endpoint]. This structure is immutable once initialized.
|
|
|
+type relayEndpointAllocWork struct {
|
|
|
+ // ep is the [*endpoint] associated with the work
|
|
|
+ ep *endpoint
|
|
|
+ // cancel() will signal all associated goroutines to return
|
|
|
+ cancel context.CancelFunc
|
|
|
+ // wg.Wait() will return once all associated goroutines have returned
|
|
|
+ wg *sync.WaitGroup
|
|
|
+}
|
|
|
+
|
|
|
+// init initializes [relayManager] if it is not already initialized.
|
|
|
+func (r *relayManager) init() {
|
|
|
+ r.initOnce.Do(func() {
|
|
|
+ r.discoInfoByServerDisco = make(map[key.DiscoPublic]*discoInfo)
|
|
|
+ r.allocWorkByEndpoint = make(map[*endpoint]*relayEndpointAllocWork)
|
|
|
+ r.allocateHandshakeCh = make(chan *endpoint)
|
|
|
+ r.allocateWorkDoneCh = make(chan relayEndpointAllocWorkDoneEvent)
|
|
|
+ r.cancelWorkCh = make(chan *endpoint)
|
|
|
+ r.newServerEndpointCh = make(chan newRelayServerEndpointEvent)
|
|
|
+ r.rxChallengeCh = make(chan relayHandshakeChallengeEvent)
|
|
|
+ r.rxCallMeMaybeViaCh = make(chan *disco.CallMeMaybeVia)
|
|
|
+ r.runLoopStoppedCh = make(chan struct{}, 1)
|
|
|
+ go r.runLoop()
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
// discoInfo returns a [*discoInfo] for 'serverDisco' if there is an
|
|
|
// active/ongoing handshake with it, otherwise it returns nil, false.
|
|
|
-func (h *relayManager) discoInfo(serverDisco key.DiscoPublic) (_ *discoInfo, ok bool) {
|
|
|
- h.mu.Lock()
|
|
|
- defer h.mu.Unlock()
|
|
|
- h.initLocked()
|
|
|
- di, ok := h.discoInfoByServerDisco[serverDisco]
|
|
|
+func (r *relayManager) discoInfo(serverDisco key.DiscoPublic) (_ *discoInfo, ok bool) {
|
|
|
+ r.discoInfoMu.Lock()
|
|
|
+ defer r.discoInfoMu.Unlock()
|
|
|
+ di, ok := r.discoInfoByServerDisco[serverDisco]
|
|
|
return di, ok
|
|
|
}
|
|
|
|
|
|
-func (h *relayManager) handleCallMeMaybeVia(dm *disco.CallMeMaybeVia) {
|
|
|
- h.mu.Lock()
|
|
|
- defer h.mu.Unlock()
|
|
|
- h.initLocked()
|
|
|
- // TODO(jwhited): implement
|
|
|
+func (r *relayManager) handleCallMeMaybeVia(dm *disco.CallMeMaybeVia) {
|
|
|
+ relayManagerInputEvent(r, nil, &r.rxCallMeMaybeViaCh, dm)
|
|
|
+}
|
|
|
+
|
|
|
+func (r *relayManager) handleBindUDPRelayEndpointChallenge(dm *disco.BindUDPRelayEndpointChallenge, di *discoInfo, src netip.AddrPort, vni uint32) {
|
|
|
+ relayManagerInputEvent(r, nil, &r.rxChallengeCh, relayHandshakeChallengeEvent{challenge: dm.Challenge, disco: di.discoKey, from: src, vni: vni, at: time.Now()})
|
|
|
+}
|
|
|
+
|
|
|
+// relayManagerInputEvent initializes [relayManager] if necessary, starts
|
|
|
+// relayManager.runLoop() if it is not running, and writes 'event' on 'eventCh'.
|
|
|
+//
|
|
|
+// [relayManager] initialization will make `*eventCh`, so it must be passed as
|
|
|
+// a pointer to a channel.
|
|
|
+//
|
|
|
+// 'ctx' can be used for returning when runLoop is waiting for the caller to
|
|
|
+// return, i.e. the calling goroutine was birthed by runLoop and is cancelable
|
|
|
+// via 'ctx'. 'ctx' may be nil.
|
|
|
+func relayManagerInputEvent[T any](r *relayManager, ctx context.Context, eventCh *chan T, event T) {
|
|
|
+ r.init()
|
|
|
+ var ctxDoneCh <-chan struct{}
|
|
|
+ if ctx != nil {
|
|
|
+ ctxDoneCh = ctx.Done()
|
|
|
+ }
|
|
|
+ for {
|
|
|
+ select {
|
|
|
+ case <-ctxDoneCh:
|
|
|
+ return
|
|
|
+ case *eventCh <- event:
|
|
|
+ return
|
|
|
+ case <-r.runLoopStoppedCh:
|
|
|
+ go r.runLoop()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// allocateAndHandshakeAllServers kicks off allocation and handshaking of relay
|
|
|
+// endpoints for 'ep' on all known relay servers, canceling any existing
|
|
|
+// in-progress work.
|
|
|
+func (r *relayManager) allocateAndHandshakeAllServers(ep *endpoint) {
|
|
|
+ relayManagerInputEvent(r, nil, &r.allocateHandshakeCh, ep)
|
|
|
+}
|
|
|
+
|
|
|
+// cancelOutstandingWork cancels all outstanding allocation & handshaking work
|
|
|
+// for 'ep'.
|
|
|
+func (r *relayManager) cancelOutstandingWork(ep *endpoint) {
|
|
|
+ relayManagerInputEvent(r, nil, &r.cancelWorkCh, ep)
|
|
|
}
|
|
|
|
|
|
-func (h *relayManager) handleBindUDPRelayEndpointChallenge(dm *disco.BindUDPRelayEndpointChallenge, di *discoInfo, src netip.AddrPort, vni uint32) {
|
|
|
- h.mu.Lock()
|
|
|
- defer h.mu.Unlock()
|
|
|
- h.initLocked()
|
|
|
- // TODO(jwhited): implement
|
|
|
+// cancelAndClearWork cancels & clears any outstanding work for 'ep'.
|
|
|
+func (r *relayManager) cancelAndClearWork(ep *endpoint) {
|
|
|
+ allocWork, ok := r.allocWorkByEndpoint[ep]
|
|
|
+ if ok {
|
|
|
+ allocWork.cancel()
|
|
|
+ allocWork.wg.Wait()
|
|
|
+ delete(r.allocWorkByEndpoint, ep)
|
|
|
+ }
|
|
|
+ // TODO(jwhited): cancel & clear handshake work
|
|
|
+}
|
|
|
+
|
|
|
+func (r *relayManager) allocateAllServersForEndpoint(ep *endpoint) {
|
|
|
+ if len(r.serversByAddrPort) == 0 {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ ctx, cancel := context.WithCancel(context.Background())
|
|
|
+ started := &relayEndpointAllocWork{ep: ep, cancel: cancel, wg: &sync.WaitGroup{}}
|
|
|
+ for k := range r.serversByAddrPort {
|
|
|
+ started.wg.Add(1)
|
|
|
+ go r.allocateEndpoint(ctx, started.wg, k, ep)
|
|
|
+ }
|
|
|
+ r.allocWorkByEndpoint[ep] = started
|
|
|
+ go func() {
|
|
|
+ started.wg.Wait()
|
|
|
+ started.cancel()
|
|
|
+ relayManagerInputEvent(r, ctx, &r.allocateWorkDoneCh, relayEndpointAllocWorkDoneEvent{ep: ep, work: started})
|
|
|
+ }()
|
|
|
+}
|
|
|
+
|
|
|
+func (r *relayManager) allocateEndpoint(ctx context.Context, wg *sync.WaitGroup, server netip.AddrPort, ep *endpoint) {
|
|
|
+ // TODO(jwhited): introduce client metrics counters for notable failures
|
|
|
+ defer wg.Done()
|
|
|
+ var b bytes.Buffer
|
|
|
+ remoteDisco := ep.disco.Load()
|
|
|
+ if remoteDisco == nil {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ type allocateRelayEndpointReq struct {
|
|
|
+ DiscoKeys []key.DiscoPublic
|
|
|
+ }
|
|
|
+ a := &allocateRelayEndpointReq{
|
|
|
+ DiscoKeys: []key.DiscoPublic{ep.c.discoPublic, remoteDisco.key},
|
|
|
+ }
|
|
|
+ err := json.NewEncoder(&b).Encode(a)
|
|
|
+ if err != nil {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const reqTimeout = time.Second * 10
|
|
|
+ reqCtx, cancel := context.WithTimeout(ctx, reqTimeout)
|
|
|
+ defer cancel()
|
|
|
+ req, err := http.NewRequestWithContext(reqCtx, httpm.POST, "http://"+server.String()+"/relay/endpoint", &b)
|
|
|
+ if err != nil {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ resp, err := http.DefaultClient.Do(req)
|
|
|
+ if err != nil {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ defer resp.Body.Close()
|
|
|
+ if resp.StatusCode != http.StatusOK {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ var se udprelay.ServerEndpoint
|
|
|
+ err = json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&se)
|
|
|
+ if err != nil {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ relayManagerInputEvent(r, ctx, &r.newServerEndpointCh, newRelayServerEndpointEvent{
|
|
|
+ ep: ep,
|
|
|
+ se: se,
|
|
|
+ })
|
|
|
}
|