ipv6: take a global source when the outgoing interface is link-local only - #1256
adamgeorge309 wants to merge 1 commit into
Conversation
…al only When an upper layer leaves the source address unspecified, Ipv6::fragmentPostRouting() fills it in after routing, from the preferred address of the outgoing interface only. An Ipv6TunnelInterface has no Media Access Control (MAC) address, so its only address is the link-local fe80:: formed from an all-zero interface token. A datagram a node originated into such a tunnel left with source fe80:: even for a global destination, and even when the node had a global address (the tunnel's own entry point). The tunnel exit forwarded it to another link, where a reply addressed to fe80:: cannot leave the receiver's link. RFC 4291 Section 2.5.6 confines a link-local address to its link. RFC 6724 Section 4 only RECOMMENDS the outgoing interface's addresses as the candidate set for a global destination, and its Rule 2 prefers a source whose scope is at least the destination's over one of smaller scope. So for a global destination whose outgoing interface has only a link-local preferred address, the source is now the global preferred address of the first other interface, in interface-table order, that is up. A tentative address does not count (RFC 4862 Section 5.4), unless its interface uses Optimistic Duplicate Address Detection (RFC 4429). Linux also widens the set: ipv6_dev_get_saddr() (net/ipv6/addrconf.c) takes the candidates from every device when the destination is a unicast address beyond link-local scope. Multicast, link-local and site-local destinations keep the outgoing interface's address, which meets the same-link and same-site MUSTs of RFC 6724 Section 4. tests/module/IPv6_tunnel.test reproduces it: host A sends User Datagram Protocol (UDP) datagrams to host H through a tunnel to router B, with no source bound. Before this commit H received them from source address fe80::, now from 2001:db8:1:0:8aa:ff:fe00:1, and the test checks it. Change: src.networklayer.ipv6 | behavior.change.fix | test whatsnew
There was a problem hiding this comment.
Devin Review found 3 potential issues.
1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)
| const Ipv6Address& candidate = candidateData->getPreferredAddress(); | ||
| bool tentative = candidateData->isTentativeAddress(candidate) && !candidateData->isOptimisticDad(); | ||
| if (candidate.getScope() == Ipv6Address::GLOBAL && !tentative) { |
There was a problem hiding this comment.
🔴 Tentative preference hides usable global source
When an alternative interface prefers a tentative global address, getPreferredAddress() hides its assigned global addresses. The fallback skips that interface, leaving off-link tunnel packets sourced from fe80:: and replies unroutable.
Learn more
An interface can have several IPv6 addresses. choosePreferredAddress selects a tentative global address before a non-tentative link-local address, but an existing non-tentative global address can also be present with different preference ordering. If getPreferredAddress() returns a tentative global address, the new fallback rejects it without scanning the interface's remaining addresses. When there is no other qualifying interface, the datagram retains the outgoing tunnel's link-local source even though a usable global source exists.
Example: Let eth0 hold an assigned global address with a shorter lifetime and a tentative global address with a longer lifetime, while tun0 holds only fe80::. A source-less UDP datagram sent to a global destination through tun0 rejects eth0's preferred tentative address and leaves with fe80::, rather than eth0's assigned address.
Recommended fix: Search each candidate interface's address list for an assigned, unexpired global unicast address before falling back to its tentative preferred address. Add a tunnel test with both tentative and assigned global addresses on the same candidate interface.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const Ipv6Address& candidate = candidateData->getPreferredAddress(); | ||
| bool tentative = candidateData->isTentativeAddress(candidate) && !candidateData->isOptimisticDad(); | ||
| if (candidate.getScope() == Ipv6Address::GLOBAL && !tentative) { | ||
| srcAddr = candidate; |
There was a problem hiding this comment.
🔴 Expired global address becomes tunnel source
After a candidate's valid lifetime expires, getPreferredAddress() can still return its cached global address. The fallback sends tunnel packets from that expired address, so peers reply to an invalid source.
Learn more
An IPv6 address assigned with a finite valid lifetime remains cached in preferredAddr until choosePreferredAddress runs again. getPreferredAddress does not check lifetime, and the fallback adds a new path that can use the stale address as the source on a different outgoing interface. When the expired address is no longer valid, a packet sent from it cannot reliably receive a reply.
Example: eth0 has a global address valid until simulation time 10s; tun0 has only fe80::. If no interface address mutation happens between 10s and a tunnel send at 11s, getPreferredAddress() still returns eth0's expired global address and the inner packet uses it.
Recommended fix: Validate candidate address expiry against simTime() when selecting a cross-interface source. Recompute the preferred address, or inspect address records with their valid lifetimes, before accepting one; cover expiry without intervening interface updates in a test.
Was this helpful? React with 👍 or 👎 to provide feedback.
| for (int i = 0; i < ift->getNumInterfaces(); i++) { | ||
| const NetworkInterface *candidateIE = ift->getInterface(i); | ||
| auto candidateData = candidateIE->findProtocolData<Ipv6InterfaceData>(); | ||
| if (candidateIE == ie || !candidateIE->isUp() || candidateData == nullptr) |
There was a problem hiding this comment.
🟡 Disconnected interface supplies tunnel source
When an alternative interface loses carrier but remains up, isUp() still accepts its global address. The fallback can choose it before a connected interface, sending replies toward a disconnected link.
Learn more
Interface administrative state and carrier state are separate. NetworkInterface exposes both isUp() and hasCarrier(), and a carrier loss does not clear its addresses. The fallback chooses the first candidate in interface order, so a carrierless interface can displace a connected one with a working global address.
Example: A host has tun0 with fe80::, eth0 with a global address but no carrier, and eth1 with a reachable global address. A packet sent through tun0 chooses eth0's address because eth0 remains administratively up. Replies routed to eth0's disconnected network fail rather than reaching eth1.
Recommended fix: Require both isUp() and hasCarrier() for cross-interface source candidates, consistent with interface availability checks in Ipv6RoutingTable. Add a test with a carrierless first candidate and a connected second candidate.
| if (candidateIE == ie || !candidateIE->isUp() || candidateData == nullptr) | |
| if (candidateIE == ie || !candidateIE->isUp() || !candidateIE->hasCarrier() || candidateData == nullptr) |
Was this helpful? React with 👍 or 👎 to provide feedback.
A datagram that a node originates, with no source address bound, through an interface that has only a link-local address, such as an
Ipv6TunnelInterface, now leaves with a global address of the node as its source when the destination is global, instead of the interface's link-localfe80::.Closes #1255
The problem
Ipv6::fragmentPostRouting()fills in an unspecified source after routing, from the preferred address of the outgoing interface only (Ipv6.cc:1005-1009on master). A tunnel interface has no Media Access Control (MAC) address, so its only address isfe80::, formed from an all-zero interface token. The inner datagram therefore carriedfe80::to a global destination, although the node's own global address is the tunnel's entry point. The tunnel exit forwarded it, and a reply tofe80::cannot leave the receiver's link.RFC 4291 Section 2.5.6: "Routers must not forward any packets with Link-Local source or destination addresses to other links." RFC 6724 Section 4 only RECOMMENDS the outgoing interface's addresses as the candidate set; for multicast and link-local destinations it restricts the set to the outgoing link, and for site-local destinations to the outgoing interface's site (both MUSTs). Its Rule 2 prefers a source whose scope is at least the destination's over one of smaller scope.
The fix
When the outgoing interface's preferred address is link-local and the destination is a global unicast address, the source is the preferred global address of the first other interface, in interface-table order, that is up and whose address is not tentative, unless that interface uses Optimistic Duplicate Address Detection (RFC 4429). A tentative address is not assigned yet (RFC 4862 Section 5.4), and taking one from another interface would bypass the queue that holds a datagram until Duplicate Address Detection (DAD) completes, because that queue checks tentativeness on the outgoing interface. Unchanged: an outgoing interface with a routable address; multicast, link-local and site-local destinations, which RFC 6724 Section 4 confines to the outgoing link or site; and a node with no global address on another interface.
Linux widens the candidate set the same way:
ipv6_dev_get_saddr()innet/ipv6/addrconf.cuses only the outgoing device's addresses for a multicast or link-local destination (or withuse_oif_addrs_only), and the addresses of every device (in the same Virtual Routing and Forwarding (VRF) domain, which INET does not model) otherwise, ranked by the RFC 6724 rules; it skips tentative, non-optimistic addresses. This fix widens the set only as a fallback and takes the first match instead of ranking. For IPv4, Linux'sinet_select_addr()also falls back to other devices when the outgoing one has no suitable address. INET's ownIpv4::fragmentPostRouting()uses the outgoing interface's address only, so it offers no model to follow.Architectural surface
Packet content: the source address of a datagram that the node originates with no bound source, through an interface whose preferred address is link-local, to a global unicast destination. No interface, parameter, packet format, signal or feature descriptor changes. No sealed path is touched, and no
AV-*orNV-*row is needed.Verification
tests/module/IPv6_tunnel.test(host A sends User Datagram Protocol (UDP) datagrams to host H through a tunnel to router B, no source bound) now also checks the source. Release build, master 4eb3bb4 with only the new check added:cd tests/module && inet_run_module_tests -m release --no-build -l ERROR -f 'IPv6_tunnel$|IPv6_tunnel\.'reportsIPv6_tunnel.test FAIL, and H logs:After the commit:
Commands, release build (
make MODE=release), run on master and on this branch:tcp_*,ConvolutionalCoder12/34,EtherHost_lifecycle,ExternalProcess_3,Ieee80211BitDomain/SymbolDomain,Ieee8021d-Rstp/Stp,IPv6_packet_too_big,MIPv6_tcp_handover,PacketGate_1,UDPSocket_1).tests/protocol/ipv6): 26 of 27 pass on both;Rfc8200OverlappingFragmentsfails on master too.inet_run_module_tests -m release --no-build -l ERROR -f 'IPv6_|MIPv6_|PMIPv6|NetfilterHooks_Ipv6|IPsec_Ipv6': 39 tests, 37 pass;IPv6_packet_too_bigandMIPv6_tcp_handoverfail on master too (two of the 46).make MODE=debug):inet_run_module_tests -m debug --no-build -l ERROR -f 'IPv6_tunnel': passes.doc/project/enforcement/check-architecture.sh src/inet/networklayer/ipv6: PASS.Not addressed here
A router forwards a datagram with a link-local source to another link:
Ipv6::routePacket()checks only the destination's scope, while RFC 4007 Section 9 requires a discard and an Internet Control Message Protocol version 6 (ICMPv6) Destination Unreachable, code 2 ("beyond scope of source address"). That is a separate defect of every router, not only of tunnel exits.Landing order
git merge-treeagainst the open pull requests that touchIpv6.cc(#1230, #1232, #1236, #1247) and against #1252 (same tunnel path):Ipv6.ccmerges cleanly with each. The only conflict isWHATSNEW, where #1230, #1232, #1236 and #1247 each add backward incompatible item 17, as this one does. Any order works; the pull request that lands later renumbers itsWHATSNEWitem.