mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-01 04:21:29 +02:00
* [client] Drop agentConnecting whenever ICE session state clears Closing a WorkerICE raced a blocked dial goroutine: Close released the agent while connect() was still inside Dial, and the goroutine's own cleanup skipped its flag reset because w.agent no longer matched. With agentConnecting stuck on true, evalConnStatus read the peer as connected, the reconnection guard stopped sending offers and same-session offers were dropped, so the peer could not recover without a restart. An aborted recreate in OnNewOffer reaches the same wedged state without any race. Route every teardown path through one abandonNegotiation helper so the agent and flag fields always clear together; Close now also cleans up residual state left by an aborted recreate. * [client] Drive the ICE teardown race test through the real dial goroutine The regression test simulated the stale goroutine by calling closeAgent directly, so it pinned the symptom rather than the mechanism. Rework it to start a real negotiation, tear it down mid-flight and let the actual goroutine run its own cleanup: with no remote responder the dial can only fail once Close cancels it, so the interleaving stays deterministic without sleeps or injection points. Assert the full idle state that abandonNegotiation owns (agent nil, connecting false, remote session ID empty) instead of only InProgress, and make the stale-cleanup ownership test verify that the newer session survives field by field. * [client] Assert live remote session ID after stale ICE cleanup The stale-cleanup test compared a snapshot captured before closeAgent ran, so clearing the field during cleanup would have gone unnoticed. Read the field under the mutex after the cleanup instead. * [client] Give the ICE race tests a no-op signal client The candidate callback fires from a real gather and dereferences the signaler, so a nil one crashes the test package intermittently when gather wins the race against Close. Build the worker with a stub signal.Client instead. * [client] Read the ICE dial cancel func from an argument in connect The error paths read w.agentDialerCancel without holding muxAgent while OnNewOffer rewrites the field for a newer negotiation, a data race the new teardown test trips under -race. Reading a stale value also let an old goroutine cancel another session's dial. Capture the cancel func at goroutine spawn, like the dial context already is. * [client] Guard the ICE dial success path against stale negotiations The stale-cleanup guard in closeAgent only protected teardown. Its success-path counterpart was missing: an older negotiation could complete agentDial after a newer one replaced w.agent, then clear the newer session's agentConnecting, record lastSuccess and publish its dead connection via onICEConnectionIsReady. Verify ownership under muxAgent twice: right after the dial returns, so a stale goroutine drops its connection before touching a closed agent, and again at the state-commit point, atomic with the agentConnecting and lastSuccess writes, so a replacement arriving in the meantime cannot get its state clobbered. Both paths close the stale connection and return without modifying worker state. A regression test holds session A's dial open until session B is installed, then releases it; the stale connection must be discarded and B's agent, connecting flag and remote session ID must survive. * [client] Fix ICE teardown test leak and document the stale delivery window A code review of the stale-negotiation guard found a leftover resource leak in TestWorkerICE_StaleCloseAgentKeepsCurrentSession: session B is never closed, so its ICE sockets and blocked dial goroutine live as long as the test process. Register t.Cleanup(w.Close). The delivery race flagged after the success-path guard is pre-existing and self-correcting - the newer negotiation overwrites the transient endpoint - so document it in the existing todo instead of locking the callback, which would invert lock order against Conn.Close. Adjust the teardown test comment to match the now-synchronous Close flag clearing.
670 lines
22 KiB
Go
670 lines
22 KiB
Go
package peer
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/pion/ice/v4"
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/client/iface"
|
|
"github.com/netbirdio/netbird/client/iface/udpmux"
|
|
"github.com/netbirdio/netbird/client/internal/peer/conntype"
|
|
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
|
"github.com/netbirdio/netbird/client/internal/portforward"
|
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
|
"github.com/netbirdio/netbird/route"
|
|
)
|
|
|
|
type ICEConnInfo struct {
|
|
RemoteConn net.Conn
|
|
RosenpassPubKey []byte
|
|
RosenpassAddr string
|
|
LocalIceCandidateType string
|
|
RemoteIceCandidateType string
|
|
RemoteIceCandidateEndpoint string
|
|
LocalIceCandidateEndpoint string
|
|
Relayed bool
|
|
RelayedOnLocal bool
|
|
}
|
|
|
|
type WorkerICE struct {
|
|
ctx context.Context
|
|
log *log.Entry
|
|
config ConnConfig
|
|
conn *Conn
|
|
signaler *Signaler
|
|
iFaceDiscover stdnet.ExternalIFaceDiscover
|
|
statusRecorder *Status
|
|
hasRelayOnLocally bool
|
|
|
|
agent *icemaker.ThreadSafeAgent
|
|
agentDialerCancel context.CancelFunc
|
|
agentConnecting bool // while it is true, drop all incoming offers
|
|
lastSuccess time.Time // with this avoid the too frequent ICE agent recreation
|
|
// remoteSessionID represents the peer's session identifier from the latest remote offer.
|
|
remoteSessionID ICESessionID
|
|
// sessionID is used to track the current session ID of the ICE agent
|
|
// increase by one when disconnecting the agent
|
|
// with it the remote peer can discard the already deprecated offer/answer
|
|
// Without it the remote peer may recreate a workable ICE connection
|
|
sessionID ICESessionID
|
|
remoteSessionChanged bool
|
|
muxAgent sync.Mutex
|
|
|
|
localUfrag string
|
|
localPwd string
|
|
|
|
// we record the last known state of the ICE agent to avoid duplicate on disconnected events
|
|
lastKnownState ice.ConnectionState
|
|
|
|
// portForwardAttempted tracks if we've already tried port forwarding this session
|
|
portForwardAttempted bool
|
|
|
|
// dialFunc, when non-nil, replaces agentDial in connect(). Only for tests.
|
|
dialFunc func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error)
|
|
}
|
|
|
|
func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, conn *Conn, signaler *Signaler, ifaceDiscover stdnet.ExternalIFaceDiscover, statusRecorder *Status, hasRelayOnLocally bool) (*WorkerICE, error) {
|
|
sessionID, err := NewICESessionID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
w := &WorkerICE{
|
|
ctx: ctx,
|
|
log: log,
|
|
config: config,
|
|
conn: conn,
|
|
signaler: signaler,
|
|
iFaceDiscover: ifaceDiscover,
|
|
statusRecorder: statusRecorder,
|
|
hasRelayOnLocally: hasRelayOnLocally,
|
|
lastKnownState: ice.ConnectionStateDisconnected,
|
|
sessionID: sessionID,
|
|
}
|
|
|
|
localUfrag, localPwd, err := icemaker.GenerateICECredentials()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
w.localUfrag = localUfrag
|
|
w.localPwd = localPwd
|
|
return w, nil
|
|
}
|
|
|
|
func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
|
|
w.log.Debugf("OnNewOffer for ICE, serial: %s", remoteOfferAnswer.SessionIDString())
|
|
w.muxAgent.Lock()
|
|
defer w.muxAgent.Unlock()
|
|
|
|
if w.agent != nil || w.agentConnecting {
|
|
// backward compatibility with old clients that do not send session ID
|
|
if remoteOfferAnswer.SessionID == nil {
|
|
w.log.Debugf("agent already exists, skipping the offer")
|
|
return
|
|
}
|
|
if w.remoteSessionID == *remoteOfferAnswer.SessionID {
|
|
w.log.Debugf("agent already exists and session ID matches, skipping the offer: %s", remoteOfferAnswer.SessionIDString())
|
|
return
|
|
}
|
|
w.log.Debugf("agent already exists, recreate the connection")
|
|
w.remoteSessionChanged = true
|
|
w.agentDialerCancel()
|
|
if w.agent != nil {
|
|
if err := w.agent.Close(); err != nil {
|
|
w.log.Warnf("failed to close ICE agent: %s", err)
|
|
}
|
|
}
|
|
|
|
sessionID, err := NewICESessionID()
|
|
if err != nil {
|
|
w.log.Errorf("failed to create new session ID: %s", err)
|
|
}
|
|
w.sessionID = sessionID
|
|
w.abandonNegotiation()
|
|
}
|
|
|
|
var preferredCandidateTypes []ice.CandidateType
|
|
if w.hasRelayOnLocally && remoteOfferAnswer.RelaySrvAddress != "" {
|
|
preferredCandidateTypes = icemaker.CandidateTypesP2P()
|
|
} else {
|
|
preferredCandidateTypes = icemaker.CandidateTypes()
|
|
}
|
|
|
|
if remoteOfferAnswer.SessionID != nil {
|
|
w.log.Debugf("recreate ICE agent: %s / %s", w.sessionID, *remoteOfferAnswer.SessionID)
|
|
}
|
|
dialerCtx, dialerCancel := context.WithCancel(w.ctx)
|
|
agent, err := w.reCreateAgent(dialerCancel, preferredCandidateTypes)
|
|
if err != nil {
|
|
w.log.Errorf("failed to recreate ICE Agent: %s", err)
|
|
return
|
|
}
|
|
w.agent = agent
|
|
w.agentDialerCancel = dialerCancel
|
|
w.agentConnecting = true
|
|
if remoteOfferAnswer.SessionID != nil {
|
|
w.remoteSessionID = *remoteOfferAnswer.SessionID
|
|
} else {
|
|
w.remoteSessionID = ""
|
|
}
|
|
|
|
// Capture the cancel func at spawn time: connect reads it from the argument
|
|
// instead of the field, which a newer OnNewOffer may already have replaced.
|
|
go w.connect(dialerCtx, dialerCancel, agent, remoteOfferAnswer)
|
|
}
|
|
|
|
// OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer.
|
|
func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HAMap) {
|
|
w.muxAgent.Lock()
|
|
defer w.muxAgent.Unlock()
|
|
w.log.Debugf("OnRemoteCandidate from peer %s -> %s", w.config.Key, candidate.String())
|
|
if w.agent == nil {
|
|
w.log.Warnf("ICE Agent is not initialized yet")
|
|
return
|
|
}
|
|
|
|
if err := w.agent.AddRemoteCandidate(candidate); err != nil {
|
|
w.log.Errorf("error while handling remote candidate")
|
|
return
|
|
}
|
|
|
|
if shouldAddExtraCandidate(candidate) {
|
|
// sends an extra server reflexive candidate to the remote peer with our related port (usually the wireguard port)
|
|
// this is useful when network has an existing port forwarding rule for the wireguard port and this peer
|
|
extraSrflx, err := extraSrflxCandidate(candidate)
|
|
if err != nil {
|
|
w.log.Errorf("failed creating extra server reflexive candidate %s", err)
|
|
return
|
|
}
|
|
|
|
if err := w.agent.AddRemoteCandidate(extraSrflx); err != nil {
|
|
w.log.Errorf("error while handling remote candidate")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *WorkerICE) GetLocalUserCredentials() (frag string, pwd string) {
|
|
return w.localUfrag, w.localPwd
|
|
}
|
|
|
|
func (w *WorkerICE) InProgress() bool {
|
|
w.muxAgent.Lock()
|
|
defer w.muxAgent.Unlock()
|
|
|
|
return w.agentConnecting
|
|
}
|
|
|
|
func (w *WorkerICE) Close() {
|
|
w.muxAgent.Lock()
|
|
defer w.muxAgent.Unlock()
|
|
|
|
if w.agent != nil {
|
|
w.agentDialerCancel()
|
|
if err := w.agent.Close(); err != nil {
|
|
w.log.Warnf("failed to close ICE agent: %s", err)
|
|
}
|
|
}
|
|
// Unconditional: a dial goroutine racing this Close skips its own cleanup
|
|
// (closeAgent finds a nil agent), so the flags must be dropped here too or
|
|
// the reconnection guard reads the stale state as Connected forever.
|
|
w.abandonNegotiation()
|
|
}
|
|
|
|
func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) {
|
|
w.portForwardAttempted = false
|
|
|
|
agent, err := icemaker.NewAgent(w.ctx, w.iFaceDiscover, w.config.ICEConfig, candidates, w.localUfrag, w.localPwd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create agent: %w", err)
|
|
}
|
|
|
|
if err := agent.OnCandidate(w.onICECandidate); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := agent.OnConnectionStateChange(w.onConnectionStateChange(agent, dialerCancel)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := agent.OnSelectedCandidatePairChange(func(c1, c2 ice.Candidate) {
|
|
w.onICESelectedCandidatePair(agent, c1, c2)
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return agent, nil
|
|
}
|
|
|
|
func (w *WorkerICE) SessionID() ICESessionID {
|
|
w.muxAgent.Lock()
|
|
defer w.muxAgent.Unlock()
|
|
|
|
return w.sessionID
|
|
}
|
|
|
|
// will block until connection succeeded
|
|
// but it won't release if ICE Agent went into Disconnected or Failed state,
|
|
// so we have to cancel it with the provided context once agent detected a broken connection
|
|
func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) {
|
|
w.log.Debugf("gather candidates")
|
|
if err := agent.GatherCandidates(); err != nil {
|
|
w.log.Warnf("failed to gather candidates: %s", err)
|
|
w.closeAgent(agent, dialerCancel)
|
|
return
|
|
}
|
|
|
|
w.log.Debugf("agent dial")
|
|
dial := func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error) {
|
|
return w.agentDial(ctx, agent, remoteOfferAnswer)
|
|
}
|
|
if w.dialFunc != nil {
|
|
dial = w.dialFunc
|
|
}
|
|
remoteConn, err := dial(ctx, agent, remoteOfferAnswer)
|
|
if err != nil {
|
|
w.log.Debugf("failed to dial the remote peer: %s", err)
|
|
w.closeAgent(agent, dialerCancel)
|
|
return
|
|
}
|
|
w.log.Debugf("agent dial succeeded")
|
|
|
|
// A newer negotiation may have replaced our agent while agentDial was
|
|
// blocked. Drop the dead connection before running pair retrieval, port
|
|
// punching or candidate work against a closed agent. The commit-point
|
|
// check below still guards a replacement arriving after this point.
|
|
w.muxAgent.Lock()
|
|
stale := w.agent != agent
|
|
w.muxAgent.Unlock()
|
|
if stale {
|
|
if err := remoteConn.Close(); err != nil {
|
|
w.log.Warnf("failed to close stale ICE connection: %s", err)
|
|
}
|
|
w.log.Warnf("discarding connection from a stale ICE negotiation")
|
|
return
|
|
}
|
|
|
|
pair, err := agent.GetSelectedCandidatePair()
|
|
if err != nil {
|
|
w.closeAgent(agent, dialerCancel)
|
|
return
|
|
}
|
|
if pair == nil {
|
|
w.log.Warnf("selected candidate pair is nil, cannot proceed")
|
|
w.closeAgent(agent, dialerCancel)
|
|
return
|
|
}
|
|
|
|
if !isRelayCandidate(pair.Local) {
|
|
// dynamically set remote WireGuard port if other side specified a different one from the default one
|
|
remoteWgPort := iface.DefaultWgPort
|
|
if remoteOfferAnswer.WgListenPort != 0 {
|
|
remoteWgPort = remoteOfferAnswer.WgListenPort
|
|
}
|
|
|
|
// To support old version's with direct mode we attempt to punch an additional role with the remote WireGuard port
|
|
go w.punchRemoteWGPort(pair, remoteWgPort)
|
|
}
|
|
|
|
ci := ICEConnInfo{
|
|
RemoteConn: remoteConn,
|
|
RosenpassPubKey: remoteOfferAnswer.RosenpassPubKey,
|
|
RosenpassAddr: remoteOfferAnswer.RosenpassAddr,
|
|
LocalIceCandidateType: pair.Local.Type().String(),
|
|
RemoteIceCandidateType: pair.Remote.Type().String(),
|
|
LocalIceCandidateEndpoint: net.JoinHostPort(pair.Local.Address(), strconv.Itoa(pair.Local.Port())),
|
|
RemoteIceCandidateEndpoint: net.JoinHostPort(pair.Remote.Address(), strconv.Itoa(pair.Remote.Port())),
|
|
Relayed: isRelayed(pair),
|
|
RelayedOnLocal: isRelayCandidate(pair.Local),
|
|
}
|
|
w.log.Debugf("on ICE conn is ready to use")
|
|
|
|
w.log.Infof("connection succeeded with offer session: %s", remoteOfferAnswer.SessionIDString())
|
|
w.muxAgent.Lock()
|
|
// Authoritative ownership guard: a negotiation that lost w.agent to a newer
|
|
// one between the post-dial check and the commit must not clear agentConnecting,
|
|
// record lastSuccess or report the connection, so the state commit has to be
|
|
// atomic with the check.
|
|
if w.agent != agent {
|
|
w.muxAgent.Unlock()
|
|
if err := remoteConn.Close(); err != nil {
|
|
w.log.Warnf("failed to close stale ICE connection: %s", err)
|
|
}
|
|
w.log.Warnf("discarding connection from a stale ICE negotiation")
|
|
return
|
|
}
|
|
w.agentConnecting = false
|
|
w.lastSuccess = time.Now()
|
|
w.muxAgent.Unlock()
|
|
|
|
// todo: the potential problem is a race between the onConnectionStateChange
|
|
// and the delivery below: after this unlock, a newer offer can replace
|
|
// w.agent before onICEConnectionIsReady runs, delivering this (now stale)
|
|
// connection. The newer negotiation overwrites it with its own delivery,
|
|
// so the window only ever downgrades an endpoint transiently.
|
|
w.conn.onICEConnectionIsReady(selectedPriority(pair), ci)
|
|
}
|
|
|
|
func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.CancelFunc) bool {
|
|
cancel()
|
|
if err := agent.Close(); err != nil {
|
|
w.log.Warnf("failed to close ICE agent: %s", err)
|
|
}
|
|
|
|
w.muxAgent.Lock()
|
|
defer w.muxAgent.Unlock()
|
|
|
|
sessionChanged := w.remoteSessionChanged
|
|
w.remoteSessionChanged = false
|
|
|
|
// Only the owner of the current session may reset its state: a stale dial
|
|
// goroutine waking after a newer attempt must not clobber it.
|
|
if w.agent == agent {
|
|
sessionID, err := NewICESessionID()
|
|
if err != nil {
|
|
w.log.Errorf("failed to create new session ID: %s", err)
|
|
}
|
|
w.sessionID = sessionID
|
|
w.abandonNegotiation()
|
|
}
|
|
return sessionChanged
|
|
}
|
|
|
|
// abandonNegotiation drops all recorded ICE session state so the worker treats the
|
|
// next offer as a fresh start instead of a duplicate of a dead negotiation. The
|
|
// agent and agentConnecting flags must change together: leaving one stale wedges
|
|
// the reconnection guard into reporting Connected forever. It neither cancels an
|
|
// in-flight dial nor closes an agent — callers dispose of those themselves first,
|
|
// so a stale goroutine can never cancel another session's dial through this path.
|
|
// Caller must hold muxAgent.
|
|
func (w *WorkerICE) abandonNegotiation() {
|
|
w.agent = nil
|
|
w.agentConnecting = false
|
|
w.remoteSessionID = ""
|
|
}
|
|
|
|
func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) {
|
|
// wait local endpoint configuration
|
|
time.Sleep(time.Second)
|
|
addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(pair.Remote.Address(), strconv.Itoa(remoteWgPort)))
|
|
if err != nil {
|
|
w.log.Warnf("got an error while resolving the udp address, err: %s", err)
|
|
return
|
|
}
|
|
|
|
mux, ok := w.config.ICEConfig.UDPMuxSrflx.(*udpmux.UniversalUDPMuxDefault)
|
|
if !ok {
|
|
w.log.Warn("invalid udp mux conversion")
|
|
return
|
|
}
|
|
_, err = mux.GetSharedConn().WriteTo([]byte{0x6e, 0x62}, addr)
|
|
if err != nil {
|
|
w.log.Warnf("got an error while sending the punch packet, err: %s", err)
|
|
}
|
|
}
|
|
|
|
// onICECandidate is a callback attached to an ICE Agent to receive new local connection candidates
|
|
// and then signals them to the remote peer
|
|
func (w *WorkerICE) onICECandidate(candidate ice.Candidate) {
|
|
// nil means candidate gathering has been ended
|
|
if candidate == nil {
|
|
return
|
|
}
|
|
|
|
// TODO: reported port is incorrect for CandidateTypeHost, makes understanding ICE use via logs confusing as port is ignored
|
|
w.log.Debugf("discovered local candidate %s", candidate.String())
|
|
go func() {
|
|
err := w.signaler.SignalICECandidate(candidate, w.config.Key)
|
|
if err != nil {
|
|
w.log.Errorf("failed signaling candidate to the remote peer %s %s", w.config.Key, err)
|
|
}
|
|
}()
|
|
|
|
if candidate.Type() == ice.CandidateTypeServerReflexive {
|
|
w.injectPortForwardedCandidate(candidate)
|
|
}
|
|
}
|
|
|
|
// injectPortForwardedCandidate signals an additional candidate using the pre-created port mapping.
|
|
func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) {
|
|
pfManager := w.conn.portForwardManager
|
|
if pfManager == nil {
|
|
return
|
|
}
|
|
|
|
mapping := pfManager.GetMapping()
|
|
if mapping == nil {
|
|
return
|
|
}
|
|
|
|
// A forwarded candidate only makes sense for an IPv4 mapping, which
|
|
// translates a port on the gateway's address. An IPv6 pinhole translates
|
|
// nothing: it unblocks the address ICE already gathers as a host candidate,
|
|
// so there is no second address to advertise. Injecting one here would also
|
|
// paste an IPv6 address onto whichever server-reflexive candidate arrived
|
|
// first, which is usually IPv4.
|
|
if mapping.ExternalIP != nil && mapping.ExternalIP.To4() == nil {
|
|
w.log.Debugf("skipping port-forwarded candidate: %s mapping is IPv6-only", mapping.NATType)
|
|
return
|
|
}
|
|
|
|
w.muxAgent.Lock()
|
|
if w.portForwardAttempted {
|
|
w.muxAgent.Unlock()
|
|
return
|
|
}
|
|
w.portForwardAttempted = true
|
|
w.muxAgent.Unlock()
|
|
|
|
forwardedCandidate, err := w.createForwardedCandidate(srflxCandidate, mapping)
|
|
if err != nil {
|
|
w.log.Warnf("create forwarded candidate: %v", err)
|
|
return
|
|
}
|
|
|
|
w.log.Debugf("injecting port-forwarded candidate: %s (mapping: %d -> %d via %s, priority: %d)",
|
|
forwardedCandidate.String(), mapping.InternalPort, mapping.ExternalPort, mapping.NATType, forwardedCandidate.Priority())
|
|
|
|
go func() {
|
|
if err := w.signaler.SignalICECandidate(forwardedCandidate, w.config.Key); err != nil {
|
|
w.log.Errorf("signal port-forwarded candidate: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// createForwardedCandidate creates a new server reflexive candidate with the forwarded port.
|
|
// It uses the NAT gateway's external IP with the forwarded port.
|
|
func (w *WorkerICE) createForwardedCandidate(srflxCandidate ice.Candidate, mapping *portforward.Mapping) (ice.Candidate, error) {
|
|
var externalIP string
|
|
if mapping.ExternalIP != nil && !mapping.ExternalIP.IsUnspecified() {
|
|
externalIP = mapping.ExternalIP.String()
|
|
} else {
|
|
// Fallback to STUN-discovered address if NAT didn't provide external IP
|
|
externalIP = srflxCandidate.Address()
|
|
}
|
|
|
|
// Per RFC 8445, the related address for srflx is the base (host candidate address).
|
|
// If the original srflx has unspecified related address, use its own address as base.
|
|
relAddr := srflxCandidate.RelatedAddress().Address
|
|
if relAddr == "" || relAddr == "0.0.0.0" || relAddr == "::" {
|
|
relAddr = srflxCandidate.Address()
|
|
}
|
|
|
|
// Arbitrary +1000 boost on top of RFC 8445 priority to favor port-forwarded candidates
|
|
// over regular srflx during ICE connectivity checks.
|
|
priority := srflxCandidate.Priority() + 1000
|
|
|
|
candidate, err := ice.NewCandidateServerReflexive(&ice.CandidateServerReflexiveConfig{
|
|
Network: srflxCandidate.NetworkType().String(),
|
|
Address: externalIP,
|
|
Port: int(mapping.ExternalPort),
|
|
Component: srflxCandidate.Component(),
|
|
Priority: priority,
|
|
RelAddr: relAddr,
|
|
RelPort: int(mapping.InternalPort),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create candidate: %w", err)
|
|
}
|
|
|
|
for _, e := range srflxCandidate.Extensions() {
|
|
if e.Key == ice.ExtensionKeyCandidateID {
|
|
e.Value = srflxCandidate.ID()
|
|
}
|
|
if err := candidate.AddExtension(e); err != nil {
|
|
return nil, fmt.Errorf("add extension: %w", err)
|
|
}
|
|
}
|
|
|
|
return candidate, nil
|
|
}
|
|
|
|
func (w *WorkerICE) onICESelectedCandidatePair(agent *icemaker.ThreadSafeAgent, c1, c2 ice.Candidate) {
|
|
w.log.Debugf("selected candidate pair [local <-> remote] -> [%s <-> %s], peer %s", c1.String(), c2.String(),
|
|
w.config.Key)
|
|
|
|
pairStat, ok := agent.GetSelectedCandidatePairStats()
|
|
if !ok {
|
|
w.log.Warnf("failed to get selected candidate pair stats")
|
|
return
|
|
}
|
|
|
|
duration := time.Duration(pairStat.CurrentRoundTripTime * float64(time.Second))
|
|
if err := w.statusRecorder.UpdateLatency(w.config.Key, duration); err != nil {
|
|
w.log.Debugf("failed to update latency for peer: %s", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (w *WorkerICE) logSuccessfulPaths(agent *icemaker.ThreadSafeAgent) {
|
|
sessionID := w.SessionID()
|
|
stats := agent.GetCandidatePairsStats()
|
|
localCandidates, _ := agent.GetLocalCandidates()
|
|
remoteCandidates, _ := agent.GetRemoteCandidates()
|
|
|
|
localMap := make(map[string]ice.Candidate)
|
|
for _, c := range localCandidates {
|
|
localMap[c.ID()] = c
|
|
}
|
|
remoteMap := make(map[string]ice.Candidate)
|
|
for _, c := range remoteCandidates {
|
|
remoteMap[c.ID()] = c
|
|
}
|
|
|
|
for _, stat := range stats {
|
|
if stat.State == ice.CandidatePairStateSucceeded {
|
|
local, lok := localMap[stat.LocalCandidateID]
|
|
remote, rok := remoteMap[stat.RemoteCandidateID]
|
|
if !lok || !rok {
|
|
continue
|
|
}
|
|
w.log.Debugf("successful ICE path %s: [%s %s %s:%d] <-> [%s %s %s:%d] rtt=%.3fms",
|
|
sessionID,
|
|
local.NetworkType(), local.Type(), local.Address(), local.Port(),
|
|
remote.NetworkType(), remote.Type(), remote.Address(), remote.Port(),
|
|
stat.CurrentRoundTripTime*1000)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dialerCancel context.CancelFunc) func(ice.ConnectionState) {
|
|
return func(state ice.ConnectionState) {
|
|
w.log.Debugf("ICE ConnectionState has changed to %s", state.String())
|
|
switch state {
|
|
case ice.ConnectionStateConnected:
|
|
w.lastKnownState = ice.ConnectionStateConnected
|
|
w.logSuccessfulPaths(agent)
|
|
return
|
|
case ice.ConnectionStateFailed, ice.ConnectionStateDisconnected, ice.ConnectionStateClosed:
|
|
// ice.ConnectionStateClosed happens when we recreate the agent. The P2P to relay switch requires
|
|
// notifying conn.onICEStateDisconnected so it can update the currently used priority.
|
|
|
|
sessionChanged := w.closeAgent(agent, dialerCancel)
|
|
|
|
if w.lastKnownState == ice.ConnectionStateConnected {
|
|
w.lastKnownState = ice.ConnectionStateDisconnected
|
|
w.conn.onICEStateDisconnected(sessionChanged)
|
|
}
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *WorkerICE) agentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) {
|
|
if isController(w.config) {
|
|
return agent.Dial(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd)
|
|
} else {
|
|
return agent.Accept(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd)
|
|
}
|
|
}
|
|
|
|
func shouldAddExtraCandidate(candidate ice.Candidate) bool {
|
|
if candidate.Type() != ice.CandidateTypeServerReflexive {
|
|
return false
|
|
}
|
|
|
|
if candidate.Port() == candidate.RelatedAddress().Port {
|
|
return false
|
|
}
|
|
|
|
// in the older version when we didn't set candidate ID extension the remote peer sent the extra candidates
|
|
// in newer version we generate locally the extra candidate
|
|
if _, ok := candidate.GetExtension(ice.ExtensionKeyCandidateID); !ok {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func extraSrflxCandidate(candidate ice.Candidate) (*ice.CandidateServerReflexive, error) {
|
|
relatedAdd := candidate.RelatedAddress()
|
|
ec, err := ice.NewCandidateServerReflexive(&ice.CandidateServerReflexiveConfig{
|
|
Network: candidate.NetworkType().String(),
|
|
Address: candidate.Address(),
|
|
Port: relatedAdd.Port,
|
|
Component: candidate.Component(),
|
|
RelAddr: relatedAdd.Address,
|
|
RelPort: relatedAdd.Port,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, e := range candidate.Extensions() {
|
|
// overwrite the original candidate ID with the new one to avoid candidate duplication
|
|
if e.Key == ice.ExtensionKeyCandidateID {
|
|
e.Value = candidate.ID()
|
|
}
|
|
if err := ec.AddExtension(e); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return ec, nil
|
|
}
|
|
|
|
func isRelayCandidate(candidate ice.Candidate) bool {
|
|
return candidate.Type() == ice.CandidateTypeRelay
|
|
}
|
|
|
|
func isRelayed(pair *ice.CandidatePair) bool {
|
|
if pair.Local.Type() == ice.CandidateTypeRelay || pair.Remote.Type() == ice.CandidateTypeRelay {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func selectedPriority(pair *ice.CandidatePair) conntype.ConnPriority {
|
|
if isRelayed(pair) {
|
|
return conntype.ICETurn
|
|
} else {
|
|
return conntype.ICEP2P
|
|
}
|
|
}
|