Install broadcast route and firewall allowance on the host while broadcast is on

LAN discovery (CS 1.6 and similar) only worked after two manual steps on every
host: route 255.255.255.255 through the TUN, and allow inbound traffic on it.
Without the route the game's limited broadcast leaves via the physical adapter
and never reaches the overlay; without the firewall allowance the server's
unicast reply (from a different address than the one the request went to, so
never matched by conntrack) is dropped before the game's socket. Verified by
hand on Linux: with both in place the server answered A2S_INFO over the overlay.

The system level (not a plugin) now installs exactly those two things for the
interface it owns, and removes them when broadcast is turned off, the network
is left or stopped, or the agent exits.

Design:
- New boundary overlay::hostrules (BroadcastHostRules: idempotent apply/clear,
  with Linux, Windows, Mock and Unsupported implementations). TunFactory gains
  host_rules(); ManagedTunFactory::with_host_rules wires it in the CLI.
- Interface::sync_broadcast_rules reconciles the host against the routing
  table (RoutingTable::broadcast_host_target) and is called after address sync,
  on live `network broadcast on/off`, on network deactivation and by remove().
  Failure is contained: the outcome is kept and logged, never fatal.
- One interface carries several networks but the host has one 255.255.255.255
  route, so the source is the lowest NetworkId among broadcast-enabled networks
  with an address (documented in docs/broadcast.md).
- Linux: the route is added on the existing netlink thread (preferred source =
  overlay address, replace semantics). The firewall rule is `iptables -I INPUT
  -i <if> -p udp -s <overlay range>` tagged with a comment `tsunagi:<if>`;
  it is UDP-only and range-limited on purpose so TCP services stay unexposed.
  iptables is located in fixed system directories, never via PATH; stale tagged
  rules (e.g. from a crash) are replaced. A missing iptables leaves the route in
  place and is reported. rp_filter is not touched.
- Windows: PowerShell (absolute path) New-NetRoute metric 1 and a tagged
  inbound UDP Defender rule scoped to the interface alias and overlay range.
- status shows a `broadcast rules` row (ok, or which half failed and why).
  IPC control protocol bumped 14 -> 15 because OverlayReport gained a field.
- AGENTS.md records the narrow exception to "never touch routing/firewall".

Compatibility: restart the agent after upgrading (control protocol 15). No wire
or schema change.

Checks run (Linux only): cargo fmt --check, cargo clippy --locked --workspace
--all-targets -D warnings, cargo test --locked --workspace --all-targets, all
passing. Tests use a mock host and a scripted iptables; no OS setting is
changed. NOT done: the Windows code was never compiled for or run on Windows
(cross-compiling failed for lack of a C toolchain), only its script building is
unit-tested; the real netlink route and iptables calls were not exercised
against a live kernel in the suite; CS 1.6 was not launched from this change.
This commit is contained in:
Aleksandr Bogomiakov committed 2026-10-01 15:03:43 +01:00
1 parent d2778f0b5a
commit 533921059a
18 files changed
+1834 -23

No files matched your search

