/*
* TeleStax, Open Source Cloud Communications
* Copyright 2011-2014, Telestax Inc and individual contributors
* by the @authors tag.
*
* This program is free software: you can redistribute it and/or modify
* under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation; either version 3 of
* the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
package org.restcomm.media.ice;
import java.io.IOException;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.channels.DatagramChannel;
import java.nio.channels.Selector;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.log4j.Logger;
import org.restcomm.media.ice.events.IceEventListener;
import org.restcomm.media.ice.events.SelectedCandidatesEvent;
import org.restcomm.media.ice.harvest.ExternalCandidateHarvester;
import org.restcomm.media.ice.harvest.HarvestException;
import org.restcomm.media.ice.harvest.HarvestManager;
import org.restcomm.media.ice.harvest.NoCandidatesGatheredException;
import org.restcomm.media.ice.network.stun.ConnectivityCheckServer;
import org.restcomm.media.network.deprecated.RtpPortManager;
/**
* Agent responsible for ICE negotiation.
*
* @author Henrique Rosa (henrique.rosa@telestax.com)
*
*/
public abstract class IceAgent implements IceAuthenticator {
private static final Logger logger = Logger.getLogger(IceAgent.class);
private final Map<String, IceMediaStream> mediaStreams;
private final HarvestManager harvestManager;
// Control message integrity
private final SecureRandom random;
protected String ufrag;
protected String password;
// Control stun checks
protected Selector selector;
protected ConnectivityCheckServer connectivityCheckServer;
// Control selection process
private volatile int selectedPairs;
private volatile int maxSelectedPairs;
// Control state of the agent
protected volatile boolean running;
// Delegate ICE-related events
protected final List<IceEventListener> iceListeners;
// External address where Media Server is installed
// Required for fake SRFLX harvesting
private InetAddress externalAddress;
protected IceAgent() {
this.mediaStreams = new LinkedHashMap<String, IceMediaStream>(5);
this.harvestManager = new HarvestManager();
this.iceListeners = new ArrayList<IceEventListener>(5);
this.random = new SecureRandom();
this.ufrag = "";
this.password = "";
this.selectedPairs = 0;
this.maxSelectedPairs = 0;
this.running = false;
}
public void generateIceCredentials() {
this.ufrag = new BigInteger(24, this.random).toString(32);
this.password = new BigInteger(128, this.random).toString(32);
}
/**
* Checks whether the Agent implements ICE Lite
*
* @return true if the agent implements ICE Lite. False, in case of full
* ICE.
*/
public abstract boolean isLite();
/**
* Checks whether the Agent is controlling the ICE process.
*
* @return
*/
public abstract boolean isControlling();
/**
* Indicates whether the ICE agent is currently started.
*
* @return
*/
public boolean isRunning() {
return running;
}
/**
* Gets the local user fragment.
*
* @return the local <code>ice-ufrag</code>
*/
public String getUfrag() {
return ufrag;
}
/**
* Gets the password of the local user fragment
*
* @return the local <code>ice-pwd</code>
*/
public String getPassword() {
return password;
}
/**
* Creates an <tt>IceMediaStream</tt> and adds to it an RTP and an RTCP
* components.
*
* @param streamName
* the name of the stream to create
* @param agent
* the <tt>Agent</tt> that should create the stream.
*
* @return the newly created <tt>IceMediaStream</tt>.
*/
public IceMediaStream addMediaStream(String streamName) {
return addMediaStream(streamName, true);
}
/**
* Creates and registers a new media stream with an RTP component.<br>
* An secondary component may be created if the stream supports RTCP.
*
* @param streamName
* the name of the media stream
* @param rtcp
* Indicates whether the media server supports RTCP.
* @return The newly created media stream.
*/
public IceMediaStream addMediaStream(String streamName, boolean rtcp) {
return addMediaStream(streamName, rtcp, false);
}
/**
* Creates and registers a new media stream with an RTP component.<br>
* An secondary component may be created if the stream supports RTCP.
*
* @param streamName
* the name of the media stream
* @param rtcp
* Indicates whether the media server supports RTCP.
* @param rtcpMux
* Indicates whether the media stream supports <a
* href=""http://tools.ietf.org/html/rfc5761">rtcp-mux</a>
* @return The newly created media stream.
*/
public IceMediaStream addMediaStream(String streamName, boolean rtcp, boolean rtcpMux) {
if (!this.mediaStreams.containsKey(streamName)) {
// Updates number of maximum allowed candidate pairs
this.maxSelectedPairs += (rtcp && !rtcpMux) ? 2 : 1;
// Register media stream
return this.mediaStreams.put(streamName, new IceMediaStream(streamName, rtcp, rtcpMux));
}
return null;
}
/**
* Gets a media stream by name
*
* @param streamName
* The name of the media stream
* @return The media stream. Returns null, if no media stream exists with
* such name.
*/
public IceMediaStream getMediaStream(String streamName) {
IceMediaStream mediaStream;
synchronized (mediaStreams) {
mediaStream = this.mediaStreams.get(streamName);
}
return mediaStream;
}
public List<IceMediaStream> getMediaStreams() {
List<IceMediaStream> copy;
synchronized (mediaStreams) {
copy = new ArrayList<IceMediaStream>(this.mediaStreams.values());
}
return copy;
}
/**
* Gathers all available candidates and sets the components of each media
* stream.
*
* @param portManager
* The manager that handles port range for ICE candidate harvest
* @throws HarvestException
* An error occurred while harvesting candidates
*/
public void harvest(RtpPortManager portManager) throws HarvestException, NoCandidatesGatheredException {
// Initialize the selector if necessary
if (this.selector == null || !this.selector.isOpen()) {
try {
this.selector = Selector.open();
} catch (IOException e) {
throw new HarvestException("Could not initialize selector", e);
}
}
// Gather candidates for each media stream
for (IceMediaStream mediaStream : getMediaStreams()) {
this.harvestManager.harvest(mediaStream, portManager, this.selector);
}
}
/**
* Starts the ICE agent by activating its STUN stack.
* <p>
* <b>Full</b> ICE implementations start connectivity checks while listening
* for incoming checks.<br>
* <b>Lite</b> implementations are restricted to listen to incoming
* connectivity checks.
* </p>
*/
public abstract void start();
/**
* Stops the ICE agent.
*/
public abstract void stop();
public boolean isSelectionFinished() {
return this.maxSelectedPairs == this.selectedPairs;
}
public CandidatePair selectCandidatePair(DatagramChannel channel) {
InetSocketAddress address = null;
try {
address = (InetSocketAddress) channel.getLocalAddress();
} catch (IOException e) {
logger.error("Candidate selection canceled: cannot get address of the candidate.", e);
return null;
}
CandidatePair candidatePair = null;
for (IceMediaStream mediaStream : getMediaStreams()) {
// Search for RTP candidates
IceComponent rtpComponent = mediaStream.getRtpComponent();
candidatePair = selectCandidatePair(rtpComponent, channel);
if (candidatePair != null) {
logger.info("Selected RTP candidate on address "+ address.getHostString() +":"+ address.getPort());
// candidate pair was selected
break;
}
// Search for RTCP candidates (if supported by stream)
if (candidatePair == null && mediaStream.supportsRtcp()) {
IceComponent rtcpComponent = mediaStream.getRtcpComponent();
candidatePair = selectCandidatePair(rtcpComponent, channel);
if (candidatePair != null) {
logger.info("Selected RTCP candidate on address "+ address.getHostString() +":"+ address.getPort());
// candidate pair was selected
break;
}
}
}
// IF found, increment number of selected candidate pairs
if (candidatePair != null) {
this.selectedPairs++;
}
// IF all candidates are selected, fire an event
if (isSelectionFinished()) {
logger.info("Selected all candidate pairs!");
fireCandidatePairSelectedEvent();
}
return candidatePair;
}
/**
* Attempts to select a candidate pair on a ICE component.<br>
* A candidate pair is only selected if the local candidate channel is
* registered with the provided Selection Key.
*
* @param component
* The component that holds the gathered candidates.
* @param key
* The key of the datagram channel of the elected candidate.
* @return Returns the selected candidate pair. If no pair was selected,
* returns null.
*/
private CandidatePair selectCandidatePair(IceComponent component, DatagramChannel channel) {
for (LocalCandidateWrapper localCandidate : component.getLocalCandidates()) {
if (channel.equals(localCandidate.getChannel())) {
return component.setCandidatePair(channel);
}
}
return null;
}
private CandidatePair getSelectedCandidate(String stream, int componentId) {
// Find media stream
IceMediaStream mediaStream = getMediaStream(stream);
if(mediaStream != null) {
// Find correct component
IceComponent component;
if(componentId == IceComponent.RTP_ID) {
component = mediaStream.getRtpComponent();
} else {
component = mediaStream.getRtcpComponent();
}
// Get selected candidate from the component
return component.getSelectedCandidates();
}
return null;
}
public CandidatePair getSelectedRtpCandidate(String stream) {
return getSelectedCandidate(stream, IceComponent.RTP_ID);
}
public CandidatePair getSelectedRtcpCandidate(String stream) {
return getSelectedCandidate(stream, IceComponent.RTCP_ID);
}
public void addIceListener(IceEventListener listener) {
synchronized (this.iceListeners) {
if (!this.iceListeners.contains(listener)) {
this.iceListeners.add(listener);
}
}
}
public void removeIceListener(IceEventListener listener) {
synchronized (this.iceListeners) {
this.iceListeners.remove(listener);
}
}
/**
* Fires an event when all candidate pairs are selected.
*
* @param candidatePair
* The selected candidate pair
*/
private void fireCandidatePairSelectedEvent() {
// Stop the ICE Agent
this.stop();
// Fire the event to all listener
List<IceEventListener> listeners;
synchronized (this.iceListeners) {
listeners = new ArrayList<IceEventListener>(this.iceListeners);
}
SelectedCandidatesEvent event = new SelectedCandidatesEvent(this);
for (IceEventListener listener : listeners) {
listener.onSelectedCandidates(event);
}
}
public byte[] getLocalKey(String ufrag) {
if (isUserRegistered(ufrag)) {
if (this.password != null) {
return this.password.getBytes();
}
}
return null;
}
public byte[] getRemoteKey(String ufrag, String media) {
// Verify if media stream exists
IceMediaStream stream = getMediaStream(media);
if (stream == null) {
return null;
}
// Check whether full username is provided or just the fragment
int colon = ufrag.indexOf(":");
if (colon < 0) {
if (ufrag.equals(stream.getRemoteUfrag())) {
return stream.getRemotePassword().getBytes();
}
} else {
if (ufrag.equals(getLocalUsername(stream))) {
if (stream.getRemotePassword() != null) {
return stream.getRemotePassword().getBytes();
}
}
}
return null;
}
/**
* Returns the user name that the ICE Agent should use in connectivity
* checks for outgoing Binding Requests. According to RFC 5245, a Binding
* Request serving as a connectivity check MUST utilize the STUN short term
* credential mechanism. The username for the credential is formed by
* concatenating the username fragment provided by the peer with the
* username fragment of the agent sending the request, separated by a colon
* (":"). The password is equal to the password provided by the peer. For
* example, consider the case where agent L is the offerer, and agent R is
* the answerer. Agent L included a username fragment of LFRAG for its
* candidates, and a password of LPASS. Agent R provided a username fragment
* of RFRAG and a password of RPASS. A connectivity check from L to R (and
* its response of course) utilize the username RFRAG:LFRAG and a password
* of RPASS. A connectivity check from R to L (and its response) utilize the
* username LFRAG:RFRAG and a password of LPASS.
*
* @param media
* media name that we want to generate local username for.
* @return a user name that this <tt>Agent</tt> can use in connectivity
* check for outgoing Binding Requests.
*/
private String getLocalUsername(IceMediaStream stream) {
if (stream != null) {
if (stream.getRemotePassword() != null) {
return this.ufrag + ":" + stream.getRemotePassword();
}
}
return null;
}
public boolean isUserRegistered(String ufrag) {
int colon = ufrag.indexOf(":");
String result = colon < 0 ? ufrag : ufrag.substring(0, colon);
return result.equals(this.ufrag);
}
public InetAddress getExternalAddress() {
return externalAddress;
}
public void setExternalAddress(final InetAddress externalAddress) {
this.externalAddress = externalAddress;
if(externalAddress != null) {
// register an SRFLX harvester
this.harvestManager.addHarvester(new ExternalCandidateHarvester(harvestManager.getFoundationsRegistry(), externalAddress));
} else {
// remove lookup for SRFLX candidates
this.harvestManager.removeHarvester(CandidateType.SRFLX);
}
}
@Override
public boolean validateUsername(String username) {
// Username must separate local and remote ufrags with a colon
int colon = username.indexOf(":");
if(colon == -1) {
return false;
}
// Local ufrag must match the one generated by this agent
return username.substring(0, colon).equals(this.ufrag);
}
public void reset() {
this.iceListeners.clear();
this.mediaStreams.clear();
this.maxSelectedPairs = 0;
this.selectedPairs = 0;
this.password = "";
this.ufrag = "";
}
}