Skip to content

ipv6: take a global source when the outgoing interface is link-local only - #1256

Open
adamgeorge309 wants to merge 1 commit into
masterfrom
topic/gy/ipv6-tunnel-inner-source
Open

adamgeorge309 wants to merge 1 commit into
masterfrom
topic/gy/ipv6-tunnel-inner-source

Conversation

@adamgeorge309

@adamgeorge309 adamgeorge309 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

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-local fe80::.

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-1009 on master). A tunnel interface has no Media Access Control (MAC) address, so its only address is fe80::, formed from an all-zero interface token. The inner datagram therefore carried fe80:: 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 to fe80:: 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() in net/ipv6/addrconf.c uses only the outgoing device's addresses for a multicast or link-local destination (or with use_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's inet_select_addr() also falls back to other devices when the outgoing one has no suitable address. INET's own Ipv4::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-* or NV-* 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\.' reports IPv6_tunnel.test FAIL, and H logs:

(UdpSink)Ipv6TunnelTestNetwork.H.app[0]: Received packet: (inet::Packet)UdpBasicAppData-0 (100 B) (inet::SequenceChunk) length = 174 B (100 bytes) fe80:::1025 --> 2001:db8:2:0:8aa:ff:fe00:4:5000 TTL=29 TOS=0 DSCP=0 on ifID=101

After the commit:

(UdpSink)Ipv6TunnelTestNetwork.H.app[0]: Received packet: (inet::Packet)UdpBasicAppData-0 (100 B) (inet::SequenceChunk) length = 174 B (100 bytes) 2001:db8:1:0:8aa:ff:fe00:1:1025 --> 2001:db8:2:0:8aa:ff:fe00:4:5000 TTL=29 TOS=0 DSCP=0 on ifID=101

Commands, release build (make MODE=release), run on master and on this branch:

cd tests/fingerprint && ./fingerprinttest -s -F tyf
cd tests/module && inet_run_module_tests -m release --no-build -l ERROR
cd tests/protocol/ipv6 && inet_run_protocol_tests -m release
  • Fingerprints, identical on both: 1774 rows, 0 failures, 62 errors. The 62 errors are rows whose optional features are disabled in this build (Voice over IP (VoIP) streaming, the lightweight IP (lwIP) Transmission Control Protocol (TCP) stack, OpenSceneGraph (OSG) visualization, Z3 gate scheduling). No row moves.
  • Module tests: 346 tests, 300 pass; the same 46 fail on unmodified master, so they are pre-existing (34 tcp_*, ConvolutionalCoder12/34, EtherHost_lifecycle, ExternalProcess_3, Ieee80211BitDomain/SymbolDomain, Ieee8021d-Rstp/Stp, IPv6_packet_too_big, MIPv6_tcp_handover, PacketGate_1, UDPSocket_1).
  • Protocol tests (tests/protocol/ipv6): 26 of 27 pass on both; Rfc8200OverlappingFragments fails on master too.
  • Focused, release: 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_big and MIPv6_tcp_handover fail on master too (two of the 46).
  • Focused, debug build (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-tree against the open pull requests that touch Ipv6.cc (#1230, #1232, #1236, #1247) and against #1252 (same tunnel path): Ipv6.cc merges cleanly with each. The only conflict is WHATSNEW, 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 its WHATSNEW item.


Devin Review

…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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +1027 to +1029
const Ipv6Address& candidate = candidateData->getPreferredAddress();
bool tentative = candidateData->isTentativeAddress(candidate) && !candidateData->isOptimisticDad();
if (candidate.getScope() == Ipv6Address::GLOBAL && !tentative) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1027 to +1030
const Ipv6Address& candidate = candidateData->getPreferredAddress();
bool tentative = candidateData->isTentativeAddress(candidate) && !candidateData->isOptimisticDad();
if (candidate.getScope() == Ipv6Address::GLOBAL && !tentative) {
srcAddr = candidate;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Devin Review


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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
if (candidateIE == ie || !candidateIE->isUp() || candidateData == nullptr)
if (candidateIE == ie || !candidateIE->isUp() || !candidateIE->hasCarrier() || candidateData == nullptr)

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ipv6: a datagram originated into a tunnel interface carries its link-local source fe80:: off-link

1 participant