+100 -3
View File
@@ -2722,6 +2722,25 @@ fn network_section(
"broadcast",
if network.broadcast { "on" } else { "off" },
));
if network.broadcast
&& let Some(rules) = network
.overlay
.as_ref()
.and_then(|o| o.broadcast_rules.as_ref())
{
section.push(Row::new(
if rules.ok {
Health::Good
} else {
Health::Degraded
},
"broadcast rules",
broadcast_rules_line(
rules,
network.overlay.as_ref().and_then(|o| o.address.as_deref()),
),
));
}
let relayed = network.relay_forwarded + network.relay_sent_via + network.relay_received_via;
if relayed > 0 {
section.push(Row::new(
@@ -4087,6 +4106,14 @@ async fn build_report(
unroutable_sample: overlay
.as_ref()
.and_then(|o| o.counters.unroutable_sample.map(|a| a.to_string())),
broadcast_rules: overlay
.as_ref()
.and_then(|o| o.broadcast_rules.as_ref())
.map(|rules| tsunagi::ipc::HostRulesReport {
ok: rules.is_ok(),
detail: rules.summary(),
source: rules.plan.source.to_string(),
}),
})
} else {
None
@@ -4155,6 +4182,21 @@ async fn build_report(
}
}
/// The status line for the host rules, saying so when they are bound to
/// another network's address.
fn broadcast_rules_line(
rules: &tsunagi::ipc::HostRulesReport,
own_address: Option<&str>,
) -> String {
match own_address {
Some(own) if own != rules.source => format!(
"{} · route source is {}, another network's address",
rules.detail, rules.source
),
_ => rules.detail.clone(),
}
}
/// Resolves when the process is asked to stop.
///
/// Both Ctrl-C and `SIGTERM` are handled, so a service manager stopping the
@@ -4193,16 +4235,22 @@ async fn stop_signal() -> &'static str {
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
use tsunagi::overlay::{ManagedTunFactory, NetlinkProvisioner};
let provisioner = NetlinkProvisioner::new()?;
Ok(Arc::new(ManagedTunFactory::new(Arc::new(provisioner))))
let rules = Arc::new(provisioner.host_rules());
Ok(Arc::new(
ManagedTunFactory::new(Arc::new(provisioner)).with_host_rules(rules),
))
}
/// The Wintun adapter, created and configured by the agent and removed when it
/// exits, the same as the Linux one.
#[cfg(target_os = "windows")]
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
use tsunagi::overlay::{ManagedTunFactory, WintunProvisioner};
use tsunagi::overlay::{ManagedTunFactory, WindowsHostRules, WintunProvisioner};
let provisioner = WintunProvisioner::new()?;
Ok(Arc::new(ManagedTunFactory::new(Arc::new(provisioner))))
Ok(Arc::new(
ManagedTunFactory::new(Arc::new(provisioner))
.with_host_rules(Arc::new(WindowsHostRules::new())),
))
}
/// There is no provisioner for this platform yet.
@@ -4452,6 +4500,55 @@ mod status_tests {
assert!(text.contains("mistyped secret"), "{text}");
}
#[test]
fn broadcast_rules_are_reported_with_the_reason_when_incomplete() {
let mut network = network_after_a_peer_returned();
network.broadcast = true;
let render = |network: &tsunagi::ipc::NetworkReport| {
let mut out = report::Report::new();
out.push(network_section(network, OWN, false));
(out.render(false), out.worst())
};
// Nothing installed (in-memory interface, or broadcast just off): no row.
let (text, _) = render(&network);
assert!(!text.contains("broadcast rules"), "{text}");
let overlay = network.overlay.as_mut().unwrap();
overlay.broadcast_rules = Some(tsunagi::ipc::HostRulesReport {
ok: true,
detail: "ok (route + firewall)".into(),
source: "10.13.37.69".into(),
});
let (text, worst) = render(&network);
assert!(text.contains("broadcast rules"), "{text}");
assert!(text.contains("ok (route + firewall)"), "{text}");
assert_ne!(worst, Health::Degraded, "{text}");
let overlay = network.overlay.as_mut().unwrap();
overlay.broadcast_rules = Some(tsunagi::ipc::HostRulesReport {
ok: false,
detail: "incomplete; route ok; firewall failed: iptables not found".into(),
source: "10.13.37.69".into(),
});
let (text, worst) = render(&network);
assert!(
text.contains("firewall failed: iptables not found"),
"{text}"
);
assert_eq!(worst, Health::Degraded, "{text}");
// Bound to another network's address: said, not hidden.
network.overlay.as_mut().unwrap().address = Some("10.99.0.5".into());
let (text, _) = render(&network);
assert!(text.contains("another network's address"), "{text}");
// Broadcast off for this network: the line is not shown.
network.broadcast = false;
let (text, _) = render(&network);
assert!(!text.contains("broadcast rules"), "{text}");
}
#[test]
fn an_agent_with_nobody_to_contact_says_so_instead_of_waiting_quietly() {
// The wiped-and-restarted case: both devices join the same network