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:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user