diff --git a/agent.go b/agent.go index 4d1327d..9cb14b2 100644 --- a/agent.go +++ b/agent.go @@ -973,6 +973,113 @@ func (a *Agent) closeMulticastConn() { } } +// GetCandidatePairsStats returns a list of candidate pair stats +func (a *Agent) GetCandidatePairsStats() []CandidatePairStats { + resultChan := make(chan []CandidatePairStats) + err := a.run(func(agent *Agent) { + result := make([]CandidatePairStats, 0, len(agent.checklist)) + for _, cp := range agent.checklist { + stat := CandidatePairStats{ + Timestamp: time.Now(), + LocalCandidateID: cp.local.ID(), + RemoteCandidateID: cp.remote.ID(), + State: cp.state, + // Nominated bool + // PacketsSent uint32 + // PacketsReceived uint32 + // BytesSent uint64 + // BytesReceived uint64 + // LastPacketSentTimestamp time.Time + // LastPacketReceivedTimestamp time.Time + // FirstRequestTimestamp time.Time + // LastRequestTimestamp time.Time + // LastResponseTimestamp time.Time + // TotalRoundTripTime float64 + // CurrentRoundTripTime float64 + // AvailableOutgoingBitrate float64 + // AvailableIncomingBitrate float64 + // CircuitBreakerTriggerCount uint32 + // RequestsReceived uint64 + // RequestsSent uint64 + // ResponsesReceived uint64 + // ResponsesSent uint64 + // RetransmissionsReceived uint64 + // RetransmissionsSent uint64 + // ConsentRequestsSent uint64 + // ConsentExpiredTimestamp time.Time + } + result = append(result, stat) + } + resultChan <- result + }) + if err != nil { + a.log.Errorf("error getting candidate pairs stats %v", err) + return []CandidatePairStats{} + } + return <-resultChan +} + +// GetLocalCandidatesStats returns a list of local candidates stats +func (a *Agent) GetLocalCandidatesStats() []CandidateStats { + resultChan := make(chan []CandidateStats) + err := a.run(func(agent *Agent) { + result := make([]CandidateStats, 0, len(agent.localCandidates)) + for networkType, localCandidates := range agent.localCandidates { + for _, c := range localCandidates { + stat := CandidateStats{ + Timestamp: time.Now(), + ID: c.ID(), + NetworkType: networkType, + IP: c.Address(), + Port: c.Port(), + CandidateType: c.Type(), + Priority: c.Priority(), + // URL string + RelayProtocol: "udp", + // Deleted bool + } + result = append(result, stat) + } + } + resultChan <- result + }) + if err != nil { + a.log.Errorf("error getting candidate pairs stats %v", err) + return []CandidateStats{} + } + return <-resultChan +} + +// GetRemoteCandidatesStats returns a list of remote candidates stats +func (a *Agent) GetRemoteCandidatesStats() []CandidateStats { + resultChan := make(chan []CandidateStats) + err := a.run(func(agent *Agent) { + result := make([]CandidateStats, 0, len(agent.remoteCandidates)) + for networkType, localCandidates := range agent.remoteCandidates { + for _, c := range localCandidates { + stat := CandidateStats{ + Timestamp: time.Now(), + ID: c.ID(), + NetworkType: networkType, + IP: c.Address(), + Port: c.Port(), + CandidateType: c.Type(), + Priority: c.Priority(), + // URL string + RelayProtocol: "udp", + } + result = append(result, stat) + } + } + resultChan <- result + }) + if err != nil { + a.log.Errorf("error getting candidate pairs stats %v", err) + return []CandidateStats{} + } + return <-resultChan +} + // Role represents ICE agent role, which can be controlling or controlled. type Role byte diff --git a/agent_test.go b/agent_test.go index 830a304..01f07e7 100644 --- a/agent_test.go +++ b/agent_test.go @@ -116,7 +116,7 @@ func TestPairPriority(t *testing.T) { p = a.addPair(hostLocal, remote) } - p.state = candidatePairStateValid + p.state = CandidatePairStateSucceeded bestPair := a.getBestValidCandidatePair() if bestPair.String() != (&candidatePair{remote: remote, local: hostLocal}).String() { t.Fatalf("Unexpected bestPair %s (expected remote: %s)", bestPair, remote) @@ -617,3 +617,305 @@ func TestInvalidGather(t *testing.T) { } }) } + +func TestCandidatePairStats(t *testing.T) { + // avoid deadlocks? + defer test.TimeOut(1 * time.Second).Stop() + + a, err := NewAgent(&AgentConfig{}) + if err != nil { + t.Fatalf("Failed to create agent: %s", err) + } + + hostLocal, err := NewCandidateHost( + "udp", + "192.168.1.1", 19216, + 1, + ) + if err != nil { + t.Fatalf("Failed to construct local host candidate: %s", err) + } + + relayRemote, err := NewCandidateRelay( + "udp", + "1.2.3.4", 2340, + 1, + "4.3.2.1", 43210, + ) + if err != nil { + t.Fatalf("Failed to construct remote relay candidate: %s", err) + } + + srflxRemote, err := NewCandidateServerReflexive( + "udp", + "10.10.10.2", 19218, + 1, + "4.3.2.1", 43212, + ) + if err != nil { + t.Fatalf("Failed to construct remote srflx candidate: %s", err) + } + + prflxRemote, err := NewCandidatePeerReflexive( + "udp", + "10.10.10.2", 19217, + 1, + "4.3.2.1", 43211, + ) + if err != nil { + t.Fatalf("Failed to construct remote prflx candidate: %s", err) + } + + hostRemote, err := NewCandidateHost( + "udp", + "1.2.3.5", 12350, + 1, + ) + if err != nil { + t.Fatalf("Failed to construct remote host candidate: %s", err) + } + + for _, remote := range []Candidate{relayRemote, srflxRemote, prflxRemote, hostRemote} { + p := a.findPair(hostLocal, remote) + + if p == nil { + a.addPair(hostLocal, remote) + } + } + + p := a.findPair(hostLocal, prflxRemote) + p.state = CandidatePairStateFailed + + stats := a.GetCandidatePairsStats() + if len(stats) != 4 { + t.Fatal("expected 4 candidate pairs stats") + } + + var relayPairStat, srflxPairStat, prflxPairStat, hostPairStat CandidatePairStats + + for _, cps := range stats { + if cps.LocalCandidateID != hostLocal.ID() { + t.Fatal("invalid local candidate id") + } + switch cps.RemoteCandidateID { + case relayRemote.ID(): + relayPairStat = cps + case srflxRemote.ID(): + srflxPairStat = cps + case prflxRemote.ID(): + prflxPairStat = cps + case hostRemote.ID(): + hostPairStat = cps + default: + t.Fatal("invalid remote candidate ID") + } + } + + if relayPairStat.RemoteCandidateID != relayRemote.ID() { + t.Fatal("missing host-relay pair stat") + } + + if srflxPairStat.RemoteCandidateID != srflxRemote.ID() { + t.Fatal("missing host-srflx pair stat") + } + + if prflxPairStat.RemoteCandidateID != prflxRemote.ID() { + t.Fatal("missing host-prflx pair stat") + } + + if hostPairStat.RemoteCandidateID != hostRemote.ID() { + t.Fatal("missing host-host pair stat") + } + + if prflxPairStat.State != CandidatePairStateFailed { + t.Fatalf("expected host-prfflx pair to have state failed, it has state %s instead", + prflxPairStat.State.String()) + } + + if err := a.Close(); err != nil { + t.Fatalf("Error on agent.Close(): %s", err) + } +} + +func TestLocalCandidateStats(t *testing.T) { + // avoid deadlocks? + defer test.TimeOut(1 * time.Second).Stop() + + a, err := NewAgent(&AgentConfig{}) + if err != nil { + t.Fatalf("Failed to create agent: %s", err) + } + + hostLocal, err := NewCandidateHost( + "udp", + "192.168.1.1", 19216, + 1, + ) + if err != nil { + t.Fatalf("Failed to construct local host candidate: %s", err) + } + + srflxLocal, err := NewCandidateServerReflexive( + "udp", + "192.168.1.1", 19217, + 1, + "4.3.2.1", 43212, + ) + if err != nil { + t.Fatalf("Failed to construct local srflx candidate: %s", err) + } + + a.localCandidates[NetworkTypeUDP4] = []Candidate{hostLocal, srflxLocal} + + localStats := a.GetLocalCandidatesStats() + if len(localStats) != 2 { + t.Fatalf("expected 2 local candidates stats, got %d instead", len(localStats)) + } + + var hostLocalStat, srflxLocalStat CandidateStats + for _, stats := range localStats { + var candidate Candidate + switch stats.ID { + case hostLocal.ID(): + hostLocalStat = stats + candidate = hostLocal + case srflxLocal.ID(): + srflxLocalStat = stats + candidate = srflxLocal + default: + t.Fatal("invalid local candidate ID") + } + + if stats.CandidateType != candidate.Type() { + t.Fatal("invalid stats CandidateType") + } + + if stats.Priority != candidate.Priority() { + t.Fatal("invalid stats CandidateType") + } + + if stats.IP != candidate.Address() { + t.Fatal("invalid stats IP") + } + } + + if hostLocalStat.ID != hostLocal.ID() { + t.Fatal("missing host local stat") + } + + if srflxLocalStat.ID != srflxLocal.ID() { + t.Fatal("missing srflx local stat") + } + + if err := a.Close(); err != nil { + t.Fatalf("Error on agent.Close(): %s", err) + } +} + +func TestRemoteCandidateStats(t *testing.T) { + // avoid deadlocks? + defer test.TimeOut(1 * time.Second).Stop() + + a, err := NewAgent(&AgentConfig{}) + if err != nil { + t.Fatalf("Failed to create agent: %s", err) + } + + relayRemote, err := NewCandidateRelay( + "udp", + "1.2.3.4", 12340, + 1, + "4.3.2.1", 43210, + ) + if err != nil { + t.Fatalf("Failed to construct remote relay candidate: %s", err) + } + + srflxRemote, err := NewCandidateServerReflexive( + "udp", + "10.10.10.2", 19218, + 1, + "4.3.2.1", 43212, + ) + if err != nil { + t.Fatalf("Failed to construct remote srflx candidate: %s", err) + } + + prflxRemote, err := NewCandidatePeerReflexive( + "udp", + "10.10.10.2", 19217, + 1, + "4.3.2.1", 43211, + ) + if err != nil { + t.Fatalf("Failed to construct remote prflx candidate: %s", err) + } + + hostRemote, err := NewCandidateHost( + "udp", + "1.2.3.5", 12350, + 1, + ) + if err != nil { + t.Fatalf("Failed to construct remote host candidate: %s", err) + } + + a.remoteCandidates[NetworkTypeUDP4] = []Candidate{relayRemote, srflxRemote, prflxRemote, hostRemote} + + remoteStats := a.GetRemoteCandidatesStats() + if len(remoteStats) != 4 { + t.Fatalf("expected 4 remote candidates stats, got %d instead", len(remoteStats)) + } + var relayRemoteStat, srflxRemoteStat, prflxRemoteStat, hostRemoteStat CandidateStats + for _, stats := range remoteStats { + var candidate Candidate + switch stats.ID { + case relayRemote.ID(): + relayRemoteStat = stats + candidate = relayRemote + case srflxRemote.ID(): + srflxRemoteStat = stats + candidate = srflxRemote + case prflxRemote.ID(): + prflxRemoteStat = stats + candidate = prflxRemote + case hostRemote.ID(): + hostRemoteStat = stats + candidate = hostRemote + default: + t.Fatal("invalid remote candidate ID") + } + + if stats.CandidateType != candidate.Type() { + t.Fatal("invalid stats CandidateType") + } + + if stats.Priority != candidate.Priority() { + t.Fatal("invalid stats CandidateType") + } + + if stats.IP != candidate.Address() { + t.Fatal("invalid stats IP") + } + } + + if relayRemoteStat.ID != relayRemote.ID() { + t.Fatal("missing relay remote stat") + } + + if srflxRemoteStat.ID != srflxRemote.ID() { + t.Fatal("missing srflx remote stat") + } + + if prflxRemoteStat.ID != prflxRemote.ID() { + t.Fatal("missing prflx remote stat") + } + + if hostRemoteStat.ID != hostRemote.ID() { + t.Fatal("missing host remote stat") + } + + if err := a.Close(); err != nil { + t.Fatalf("Error on agent.Close(): %s", err) + } +} diff --git a/candidate.go b/candidate.go index 2f38a70..9ae95d0 100644 --- a/candidate.go +++ b/candidate.go @@ -17,6 +17,7 @@ const ( // Candidate represents an ICE candidate type Candidate interface { + ID() string Component() uint16 Address() string LastReceived() time.Time diff --git a/candidate_base.go b/candidate_base.go index 7793780..b5cb216 100644 --- a/candidate_base.go +++ b/candidate_base.go @@ -11,6 +11,7 @@ import ( ) type candidateBase struct { + id string networkType NetworkType candidateType CandidateType @@ -31,6 +32,11 @@ type candidateBase struct { closedCh chan struct{} } +// ID returns Candidate ID +func (c *candidateBase) ID() string { + return c.id +} + // Address returns Candidate Address func (c *candidateBase) Address() string { return c.address diff --git a/candidate_host.go b/candidate_host.go index 3a590bf..e8b766a 100644 --- a/candidate_host.go +++ b/candidate_host.go @@ -14,8 +14,14 @@ type CandidateHost struct { // NewCandidateHost creates a new host candidate func NewCandidateHost(network string, address string, port int, component uint16) (*CandidateHost, error) { + candidateID, err := generateCandidateID() + if err != nil { + return nil, err + } + c := &CandidateHost{ candidateBase: candidateBase{ + id: candidateID, address: address, candidateType: CandidateTypeHost, component: component, diff --git a/candidate_peer_reflexive.go b/candidate_peer_reflexive.go index c6ddd39..b5a5162 100644 --- a/candidate_peer_reflexive.go +++ b/candidate_peer_reflexive.go @@ -19,8 +19,14 @@ func NewCandidatePeerReflexive(network string, address string, port int, compone return nil, err } + candidateID, err := generateCandidateID() + if err != nil { + return nil, err + } + return &CandidatePeerReflexive{ candidateBase: candidateBase{ + id: candidateID, networkType: networkType, candidateType: CandidateTypePeerReflexive, address: address, diff --git a/candidate_relay.go b/candidate_relay.go index 92c41c5..7a983e1 100644 --- a/candidate_relay.go +++ b/candidate_relay.go @@ -29,8 +29,14 @@ func NewCandidateRelay(network string, address string, port int, component uint1 return nil, err } + candidateID, err := generateCandidateID() + if err != nil { + return nil, err + } + return &CandidateRelay{ candidateBase: candidateBase{ + id: candidateID, networkType: networkType, candidateType: CandidateTypeRelay, address: address, diff --git a/candidate_server_reflexive.go b/candidate_server_reflexive.go index 8ff5ec3..c7ca3e2 100644 --- a/candidate_server_reflexive.go +++ b/candidate_server_reflexive.go @@ -19,8 +19,14 @@ func NewCandidateServerReflexive(network string, address string, port int, compo return nil, err } + candidateID, err := generateCandidateID() + if err != nil { + return nil, err + } + return &CandidateServerReflexive{ candidateBase: candidateBase{ + id: candidateID, networkType: networkType, candidateType: CandidateTypeServerReflexive, address: address, diff --git a/mdns.go b/mdns.go index 908a4fe..3b06781 100644 --- a/mdns.go +++ b/mdns.go @@ -2,7 +2,6 @@ package ice import ( "crypto/rand" - "fmt" ) // MulticastDNSMode represents the different Multicast modes ICE can run in @@ -28,5 +27,5 @@ func generateMulticastDNSName() (string, error) { return "", err } - return fmt.Sprintf("%X-%X-%X-%X-%X.local", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil + return generateRandString("", ".local") } diff --git a/stats.go b/stats.go new file mode 100644 index 0000000..f59d89f --- /dev/null +++ b/stats.go @@ -0,0 +1,177 @@ +package ice + +import ( + "time" +) + +// CandidatePairStats contains ICE candidate pair statistics +type CandidatePairStats struct { + // Timestamp is the timestamp associated with this object. + Timestamp time.Time + + // LocalCandidateID is the ID of the local candidate + LocalCandidateID string + + // RemoteCandidateID is the ID of the remote candidate + RemoteCandidateID string + + // State represents the state of the checklist for the local and remote + // candidates in a pair. + State CandidatePairState + + // Nominated is true when this valid pair that should be used for media + // if it is the highest-priority one amongst those whose nominated flag is set + Nominated bool + + // PacketsSent represents the total number of packets sent on this candidate pair. + PacketsSent uint32 + + // PacketsReceived represents the total number of packets received on this candidate pair. + PacketsReceived uint32 + + // BytesSent represents the total number of payload bytes sent on this candidate pair + // not including headers or padding. + BytesSent uint64 + + // BytesReceived represents the total number of payload bytes received on this candidate pair + // not including headers or padding. + BytesReceived uint64 + + // LastPacketSentTimestamp represents the timestamp at which the last packet was + // sent on this particular candidate pair, excluding STUN packets. + LastPacketSentTimestamp time.Time + + // LastPacketReceivedTimestamp represents the timestamp at which the last packet + // was received on this particular candidate pair, excluding STUN packets. + LastPacketReceivedTimestamp time.Time + + // FirstRequestTimestamp represents the timestamp at which the first STUN request + // was sent on this particular candidate pair. + FirstRequestTimestamp time.Time + + // LastRequestTimestamp represents the timestamp at which the last STUN request + // was sent on this particular candidate pair. The average interval between two + // consecutive connectivity checks sent can be calculated with + // (LastRequestTimestamp - FirstRequestTimestamp) / RequestsSent. + LastRequestTimestamp time.Time + + // LastResponseTimestamp represents the timestamp at which the last STUN response + // was received on this particular candidate pair. + LastResponseTimestamp time.Time + + // TotalRoundTripTime represents the sum of all round trip time measurements + // in seconds since the beginning of the session, based on STUN connectivity + // check responses (ResponsesReceived), including those that reply to requests + // that are sent in order to verify consent. The average round trip time can + // be computed from TotalRoundTripTime by dividing it by ResponsesReceived. + TotalRoundTripTime float64 + + // CurrentRoundTripTime represents the latest round trip time measured in seconds, + // computed from both STUN connectivity checks, including those that are sent + // for consent verification. + CurrentRoundTripTime float64 + + // AvailableOutgoingBitrate is calculated by the underlying congestion control + // by combining the available bitrate for all the outgoing RTP streams using + // this candidate pair. The bitrate measurement does not count the size of the + // IP or other transport layers like TCP or UDP. It is similar to the TIAS defined + // in RFC 3890, i.e., it is measured in bits per second and the bitrate is calculated + // over a 1 second window. + AvailableOutgoingBitrate float64 + + // AvailableIncomingBitrate is calculated by the underlying congestion control + // by combining the available bitrate for all the incoming RTP streams using + // this candidate pair. The bitrate measurement does not count the size of the + // IP or other transport layers like TCP or UDP. It is similar to the TIAS defined + // in RFC 3890, i.e., it is measured in bits per second and the bitrate is + // calculated over a 1 second window. + AvailableIncomingBitrate float64 + + // CircuitBreakerTriggerCount represents the number of times the circuit breaker + // is triggered for this particular 5-tuple, ceasing transmission. + CircuitBreakerTriggerCount uint32 + + // RequestsReceived represents the total number of connectivity check requests + // received (including retransmissions). It is impossible for the receiver to + // tell whether the request was sent in order to check connectivity or check + // consent, so all connectivity checks requests are counted here. + RequestsReceived uint64 + + // RequestsSent represents the total number of connectivity check requests + // sent (not including retransmissions). + RequestsSent uint64 + + // ResponsesReceived represents the total number of connectivity check responses received. + ResponsesReceived uint64 + + // ResponsesSent epresents the total number of connectivity check responses sent. + // Since we cannot distinguish connectivity check requests and consent requests, + // all responses are counted. + ResponsesSent uint64 + + // RetransmissionsReceived represents the total number of connectivity check + // request retransmissions received. + RetransmissionsReceived uint64 + + // RetransmissionsSent represents the total number of connectivity check + // request retransmissions sent. + RetransmissionsSent uint64 + + // ConsentRequestsSent represents the total number of consent requests sent. + ConsentRequestsSent uint64 + + // ConsentExpiredTimestamp represents the timestamp at which the latest valid + // STUN binding response expired. + ConsentExpiredTimestamp time.Time +} + +// CandidateStats contains ICE candidate statistics related to the ICETransport objects. +type CandidateStats struct { + // Timestamp is the timestamp associated with this object. + Timestamp time.Time + + // ID is the candidate ID + ID string + + // NetworkType represents the type of network interface used by the base of a + // local candidate (the address the ICE agent sends from). Only present for + // local candidates; it's not possible to know what type of network interface + // a remote candidate is using. + // + // Note: + // This stat only tells you about the network interface used by the first "hop"; + // it's possible that a connection will be bottlenecked by another type of network. + // For example, when using Wi-Fi tethering, the networkType of the relevant candidate + // would be "wifi", even when the next hop is over a cellular connection. + NetworkType NetworkType + + // IP is the IP address of the candidate, allowing for IPv4 addresses and + // IPv6 addresses, but fully qualified domain names (FQDNs) are not allowed. + IP string + + // Port is the port number of the candidate. + Port int + + // CandidateType is the "Type" field of the ICECandidate. + CandidateType CandidateType + + // Priority is the "Priority" field of the ICECandidate. + Priority uint32 + + // URL is the URL of the TURN or STUN server indicated in the that translated + // this IP address. It is the URL address surfaced in an PeerConnectionICEEvent. + URL string + + // RelayProtocol is the protocol used by the endpoint to communicate with the + // TURN server. This is only present for local candidates. Valid values for + // the TURN URL protocol is one of udp, tcp, or tls. + RelayProtocol string + + // Deleted is true if the candidate has been deleted/freed. For host candidates, + // this means that any network resources (typically a socket) associated with the + // candidate have been released. For TURN candidates, this means the TURN allocation + // is no longer active. + // + // Only defined for local candidates. For remote candidates, this property is not applicable. + Deleted bool +} diff --git a/util.go b/util.go index 7691e99..152922a 100644 --- a/util.go +++ b/util.go @@ -1,6 +1,7 @@ package ice import ( + "fmt" "math/rand" "net" "sync/atomic" @@ -73,3 +74,18 @@ func addrEqual(a, b net.Addr) bool { return aType == bType && aIP.Equal(bIP) && aPort == bPort } + +func generateCandidateID() (string, error) { + return generateRandString("candidate:", "") +} + +func generateRandString(prefix, sufix string) (string, error) { + b := make([]byte, 16) + _, err := rand.Read(b) //nolint + + if err != nil { + return "", err + } + + return fmt.Sprintf("%s%X-%X-%X-%X-%X%s", prefix, b[0:4], b[4:6], b[6:8], b[8:10], b[10:], sufix), nil +}