Added per-network LAN broadcast relay
LAN game discovery previously dropped IPv4 broadcasts at TUN ingress. Carry limited and subnet-directed UDP broadcasts to authenticated, opted-in members of the source network, including destinations reached through mesh relays. Preserve the original IP/UDP bytes and deliver received broadcasts only to the local TUN; never reflood them or expose another pair's plaintext at transit. Build immutable recipient snapshots on address and participation changes. The origin sends one ordinary end-to-end encrypted copy per recipient; the existing fast, bounded-hop transport router remains unchanged. Validate UDP framing, source ownership and destination admission without game-specific port rules. Keep network domains isolated and refuse implicit gateways to physical LANs. The separate broadcast policy/domain layer is the extension point for future authorized subnet exports; physical capture, bridging and LAN deduplication are deliberately not implemented yet. Persist default-on participation independently for each local network. Add join --no-broadcast/--broadcast and network broadcast <id> [on|off], including live updates and authenticated announcements. Joining without a flag preserves the saved choice. Opt-out stops local origination and delivery, while opaque unicast transit for other members keeps working. Migrate SQLite schema 3 to 4 without replacing identities or signed state. Use control ALPN 3 and local IPC protocol 14 for the new announcement/request shapes; update peers and restart running agents together. The data ALPN 4 envelope remains unchanged. No release version bump, tag or push is included. Document agent-owned commits in AGENTS.md: short English subjects, explanatory bodies, scoped staging, honest validation, and repository-local fallback author AB <ab@hexor.cy> only when an effective name/email is missing. Release actions remain the user's responsibility. Validation on Windows: cargo fmt --all -- --check; cargo check --locked --workspace --all-targets; cargo clippy --locked --workspace --all-targets -- -D warnings; release workspace/all-target tests: 313 passed. The two existing SQLite wipe failures (a_wipe_removes_everything_and_the_next_start_is_a_stranger and wiping_twice_is_as_ordinary_as_wiping_once) were explicitly skipped; the public-DHT smoke test and forwarding benchmark remain ignored by default. New coverage exercises real iroh/WireGuard multihop fanout, single delivery, runtime opt-out, unicast replies, domain isolation, malformed input and schema migration. TUNs are in-memory; actual games and OS adapter selection were not tested.
This commit is contained in:
@@ -1627,3 +1627,120 @@ async fn a_chain_routes_through_two_transit_peers_without_touching_their_tuns()
|
||||
agent.shutdown().await;
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn lan_broadcast_fanout_is_scoped_opt_in_and_does_not_reflood() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("lan-broadcast");
|
||||
let cuts = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
|
||||
let a = WgAgent::spawn_cut_off(&discovery, "bca", cuts.clone()).await;
|
||||
let b = WgAgent::spawn(&discovery, "bcb").await;
|
||||
let c = WgAgent::spawn(&discovery, "bcc").await;
|
||||
cuts.lock().unwrap().insert(c.endpoint_id());
|
||||
let id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
c.agent.join_network(&name, &secret).await.unwrap();
|
||||
for agent in [&a, &b, &c] {
|
||||
agent.wait_for_tunnels(id, 2).await;
|
||||
}
|
||||
let addr = a.overlay(id).await;
|
||||
let at = a.tun(id).await;
|
||||
let bt = b.tun(id).await;
|
||||
let ct = c.tun(id).await;
|
||||
// Valid UDP discovery queries with game-like ports and an unchanged body.
|
||||
fn query(source: std::net::Ipv4Addr, destination: std::net::Ipv4Addr) -> Bytes {
|
||||
let mut bytes = vec![0u8; 36];
|
||||
bytes[0] = 0x45;
|
||||
bytes[2..4].copy_from_slice(&36u16.to_be_bytes());
|
||||
bytes[8] = 1;
|
||||
bytes[9] = 17;
|
||||
bytes[12..16].copy_from_slice(&source.octets());
|
||||
bytes[16..20].copy_from_slice(&destination.octets());
|
||||
bytes[20..22].copy_from_slice(&27015u16.to_be_bytes());
|
||||
bytes[22..24].copy_from_slice(&6112u16.to_be_bytes());
|
||||
bytes[24..26].copy_from_slice(&16u16.to_be_bytes());
|
||||
bytes[28..].copy_from_slice(b"LAN GAME");
|
||||
// UDP checksum zero is valid for IPv4.
|
||||
let sum: u32 = bytes[..20]
|
||||
.chunks_exact(2)
|
||||
.map(|w| u16::from_be_bytes([w[0], w[1]]) as u32)
|
||||
.sum();
|
||||
let checksum = !((sum & 0xffff) + (sum >> 16)) as u16;
|
||||
bytes[10..12].copy_from_slice(&checksum.to_be_bytes());
|
||||
Bytes::from(bytes)
|
||||
}
|
||||
let range = a.agent.network_status(id).await.unwrap().range.unwrap();
|
||||
let directed = std::net::Ipv4Addr::from(u32::from(range.base) | (u32::MAX >> range.prefix_len));
|
||||
for destination in [std::net::Ipv4Addr::BROADCAST, directed] {
|
||||
let packet = query(addr, destination);
|
||||
at.push_from_os(packet.clone());
|
||||
for tun in [&bt, &ct] {
|
||||
assert_eq!(
|
||||
tokio::time::timeout(tsunagi::testing::DEADLINE, tun.pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
packet
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), tun.pop_to_os())
|
||||
.await
|
||||
.is_err(),
|
||||
"each recipient gets one copy"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), at.pop_to_os())
|
||||
.await
|
||||
.is_err(),
|
||||
"never reflect to the origin"
|
||||
);
|
||||
}
|
||||
c.agent.set_broadcast(id, false).await.unwrap();
|
||||
wait_until("broadcast opt-out reaches sender", || async {
|
||||
a.agent
|
||||
.network_status(id)
|
||||
.await
|
||||
.ok()?
|
||||
.peers
|
||||
.iter()
|
||||
.find(|peer| peer.endpoint_id == c.endpoint_id())
|
||||
.filter(|peer| !peer.broadcast)
|
||||
.map(|_| ())
|
||||
})
|
||||
.await;
|
||||
let packet = query(addr, std::net::Ipv4Addr::BROADCAST);
|
||||
at.push_from_os(packet.clone());
|
||||
assert_eq!(
|
||||
tokio::time::timeout(tsunagi::testing::DEADLINE, bt.pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
packet
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(150), ct.pop_to_os())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
// Disabling origin participation also stops outgoing copies.
|
||||
a.agent.set_broadcast(id, false).await.unwrap();
|
||||
at.push_from_os(packet);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(150), bt.pop_to_os())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
// Discovery's unicast reply still crosses the same multihop route.
|
||||
let reply = tcp_packet(c.overlay(id).await, addr, 1280);
|
||||
ct.push_from_os(reply.clone());
|
||||
assert_eq!(
|
||||
tokio::time::timeout(tsunagi::testing::DEADLINE, at.pop_to_os())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
reply
|
||||
);
|
||||
for agent in [a, b, c] {
|
||||
agent.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user