From 706cd5a12981e4281a464f874130ad8e60e240ec Mon Sep 17 00:00:00 2001 From: zzz <zzz@i2pmail.org> Date: Sun, 4 Dec 2022 10:04:18 -0500 Subject: [PATCH] SSU2: Token improvements and fixes part 1 - Set cache size based on connection limit - Track average inbound cache eviction time - Set inbound expiration based on cache time - Reduce max inbound expiration - Fix saving inbound token sent after relay response or hole punch - Dont send or save tokens if we are symmetric natted - Sort persisted tokens by expiration so they are expired in correct order on reload - Periodically expire tokens from cache - Add getters to Token class - Add missing case IPV4_SNAT_IPV6_UNKNOWN to EnumSets --- history.txt | 3 + .../src/net/i2p/router/RouterVersion.java | 2 +- .../transport/udp/EstablishmentManager.java | 217 ++++++++++++++---- .../transport/udp/InboundEstablishState2.java | 5 + .../transport/udp/IntroductionManager.java | 2 +- .../udp/OutboundEstablishState2.java | 5 + .../router/transport/udp/PacketBuilder2.java | 11 +- .../i2p/router/transport/udp/PeerState2.java | 16 +- .../i2p/router/transport/udp/SSU2Payload.java | 11 +- .../router/transport/udp/UDPTransport.java | 14 ++ 10 files changed, 223 insertions(+), 63 deletions(-) diff --git a/history.txt b/history.txt index 7ac8214988..ad15a2833d 100644 --- a/history.txt +++ b/history.txt @@ -1,3 +1,6 @@ +2022-12-04 zzz + * SSU2: Token improvements and fixes + 2022-12-02 zzz * Debian: Fix for stray symlinks in / (gitlab #376) diff --git a/router/java/src/net/i2p/router/RouterVersion.java b/router/java/src/net/i2p/router/RouterVersion.java index 2d30a2c121..35ab2b80b1 100644 --- a/router/java/src/net/i2p/router/RouterVersion.java +++ b/router/java/src/net/i2p/router/RouterVersion.java @@ -18,7 +18,7 @@ public class RouterVersion { /** deprecated */ public final static String ID = "Git"; public final static String VERSION = CoreVersion.VERSION; - public final static long BUILD = 3; + public final static long BUILD = 4; /** for example "-test" */ public final static String EXTRA = ""; diff --git a/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java b/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java index 643dc960a0..4607ca033e 100644 --- a/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java +++ b/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java @@ -14,6 +14,8 @@ import java.net.UnknownHostException; import java.security.GeneralSecurityException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -45,6 +47,8 @@ import static net.i2p.router.transport.udp.OutboundEstablishState2.IntroState.*; import static net.i2p.router.transport.udp.SSU2Util.*; import net.i2p.router.util.DecayingHashSet; import net.i2p.router.util.DecayingBloomFilter; +import net.i2p.stat.Rate; +import net.i2p.stat.RateStat; import net.i2p.util.Addresses; import net.i2p.util.HexDump; import net.i2p.util.I2PThread; @@ -176,8 +180,9 @@ class EstablishmentManager { private static final String PROP_DISABLE_EXT_OPTS = "i2np.udp.disableExtendedOptions"; // SSU 2 - private static final int MAX_TOKENS = 512; - public static final long IB_TOKEN_EXPIRATION = 2*60*60*1000L; + private static final int MIN_TOKENS = 128; + private static final int MAX_TOKENS = 2048; + public static final long IB_TOKEN_EXPIRATION = 60*60*1000L; private static final long MAX_SKEW = 2*60*1000; private static final String TOKEN_FILE = "ssu2tokens.txt"; @@ -198,8 +203,10 @@ class EstablishmentManager { _outboundByHash = new ConcurrentHashMap<Hash, OutboundEstablishState>(); _inboundBans = new LHMCache<RemoteHostId, Long>(32); if (_enableSSU2) { - _inboundTokens = new LHMCache<RemoteHostId, Token>(MAX_TOKENS); - _outboundTokens = new LHMCache<RemoteHostId, Token>(MAX_TOKENS); + // roughly scale based on expected traffic + int tokenCacheSize = Math.max(MIN_TOKENS, Math.min(MAX_TOKENS, 3 * _transport.getMaxConnections() / 4)); + _inboundTokens = new InboundTokens(tokenCacheSize); + _outboundTokens = new LHMCache<RemoteHostId, Token>(tokenCacheSize); } else { _inboundTokens = null; _outboundTokens = null; @@ -233,6 +240,8 @@ class EstablishmentManager { //_context.statManager().createRateStat("udp.queueDropSize", "How many messages were queued up when it was considered full, causing a tail drop?", "udp", UDPTransport.RATES); //_context.statManager().createRateStat("udp.queueAllowTotalLifetime", "When a peer is retransmitting and we probabalistically allow a new message, what is the sum of the pending message lifetimes? (period is the new message's lifetime)?", "udp", UDPTransport.RATES); _context.statManager().createRateStat("udp.dupDHX", "Session request replay", "udp", new long[] { 24*60*60*1000L } ); + if (_enableSSU2) + _context.statManager().createRequiredRateStat("udp.inboundTokenLifetime", "SSU2 token lifetime (ms)", "udp", new long[] { 5*60*1000L } ); } public synchronized void startup() { @@ -2538,14 +2547,20 @@ class EstablishmentManager { * Remember a token that can be used later to connect to the peer * * @param token nonzero + * @param expires absolute time * @since 0.9.54 */ public void addOutboundToken(RemoteHostId peer, long token, long expires) { - // so we don't use a token about to expire - expires -= 2*60*1000; - if (expires < _context.clock().now()) + long now = _context.clock().now(); + if (expires < now) return; - Token tok = new Token(token, expires); + if (expires > now + 2*60*1000) { + // don't save if symmetric natted + byte[] ip = peer.getIP(); + if (ip != null && ip.length == 4 && _transport.isSnatted()) + return; + } + Token tok = new Token(token, expires, now); synchronized(_outboundTokens) { _outboundTokens.put(peer, tok); } @@ -2564,9 +2579,9 @@ class EstablishmentManager { } if (tok == null) return 0; - if (tok.expires < _context.clock().now()) + if (tok.getExpiration() < _context.clock().now()) return 0; - return tok.token; + return tok.getToken(); } /** @@ -2624,9 +2639,10 @@ class EstablishmentManager { } /** - * Get a token that can be used later for the peer to connect to us + * Get a token that can be used later for the peer to connect to us. * - * @param expiration time from now + * @param expiration time from now, will be reduced if necessary based on cache eviction time. + * @return non-null * @since 0.9.55 */ public Token getInboundToken(RemoteHostId peer, long expiration) { @@ -2635,15 +2651,27 @@ class EstablishmentManager { token = _context.random().nextLong(); } while (token == 0); long now = _context.clock().now(); - Token tok; + // shorten expiration based on average eviction time + RateStat rs = _context.statManager().getRate("udp.inboundTokenLifetime"); + if (rs != null) { + Rate r = rs.getRate(5*60*1000); + if (r != null) { + long lifetime = (long) (r.getAverageValue() * 0.9d); // margin + if (lifetime > 0) { + if (lifetime < 2*60*1000) + lifetime = 2*60*1000; + if (lifetime < expiration) + expiration = lifetime; + } + } + } + long expires = now + expiration; + Token tok = new Token(token, expires, now); synchronized(_inboundTokens) { - // shorten expiration based on _inboundTokens size - if (expiration > 2*60*1000 && _inboundTokens.size() > MAX_TOKENS / 2) - expiration /= 2; - long expires = now + expiration; - tok = new Token(token, expires); _inboundTokens.put(peer, tok); } + if (_log.shouldDebug()) + _log.debug("Add inbound " + tok + " for " + peer); return tok; } @@ -2661,21 +2689,45 @@ class EstablishmentManager { tok = _inboundTokens.get(peer); if (tok == null) return false; - if (tok.token != token) + if (tok.getToken() != token) return false; _inboundTokens.remove(peer); } - return tok.expires >= _context.clock().now(); + boolean rv = tok.getExpiration() >= _context.clock().now(); + if (rv && _log.shouldDebug()) + _log.debug("Used inbound " + tok + " for " + peer); + return rv; } public static class Token { - public final long token, expires; - public Token(long tok, long exp) { - token = tok; expires = exp; + private final long token; + // save space until 2106 + private final int expires; + private final int added; + + /** + * @param exp absolute time, not relative to now + */ + public Token(long tok, long exp, long now) { + token = tok; + expires = (int) (exp >> 10); + added = (int) (now >> 10); + } + /** @since 0.9.57 */ + public long getToken() { return token; } + /** @since 0.9.57 */ + public long getExpiration() { return (expires & 0xFFFFFFFFL) << 10; } + /** @since 0.9.57 */ + public long getWhenAdded() { return (added & 0xFFFFFFFFL) << 10; } + /** @since 0.9.57 */ + public String toString() { + return "Token " + token + " added " + DataHelper.formatTime(getWhenAdded()) + " expires " + DataHelper.formatTime(getExpiration()); } } /** + * Not threaded, because we're holding the token cache locks anyway. + * * Format: * *<pre> @@ -2741,7 +2793,7 @@ class EstablishmentManager { int port = Integer.parseInt(s[2]); long tok = Long.parseLong(s[3]); RemoteHostId id = new RemoteHostId(ip, port); - Token token = new Token(tok, exp); + Token token = new Token(tok, exp, now); if (s[0].equals("I")) _inboundTokens.put(id, token); else @@ -2790,27 +2842,39 @@ class EstablishmentManager { } long now = _context.clock().now(); int count = 0; + // Roughly speaking, the LHMCache will iterate newest-first, + // so when we add them back in loadTokens(), the oldest would be at + // the head of the map and the newest would be purged first. + // Sort them by expiration oldest-first so loadTokens() will + // put them in the LHMCache in the right order. + TokenComparator comp = new TokenComparator(); + List<Map.Entry<RemoteHostId, Token>> tmp; synchronized(_inboundTokens) { - for (Map.Entry<RemoteHostId, Token> e : _inboundTokens.entrySet()) { - Token token = e.getValue(); - long exp = token.expires; - if (exp <= now) - continue; - RemoteHostId id = e.getKey(); - out.println("I " + Addresses.toString(id.getIP()) + ' ' + id.getPort() + ' ' + token.token + ' ' + exp); - count++; - } - } + tmp = new ArrayList<Map.Entry<RemoteHostId, Token>>(_inboundTokens.entrySet()); + } + Collections.sort(tmp, comp); + for (Map.Entry<RemoteHostId, Token> e : tmp) { + Token token = e.getValue(); + long exp = token.getExpiration(); + if (exp <= now) + continue; + RemoteHostId id = e.getKey(); + out.println("I " + Addresses.toString(id.getIP()) + ' ' + id.getPort() + ' ' + token.getToken() + ' ' + exp); + count++; + } + tmp.clear(); synchronized(_outboundTokens) { - for (Map.Entry<RemoteHostId, Token> e : _outboundTokens.entrySet()) { - Token token = e.getValue(); - long exp = token.expires; - if (exp <= now) - continue; - RemoteHostId id = e.getKey(); - out.println("O " + Addresses.toString(id.getIP()) + ' ' + id.getPort() + ' ' + token.token + ' ' + exp); - count++; - } + tmp.addAll(_outboundTokens.entrySet()); + } + Collections.sort(tmp, comp); + for (Map.Entry<RemoteHostId, Token> e : tmp) { + Token token = e.getValue(); + long exp = token.getExpiration(); + if (exp <= now) + continue; + RemoteHostId id = e.getKey(); + out.println("O " + Addresses.toString(id.getIP()) + ' ' + id.getPort() + ' ' + token.getToken() + ' ' + exp); + count++; } if (out.checkError()) throw new IOException("Failed write to " + f); @@ -2825,6 +2889,45 @@ class EstablishmentManager { } + /** + * Soonest expiration first + * @since 0.9.57 + */ + private static class TokenComparator implements Comparator<Map.Entry<RemoteHostId, Token>> { + public int compare(Map.Entry<RemoteHostId, Token> l, Map.Entry<RemoteHostId, Token> r) { + long le = l.getValue().expires; + long re = r.getValue().expires; + if (le < re) return -1; + if (le > re) return 1; + return 0; + } + } + + /** + * For inbound tokens only, to record eviction time in a stat, + * for use in setting expiration times. + * + * @since 0.9.57 + */ + private class InboundTokens extends LHMCache<RemoteHostId, Token> { + + public InboundTokens(int max) { + super(max); + } + + @Override + protected boolean removeEldestEntry(Map.Entry<RemoteHostId, Token> eldest) { + boolean rv = super.removeEldestEntry(eldest); + if (rv) { + long lifetime = _context.clock().now() - eldest.getValue().getWhenAdded(); + _context.statManager().addRateData("udp.inboundTokenLifetime", lifetime); + if (_log.shouldDebug()) + _log.debug("Remove oldest inbound " + eldest.getValue() + " for " + eldest.getKey()); + } + return rv; + } + } + /** * Process SSU2 hole punch payload * @@ -2968,7 +3071,7 @@ class EstablishmentManager { _activity = 0; if (_lastFailsafe + FAILSAFE_INTERVAL < now) { _lastFailsafe = now; - doFailsafe(); + doFailsafe(now); } long nextSendTime = Math.min(handleInbound(), handleOutbound()); @@ -2991,7 +3094,7 @@ class EstablishmentManager { } /** @since 0.9.2 */ - private void doFailsafe() { + private void doFailsafe(long now) { for (Iterator<OutboundEstablishState> iter = _liveIntroductions.values().iterator(); iter.hasNext(); ) { OutboundEstablishState state = iter.next(); if (state.getLifetime() > 3*MAX_OB_ESTABLISH_TIME) { @@ -3016,6 +3119,30 @@ class EstablishmentManager { _log.warn("Failsafe remove OBBH " + state); } } + int count = 0; + synchronized(_inboundTokens) { + for (Iterator<Token> iter = _inboundTokens.values().iterator(); iter.hasNext(); ) { + Token tok = iter.next(); + if (tok.getExpiration() < now) { + iter.remove(); + count++; + } + } + } + if (count > 0 && _log.shouldDebug()) + _log.debug("Expired " + count + " inbound tokens"); + count = 0; + synchronized(_outboundTokens) { + for (Iterator<Token> iter = _outboundTokens.values().iterator(); iter.hasNext(); ) { + Token tok = iter.next(); + if (tok.getExpiration() < now) { + iter.remove(); + count++; + } + } + } + if (count > 0 && _log.shouldDebug()) + _log.debug("Expired " + count + " outbound tokens"); } } } diff --git a/router/java/src/net/i2p/router/transport/udp/InboundEstablishState2.java b/router/java/src/net/i2p/router/transport/udp/InboundEstablishState2.java index 1b7c572749..1505a3a32c 100644 --- a/router/java/src/net/i2p/router/transport/udp/InboundEstablishState2.java +++ b/router/java/src/net/i2p/router/transport/udp/InboundEstablishState2.java @@ -469,7 +469,12 @@ class InboundEstablishState2 extends InboundEstablishState implements SSU2Payloa public long getSendConnID() { return _sendConnID; } public long getRcvConnID() { return _rcvConnID; } public long getToken() { return _token; } + /** + * @return may be null + */ public EstablishmentManager.Token getNextToken() { + if (_aliceIP.length == 4 && _transport.isSnatted()) + return null; return _transport.getEstablisher().getInboundToken(_remoteHostId); } public HandshakeState getHandshakeState() { return _handshakeState; } diff --git a/router/java/src/net/i2p/router/transport/udp/IntroductionManager.java b/router/java/src/net/i2p/router/transport/udp/IntroductionManager.java index c278fc6cdc..915e79d2af 100644 --- a/router/java/src/net/i2p/router/transport/udp/IntroductionManager.java +++ b/router/java/src/net/i2p/router/transport/udp/IntroductionManager.java @@ -1059,7 +1059,7 @@ class IntroductionManager { if (rcode == SSU2Util.RELAY_ACCEPT) { RemoteHostId aliceID = new RemoteHostId(testIP, testPort); EstablishmentManager.Token tok = _transport.getEstablisher().getInboundToken(aliceID, 60*1000); - token = tok.token; + token = tok.getToken(); } else { token = 0; } diff --git a/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState2.java b/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState2.java index ac5da33025..7a6e6aabc0 100644 --- a/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState2.java +++ b/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState2.java @@ -407,7 +407,12 @@ class OutboundEstablishState2 extends OutboundEstablishState implements SSU2Payl public long getSendConnID() { return _sendConnID; } public long getRcvConnID() { return _rcvConnID; } public long getToken() { return _token; } + /** + * @return may be null + */ public EstablishmentManager.Token getNextToken() { + if (_bobIP != null && _bobIP.length == 4 && _transport.isSnatted()) + return null; return _transport.getEstablisher().getInboundToken(_remoteHostId); } public HandshakeState getHandshakeState() { return _handshakeState; } diff --git a/router/java/src/net/i2p/router/transport/udp/PacketBuilder2.java b/router/java/src/net/i2p/router/transport/udp/PacketBuilder2.java index 79af792eb5..1241b4afaf 100644 --- a/router/java/src/net/i2p/router/transport/udp/PacketBuilder2.java +++ b/router/java/src/net/i2p/router/transport/udp/PacketBuilder2.java @@ -361,10 +361,11 @@ class PacketBuilder2 { _log.debug("Sending termination " + reason + " to : " + peer); List<Block> blocks = new ArrayList<Block>(2); if (peer.getKeyEstablishedTime() - _context.clock().now() > EstablishmentManager.IB_TOKEN_EXPIRATION / 2 && - !_context.router().gracefulShutdownInProgress()) { + !_context.router().gracefulShutdownInProgress() && + (peer.isIPv6() || !_transport.isSnatted())) { // update token EstablishmentManager.Token token = _transport.getEstablisher().getInboundToken(peer.getRemoteHostId()); - Block block = new SSU2Payload.NewTokenBlock(token.token, token.expires); + Block block = new SSU2Payload.NewTokenBlock(token); blocks.add(block); } Block block = new SSU2Payload.TerminationBlock(reason, peer.getReceivedMessages().getHighestSet()); @@ -904,6 +905,7 @@ class PacketBuilder2 { /** * @param packet containing only 32 byte header + * @param token may be null */ private void encryptSessionCreated(UDPPacket packet, HandshakeState state, byte[] hdrKey1, byte[] hdrKey2, long relayTag, @@ -925,7 +927,7 @@ class PacketBuilder2 { blocks.add(block); } if (token != null) { - block = new SSU2Payload.NewTokenBlock(token.token, token.expires); + block = new SSU2Payload.NewTokenBlock(token); len += block.getTotalLength(); blocks.add(block); } @@ -1067,6 +1069,7 @@ class PacketBuilder2 { * * @param packet containing only 16 byte header * @param addPadding force-add exactly this size a padding block, for jumbo only + * @param token may be null */ private void encryptSessionConfirmed(UDPPacket packet, HandshakeState state, int mtu, int numFragments, int addPadding, boolean isIPv6, byte[] hdrKey1, byte[] hdrKey2, @@ -1083,7 +1086,7 @@ class PacketBuilder2 { blocks.add(riblock); // only if room if (token != null && mtu - (SHORT_HEADER_SIZE + KEY_LEN + MAC_LEN + len + MAC_LEN) >= 15) { - Block block = new SSU2Payload.NewTokenBlock(token.token, token.expires); + Block block = new SSU2Payload.NewTokenBlock(token); len += block.getTotalLength(); blocks.add(block); } diff --git a/router/java/src/net/i2p/router/transport/udp/PeerState2.java b/router/java/src/net/i2p/router/transport/udp/PeerState2.java index a9a6b08aa1..0f62c34bef 100644 --- a/router/java/src/net/i2p/router/transport/udp/PeerState2.java +++ b/router/java/src/net/i2p/router/transport/udp/PeerState2.java @@ -797,12 +797,16 @@ public class PeerState2 extends PeerState implements SSU2Payload.PayloadCallback _log.warn("Migration successful, changed address from " + _remoteHostId + " to " + from + " for " + this); _transport.changePeerAddress(this, from); _mtu = MIN_MTU; - EstablishmentManager.Token token = _transport.getEstablisher().getInboundToken(from); - SSU2Payload.Block block = new SSU2Payload.NewTokenBlock(token.token, token.expires); - UDPPacket pkt = _transport.getBuilder2().buildPacket(Collections.<Fragment>emptyList(), - Collections.singletonList(block), - this); - _transport.send(pkt); + if (isIPv6() || !_transport.isSnatted()) { + EstablishmentManager.Token token = _transport.getEstablisher().getInboundToken(from); + SSU2Payload.Block block = new SSU2Payload.NewTokenBlock(token); + UDPPacket pkt = _transport.getBuilder2().buildPacket(Collections.<Fragment>emptyList(), + Collections.singletonList(block), + this); + _transport.send(pkt); + } else { + messagePartiallyReceived(); + } } else { // caller will handle // ACK-eliciting diff --git a/router/java/src/net/i2p/router/transport/udp/SSU2Payload.java b/router/java/src/net/i2p/router/transport/udp/SSU2Payload.java index ec68e304f3..abb01b0be6 100644 --- a/router/java/src/net/i2p/router/transport/udp/SSU2Payload.java +++ b/router/java/src/net/i2p/router/transport/udp/SSU2Payload.java @@ -846,12 +846,11 @@ class SSU2Payload { } public static class NewTokenBlock extends Block { - private final long t, e; + private final EstablishmentManager.Token tok; - public NewTokenBlock(long token, long expires) { + public NewTokenBlock(EstablishmentManager.Token token) { super(BLOCK_NEWTOKEN); - t = token; - e = expires / 1000; + tok = token; } public int getDataLength() { @@ -859,9 +858,9 @@ class SSU2Payload { } public int writeData(byte[] tgt, int off) { - DataHelper.toLong(tgt, off, 4, e); + DataHelper.toLong(tgt, off, 4, tok.getExpiration() / 1000); off += 4; - DataHelper.toLong8(tgt, off, t); + DataHelper.toLong8(tgt, off, tok.getToken()); return off + 8; } } diff --git a/router/java/src/net/i2p/router/transport/udp/UDPTransport.java b/router/java/src/net/i2p/router/transport/udp/UDPTransport.java index b28adb297b..d902975e90 100644 --- a/router/java/src/net/i2p/router/transport/udp/UDPTransport.java +++ b/router/java/src/net/i2p/router/transport/udp/UDPTransport.java @@ -287,6 +287,7 @@ public class UDPTransport extends TransportImpl implements TimedWeightedPriority Status.REJECT_UNSOLICITED, Status.IPV4_FIREWALLED_IPV6_OK, Status.IPV4_SNAT_IPV6_OK, + Status.IPV4_SNAT_IPV6_UNKNOWN, Status.IPV4_FIREWALLED_IPV6_UNKNOWN); private static final Set<Status> STATUS_IPV6_FW = EnumSet.of(Status.IPV4_OK_IPV6_FIREWALLED, @@ -297,6 +298,7 @@ public class UDPTransport extends TransportImpl implements TimedWeightedPriority Status.REJECT_UNSOLICITED, Status.IPV4_FIREWALLED_IPV6_OK, Status.IPV4_SNAT_IPV6_OK, + Status.IPV4_SNAT_IPV6_UNKNOWN, Status.IPV4_FIREWALLED_IPV6_UNKNOWN, Status.IPV4_OK_IPV6_FIREWALLED, Status.IPV4_UNKNOWN_IPV6_FIREWALLED, @@ -329,6 +331,10 @@ public class UDPTransport extends TransportImpl implements TimedWeightedPriority private static final Set<Status> STATUS_OK = EnumSet.of(Status.OK, Status.IPV4_DISABLED_IPV6_OK); + private static final Set<Status> STATUS_IPV4_SNAT = EnumSet.of(Status.DIFFERENT, + Status.IPV4_SNAT_IPV6_OK, + Status.IPV4_SNAT_IPV6_UNKNOWN); + /** * @param dh non-null to enable SSU1 @@ -3906,6 +3912,14 @@ public class UDPTransport extends TransportImpl implements TimedWeightedPriority return _reachabilityStatus; } + /** + * Is IPv4 Symmetric NATted? + * @since 0.9.57 + */ + boolean isSnatted() { + return STATUS_IPV4_SNAT.contains(getReachabilityStatus()); + } + /** * @deprecated unused */ -- GitLab