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
+98 -4
View File
@@ -116,6 +116,13 @@ struct NetworkArgs {
/// so it can be passed on.
#[derive(Debug, Args)]
struct JoinArgs {
/// Enable LAN UDP broadcast relay for this network (default for new networks).
#[arg(long, conflicts_with = "no_broadcast")]
broadcast: bool,
/// Disable LAN UDP broadcast relay for this network, including after restart.
#[arg(long, conflicts_with = "broadcast")]
no_broadcast: bool,
#[command(flatten)]
paths: PathArgs,
@@ -138,6 +145,14 @@ struct JoinArgs {
#[derive(Debug, Subcommand)]
enum NetworkAction {
/// Shows or changes this network's local LAN broadcast participation.
Broadcast {
/// Network id or unique id prefix.
network: String,
/// New choice; omit to show the stored choice.
#[arg(value_enum)]
choice: Option<BroadcastChoice>,
},
/// Makes a network, or joins one, in the agent that is already running.
///
/// The state directory belongs to one live agent, so this is how a
@@ -196,6 +211,12 @@ enum NetworkAction {
},
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum BroadcastChoice {
On,
Off,
}
#[derive(Debug, Subcommand)]
enum SecretAction {
/// Prints a fresh random secret, for a network that does not exist yet.
@@ -1326,6 +1347,7 @@ impl tsunagi::ipc::ReportSource for AgentControl {
&self,
name: String,
secret: String,
broadcast: Option<bool>,
) -> tsunagi::BoxFuture<'_, Result<tsunagi::ipc::JoinedReport, String>> {
Box::pin(async move {
let name = NetworkName::new(&name).map_err(|err| err.to_string())?;
@@ -1349,7 +1371,7 @@ impl tsunagi::ipc::ReportSource for AgentControl {
let network_id = self
.agent
.join_network(&name, &secret)
.join_network_with_broadcast(&name, &secret, broadcast)
.await
.map_err(|err| err.to_string())?;
Ok(tsunagi::ipc::JoinedReport {
@@ -1361,6 +1383,23 @@ impl tsunagi::ipc::ReportSource for AgentControl {
})
}
fn set_broadcast(
&self,
network_id: String,
enabled: bool,
) -> tsunagi::BoxFuture<'_, Result<bool, String>> {
Box::pin(async move {
let id = network_id
.parse()
.map_err(|err| format!("invalid network id: {err}"))?;
self.agent
.set_broadcast(id, enabled)
.await
.map_err(|err| err.to_string())?;
Ok(enabled)
})
}
fn set_active(
&self,
network_id: String,
@@ -1506,6 +1545,9 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Er
let socket = control_socket(&paths, args.control_socket.as_ref());
match args.action {
None => show_networks(&paths, &socket).await,
Some(NetworkAction::Broadcast { network, choice }) => {
broadcast_command(&paths, &socket, &network, choice).await
}
Some(NetworkAction::Join(args)) => join_command(args).await,
Some(NetworkAction::Stop { network }) => set_active(&paths, &socket, &network, false).await,
Some(NetworkAction::Start { network }) => set_active(&paths, &socket, &network, true).await,
@@ -1549,7 +1591,14 @@ async fn join_command(args: JoinArgs) -> Result<(), Box<dyn std::error::Error>>
// configured and the difference is what the user needs to see.
let network_id = tsunagi::identity::NetworkKeys::derive(&name, &secret).network_id();
let (standing, _) = network_context(&stored_networks(&paths), &name, network_id);
join_network(&paths, &socket, &name, secret, origin, standing).await
let broadcast = if args.no_broadcast {
Some(false)
} else if args.broadcast {
Some(true)
} else {
None
};
join_network(&paths, &socket, &name, secret, origin, standing, broadcast).await
}
/// Joins a network: into the running agent if there is one.
@@ -1560,13 +1609,19 @@ async fn join_network(
secret: NetworkSecret,
origin: SecretOrigin,
standing: NetworkStanding,
broadcast: Option<bool>,
) -> Result<(), Box<dyn std::error::Error>> {
// The running agent, because a second `up` cannot have the directory
// and because this way the network starts at once instead of at the
// next restart.
if tsunagi::ipc::is_serving(socket).await {
let report =
tsunagi::ipc::join_network(socket, name.as_str(), secret.encode().as_str()).await?;
let report = tsunagi::ipc::join_network_with_broadcast(
socket,
name.as_str(),
secret.encode().as_str(),
broadcast,
)
.await?;
// The id in full either way: it is what every other command takes,
// and the shortened form in a report is for reading, not copying.
match standing {
@@ -1614,6 +1669,9 @@ async fn join_network(
storage
.upsert_network(keys.network_id(), name.clone(), secret.clone(), true)
.await?;
if let Some(enabled) = broadcast {
storage.set_broadcast(keys.network_id(), enabled).await?;
}
storage.release_ownership_lock();
match standing {
@@ -1747,6 +1805,33 @@ async fn show_networks(
/// Deliberately not a signed anything: stopping is this device being away,
/// which is an ordinary condition the others already handle, and the whole
/// point is that everything is still here when it comes back.
async fn broadcast_command(
paths: &StoragePaths,
socket: &std::path::Path,
wanted: &str,
choice: Option<BroadcastChoice>,
) -> Result<(), Box<dyn std::error::Error>> {
let configured = stored_networks(paths);
let network = resolve_network(&configured, wanted)?;
let mut enabled = network.broadcast;
if let Some(choice) = choice {
enabled = matches!(choice, BroadcastChoice::On);
if tsunagi::ipc::is_serving(socket).await {
tsunagi::ipc::set_broadcast(socket, &network.network_id.to_string(), enabled).await?;
} else {
let storage = tsunagi::storage::Storage::open(paths)?;
storage.set_broadcast(network.network_id, enabled).await?;
}
}
println!(
"broadcast {} for `{}` ({})",
if enabled { "on" } else { "off" },
network.name,
network.network_id
);
Ok(())
}
async fn set_active(
paths: &StoragePaths,
socket: &std::path::Path,
@@ -2555,6 +2640,11 @@ fn network_section(
// Only when something has: a relay that has carried nothing is not
// worth a line, and one that has is worth knowing about — it is
// somebody else's traffic on this device's uplink.
section.push(Row::new(
Health::Info,
"broadcast",
if network.broadcast { "on" } else { "off" },
));
let relayed = network.relay_forwarded + network.relay_sent_via + network.relay_received_via;
if relayed > 0 {
section.push(Row::new(
@@ -3577,6 +3667,7 @@ async fn build_report(
});
NetworkReport {
broadcast: network.broadcast,
name: network.name.to_string(),
network_id: network.network_id.to_string(),
active: matches!(network.state, tsunagi::agent::NetworkState::Active),
@@ -3884,6 +3975,7 @@ mod status_tests {
/// The situation that prompted this: one peer left and came back.
fn network_after_a_peer_returned() -> NetworkReport {
NetworkReport {
broadcast: true,
name: "LAB".into(),
network_id: "xa7gyz".into(),
active: true,
@@ -4205,6 +4297,7 @@ mod network_tests {
NetworkSecret::from_bytes(&[secret.as_bytes(), &[0u8; 32]].concat()[..32]).unwrap();
let keys = NetworkKeys::derive(&name, &secret);
StoredNetwork {
broadcast: true,
network_id: keys.network_id(),
name,
secret,
@@ -4373,6 +4466,7 @@ mod network_context_tests {
let secret = NetworkSecret::from_bytes([seed; 32]).unwrap();
let keys = NetworkKeys::derive(&name, &secret);
StoredNetwork {
broadcast: true,
network_id: keys.network_id(),
name,
secret,
+62
View File
@@ -250,3 +250,65 @@ fn up_is_the_agent_and_takes_no_network_at_all() {
);
}
}
#[test]
fn broadcast_choice_is_per_network_live_persistent_and_preserved_by_rejoin() {
let agent = start_bare(0);
for args in [
vec!["join", "-n", "broadcast-default"],
vec!["join", "-n", "broadcast-off", "--no-broadcast"],
] {
let result = agent.run(&args);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
}
let read = || {
tsunagi::storage::StateStore::open(agent.dir.path().join("state/state.sqlite"))
.unwrap()
.list_networks()
.unwrap()
};
let networks = read();
let enabled = networks
.iter()
.find(|n| n.name.as_str() == "broadcast-default")
.unwrap();
let disabled = networks
.iter()
.find(|n| n.name.as_str() == "broadcast-off")
.unwrap();
assert!(enabled.broadcast);
assert!(!disabled.broadcast);
assert!(agent.run(&["join", "-n", "broadcast-off"]).status.success());
assert!(
!read()
.iter()
.find(|n| n.name.as_str() == "broadcast-off")
.unwrap()
.broadcast
);
let id = disabled.network_id.to_string();
let change = agent.run(&["network", "broadcast", &id, "on"]);
assert!(
change.status.success(),
"{}",
String::from_utf8_lossy(&change.stderr)
);
assert!(read().iter().all(|n| n.broadcast));
let change = agent.run(&["join", "-n", "broadcast-off", "--no-broadcast"]);
assert!(
change.status.success(),
"{}",
String::from_utf8_lossy(&change.stderr)
);
let status = agent.run(&["network", "broadcast", &id]);
assert!(String::from_utf8_lossy(&status.stdout).contains("broadcast off"));
assert!(
!agent
.run(&["join", "-n", "conflict", "--broadcast", "--no-broadcast"])
.status
.success()
);
}