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:
ab
2026-09-22 18:33:52 +03:00
parent b4f3e57c8d
commit c724981bfd
23 changed files with 1110 additions and 21 deletions
@@ -247,6 +247,7 @@ impl tsunagi::ipc::ReportSource for Control {
&self,
name: String,
secret: String,
_broadcast: Option<bool>,
) -> BoxFuture<'_, Result<tsunagi::ipc::JoinedReport, String>> {
Box::pin(async move {
let name = tsunagi::identity::NetworkName::new(&name).map_err(|e| e.to_string())?;
+117
View File
@@ -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;
}
}