From 706cd5a12981e4281a464f874130ad8e60e240ec Mon Sep 17 00:00:00 2001 From: zzz 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 | 215 ++++++++++++++---- .../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, 222 insertions(+), 62 deletions(-) diff --git a/history.txt b/history.txt index 7ac821498..ad15a2833 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 2d30a2c12..35ab2b80b 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 643dc960a..4607ca033 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(); _inboundBans = new LHMCache(32); if (_enableSSU2) { - _inboundTokens = new LHMCache(MAX_TOKENS); - _outboundTokens = new LHMCache(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(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: * *
@@ -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> tmp;
             synchronized(_inboundTokens) {
-                for (Map.Entry 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>(_inboundTokens.entrySet());
             }
+            Collections.sort(tmp, comp);
+            for (Map.Entry 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 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 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> {
+        public int compare(Map.Entry l, Map.Entry 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 {
+
+        public InboundTokens(int max) {
+            super(max);
+        }
+
+        @Override
+        protected boolean removeEldestEntry(Map.Entry 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 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 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 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 1b7c57274..1505a3a32 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 c278fc6cd..915e79d2a 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 ac5da3302..7a6e6aabc 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 79af792eb..1241b4afa 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 blocks = new ArrayList(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 a9a6b08aa..0f62c34be 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.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.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 ec68e304f..abb01b0be 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 b28adb297..d902975e9 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_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_OK =         EnumSet.of(Status.OK,
                                                                     Status.IPV4_DISABLED_IPV6_OK);
 
+    private static final Set 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
      */