package net.i2p.router.transport.udp;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import net.i2p.data.Base64;
import net.i2p.data.router.RouterAddress;
import net.i2p.data.router.RouterInfo;
import net.i2p.data.SessionKey;
import net.i2p.router.RouterContext;
import net.i2p.util.Addresses;
import net.i2p.util.ConcurrentHashSet;
import net.i2p.util.Log;
import net.i2p.router.transport.TransportUtil;
/**
* Keep track of inbound and outbound introductions.
*
* IPv6 info: Alice-Bob communication may be via IPv4 or IPv6.
* Bob-Charlie communication must be via established IPv4 session as that's the only way
* that Bob knows Charlie's IPv4 address to give it to Alice.
* Alice-Charlie communication is via IPv4.
* If Alice-Bob is over IPv6, Alice must include her IPv4 address in
* the RelayRequest message.
*
* From udp.html on the website:
<p>Indirect session establishment by means of a third party introduction
is necessary for efficient NAT traversal. Charlie, a router behind a
NAT or firewall which does not allow unsolicited inbound UDP packets,
first contacts a few peers, choosing some to serve as introducers. Each
of these peers (Bob, Bill, Betty, etc) provide Charlie with an introduction
tag - a 4 byte random number - which he then makes available to the public
as methods of contacting him. Alice, a router who has Charlie's published
contact methods, first sends a RelayRequest packet to one or more of the
introducers, asking each to introduce her to Charlie (offering the
introduction tag to identify Charlie). Bob then forwards a RelayIntro
packet to Charlie including Alice's public IP and port number, then sends
Alice back a RelayResponse packet containing Charlie's public IP and port
number. When Charlie receives the RelayIntro packet, he sends off a small
random packet to Alice's IP and port (poking a hole in his NAT/firewall),
and when Alice receives Bob's RelayResponse packet, she begins a new
full direction session establishment with the specified IP and port.</p>
<p>
Alice first connects to introducer Bob, who relays the request to Charlie.
</p>
<pre>
Alice Bob Charlie
RelayRequest ---------------------->
<-------------- RelayResponse RelayIntro ----------->
<-------------------------------------------- HolePunch (data ignored)
SessionRequest -------------------------------------------->
<-------------------------------------------- SessionCreated
SessionConfirmed ------------------------------------------>
<-------------------------------------------- DeliveryStatusMessage
<-------------------------------------------- DatabaseStoreMessage
DatabaseStoreMessage -------------------------------------->
Data <--------------------------------------------------> Data
</pre>
<p>
After the hole punch, the session is established between Alice and Charlie as in a direct establishment.
</p>
*/
class IntroductionManager {
private final RouterContext _context;
private final Log _log;
private final UDPTransport _transport;
private final PacketBuilder _builder;
/** map of relay tag to PeerState that should receive the introduction */
private final Map<Long, PeerState> _outbound;
/** list of peers (PeerState) who have given us introduction tags */
private final Set<PeerState> _inbound;
private final Set<InetAddress> _recentHolePunches;
private long _lastHolePunchClean;
/**
* Limit since we ping to keep the conn open
* @since 0.8.11
*/
private static final int MAX_INBOUND = 20;
/**
* This is enforced in EstablishmentManager
* @since 0.8.11
*/
public static final int MAX_OUTBOUND = 100;
/** Max one per target in this time */
private static final long PUNCH_CLEAN_TIME = 5*1000;
/** Max for all targets per PUNCH_CLEAN_TIME */
private static final int MAX_PUNCHES = 8;
private static final long INTRODUCER_EXPIRATION = 80*60*1000L;
public IntroductionManager(RouterContext ctx, UDPTransport transport) {
_context = ctx;
_log = ctx.logManager().getLog(IntroductionManager.class);
_transport = transport;
_builder = new PacketBuilder(ctx, transport);
_outbound = new ConcurrentHashMap<Long, PeerState>(MAX_OUTBOUND);
_inbound = new ConcurrentHashSet<PeerState>(MAX_INBOUND);
_recentHolePunches = new HashSet<InetAddress>(16);
ctx.statManager().createRateStat("udp.receiveRelayIntro", "How often we get a relayed request for us to talk to someone?", "udp", UDPTransport.RATES);
ctx.statManager().createRateStat("udp.receiveRelayRequest", "How often we receive a good request to relay to someone else?", "udp", UDPTransport.RATES);
ctx.statManager().createRateStat("udp.receiveRelayRequestBadTag", "Received relay requests with bad/expired tag", "udp", UDPTransport.RATES);
ctx.statManager().createRateStat("udp.relayBadIP", "Received IP or port was bad", "udp", UDPTransport.RATES);
}
public void reset() {
_inbound.clear();
_outbound.clear();
}
public void add(PeerState peer) {
if (peer == null) return;
// let's not use an introducer on a privileged port, sounds like trouble
if (!TransportUtil.isValidPort(peer.getRemotePort()))
return;
// Only allow relay as Bob or Charlie if the Bob-Charlie session is IPv4
if (peer.getRemoteIP().length != 4)
return;
if (_log.shouldLog(Log.DEBUG))
_log.debug("Adding peer " + peer.getRemoteHostId() + ", weRelayToThemAs "
+ peer.getWeRelayToThemAs() + ", theyRelayToUsAs " + peer.getTheyRelayToUsAs());
if (peer.getWeRelayToThemAs() > 0)
_outbound.put(Long.valueOf(peer.getWeRelayToThemAs()), peer);
if (peer.getTheyRelayToUsAs() > 0 && _inbound.size() < MAX_INBOUND) {
_inbound.add(peer);
}
}
public void remove(PeerState peer) {
if (peer == null) return;
if (_log.shouldLog(Log.DEBUG))
_log.debug("removing peer " + peer.getRemoteHostId() + ", weRelayToThemAs "
+ peer.getWeRelayToThemAs() + ", theyRelayToUsAs " + peer.getTheyRelayToUsAs());
long id = peer.getWeRelayToThemAs();
if (id > 0)
_outbound.remove(Long.valueOf(id));
if (peer.getTheyRelayToUsAs() > 0) {
_inbound.remove(peer);
}
}
private PeerState get(long id) {
return _outbound.get(Long.valueOf(id));
}
/**
* Grab a bunch of peers who are willing to be introducers for us that
* are locally known (duh) and have published their own SSU address (duh^2).
* The picked peers have their info tacked on to the ssuOptions parameter for
* use in the SSU RouterAddress.
*
* Try to use "good" peers (i.e. reachable, active)
*
* Also, ping all idle peers that were introducers in the last 2 hours,
* to keep the connection up, since the netDb can have quite stale information,
* and we want to keep our introducers valid.
*
* @param current current router address, may be null
* @param ssuOptions out parameter, options are added
* @return number of introducers added
*/
public int pickInbound(RouterAddress current, Properties ssuOptions, int howMany) {
int start = _context.random().nextInt();
if (_log.shouldLog(Log.DEBUG))
_log.debug("Picking inbound out of " + _inbound.size());
if (_inbound.isEmpty()) return 0;
List<PeerState> peers = new ArrayList<PeerState>(_inbound);
int sz = peers.size();
start = start % sz;
int found = 0;
long now = _context.clock().now();
long inactivityCutoff = now - (UDPTransport.EXPIRE_TIMEOUT / 2); // 15 min
// if not too many to choose from, be less picky
if (sz <= howMany + 2)
inactivityCutoff -= UDPTransport.EXPIRE_TIMEOUT / 4;
List<Introducer> introducers = new ArrayList<Introducer>(howMany);
for (int i = 0; i < sz && found < howMany; i++) {
PeerState cur = peers.get((start + i) % sz);
RouterInfo ri = _context.netDb().lookupRouterInfoLocally(cur.getRemotePeer());
if (ri == null) {
if (_log.shouldLog(Log.INFO))
_log.info("Picked peer has no local routerInfo: " + cur);
continue;
}
// FIXME we can include all his addresses including IPv6 even if we don't support IPv6 (isValid() is false)
// but requires RelayRequest support, see below
RouterAddress ra = _transport.getTargetAddress(ri);
if (ra == null) {
if (_log.shouldLog(Log.INFO))
_log.info("Picked peer has no SSU address: " + ri);
continue;
}
if ( /* _context.profileOrganizer().isFailing(cur.getRemotePeer()) || */
_context.banlist().isBanlisted(cur.getRemotePeer()) ||
_transport.wasUnreachable(cur.getRemotePeer())) {
if (_log.shouldLog(Log.INFO))
_log.info("Peer is failing, shistlisted or was unreachable: " + cur);
continue;
}
// Try to pick active peers...
// FIXME this is really strict and causes us to run out of introducers
// We have much less introducers than we used to have because routers don't offer
// if they are approaching max connections (see EstablishmentManager)
// FIXED, was ||, is this OK now?
if (cur.getLastReceiveTime() < inactivityCutoff && cur.getLastSendTime() < inactivityCutoff) {
if (_log.shouldLog(Log.INFO))
_log.info("Peer is idle too long: " + cur);
continue;
}
// FIXME we can include all his addresses including IPv6 even if we don't support IPv6 (isValid() is false)
// but requires RelayRequest support, see below
byte[] ip = cur.getRemoteIP();
int port = cur.getRemotePort();
if (!isValid(ip, port))
continue;
if (_log.shouldLog(Log.INFO))
_log.info("Picking introducer: " + cur);
cur.setIntroducerTime();
UDPAddress ura = new UDPAddress(ra);
byte[] ikey = ura.getIntroKey();
if (ikey == null)
continue;
introducers.add(new Introducer(ip, port, ikey, cur.getTheyRelayToUsAs()));
found++;
}
// we sort them so a change in order only won't happen, and won't cause a republish
Collections.sort(introducers);
String exp = Long.toString((now + INTRODUCER_EXPIRATION) / 1000);
for (int i = 0; i < found; i++) {
Introducer in = introducers.get(i);
ssuOptions.setProperty(UDPAddress.PROP_INTRO_HOST_PREFIX + i, in.sip);
ssuOptions.setProperty(UDPAddress.PROP_INTRO_PORT_PREFIX + i, in.sport);
ssuOptions.setProperty(UDPAddress.PROP_INTRO_KEY_PREFIX + i, in.skey);
ssuOptions.setProperty(UDPAddress.PROP_INTRO_TAG_PREFIX + i, in.stag);
String sexp = exp;
// look for existing expiration in current published
// and reuse if still recent enough, so deepEquals() won't fail in UDPT.rEA
if (current != null) {
for (int j = 0; j < UDPTransport.PUBLIC_RELAY_COUNT; j++) {
if (in.sip.equals(current.getOption(UDPAddress.PROP_INTRO_HOST_PREFIX + j)) &&
in.sport.equals(current.getOption(UDPAddress.PROP_INTRO_PORT_PREFIX + j)) &&
in.skey.equals(current.getOption(UDPAddress.PROP_INTRO_KEY_PREFIX + j)) &&
in.stag.equals(current.getOption(UDPAddress.PROP_INTRO_TAG_PREFIX + j))) {
// found old one
String oexp = current.getOption(UDPAddress.PROP_INTRO_EXP_PREFIX + j);
if (oexp != null) {
try {
long oex = Long.parseLong(oexp) * 1000;
if (oex > now + UDPTransport.INTRODUCER_EXPIRATION_MARGIN) {
// still good, use old expiration time
sexp = oexp;
}
} catch (NumberFormatException nfe) {}
}
break;
}
}
}
ssuOptions.setProperty(UDPAddress.PROP_INTRO_EXP_PREFIX + i, sexp);
}
// FIXME failsafe if found == 0, relax inactivityCutoff and try again?
pingIntroducers();
return found;
}
/**
* So we can sort them
* @since 0.9.18
*/
private static class Introducer implements Comparable<Introducer> {
public final String sip, sport, skey, stag;
public Introducer(byte[] ip, int port, byte[] key, long tag) {
sip = Addresses.toString(ip);
sport = String.valueOf(port);
skey = Base64.encode(key);
stag = String.valueOf(tag);
}
@Override
public int compareTo(Introducer i) {
return skey.compareTo(i.skey);
}
@Override
public boolean equals(Object o) {
if (o == null) {
return false;
}
if (!(o instanceof Introducer)) {
return false;
}
Introducer i = (Introducer) o;
return this.compareTo(i) == 0;
}
@Override
public int hashCode() {
return skey.hashCode();
}
}
/**
* Was part of pickInbound(), moved out so we can call it more often
* @since 0.8.11
*/
public void pingIntroducers() {
// Try to keep the connection up for two hours after we made anybody an introducer
long now = _context.clock().now();
long pingCutoff = now - (105 * 60 * 1000);
long inactivityCutoff = now - UDPTransport.MIN_EXPIRE_TIMEOUT;
for (PeerState cur : _inbound) {
if (cur.getIntroducerTime() > pingCutoff &&
cur.getLastSendTime() < inactivityCutoff) {
if (_log.shouldLog(Log.INFO))
_log.info("Pinging introducer: " + cur);
cur.setLastSendTime(now);
_transport.send(_builder.buildPing(cur));
}
}
}
/**
* Not as elaborate as pickInbound() above.
* Just a quick check to see how many volunteers we know,
* which the Transport uses to see if we need more.
* @return number of peers that have volunteered to introduce us
*/
int introducerCount() {
return _inbound.size();
}
/**
* @return number of peers we have volunteered to introduce
* @since 0.9.3
*/
int introducedCount() {
return _outbound.size();
}
/**
* We are Charlie and we got this from Bob.
* Send a HolePunch to Alice, who will soon be sending us a RelayRequest.
* We should already have a session with Bob, but probably not with Alice.
*
* If we don't have a session with Bob, we removed the relay tag from
* our _outbound table, so this won't work.
*
* We do some throttling here.
*/
void receiveRelayIntro(RemoteHostId bob, UDPPacketReader reader) {
if (_context.router().isHidden())
return;
_context.statManager().addRateData("udp.receiveRelayIntro", 1);
if (!_transport.allowConnection()) {
if (_log.shouldLog(Log.WARN))
_log.warn("Dropping RelayIntro, over conn limit");
return;
}
int ipSize = reader.getRelayIntroReader().readIPSize();
byte ip[] = new byte[ipSize];
reader.getRelayIntroReader().readIP(ip, 0);
int port = reader.getRelayIntroReader().readPort();
if ((!isValid(ip, port)) || (!isValid(bob.getIP(), bob.getPort()))) {
if (_log.shouldLog(Log.WARN))
_log.warn("Bad relay intro from " + bob + " for " + Addresses.toString(ip, port));
_context.statManager().addRateData("udp.relayBadIP", 1);
return;
}
if (_log.shouldLog(Log.INFO))
_log.info("Receive relay intro from " + bob + " for " + Addresses.toString(ip, port));
InetAddress to = null;
try {
to = InetAddress.getByAddress(ip);
} catch (UnknownHostException uhe) {
// banlist Bob?
if (_log.shouldLog(Log.WARN))
_log.warn("IP for alice to hole punch to is invalid", uhe);
_context.statManager().addRateData("udp.relayBadIP", 1);
return;
}
RemoteHostId alice = new RemoteHostId(ip, port);
if (_transport.getPeerState(alice) != null) {
if (_log.shouldLog(Log.INFO))
_log.info("Ignoring RelayIntro, already have a session to " + to);
return;
}
EstablishmentManager establisher = _transport.getEstablisher();
if (establisher != null) {
if (establisher.getInboundState(alice) != null) {
// This check may be common, as Alice sends RelayRequests to
// several introducers at once.
if (_log.shouldLog(Log.INFO))
_log.info("Ignoring RelayIntro, establishment in progress to " + to);
return;
}
if (!establisher.shouldAllowInboundEstablishment()) {
if (_log.shouldLog(Log.WARN))
_log.warn("Dropping RelayIntro, too many establishments in progress - for " + to);
return;
}
}
// basic throttle, don't bother saving per-peer send times
// we throttle on IP only, ignoring port
boolean tooMany = false;
boolean already = false;
synchronized (_recentHolePunches) {
long now = _context.clock().now();
if (now > _lastHolePunchClean + PUNCH_CLEAN_TIME) {
_recentHolePunches.clear();
_lastHolePunchClean = now;
_recentHolePunches.add(to);
} else {
tooMany = _recentHolePunches.size() >= MAX_PUNCHES;
if (!tooMany)
already = !_recentHolePunches.add(to);
}
}
if (tooMany) {
if (_log.shouldLog(Log.WARN))
_log.warn("Dropping - too many - RelayIntro for " + to);
return;
}
if (already) {
// This check will trigger a lot, as Alice sends RelayRequests to
// several introducers at once.
if (_log.shouldLog(Log.INFO))
_log.info("Ignoring dup RelayIntro for " + to);
return;
}
_transport.send(_builder.buildHolePunch(to, port));
}
/**
* We are Bob and we got this from Alice.
* Send a RelayIntro to Charlie and a RelayResponse to Alice.
* We should already have a session with Charlie, but not necessarily with Alice.
*/
void receiveRelayRequest(RemoteHostId alice, UDPPacketReader reader) {
if (_context.router().isHidden())
return;
UDPPacketReader.RelayRequestReader rrReader = reader.getRelayRequestReader();
long tag = rrReader.readTag();
int ipSize = rrReader.readIPSize();
int port = rrReader.readPort();
// ip/port inside message should be 0:0, as it's unimplemented on send -
// see PacketBuilder.buildRelayRequest()
// and we don't read it here.
// FIXME implement for getting Alice's IPv4 in RelayRequest sent over IPv6?
// or is that just too easy to spoof?
byte[] aliceIP = alice.getIP();
int alicePort = alice.getPort();
if (!isValid(alice.getIP(), alice.getPort())) {
if (_log.shouldWarn())
_log.warn("Bad relay req from " + alice + " for " + Addresses.toString(aliceIP, alicePort));
_context.statManager().addRateData("udp.relayBadIP", 1);
return;
}
// prior to 0.9.24 we rejected any non-zero-length ip
// here we reject anything different
// TODO relay request over IPv6
if (ipSize != 0) {
byte ip[] = new byte[ipSize];
rrReader.readIP(ip, 0);
if (!Arrays.equals(aliceIP, ip)) {
if (_log.shouldWarn())
_log.warn("Bad relay req from " + alice + " for " + Addresses.toString(ip, port));
_context.statManager().addRateData("udp.relayBadIP", 1);
return;
}
}
// prior to 0.9.24 we rejected any nonzero port
// here we reject anything different
// TODO relay request over IPv6
if (port != 0 && port != alicePort) {
if (_log.shouldWarn())
_log.warn("Bad relay req from " + alice + " for " + Addresses.toString(aliceIP, port));
_context.statManager().addRateData("udp.relayBadIP", 1);
return;
}
PeerState charlie = get(tag);
if (charlie == null) {
if (_log.shouldLog(Log.INFO))
_log.info("Receive relay request from " + alice
+ " with unknown tag");
_context.statManager().addRateData("udp.receiveRelayRequestBadTag", 1);
return;
}
if (_log.shouldLog(Log.INFO))
_log.info("Receive relay request from " + alice
+ " for tag " + tag
+ " and relaying with " + charlie);
// TODO throttle based on alice identity and/or intro tag?
_context.statManager().addRateData("udp.receiveRelayRequest", 1);
// send that peer an introduction for alice
_transport.send(_builder.buildRelayIntro(alice, charlie, reader.getRelayRequestReader()));
// send alice back charlie's info
// lookup session so we can use session key if available
SessionKey cipherKey = null;
SessionKey macKey = null;
PeerState aliceState = _transport.getPeerState(alice);
if (aliceState != null) {
// established session (since 0.9.12)
cipherKey = aliceState.getCurrentCipherKey();
macKey = aliceState.getCurrentMACKey();
}
if (cipherKey == null || macKey == null) {
// no session, use intro key (was only way before 0.9.12)
byte key[] = new byte[SessionKey.KEYSIZE_BYTES];
reader.getRelayRequestReader().readAliceIntroKey(key, 0);
cipherKey = new SessionKey(key);
macKey = cipherKey;
if (_log.shouldLog(Log.INFO))
_log.info("Sending relay response (w/ intro key) to " + alice);
} else {
if (_log.shouldLog(Log.INFO))
_log.info("Sending relay response (in-session) to " + alice);
}
_transport.send(_builder.buildRelayResponse(alice, charlie, reader.getRelayRequestReader().readNonce(),
cipherKey, macKey));
}
/**
* Are IP and port valid?
* Reject all IPv6, for now, even if we are configured for it.
* Refuse anybody in the same /16
* @since 0.9.3
*/
private boolean isValid(byte[] ip, int port) {
return TransportUtil.isValidPort(port) &&
ip != null && ip.length == 4 &&
_transport.isValid(ip) &&
(!_transport.isTooClose(ip)) &&
(!_context.blocklist().isBlocklisted(ip));
}
}