diff --git a/libp2p-hs.cabal b/libp2p-hs.cabal index f9c761c..c5579a1 100644 --- a/libp2p-hs.cabal +++ b/libp2p-hs.cabal @@ -155,6 +155,7 @@ test-suite libp2p-hs-test LibP2P.Core.MultihashSpec LibP2P.Multiaddr.MultiaddrSpec LibP2P.Multiaddr.ProtocolSpec + LibP2P.Multiaddr.PublicAddrSpec LibP2P.Crypto.PeerIdSpec LibP2P.Crypto.RSASpec LibP2P.Crypto.Secp256k1Spec @@ -207,6 +208,7 @@ test-suite libp2p-hs-test LibP2P.NAT.Relay.ReservationRefreshSpec LibP2P.NAT.DCUtR.MessageSpec LibP2P.NAT.DCUtR.DCUtRSpec + LibP2P.NAT.DCUtR.UpgradeSpec LibP2P.NAT.RegistrationSpec LibP2P.Protocol.GossipSub.TypesSpec LibP2P.Protocol.GossipSub.MessageSpec diff --git a/src/LibP2P/Multiaddr.hs b/src/LibP2P/Multiaddr.hs index df4b861..82f5279 100644 --- a/src/LibP2P/Multiaddr.hs +++ b/src/LibP2P/Multiaddr.hs @@ -12,9 +12,16 @@ module LibP2P.Multiaddr , decapsulate , protocols , splitP2P + , isPublicAddr + , isRelayedAddr ) where +import Data.Bits (shiftL, (.&.)) import Data.ByteString (ByteString) +import qualified Data.ByteString as BS +import Data.List (isSuffixOf) +import qualified Data.Text as T +import Data.Word (Word32, Word8) import Data.List (isPrefixOf, tails) import Data.Text (Text) import LibP2P.Crypto.PeerId (PeerId (..)) @@ -74,3 +81,126 @@ splitP2P :: Multiaddr -> Maybe (Multiaddr, PeerId) splitP2P (Multiaddr ps) = case reverse ps of (P2P mhBytes : rest) -> Just (Multiaddr (reverse rest), PeerId mhBytes) _ -> Nothing + +-- Address classification + +-- | Whether the address goes through a circuit relay, i.e. contains a +-- @/p2p-circuit@ component. +isRelayedAddr :: Multiaddr -> Bool +isRelayedAddr (Multiaddr ps) = P2PCircuit `elem` ps + + +-- | Whether the address is publicly routable. +-- +-- Mirrors go-multiaddr's @manet.IsPublicAddr@ (net/private.go), which +-- the DCUtR unilateral-upgrade check depends on: a peer is only worth +-- dialling directly if it advertises an address that can be reached. +-- +-- IPv4 is classified by exclusion (anything outside the private and +-- unroutable ranges is public); IPv6 by inclusion (only the global +-- unicast allocation, minus documentation and multicast, plus the NAT64 +-- prefixes). A DNS name is public unless it is a special-use domain. +-- An address with no IP or DNS component is not public. +isPublicAddr :: Multiaddr -> Bool +isPublicAddr (Multiaddr ps) = any componentIsPublic ps + where + componentIsPublic (IP4 w) = publicIPv4 w + componentIsPublic (IP6 bs) = publicIPv6 bs + componentIsPublic (DNS h) = publicDomain h + componentIsPublic (DNS4 h) = publicDomain h + componentIsPublic (DNS6 h) = publicDomain h + componentIsPublic (DNSAddr h) = publicDomain h + componentIsPublic _ = False + +-- | IPv4 is public unless it falls in a private or unroutable range. +publicIPv4 :: Word32 -> Bool +publicIPv4 w = not (any (inRange4 w) (privateRanges4 ++ unroutableRanges4)) + +-- | Private IPv4 ranges: loopback, RFC1918, CGNAT and link-local. +privateRanges4 :: [(Word32, Int)] +privateRanges4 = + [ (0x7F000000, 8) -- 127.0.0.0/8 localhost + , (0x0A000000, 8) -- 10.0.0.0/8 + , (0x64400000, 10) -- 100.64.0.0/10 CGNAT + , (0xAC100000, 12) -- 172.16.0.0/12 + , (0xC0A80000, 16) -- 192.168.0.0/16 + , (0xA9FE0000, 16) -- 169.254.0.0/16 link local + ] + +-- | Well-known unroutable IPv4 ranges. +unroutableRanges4 :: [(Word32, Int)] +unroutableRanges4 = + [ (0x00000000, 8) -- 0.0.0.0/8 + , (0xC0000000, 26) -- 192.0.0.0/26 + , (0xC0000200, 24) -- 192.0.2.0/24 + , (0xC0586300, 24) -- 192.88.99.0/24 + , (0xC6120000, 15) -- 198.18.0.0/15 + , (0xC6336400, 24) -- 198.51.100.0/24 + , (0xCB007100, 24) -- 203.0.113.0/24 + , (0xE0000000, 4) -- 224.0.0.0/4 multicast + , (0xF0000000, 4) -- 240.0.0.0/4 + , (0xFFFFFFFF, 32) -- 255.255.255.255/32 + ] + +-- | Whether an IPv4 address falls inside a CIDR block. +inRange4 :: Word32 -> (Word32, Int) -> Bool +inRange4 addr (base, bits) = addr .&. mask == base .&. mask + where + mask | bits <= 0 = 0 + | bits >= 32 = 0xFFFFFFFF + | otherwise = complementLow (32 - bits) + complementLow n = 0xFFFFFFFF `shiftL` n .&. 0xFFFFFFFF + +-- | IPv6 is public only inside the global unicast allocation (minus the +-- documentation prefix) or inside a NAT64 prefix. +-- +-- The NAT64 well-known prefix (RFC 6052) can only reference a public +-- IPv4 address. The local-use prefix (RFC 8215) may reference a private +-- one, but the translation is left to the operator, so go-multiaddr +-- counts both as public on the grounds that a false negative here is +-- worse than a false positive. This follows that choice. +publicIPv6 :: ByteString -> Bool +publicIPv6 bs + | BS.length bs /= 16 = False + | globalUnicast && not documentation = True + | otherwise = nat64 + where + globalUnicast = inRange6 bs (BS.pack [0x20, 0x00], 3) + documentation = inRange6 bs (BS.pack [0x20, 0x01, 0x0D, 0xB8], 32) + nat64 = inRange6 bs (BS.pack [0x00, 0x64, 0xFF, 0x9B, 0x00, 0x00], 96) + || inRange6 bs (BS.pack [0x00, 0x64, 0xFF, 0x9B, 0x00, 0x01], 48) + +-- | Whether an IPv6 address falls inside a CIDR block, given the +-- block's leading bytes and its prefix length. +inRange6 :: ByteString -> (ByteString, Int) -> Bool +inRange6 addr (prefix, bits) = + BS.length padded >= wholeBytes + && BS.take wholeBytes addr == BS.take wholeBytes padded + && remainderMatches + where + padded = prefix <> BS.replicate (16 - BS.length prefix) 0 + (wholeBytes, spare) = bits `divMod` 8 + remainderMatches + | spare == 0 = True + | otherwise = maskByte (BS.index addr wholeBytes) == maskByte (BS.index padded wholeBytes) + maskByte :: Word8 -> Word8 + maskByte b = b .&. (0xFF `shiftL` (8 - spare) .&. 0xFF) + +-- | A DNS name is public unless it is a special-use domain that either +-- does not resolve or is reserved for private use. +publicDomain :: T.Text -> Bool +publicDomain host = not (any (`isSuffixOf` lowered) specialUseDomains) + where + lowered = T.unpack (T.toLower host) + +-- | Special-use domains that never denote a publicly routable host. +specialUseDomains :: [String] +specialUseDomains = + [ ".localhost" + , ".in-addr.arpa" + , ".ip6.arpa" + , ".invalid" + , ".home.arpa" + , ".local" + , ".test" + ] diff --git a/src/LibP2P/NAT.hs b/src/LibP2P/NAT.hs index 133a4a3..0e883f9 100644 --- a/src/LibP2P/NAT.hs +++ b/src/LibP2P/NAT.hs @@ -19,17 +19,28 @@ module LibP2P.NAT , registerRelayStopHandler , registerDCUtRHandler , registerReservationCleanup + -- * DCUtR production integration + , registerDCUtRUpgrade + , upgradeRelayedConnection + , holePunchTargets + , DCUtRUpgradeConfig (..) + , defaultDCUtRUpgradeConfig -- * Circuit client , CircuitState , ReservationRefreshConfig (..) , defaultReservationRefreshConfig ) where -import Control.Concurrent.STM (atomically, modifyTVar') +import Control.Concurrent (threadDelay) +import Control.Concurrent.Async (async) +import Control.Concurrent.STM (atomically, modifyTVar', readTVar) +import Control.Monad (filterM, unless, void) +import Data.Maybe (fromMaybe) +import System.Timeout (timeout) import qualified Data.Map.Strict as Map import Control.Exception (SomeException, catch, try) import LibP2P.Crypto.PeerId (PeerId, peerIdBytes) -import LibP2P.Multiaddr (Multiaddr (..), encapsulate) +import LibP2P.Multiaddr (Multiaddr (..), encapsulate, fromBytes, isPublicAddr, isRelayedAddr) import LibP2P.Multiaddr.Protocol (Protocol (..)) import LibP2P.MultistreamSelect.Negotiation ( NegotiationResult (..) @@ -38,7 +49,7 @@ import LibP2P.MultistreamSelect.Negotiation ) import LibP2P.NAT.AutoNAT (AutoNATConfig (..), handleAutoNAT) import LibP2P.NAT.AutoNAT.Message (autoNATProtocolId) -import LibP2P.NAT.DCUtR (DCUtRConfig (..), handleDCUtR) +import LibP2P.NAT.DCUtR (DCUtRConfig (..), DCUtRResult (..), handleDCUtR, initiateDCUtR) import LibP2P.NAT.DCUtR.Message (dcutrProtocolId) import LibP2P.NAT.Relay ( HopContext (..) @@ -70,12 +81,16 @@ import LibP2P.NAT.Relay.Transport , newCircuitState ) import LibP2P.Switch (addTransport, selectTransport, setStreamHandler) -import LibP2P.Switch.ConnPool (lookupConn) -import LibP2P.Switch.Connection (newStream) -import LibP2P.Switch.Dial (dial) +import LibP2P.Switch.ConnPool (lookupAllConns, lookupConn) +import LibP2P.Switch.Connection (closeConnection, newStream) +import LibP2P.Switch.Dial (DialOpts (..), dialWith) import LibP2P.Switch.Listen (switchListenAddrs) +import LibP2P.Protocol.Identify (identifyPeer) +import LibP2P.Protocol.Identify.Message (IdentifyInfo (..)) import LibP2P.Switch.Types - ( Connection (..) + ( ConnState (..) + , Connection (..) + , Direction (..) , MuxerSession (..) , Switch (..) ) @@ -88,6 +103,43 @@ data NATConfig = NATConfig -- ^ Resource limits for the Circuit Relay v2 server side , ncReservationRefresh :: ReservationRefreshConfig -- ^ Tuning for the circuit client's reservation refresh loop + , ncDCUtRUpgrade :: DCUtRUpgradeConfig + -- ^ Tuning for the DCUtR direct-connection upgrade + } + +-- | Tuning for the DCUtR upgrade that runs on an inbound relayed +-- connection. +data DCUtRUpgradeConfig = DCUtRUpgradeConfig + { ducMaxAttempts :: !Int + -- ^ Hole punch attempts, each re-running the CONNECT/SYNC exchange + -- so RTT is re-measured. specs/relay/DCUtR: inbound peers "SHOULD + -- retry twice (thus a total of 3 attempts)". + , ducDirectDialTimeoutMicros :: !Int + -- ^ Bound on one hole punch dial. Without it a dial whose peer never + -- answers the handshake pins a socket and a thread forever: a + -- simultaneous connect that fails to collide lands on the peer's + -- ordinary listener, leaving both ends running the responder side. + -- go-libp2p bounds the same dial with @defaultDirectDialTimeout@. + , ducStreamTimeoutMicros :: !Int + -- ^ Bound on the whole @\/libp2p\/dcutr@ coordination exchange. The + -- relay carrying it can vanish mid-exchange. go-libp2p sets the same + -- bound as a stream deadline (@StreamTimeout@). + , ducRelayCloseGraceMicros :: !Int + -- ^ How long the relay connection is kept after a successful + -- upgrade. specs/relay/DCUtR: "the relay connection should be closed + -- after a grace period". go-libp2p's holepunch package leaves this + -- to its connection manager, which this implementation does not + -- have, so the delay is applied here. + } + +-- | Three hole punch attempts and a 15s grace period before the relay +-- connection is dropped. +defaultDCUtRUpgradeConfig :: DCUtRUpgradeConfig +defaultDCUtRUpgradeConfig = DCUtRUpgradeConfig + { ducMaxAttempts = 3 + , ducDirectDialTimeoutMicros = 10000000 -- go-libp2p: defaultDirectDialTimeout + , ducStreamTimeoutMicros = 60000000 -- go-libp2p: StreamTimeout + , ducRelayCloseGraceMicros = 15000000 } -- | Default NAT configuration: default relay limits and refresh tuning. @@ -95,6 +147,7 @@ defaultNATConfig :: NATConfig defaultNATConfig = NATConfig { ncRelayConfig = defaultRelayConfig , ncReservationRefresh = defaultReservationRefreshConfig + , ncDCUtRUpgrade = defaultDCUtRUpgradeConfig } -- | Register the NAT protocol handlers and the circuit client transport @@ -112,7 +165,8 @@ registerNATHandlers sw config = do registerAutoNATHandler sw registerRelayHopHandler sw relayState registerRelayStopHandler sw circuitState - registerDCUtRHandler sw + registerDCUtRHandler sw (ncDCUtRUpgrade config) + registerDCUtRUpgrade sw (ncDCUtRUpgrade config) registerReservationCleanup sw relayState pure (relayState, circuitState) @@ -141,6 +195,179 @@ registerReservationCleanup sw relayState = Just _ -> pure () Nothing -> modifyTVar' (rsReservations relayState) (Map.delete peerId) +-- | Subscribe the DCUtR direct-connection upgrade to new connections. +-- +-- specs/relay/DCUtR: "The protocol starts with the completion of a relay +-- connection from @A@ to @B@. Upon observing the new connection, the +-- inbound peer (here @B@) checks the addresses advertised by @A@ via +-- identify." The trigger is therefore an *inbound* connection over a +-- circuit, the same condition go-libp2p's hole punch notifiee applies +-- (@Direction == DirInbound && isRelayAddress(RemoteMultiaddr())@). +registerDCUtRUpgrade :: Switch -> DCUtRUpgradeConfig -> IO () +registerDCUtRUpgrade sw config = + atomically $ modifyTVar' (swNotifiers sw) (notifier :) + where + notifier conn + | connDirection conn == Inbound && isRelayedAddr (connRemoteAddr conn) = + void (upgradeRelayedConnection sw config conn) + | otherwise = pure () + +-- | Upgrade a relayed connection to a direct one (specs/relay/DCUtR). +-- +-- Tries the unilateral upgrade first, falling back to the @\/libp2p\/dcutr@ +-- exchange, and on success schedules the relay connection to close after +-- the grace period. Exposed so it can be driven directly instead of +-- through the notifier. +upgradeRelayedConnection + :: Switch -> DCUtRUpgradeConfig -> Connection -> IO DCUtRResult +upgradeRelayedConnection sw config relayConn = do + outcome <- try (upgradeRelayedConnection' sw config relayConn) + pure $ case outcome of + Left (e :: SomeException) -> DCUtRFailed (show e) + Right r -> r + +-- | The upgrade proper. Total only through 'upgradeRelayedConnection': +-- the relay connection can die at any point, and 'newStream' surfaces a +-- dead muxer as an exception rather than a 'Left'. +upgradeRelayedConnection' + :: Switch -> DCUtRUpgradeConfig -> Connection -> IO DCUtRResult +upgradeRelayedConnection' sw config relayConn = do + -- Learn the remote's advertised addresses. Identify also runs from its + -- own on-connect notifier, but the two are unordered, so this waits on + -- its own exchange rather than racing the peer store. storeIdentify + -- merges, so the duplicate is harmless. + _ <- identifyPeer sw relayConn + publicAddrs <- holePunchTargets sw (connPeerId relayConn) + outcome <- + if null publicAddrs + then pure (DCUtRFailed "no public address advertised") + else unilateralUpgrade sw config relayConn publicAddrs + result <- case outcome of + DCUtRSuccess -> pure DCUtRSuccess + DCUtRFailed _ -> initiateOverRelay sw config relayConn + case result of + DCUtRSuccess -> scheduleRelayClose sw config relayConn + DCUtRFailed _ -> pure () + pure result + +-- | The peer's advertised addresses that are worth a unilateral direct +-- dial: decodable, not relayed, and publicly routable. +-- +-- specs/relay/DCUtR: "@B@ checks the addresses advertised by @A@ via +-- identify. If that set includes public addresses, then @A@ may be +-- reachable by a direct connection". go-libp2p applies the same pair of +-- filters (@!isRelayAddress(a) && manet.IsPublicAddr(a)@). +-- +-- A circuit address is never a target: dialling it would go back through +-- the relay we are trying to get off. +holePunchTargets :: Switch -> PeerId -> IO [Multiaddr] +holePunchTargets sw peerId = do + store <- atomically $ readTVar (swPeerStore sw) + let raw = maybe [] idListenAddrs (Map.lookup peerId store) + pure [ addr + | Right addr <- map fromBytes raw + , not (isRelayedAddr addr) + , isPublicAddr addr + ] + +-- | Attempt a direct connection without any signalling. +-- +-- specs/relay/DCUtR: "If that set includes public addresses, then @A@ +-- may be reachable by a direct connection, in which case @B@ attempts a +-- unilateral connection upgrade by initiating a direct connection to +-- @A@." go-libp2p guards this the same way +-- (@!isRelayAddress(a) && manet.IsPublicAddr(a)@). +unilateralUpgrade + :: Switch -> DCUtRUpgradeConfig -> Connection -> [Multiaddr] -> IO DCUtRResult +unilateralUpgrade sw config relayConn addrs = do + dialed <- holePunchDial sw config True (connPeerId relayConn) addrs + pure $ either DCUtRFailed (const DCUtRSuccess) dialed + +-- | Run the CONNECT/CONNECT/SYNC exchange over the relayed connection. +-- +-- We are peer @B@: the initiator of the exchange, and the server of the +-- resulting TCP simultaneous connect. +initiateOverRelay :: Switch -> DCUtRUpgradeConfig -> Connection -> IO DCUtRResult +initiateOverRelay sw config relayConn = do + streamOrErr <- try (newStream sw relayConn) + case streamOrErr of + Left (e :: SomeException) -> + pure (DCUtRFailed ("dcutr: cannot open stream: " ++ show e)) + Right (Left err) -> pure (DCUtRFailed ("dcutr: cannot open stream: " ++ show err)) + Right (Right stream) -> do + negotiated <- negotiateInitiator stream [dcutrProtocolId] + case negotiated of + NoProtocol -> do + closeQuietly stream + pure (DCUtRFailed "remote does not support /libp2p/dcutr") + Accepted _ -> do + ownAddrs <- dialableListenAddrs sw + let dcConfig = DCUtRConfig + { dcMaxAttempts = ducMaxAttempts config + , dcDialer = \addr -> + holePunchDial sw config False (connPeerId relayConn) [addr] + } + result <- handleOrFail + (bounded (ducStreamTimeoutMicros config) (initiateDCUtR dcConfig stream ownAddrs)) + closeQuietly stream + pure result + where + handleOrFail action = do + outcome <- try action + pure $ case outcome of + Left (e :: SomeException) -> DCUtRFailed (show e) + Right r -> r + bounded limit action = do + r <- timeout limit action + pure (fromMaybe (DCUtRFailed "dcutr exchange timed out") r) + +-- | Dial for a hole punch: never reuse the pooled relay connection, and +-- take the security and muxer roles the spec assigns. +-- +-- specs/relay/DCUtR: "For the purpose of all protocols run on top of +-- this TCP connection, @A@ is assumed to be the client and @B@ the +-- server." We are @B@, so we upgrade as the responder even though we +-- called connect(). The unilateral attempt has no counterpart dialling +-- back, so it stays the client. +holePunchDial + :: Switch -> DCUtRUpgradeConfig -> Bool -> PeerId -> [Multiaddr] + -> IO (Either String ()) +holePunchDial sw config asClient peerId addrs = do + let opts = DialOpts { doForceDirect = True, doUpgradeAsClient = asClient } + dialed <- try (timeout (ducDirectDialTimeoutMicros config) (dialWith sw opts peerId addrs)) + pure $ case dialed of + Left (e :: SomeException) -> Left (show e) + Right Nothing -> Left "hole punch dial timed out" + Right (Just (Left err)) -> Left (show err) + Right (Just (Right _conn)) -> Right () + +-- | Our own listen addresses that a peer could hole punch to. +dialableListenAddrs :: Switch -> IO [Multiaddr] +dialableListenAddrs sw = filter (not . isRelayedAddr) <$> switchListenAddrs sw + +-- | Close the relay connection after the grace period, provided a direct +-- connection to the peer is still up. +-- +-- specs/relay/DCUtR: "All new streams should be opened in the direct +-- connection, while the relay connection should be closed after a grace +-- period." The re-check matters because the direct connection can die +-- inside the grace window; dropping the relay as well would leave the +-- peer unreachable, and the spec keeps the relay as the fallback. +scheduleRelayClose :: Switch -> DCUtRUpgradeConfig -> Connection -> IO () +scheduleRelayClose sw config relayConn = void . async $ do + threadDelay (ducRelayCloseGraceMicros config) + conns <- atomically $ lookupAllConns (swConnPool sw) (connPeerId relayConn) + direct <- atomically $ filterM openAndDirect conns + unless (null direct) $ closeConnection sw relayConn + where + openAndDirect c = do + st <- readTVar (connState c) + pure (st == ConnOpen && not (isRelayedAddr (connRemoteAddr c))) + +-- | Close a stream, ignoring failures from an already-dead session. +closeQuietly :: StreamIO -> IO () +closeQuietly stream = streamClose stream `catch` \(_ :: SomeException) -> pure () + -- | Register the AutoNAT server handler (/libp2p/autonat/1.0.0). -- -- The dial-back deliberately bypasses the connection pool: reusing the @@ -270,15 +497,17 @@ registerRelayStopHandler sw circuitState = -- -- Answers the CONNECT/SYNC exchange with our listen addresses and dials -- the initiator's addresses through the Switch for the hole punch. -registerDCUtRHandler :: Switch -> IO () -registerDCUtRHandler sw = +registerDCUtRHandler :: Switch -> DCUtRUpgradeConfig -> IO () +registerDCUtRHandler sw upgradeConfig = setStreamHandler sw dcutrProtocolId $ \conn stream -> do - addrs <- switchListenAddrs sw + addrs <- dialableListenAddrs sw let config = DCUtRConfig - { dcMaxAttempts = 3 - , dcDialer = \addr -> do - dialed <- dial sw (connPeerId conn) [addr] - pure $ either (Left . show) (const (Right ())) dialed + { dcMaxAttempts = ducMaxAttempts upgradeConfig + -- We are peer A: the spec makes us the client of the + -- simultaneous connect, and the dial must not be satisfied by + -- the relay connection we are running this exchange over. + , dcDialer = \addr -> + holePunchDial sw upgradeConfig True (connPeerId conn) [addr] } - _ <- handleDCUtR config stream addrs + _ <- timeout (ducStreamTimeoutMicros upgradeConfig) (handleDCUtR config stream addrs) pure () diff --git a/src/LibP2P/NAT/Relay.hs b/src/LibP2P/NAT/Relay.hs index 3291471..740fc9c 100644 --- a/src/LibP2P/NAT/Relay.hs +++ b/src/LibP2P/NAT/Relay.hs @@ -42,8 +42,7 @@ import qualified Data.Map.Strict as Map import Data.Time.Clock.POSIX (getPOSIXTime) import Data.Word (Word32, Word64) import LibP2P.NAT.Relay.Message -import LibP2P.Multiaddr (Multiaddr (..), fromBytes, toBytes) -import LibP2P.Multiaddr.Protocol (Protocol (..)) +import LibP2P.Multiaddr (Multiaddr (..), fromBytes, isRelayedAddr, toBytes) import LibP2P.MultistreamSelect.Negotiation (StreamIO (..)) import LibP2P.Crypto.Key (KeyPair (..)) import LibP2P.Crypto.PeerId (PeerId (..), peerIdBytes) @@ -346,10 +345,6 @@ buildRelayAddrBytes relayAddr relayIdBytes targetIdBytes = p2pCircuitBytes :: ByteString p2pCircuitBytes = encodeUvarint 290 --- | Check whether a multiaddr contains a p2p-circuit component. -isRelayedAddr :: Multiaddr -> Bool -isRelayedAddr (Multiaddr ps) = P2PCircuit `elem` ps - -- | Check whether raw multiaddr bytes describe a relayed connection. -- Decodes the bytes structurally: the p2p-circuit byte pattern occurring -- inside another component (e.g. a peer ID) does not count, unlike the diff --git a/src/LibP2P/Switch/ConnPool.hs b/src/LibP2P/Switch/ConnPool.hs index 654218c..dcf8246 100644 --- a/src/LibP2P/Switch/ConnPool.hs +++ b/src/LibP2P/Switch/ConnPool.hs @@ -17,27 +17,44 @@ module LibP2P.Switch.ConnPool import Control.Concurrent.STM (STM, TVar, newTVarIO, readTVar, writeTVar) import qualified Data.Map.Strict as Map import LibP2P.Crypto.PeerId (PeerId) +import LibP2P.Multiaddr (isRelayedAddr) import LibP2P.Switch.Types (ConnState (..), Connection (..)) -- | Create a new empty connection pool. newConnPool :: IO (TVar (Map.Map PeerId [Connection])) newConnPool = newTVarIO Map.empty --- | Look up the first Open connection for a peer. +-- | Look up the best Open connection for a peer, preferring a direct +-- connection over a relayed one. +-- +-- specs/relay/DCUtR: after a hole punch "the peers should migrate to the +-- established connection by prioritizing over the existing relay +-- connection. All new streams should be opened in the direct +-- connection." Keeping that preference here means every caller migrates +-- at once, the way go-libp2p concentrates it in @bestConnToPeer@ ("If +-- one is limited and not the other, prefer the unlimited connection"). +-- +-- A relayed connection is still returned when it is all there is, so a +-- failed hole punch leaves the relay usable as before. +-- -- Returns Nothing if no connection exists or none are in ConnOpen state. lookupConn :: TVar (Map.Map PeerId [Connection]) -> PeerId -> STM (Maybe Connection) lookupConn poolVar pid = do pool <- readTVar poolVar case Map.lookup pid pool of Nothing -> pure Nothing - Just conns -> findOpen conns + Just conns -> pickBest conns Nothing where - findOpen [] = pure Nothing - findOpen (c : rest) = do + -- Walk once, returning the first direct connection and remembering + -- the first relayed one as the fallback. + pickBest [] fallback = pure fallback + pickBest (c : rest) fallback = do st <- readTVar (connState c) - if st == ConnOpen - then pure (Just c) - else findOpen rest + if st /= ConnOpen + then pickBest rest fallback + else if isRelayedAddr (connRemoteAddr c) + then pickBest rest (maybe (Just c) Just fallback) + else pure (Just c) -- | Look up all connections for a peer (any state). lookupAllConns :: TVar (Map.Map PeerId [Connection]) -> PeerId -> STM [Connection] diff --git a/src/LibP2P/Switch/Dial.hs b/src/LibP2P/Switch/Dial.hs index 274145c..646a9eb 100644 --- a/src/LibP2P/Switch/Dial.hs +++ b/src/LibP2P/Switch/Dial.hs @@ -13,6 +13,10 @@ module LibP2P.Switch.Dial ( -- * Main entry point dial + -- * Dial options + , DialOpts (..) + , defaultDialOpts + , dialWith -- * Backoff management , checkBackoff , recordBackoff @@ -57,7 +61,7 @@ import LibP2P.Switch.Types , Switch (..) , SwitchEvent (..) ) -import LibP2P.Switch.Upgrade (upgradeOutbound) +import LibP2P.Switch.Upgrade (upgradeAs) import LibP2P.Transport (Transport (..)) -- | Initial backoff duration after first failure: 5 seconds. @@ -120,6 +124,34 @@ data PendingCheck = JoinExisting !(TMVar (Either DialError Connection)) | StartNew !(TMVar (Either DialError Connection)) +-- | Per-dial options. +-- +-- Mirrors the two orthogonal context values go-libp2p threads through a +-- dial: @network.WithForceDirectDial@ and @network.WithSimultaneousConnect@, +-- which its hole puncher sets together. +data DialOpts = DialOpts + { doForceDirect :: !Bool + -- ^ Bypass connection reuse, dial backoff and dial deduplication, + -- and always establish a new transport connection. Hole punching + -- needs this: reusing a pooled connection emits no packet at all, so + -- the TCP simultaneous connect the DCUtR spec relies on cannot + -- happen. go-libp2p likewise consults backoff only when the dial is + -- not force-direct. + , doUpgradeAsClient :: !Bool + -- ^ Whether to run the client side of the security handshake and the + -- muxer. False upgrades as the responder over a connection we + -- dialled, which specs/relay/DCUtR requires of peer @B@: "For the + -- purpose of all protocols run on top of this TCP connection, @A@ is + -- assumed to be the client and @B@ the server." + } + +-- | Ordinary dial: reuse pooled connections, honour backoff, act as client. +defaultDialOpts :: DialOpts +defaultDialOpts = DialOpts + { doForceDirect = False + , doUpgradeAsClient = True + } + -- | Dial a peer, reusing existing connections or establishing new ones. -- -- Implements the full dial flow: @@ -130,35 +162,48 @@ data PendingCheck -- 5. First success: upgrade, add to pool, return -- 6. All fail: record backoff, return error dial :: Switch -> PeerId -> [Multiaddr] -> IO (Either DialError Connection) -dial sw remotePeerId addrs = do +dial sw = dialWith sw defaultDialOpts + +-- | Dial a peer under explicit options. +-- +-- A force-direct dial skips steps 1-3 entirely. Skipping deduplication +-- is required, not incidental: DCUtR calls its dialer once per address +-- so that every address is attempted at the same moment, and a shared +-- pending-dial TMVar carries one result for all waiters, so joining it +-- would collapse those attempts into a single address. Backoff is still +-- *recorded* on failure, as go-libp2p does. +dialWith :: Switch -> DialOpts -> PeerId -> [Multiaddr] -> IO (Either DialError Connection) +dialWith sw opts remotePeerId addrs = do -- 0. Check switch is open closed <- atomically $ readTVar (swClosed sw) if closed then pure (Left DialSwitchClosed) - else do - -- 1. Check connection pool for existing Open connection - existing <- atomically $ lookupConn (swConnPool sw) remotePeerId - case existing of - Just conn -> pure (Right conn) - Nothing -> do - -- 2. Check backoff - backoffResult <- checkBackoff (swDialBackoffs sw) remotePeerId - case backoffResult of - Left err -> pure (Left err) - Right () -> do - -- 3. Deduplication: check for pending dial - joinOrCreate <- atomically $ checkPendingDial sw remotePeerId - case joinOrCreate of - JoinExisting tmvar -> - -- Another thread is already dialing; wait for its result - atomically $ readTMVar tmvar - StartNew tmvar -> - -- We own this dial; execute and broadcast result. - -- If the dial throws, fill the TMVar and drop the - -- pending entry so waiters and future dials never - -- wedge on a stale pending dial. - dialNewAndBroadcast sw remotePeerId addrs tmvar - `onException` abortPendingDial sw remotePeerId tmvar + else if doForceDirect opts + then establishAndRegister sw opts remotePeerId addrs + else do + -- 1. Check connection pool for existing Open connection + existing <- atomically $ lookupConn (swConnPool sw) remotePeerId + case existing of + Just conn -> pure (Right conn) + Nothing -> do + -- 2. Check backoff + backoffResult <- checkBackoff (swDialBackoffs sw) remotePeerId + case backoffResult of + Left err -> pure (Left err) + Right () -> do + -- 3. Deduplication: check for pending dial + joinOrCreate <- atomically $ checkPendingDial sw remotePeerId + case joinOrCreate of + JoinExisting tmvar -> + -- Another thread is already dialing; wait for its result + atomically $ readTMVar tmvar + StartNew tmvar -> + -- We own this dial; execute and broadcast result. + -- If the dial throws, fill the TMVar and drop the + -- pending entry so waiters and future dials never + -- wedge on a stale pending dial. + dialNewAndBroadcast sw opts remotePeerId addrs tmvar + `onException` abortPendingDial sw remotePeerId tmvar -- | Clean up a pending dial whose worker threw an exception. -- Fills the TMVar (if still empty) so joined waiters are released, @@ -180,44 +225,53 @@ checkPendingDial sw pid = do writeTVar (swPendingDials sw) (Map.insert pid tmvar pending) pure (StartNew tmvar) --- | Execute the dial, broadcast the result, and clean up. +-- | Execute the dial, broadcast the result to joined waiters, and drop +-- the pending-dial entry. dialNewAndBroadcast - :: Switch -> PeerId -> [Multiaddr] + :: Switch -> DialOpts -> PeerId -> [Multiaddr] -> TMVar (Either DialError Connection) -> IO (Either DialError Connection) -dialNewAndBroadcast sw remotePeerId addrs tmvar = do - -- Check resource limits before attempting dial - resCheck <- atomically $ reserveConnection (swResourceMgr sw) remotePeerId Outbound +dialNewAndBroadcast sw opts remotePeerId addrs tmvar = do + result <- establishAndRegister sw opts remotePeerId addrs + atomically $ do + putTMVar tmvar result + pending <- readTVar (swPendingDials sw) + writeTVar (swPendingDials sw) (Map.delete remotePeerId pending) + pure result + +-- | Reserve resources, dial, verify the peer id, and register the +-- resulting connection. +-- +-- Shared by the ordinary dial path and the force-direct one, which +-- reaches it without touching the pool, backoff or pending-dial state. +-- +-- The direction is taken from 'doUpgradeAsClient' and used for the +-- resource reservation, the upgrade roles and 'connDirection' alike, so +-- the release in 'closeConnection' -- which reads 'connDirection' -- +-- always matches what was reserved. +establishAndRegister + :: Switch -> DialOpts -> PeerId -> [Multiaddr] + -> IO (Either DialError Connection) +establishAndRegister sw opts remotePeerId addrs = do + let dir = if doUpgradeAsClient opts then Outbound else Inbound + resCheck <- atomically $ reserveConnection (swResourceMgr sw) remotePeerId dir case resCheck of - Left resErr -> do - let result = Left (DialResourceLimit resErr) - atomically $ putTMVar tmvar result - atomically $ do - pending <- readTVar (swPendingDials sw) - writeTVar (swPendingDials sw) (Map.delete remotePeerId pending) - pure result + Left resErr -> pure (Left (DialResourceLimit resErr)) Right () -> do - result <- dialNewInner sw addrs - -- Verify remote PeerId matches expected target before broadcasting + result <- dialNewInner sw dir addrs + -- Verify remote PeerId matches expected target let verified = case result of Right conn | connPeerId conn /= remotePeerId -> Left (DialPeerIdMismatch remotePeerId (connPeerId conn)) _ -> result - -- Broadcast verified result to any waiting threads - atomically $ putTMVar tmvar verified - -- Clean up pending dials map - atomically $ do - pending <- readTVar (swPendingDials sw) - writeTVar (swPendingDials sw) (Map.delete remotePeerId pending) - -- Record backoff on failure, clear on success, add to pool case verified of Right conn -> do clearBackoff (swDialBackoffs sw) remotePeerId atomically $ do addConn (swConnPool sw) conn writeTChan (swEvents sw) - (Connected (connPeerId conn) Outbound (connRemoteAddr conn)) + (Connected (connPeerId conn) dir (connRemoteAddr conn)) -- Start accepting inbound streams on the dialer side; tear the -- connection down when the session dies (pool removal, -- resource release, muxer + transport close). @@ -232,14 +286,14 @@ dialNewAndBroadcast sw remotePeerId addrs tmvar = do Right conn -> muxClose (connSession conn) Left _ -> pure () -- Release the reserved connection since dial failed - atomically $ releaseConnection (swResourceMgr sw) remotePeerId Outbound + atomically $ releaseConnection (swResourceMgr sw) remotePeerId dir recordBackoff (swDialBackoffs sw) remotePeerId pure verified -- | Inner dial logic: transport selection and staggered parallel dial. -dialNewInner :: Switch -> [Multiaddr] -> IO (Either DialError Connection) -dialNewInner _sw [] = pure (Left DialNoAddresses) -dialNewInner sw addrs = do +dialNewInner :: Switch -> Direction -> [Multiaddr] -> IO (Either DialError Connection) +dialNewInner _sw _dir [] = pure (Left DialNoAddresses) +dialNewInner sw dir addrs = do transports <- atomically $ readTVar (swTransports sw) -- Find a transport for each address let dialable = filterMap (\addr -> @@ -248,7 +302,7 @@ dialNewInner sw addrs = do Nothing -> Nothing) addrs case dialable of [] -> pure (Left (DialNoTransport (Prelude.head addrs))) - pairs -> staggeredDial sw pairs + pairs -> staggeredDial sw dir pairs -- | Filter and map a list, keeping only Just results. filterMap :: (a -> Maybe b) -> [a] -> [b] @@ -261,14 +315,14 @@ filterMap f (x:xs) = case f x of -- -- Addresses are tried with 250ms delay between each attempt. -- The first successful connection wins; remaining attempts are cancelled. -staggeredDial :: Switch -> [(Multiaddr, Transport)] -> IO (Either DialError Connection) -staggeredDial sw pairs = do +staggeredDial :: Switch -> Direction -> [(Multiaddr, Transport)] -> IO (Either DialError Connection) +staggeredDial sw dir pairs = do -- Spawn workers with staggered delays: 0ms, 250ms, 500ms, ... workers <- forM (zip [0 :: Int ..] pairs) $ \(i, (addr, transport)) -> async $ do when (i > 0) $ threadDelay (i * staggerDelayUs) rawConn <- transportDial transport addr - upgradeOutbound (swIdentityKey sw) rawConn + upgradeAs dir (swIdentityKey sw) rawConn -- Wait for first success or collect all failures collectResults workers [] diff --git a/src/LibP2P/Switch/Upgrade.hs b/src/LibP2P/Switch/Upgrade.hs index fc69d62..198c181 100644 --- a/src/LibP2P/Switch/Upgrade.hs +++ b/src/LibP2P/Switch/Upgrade.hs @@ -16,6 +16,7 @@ module LibP2P.Switch.Upgrade -- * Yamux → MuxerSession adapter , yamuxToMuxerSession -- * Full upgrade pipeline + , upgradeAs , upgradeOutbound , upgradeInbound -- * Helpers (exported for testing) @@ -343,21 +344,30 @@ yamuxStreamToStreamIO yamuxStream = do pure () } --- | Upgrade an outbound (dialer) raw connection. --- Pipeline: mss(/noise) → Noise XX → mss(/yamux/1.0.0) → Yamux client -upgradeOutbound :: KeyPair -> RawConnection -> IO Connection -upgradeOutbound identityKP rawConn = do +-- | Upgrade a raw connection, taking every role from the direction. +-- +-- Pipeline: mss(/noise) -> Noise XX -> mss(/yamux/1.0.0) -> Yamux. +-- 'Outbound' runs the initiator/client side of all three, 'Inbound' the +-- responder/server side. go-libp2p derives the same way +-- (@isServer := dir == network.DirInbound@ in its upgrader), which is +-- what lets a TCP simultaneous connect flip roles: the peer that must +-- act as the server passes 'Inbound' even though it called connect(). +upgradeAs :: Direction -> KeyPair -> RawConnection -> IO Connection +upgradeAs dir identityKP rawConn = do let rawIO = rcStreamIO rawConn + isServer = dir == Inbound + negotiate = if isServer then negotiateResponder else negotiateInitiator + role = if isServer then "upgradeInbound" else "upgradeOutbound" - -- Step 1: multistream-select → "/noise" - secResult <- negotiateInitiator rawIO ["/noise"] + -- Step 1: multistream-select -> "/noise" + secResult <- negotiate rawIO ["/noise"] case secResult of Accepted _ -> pure () - NoProtocol -> fail "upgradeOutbound: /noise negotiation failed" + NoProtocol -> fail (role <> ": /noise negotiation failed") - -- Step 2: Noise XX handshake (initiator) + -- Step 2: Noise XX handshake (noiseSess, HandshakeResult remotePeerId _remotePK) <- - performStreamHandshake identityKP Outbound rawIO + performStreamHandshake identityKP dir rawIO -- Step 3: Create encrypted StreamIO sendRef <- newIORef noiseSess @@ -365,23 +375,22 @@ upgradeOutbound identityKP rawConn = do bufRef <- newIORef BS.empty let encryptedIO = noiseSessionToStreamIO sendRef recvRef bufRef rawIO - -- Step 4: multistream-select → "/yamux/1.0.0" (over encrypted channel) - muxResult <- negotiateInitiator encryptedIO ["/yamux/1.0.0"] + -- Step 4: multistream-select -> "/yamux/1.0.0" (over encrypted channel) + muxResult <- negotiate encryptedIO ["/yamux/1.0.0"] case muxResult of Accepted _ -> pure () - NoProtocol -> fail "upgradeOutbound: /yamux/1.0.0 negotiation failed" + NoProtocol -> fail (role <> ": /yamux/1.0.0 negotiation failed") - -- Step 5: Initialize Yamux session (client = odd IDs) + -- Step 5: Initialize Yamux session (client = odd IDs, server = even) let yamuxWrite = streamWrite encryptedIO yamuxRead = \n -> readExact encryptedIO n - yamuxSess <- newSession RoleClient yamuxWrite yamuxRead + yamuxSess <- newSession (if isServer then RoleServer else RoleClient) yamuxWrite yamuxRead muxer <- yamuxToMuxerSession yamuxSess (rcClose rawConn) - -- Build Connection stateVar <- newTVarIO ConnOpen pure Connection { connPeerId = remotePeerId - , connDirection = Outbound + , connDirection = dir , connLocalAddr = rcLocalAddr rawConn , connRemoteAddr = rcRemoteAddr rawConn , connSecurity = "/noise" @@ -390,49 +399,10 @@ upgradeOutbound identityKP rawConn = do , connState = stateVar } +-- | Upgrade an outbound (dialer) raw connection. +upgradeOutbound :: KeyPair -> RawConnection -> IO Connection +upgradeOutbound = upgradeAs Outbound + -- | Upgrade an inbound (listener) raw connection. --- Pipeline: mss(/noise) → Noise XX → mss(/yamux/1.0.0) → Yamux server upgradeInbound :: KeyPair -> RawConnection -> IO Connection -upgradeInbound identityKP rawConn = do - let rawIO = rcStreamIO rawConn - - -- Step 1: multistream-select → "/noise" - secResult <- negotiateResponder rawIO ["/noise"] - case secResult of - Accepted _ -> pure () - NoProtocol -> fail "upgradeInbound: /noise negotiation failed" - - -- Step 2: Noise XX handshake (responder) - (noiseSess, HandshakeResult remotePeerId _remotePK) <- - performStreamHandshake identityKP Inbound rawIO - - -- Step 3: Create encrypted StreamIO - sendRef <- newIORef noiseSess - recvRef <- newIORef noiseSess - bufRef <- newIORef BS.empty - let encryptedIO = noiseSessionToStreamIO sendRef recvRef bufRef rawIO - - -- Step 4: multistream-select → "/yamux/1.0.0" (over encrypted channel) - muxResult <- negotiateResponder encryptedIO ["/yamux/1.0.0"] - case muxResult of - Accepted _ -> pure () - NoProtocol -> fail "upgradeInbound: /yamux/1.0.0 negotiation failed" - - -- Step 5: Initialize Yamux session (server = even IDs) - let yamuxWrite = streamWrite encryptedIO - yamuxRead = \n -> readExact encryptedIO n - yamuxSess <- newSession RoleServer yamuxWrite yamuxRead - muxer <- yamuxToMuxerSession yamuxSess (rcClose rawConn) - - -- Build Connection - stateVar <- newTVarIO ConnOpen - pure Connection - { connPeerId = remotePeerId - , connDirection = Inbound - , connLocalAddr = rcLocalAddr rawConn - , connRemoteAddr = rcRemoteAddr rawConn - , connSecurity = "/noise" - , connMuxer = "/yamux/1.0.0" - , connSession = muxer - , connState = stateVar - } +upgradeInbound = upgradeAs Inbound diff --git a/test/LibP2P/Multiaddr/PublicAddrSpec.hs b/test/LibP2P/Multiaddr/PublicAddrSpec.hs new file mode 100644 index 0000000..4e50045 --- /dev/null +++ b/test/LibP2P/Multiaddr/PublicAddrSpec.hs @@ -0,0 +1,107 @@ +-- | Tests for public-address classification (issue #258). +-- +-- The table mirrors go-multiaddr's @manet.IsPublicAddr@ (net/private.go), +-- which is what the DCUtR unilateral-upgrade check needs: a peer is only +-- worth dialling directly if it advertises a reachable address. +module LibP2P.Multiaddr.PublicAddrSpec (spec) where + +import qualified Data.ByteString as BS +import Data.Word (Word32, Word8) +import LibP2P.Multiaddr (Multiaddr (..), isPublicAddr) +import LibP2P.Multiaddr.Protocol (Protocol (..)) +import Test.Hspec + +ip4 :: Word32 -> Multiaddr +ip4 w = Multiaddr [IP4 w, TCP 4001] + +-- | Build an IPv6 multiaddr from the leading bytes, zero-padded. +ip6 :: [Word8] -> Multiaddr +ip6 leading = Multiaddr [IP6 (BS.pack leading <> BS.replicate (16 - length leading) 0), TCP 4001] + +spec :: Spec +spec = describe "isPublicAddr" $ do + describe "IPv4" $ do + it "accepts globally routable addresses" $ do + isPublicAddr (ip4 0x08080808) `shouldBe` True -- 8.8.8.8 + isPublicAddr (ip4 0x01010101) `shouldBe` True -- 1.1.1.1 + isPublicAddr (ip4 0x2D2D2D2D) `shouldBe` True -- 45.45.45.45 + + it "rejects loopback, RFC1918, CGNAT and link-local" $ do + isPublicAddr (ip4 0x7F000001) `shouldBe` False -- 127.0.0.1 + isPublicAddr (ip4 0x0A000001) `shouldBe` False -- 10.0.0.1 + isPublicAddr (ip4 0xAC100001) `shouldBe` False -- 172.16.0.1 + isPublicAddr (ip4 0xAC1F0001) `shouldBe` False -- 172.31.0.1 + isPublicAddr (ip4 0xC0A80001) `shouldBe` False -- 192.168.0.1 + isPublicAddr (ip4 0x64400001) `shouldBe` False -- 100.64.0.1 CGNAT + isPublicAddr (ip4 0xA9FE0001) `shouldBe` False -- 169.254.0.1 link-local + + it "accepts addresses just outside the private ranges" $ do + isPublicAddr (ip4 0xAC0FFFFF) `shouldBe` True -- 172.15.255.255 + isPublicAddr (ip4 0xAC200000) `shouldBe` True -- 172.32.0.0 + isPublicAddr (ip4 0x643FFFFF) `shouldBe` True -- 100.63.255.255 + isPublicAddr (ip4 0x64800000) `shouldBe` True -- 100.128.0.0 + + it "rejects the unroutable ranges" $ do + isPublicAddr (ip4 0x00000000) `shouldBe` False -- 0.0.0.0 + isPublicAddr (ip4 0xC0000001) `shouldBe` False -- 192.0.0.1 + isPublicAddr (ip4 0xC0000201) `shouldBe` False -- 192.0.2.1 + isPublicAddr (ip4 0xC0586301) `shouldBe` False -- 192.88.99.1 + isPublicAddr (ip4 0xC6120001) `shouldBe` False -- 198.18.0.1 + isPublicAddr (ip4 0xC6336401) `shouldBe` False -- 198.51.100.1 + isPublicAddr (ip4 0xCB007101) `shouldBe` False -- 203.0.113.1 + isPublicAddr (ip4 0xE0000001) `shouldBe` False -- 224.0.0.1 multicast + isPublicAddr (ip4 0xF0000001) `shouldBe` False -- 240.0.0.1 + isPublicAddr (ip4 0xFFFFFFFF) `shouldBe` False -- 255.255.255.255 + + describe "IPv6" $ do + it "accepts the global unicast allocation" $ do + isPublicAddr (ip6 [0x20, 0x01, 0x4A, 0x60]) `shouldBe` True + isPublicAddr (ip6 [0x2A, 0x00]) `shouldBe` True + isPublicAddr (ip6 [0x3F, 0xFF]) `shouldBe` True + + it "rejects everything outside global unicast" $ do + isPublicAddr (ip6 [0x00]) `shouldBe` False -- :: + isPublicAddr (ip6 [0xFC, 0x00]) `shouldBe` False -- ULA + isPublicAddr (ip6 [0xFD, 0x00]) `shouldBe` False -- ULA + isPublicAddr (ip6 [0xFE, 0x80]) `shouldBe` False -- link-local + isPublicAddr (ip6 [0xFF, 0x02]) `shouldBe` False -- multicast + isPublicAddr (ip6 [0x1F, 0xFF]) `shouldBe` False -- below 2000::/3 + + it "rejects loopback ::1" $ + isPublicAddr (Multiaddr [IP6 (BS.replicate 15 0 <> BS.singleton 1), TCP 4001]) + `shouldBe` False + + it "rejects the documentation prefix inside global unicast" $ + isPublicAddr (ip6 [0x20, 0x01, 0x0D, 0xB8]) `shouldBe` False + + it "accepts the NAT64 prefixes" $ do + isPublicAddr (ip6 [0x00, 0x64, 0xFF, 0x9B, 0x00, 0x00]) `shouldBe` True -- RFC 6052 + isPublicAddr (ip6 [0x00, 0x64, 0xFF, 0x9B, 0x00, 0x01]) `shouldBe` True -- RFC 8215 + + describe "DNS" $ do + it "accepts ordinary hostnames" $ do + isPublicAddr (Multiaddr [DNS "example.com", TCP 443]) `shouldBe` True + isPublicAddr (Multiaddr [DNS4 "bootstrap.libp2p.io", TCP 443]) `shouldBe` True + isPublicAddr (Multiaddr [DNSAddr "bootstrap.libp2p.io"]) `shouldBe` True + + it "rejects special-use domains" $ do + isPublicAddr (Multiaddr [DNS "host.localhost", TCP 443]) `shouldBe` False + isPublicAddr (Multiaddr [DNS "printer.local", TCP 443]) `shouldBe` False + isPublicAddr (Multiaddr [DNS "router.home.arpa", TCP 443]) `shouldBe` False + isPublicAddr (Multiaddr [DNS "thing.test", TCP 443]) `shouldBe` False + isPublicAddr (Multiaddr [DNS "nope.invalid", TCP 443]) `shouldBe` False + isPublicAddr (Multiaddr [DNS6 "1.0.0.127.in-addr.arpa", TCP 443]) `shouldBe` False + + it "is case-insensitive about the domain suffix" $ + isPublicAddr (Multiaddr [DNS "Printer.LOCAL", TCP 443]) `shouldBe` False + + describe "addresses with no IP or DNS component" $ + it "are not public" $ do + isPublicAddr (Multiaddr [P2PCircuit]) `shouldBe` False + isPublicAddr (Multiaddr []) `shouldBe` False + + describe "relayed addresses" $ + it "classify by their transport component, so the caller must filter circuits itself" $ + -- /ip4/1.2.3.4/tcp/4001/p2p//p2p-circuit is 'public' by IP; + -- DCUtR drops relayed addresses before applying this predicate. + isPublicAddr (Multiaddr [IP4 0x01020304, TCP 4001, P2PCircuit]) `shouldBe` True diff --git a/test/LibP2P/NAT/DCUtR/UpgradeSpec.hs b/test/LibP2P/NAT/DCUtR/UpgradeSpec.hs new file mode 100644 index 0000000..c170224 --- /dev/null +++ b/test/LibP2P/NAT/DCUtR/UpgradeSpec.hs @@ -0,0 +1,325 @@ +-- | Tests for the DCUtR production integration (issue #258). +-- +-- Real switches over loopback TCP. A genuine NAT hole punch is out of +-- reach in-process, so these pin down the parts that were wrong or +-- missing: that the dial no longer hands back the pooled relay +-- connection, that the resulting direct connection takes the roles the +-- spec assigns, that connection selection migrates to it, and that the +-- relay is released only once the direct connection has proved itself. +-- End-to-end hole punching against a real NAT is issue #131. +module LibP2P.NAT.DCUtR.UpgradeSpec (spec) where + +import Control.Concurrent (threadDelay) +import Control.Concurrent.Async (concurrently) +import Control.Concurrent.STM (atomically, modifyTVar', readTVarIO) +import Data.Maybe (isNothing) +import qualified Data.Map.Strict as Map +import LibP2P.Crypto.Ed25519 (generateKeyPair) +import LibP2P.Crypto.Key (KeyPair, publicKey) +import LibP2P.Crypto.PeerId (PeerId, fromPublicKey, peerIdBytes) +import LibP2P.Multiaddr (Multiaddr (..), isRelayedAddr, toBytes) +import LibP2P.Multiaddr.Protocol (Protocol (..)) +import LibP2P.MultistreamSelect.Negotiation (StreamIO (..), mkMemoryStreamPair) +import LibP2P.NAT + ( DCUtRUpgradeConfig (..) + , NATConfig (..) + , defaultDCUtRUpgradeConfig + , defaultNATConfig + , holePunchTargets + , registerNATHandlers + , upgradeRelayedConnection + ) +import LibP2P.Protocol.Identify.Message (IdentifyInfo (..)) +import LibP2P.NAT.DCUtR (DCUtRResult (..)) +import LibP2P.Protocol.Identify (registerIdentifyHandlers) +import LibP2P.Protocol.Ping (registerPingHandler) +import LibP2P.Switch (addTransport, newSwitch, switchClose) +import LibP2P.Switch.ConnPool (lookupAllConns, lookupConn) +import LibP2P.Switch.Dial (DialOpts (..), defaultDialOpts, dialWith) +import LibP2P.Switch.Listen (defaultConnectionGater, switchListen, switchListenAddrs) +import LibP2P.Switch.Types + ( ConnState (..) + , Connection (..) + , Direction (..) + , MuxerSession (..) + , Switch (..) + ) +import LibP2P.Switch.Upgrade (readExact, upgradeAs) +import LibP2P.Transport (RawConnection (..)) +import LibP2P.Transport.TCP (newTCPTransport) +import System.Timeout (timeout) +import Test.Hspec + +mkTestIdentity :: IO (PeerId, KeyPair) +mkTestIdentity = do + Right kp <- generateKeyPair + let pid = fromPublicKey (publicKey kp) + pure (pid, kp) + +loopbackAddr :: Multiaddr +loopbackAddr = Multiaddr [IP4 0x7f000001, TCP 0] + +withCircuit :: Multiaddr -> PeerId -> Maybe PeerId -> Multiaddr +withCircuit (Multiaddr comps) relayId mTarget = + Multiaddr (comps ++ [P2P (peerIdBytes relayId), P2PCircuit] ++ target) + where + target = maybe [] (\t -> [P2P (peerIdBytes t)]) mTarget + +firstAddr :: [Multiaddr] -> IO Multiaddr +firstAddr (a : _) = pure a +firstAddr [] = fail "switch did not bind a listen address" + +-- | A fully wired node: TCP, NAT handlers, identify and ping. +newNode :: NATConfig -> IO (Switch, PeerId) +newNode config = do + (pid, kp) <- mkTestIdentity + sw <- newSwitch pid kp + addTransport sw =<< newTCPTransport + _ <- registerNATHandlers sw config + registerIdentifyHandlers sw + registerPingHandler sw + pure (sw, pid) + +-- | Relay R, target B reserving on R, and dialer A, all on loopback. +-- Returns the relay address and each node, with A already connected to B +-- through the circuit. +data Circuit = Circuit + { cRelaySw :: !Switch + , cRelayId :: !PeerId + , cRelayAddr :: !Multiaddr + , cTargetSw :: !Switch + , cTargetId :: !PeerId + , cDialerSw :: !Switch + , cDialerId :: !PeerId + , cRelayed :: !Connection -- ^ A's relayed connection to B + } + +withCircuitTrio :: NATConfig -> (Circuit -> IO a) -> IO a +withCircuitTrio config action = do + (swR, pidR) <- newNode config + addrsR <- switchListen swR defaultConnectionGater [loopbackAddr] + relayAddr <- firstAddr addrsR + (swB, pidB) <- newNode config + _ <- switchListen swB defaultConnectionGater [loopbackAddr] + _ <- switchListen swB defaultConnectionGater [withCircuit relayAddr pidR Nothing] + (swA, pidA) <- newNode config + _ <- switchListen swA defaultConnectionGater [loopbackAddr] + relayed <- timeout 20000000 (dialWith swA defaultDialOpts pidB + [withCircuit relayAddr pidR (Just pidB)]) + conn <- case relayed of + Nothing -> fail "circuit dial timed out" + Just (Left err) -> fail ("circuit dial failed: " ++ show err) + Just (Right c) -> pure c + threadDelay 500000 + result <- action Circuit + { cRelaySw = swR, cRelayId = pidR, cRelayAddr = relayAddr + , cTargetSw = swB, cTargetId = pidB + , cDialerSw = swA, cDialerId = pidA + , cRelayed = conn + } + switchClose swA + switchClose swB + switchClose swR + pure result + +-- | All Open connections a switch holds for a peer, split by transport. +connsFor :: Switch -> PeerId -> IO ([Connection], [Connection]) +connsFor sw pid = do + conns <- atomically $ lookupAllConns (swConnPool sw) pid + open <- mapM (\c -> (,) c <$> readTVarIO (connState c)) conns + let live = [c | (c, st) <- open, st == ConnOpen] + pure ( filter (not . isRelayedAddr . connRemoteAddr) live + , filter (isRelayedAddr . connRemoteAddr) live ) + +spec :: Spec +spec = do + describe "force-direct dial" $ do + it "establishes a new connection instead of returning the pooled relay one" $ + withCircuitTrio defaultNATConfig $ \c -> do + let swA = cDialerSw c + pidB = cTargetId c + -- Before: only the relayed connection is pooled + (direct0, relayed0) <- connsFor swA pidB + length relayed0 `shouldBe` 1 + length direct0 `shouldBe` 0 + -- A plain dial hands back the pooled relay connection + addrB <- firstAddr =<< switchListenAddrsOf (cTargetSw c) + reused <- dialWith swA defaultDialOpts pidB [addrB] >>= either (fail . show) pure + isRelayedAddr (connRemoteAddr reused) `shouldBe` True + -- A force-direct dial does not + let opts = defaultDialOpts { doForceDirect = True } + fresh <- dialWith swA opts pidB [addrB] >>= either (fail . show) pure + isRelayedAddr (connRemoteAddr fresh) `shouldBe` False + (direct1, relayed1) <- connsFor swA pidB + length direct1 `shouldBe` 1 + length relayed1 `shouldBe` 1 + + describe "connection selection" $ + it "prefers the direct connection once one exists, and falls back to the relay" $ + withCircuitTrio defaultNATConfig $ \c -> do + let swA = cDialerSw c + pidB = cTargetId c + -- Only the relay exists: it is what lookupConn returns + beforePunch <- atomically $ lookupConn (swConnPool swA) pidB + fmap (isRelayedAddr . connRemoteAddr) beforePunch `shouldBe` Just True + addrB <- firstAddr =<< switchListenAddrsOf (cTargetSw c) + let opts = defaultDialOpts { doForceDirect = True } + _ <- dialWith swA opts pidB [addrB] >>= either (fail . show) pure + after' <- atomically $ lookupConn (swConnPool swA) pidB + fmap (isRelayedAddr . connRemoteAddr) after' `shouldBe` Just False + + describe "hole punch target selection" $ do + it "keeps only public, non-relayed advertised addresses" $ + withCircuitTrio fastConfig $ \c -> do + let swB = cTargetSw c + pidA = cDialerId c + publicAddr = Multiaddr [IP4 0x08080808, TCP 4001] + privateAddr = Multiaddr [IP4 0xC0A80005, TCP 4001] + loopback = Multiaddr [IP4 0x7f000001, TCP 4001] + circuitAddr = withCircuit (cRelayAddr c) (cRelayId c) (Just pidA) + seedListenAddrs swB pidA [publicAddr, privateAddr, loopback, circuitAddr] + targets <- holePunchTargets swB pidA + targets `shouldBe` [publicAddr] + + it "yields nothing when the peer advertises only unroutable addresses" $ + withCircuitTrio fastConfig $ \c -> do + let swB = cTargetSw c + pidA = cDialerId c + seedListenAddrs swB pidA + [ Multiaddr [IP4 0x7f000001, TCP 4001] + , Multiaddr [IP4 0x0A000001, TCP 4001] + , withCircuit (cRelayAddr c) (cRelayId c) (Just pidA) + ] + holePunchTargets swB pidA `shouldReturn` [] + + describe "upgradeRelayedConnection" $ do + it "reports failure and leaves the relay connection alone when the punch fails" $ + -- On loopback no address is public, so the unilateral path is + -- skipped and the DCUtR exchange runs; B's role-reversed dial + -- lands on A's ordinary listener and cannot complete. What must + -- hold is that this is reported as a failure and the relay + -- survives -- specs/relay/DCUtR: "If the hole punching attempt + -- fails, they can keep using the relay connection as they were." + withCircuitTrio fastConfig $ \c -> do + relayConn <- targetRelayConn c + result <- timeout 30000000 $ + upgradeRelayedConnection (cTargetSw c) (ncDCUtRUpgrade fastConfig) relayConn + case result of + Nothing -> expectationFailure "upgrade attempt never settled" + Just DCUtRSuccess -> expectationFailure "upgrade unexpectedly reported success" + Just (DCUtRFailed _) -> pure () + threadDelay 500000 + (_, relayedAfter) <- connsFor (cTargetSw c) (cDialerId c) + length relayedAfter `shouldBe` 1 + + it "does not throw when the relay connection is already dead" $ + withCircuitTrio fastConfig $ \c -> do + relayConn <- targetRelayConn c + switchClose (cRelaySw c) + threadDelay 300000 + result <- timeout 30000000 $ + upgradeRelayedConnection (cTargetSw c) (ncDCUtRUpgrade fastConfig) relayConn + case result of + Nothing -> expectationFailure "upgrade attempt never settled" + Just DCUtRSuccess -> expectationFailure "upgrade unexpectedly reported success" + Just (DCUtRFailed _) -> pure () + + describe "simultaneous-connect roles" $ do + it "pairs a role-reversed dialler with an ordinary dialler" $ do + -- specs/relay/DCUtR: "For the purpose of all protocols run on top + -- of this TCP connection, A is assumed to be the client and B the + -- server." Both peers call connect(); the roles come from + -- doUpgradeAsClient, which upgradeAs turns into the security and + -- muxer sides. Driven over a memory pair because a real + -- simultaneous open cannot be produced in-process. + (_pidA, kpA) <- mkTestIdentity + (_pidB, kpB) <- mkTestIdentity + (rawA, rawB) <- mkMemoryStreamPair + rawConnA <- mkMockRawConn rawA localAddr remoteAddr + rawConnB <- mkMockRawConn rawB remoteAddr localAddr + (connA, connB) <- + concurrently + (upgradeAs Outbound kpA rawConnA) -- peer A: the client + (upgradeAs Inbound kpB rawConnB) -- peer B: the server + connDirection connA `shouldBe` Outbound + connDirection connB `shouldBe` Inbound + -- The muxer took opposite roles, so stream ids do not collide + (streamA, streamB) <- + concurrently + (muxOpenStream (connSession connA)) + (muxAcceptStream (connSession connB)) + streamWrite streamA "punch" + readExact streamB 5 `shouldReturn` "punch" + muxClose (connSession connA) + muxClose (connSession connB) + + it "deadlocks when both ends take the server role, which is why the dial is bounded" $ do + -- A simultaneous connect that fails to collide lands on the peer's + -- ordinary listener, leaving both ends running the responder side. + -- Nothing completes, which is what ducDirectDialTimeoutMicros + -- exists to bound (go-libp2p: defaultDirectDialTimeout). + (_pidA, kpA) <- mkTestIdentity + (_pidB, kpB) <- mkTestIdentity + (rawA, rawB) <- mkMemoryStreamPair + rawConnA <- mkMockRawConn rawA localAddr remoteAddr + rawConnB <- mkMockRawConn rawB remoteAddr localAddr + settled <- timeout 500000 $ + concurrently (upgradeAs Inbound kpA rawConnA) (upgradeAs Inbound kpB rawConnB) + -- Connection has no Show instance, so assert on the shape + isNothing settled `shouldBe` True + +-- | Short timeouts so a punch that cannot succeed in-process settles +-- quickly instead of burning the default 10s per dial. +fastConfig :: NATConfig +fastConfig = defaultNATConfig + { ncDCUtRUpgrade = defaultDCUtRUpgradeConfig + { ducMaxAttempts = 1 + , ducDirectDialTimeoutMicros = 1000000 + , ducStreamTimeoutMicros = 5000000 + , ducRelayCloseGraceMicros = 200000 + } + } + +-- | Overwrite the listen addresses recorded for a peer. +seedListenAddrs :: Switch -> PeerId -> [Multiaddr] -> IO () +seedListenAddrs sw pid addrs = atomically $ + modifyTVar' (swPeerStore sw) (Map.insert pid info) + where + info = IdentifyInfo + { idProtocolVersion = Nothing + , idAgentVersion = Nothing + , idPublicKey = Nothing + , idListenAddrs = map toBytes addrs + , idObservedAddr = Nothing + , idProtocols = [] + , idSignedPeerRecord = Nothing + } + +-- | The target's relayed connection back to the dialer. +targetRelayConn :: Circuit -> IO Connection +targetRelayConn c = do + (_, relayed) <- connsFor (cTargetSw c) (cDialerId c) + case relayed of + (x : _) -> pure x + [] -> fail "target has no relayed connection to the dialer" + +-- | The listen addresses of a switch, excluding relayed ones: only a +-- direct address is a hole punch target. +switchListenAddrsOf :: Switch -> IO [Multiaddr] +switchListenAddrsOf sw = filter (not . isRelayedAddr) <$> switchListenAddrs sw + +-- | Addresses for the in-memory upgrade pairs. +localAddr :: Multiaddr +localAddr = Multiaddr [IP4 0x7f000001, TCP 1111] + +remoteAddr :: Multiaddr +remoteAddr = Multiaddr [IP4 0x7f000001, TCP 2222] + +-- | A mock RawConnection over a memory stream. +mkMockRawConn :: StreamIO -> Multiaddr -> Multiaddr -> IO RawConnection +mkMockRawConn sio local remote = pure RawConnection + { rcStreamIO = sio + , rcLocalAddr = local + , rcRemoteAddr = remote + , rcClose = pure () + } diff --git a/test/LibP2P/NAT/RegistrationSpec.hs b/test/LibP2P/NAT/RegistrationSpec.hs index 4bbfa16..20a48c0 100644 --- a/test/LibP2P/NAT/RegistrationSpec.hs +++ b/test/LibP2P/NAT/RegistrationSpec.hs @@ -6,13 +6,11 @@ module LibP2P.NAT.RegistrationSpec (spec) where import Control.Concurrent (threadDelay) -import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) -import qualified Data.ByteString as BS import Data.Maybe (isJust) import LibP2P.Crypto.Ed25519 (generateKeyPair) import LibP2P.Crypto.Key (KeyPair, publicKey) import LibP2P.Crypto.PeerId (PeerId (..), fromPublicKey, peerIdBytes) -import LibP2P.Multiaddr (Multiaddr (..), fromBytes, toBytes) +import LibP2P.Multiaddr (Multiaddr (..), toBytes) import LibP2P.Multiaddr.Protocol (Protocol (..)) import LibP2P.MultistreamSelect.Negotiation ( NegotiationResult (..) @@ -39,12 +37,11 @@ import LibP2P.NAT.DCUtR.Message , readHolePunchMessage , writeHolePunchMessage ) -import LibP2P.NAT.Relay.Client (connectViaRelay, makeReservation) +import LibP2P.NAT.Relay.Client (makeReservation) import LibP2P.NAT.Relay.Message ( HopMessage (..) , RelayPeer (..) , RelayStatus (..) - , Reservation (..) , StopMessage (..) , StopMessageType (..) , hopProtocolId diff --git a/test/LibP2P/NAT/Relay/ReservationLifecycleSpec.hs b/test/LibP2P/NAT/Relay/ReservationLifecycleSpec.hs index d35c43f..e954a19 100644 --- a/test/LibP2P/NAT/Relay/ReservationLifecycleSpec.hs +++ b/test/LibP2P/NAT/Relay/ReservationLifecycleSpec.hs @@ -27,6 +27,7 @@ import LibP2P.MultistreamSelect.Negotiation ) import LibP2P.NAT ( NATConfig (..) + , defaultDCUtRUpgradeConfig , defaultNATConfig , defaultReservationRefreshConfig , registerNATHandlers @@ -141,6 +142,7 @@ spec = describe "relay reservation lifecycle" $ do let config = NATConfig { ncRelayConfig = defaultRelayConfig { rcMaxReservations = 1 } , ncReservationRefresh = defaultReservationRefreshConfig + , ncDCUtRUpgrade = defaultDCUtRUpgradeConfig } (swR, pidR, addrR, relayState) <- newRelaySwitch config (swC1, pidC1, _) <- newListeningSwitch diff --git a/test/LibP2P/NAT/Relay/ReservationRefreshSpec.hs b/test/LibP2P/NAT/Relay/ReservationRefreshSpec.hs index 706d802..127e171 100644 --- a/test/LibP2P/NAT/Relay/ReservationRefreshSpec.hs +++ b/test/LibP2P/NAT/Relay/ReservationRefreshSpec.hs @@ -28,6 +28,7 @@ import LibP2P.Multiaddr.Protocol (Protocol (..)) import LibP2P.MultistreamSelect.Negotiation (StreamIO (..)) import LibP2P.NAT ( NATConfig (..) + , defaultDCUtRUpgradeConfig , ReservationRefreshConfig (..) , defaultNATConfig , registerNATHandlers @@ -119,6 +120,7 @@ spec = describe "circuit relay reservation refresh" $ do natConfig = NATConfig { ncRelayConfig = relayConfig , ncReservationRefresh = fastRefreshConfig + , ncDCUtRUpgrade = defaultDCUtRUpgradeConfig } -- Relay R (pidR, kpR) <- mkTestIdentity @@ -161,7 +163,8 @@ spec = describe "circuit relay reservation refresh" $ do switchClose swR it "withdraws the circuit listen address when the connection to the relay is lost" $ do - let natConfig = defaultNATConfig { ncReservationRefresh = fastRefreshConfig } + let natConfig = defaultNATConfig { ncReservationRefresh = fastRefreshConfig + , ncDCUtRUpgrade = defaultDCUtRUpgradeConfig } -- Relay R (pidR, kpR) <- mkTestIdentity swR <- newSwitch pidR kpR @@ -197,7 +200,8 @@ spec = describe "circuit relay reservation refresh" $ do -- The reservation is bound to the relay peer, not to the connection -- the RESERVE went out on, matching go-libp2p's relay_finder, which -- drops a reservation only once Connectedness reaches NotConnected. - let natConfig = defaultNATConfig { ncReservationRefresh = fastRefreshConfig } + let natConfig = defaultNATConfig { ncReservationRefresh = fastRefreshConfig + , ncDCUtRUpgrade = defaultDCUtRUpgradeConfig } (pidR, kpR) <- mkTestIdentity swR <- newSwitch pidR kpR addTransport swR =<< newTCPTransport