Exit nodes: offer one per network, send all traffic through a member

A member can offer itself as an exit node per network (off by default); it is
announced over the control channel (ctrl/4) once its iptables masquerade and
forward rules are in place, with a warning when kernel forwarding is off. A
device picks one member as its exit node via policy routing, with the agent's
own uid exempt from the tunnel; if the exit node disappears the rules stay
and traffic is dropped, loudly reported. CLI: `network exit-node`,
`exit-node`; tray: tile checkbox, warnings and a per-device icon with
confirmation. Linux only.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Aleksandr BogomiakovandClaude committed 2026-10-03 01:14:51 +01:00
1 parent c62d29fa14
commit b2431d7eb1
34 files changed
+4148 -61

No files matched your search

+606
View File
@@ -76,6 +76,12 @@ enum Command {
Network(NetworkArgs),
/// Shows the local resolver, and turns it on or off.
Dns(DnsArgs),
/// Lists devices offering to be an exit node, and sends all this
/// device's internet traffic through one.
///
/// The other half of `tsunagi network exit-node`, which is what makes a
/// device offer itself. A device uses one exit node at a time.
ExitNode(ExitNodeArgs),
/// Removes everything this device has stored and starts over.
Wipe(WipeArgs),
}
@@ -105,6 +111,25 @@ enum DnsAction {
Off,
}
#[derive(Debug, Args)]
struct ExitNodeArgs {
#[command(flatten)]
paths: PathArgs,
/// Control socket to talk to. Derived from the state directory by default.
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET")]
control_socket: Option<PathBuf>,
/// Only look in this network: exact name, full id or unique id prefix.
#[arg(long, short = 'n')]
network: Option<String>,
/// The device to use: its hostname, endpoint id or a unique prefix of
/// the id. `off` goes back to sending traffic the ordinary way. Omit to
/// list the devices that offer.
target: Option<String>,
}
#[derive(Debug, Args)]
struct NetworkArgs {
#[command(flatten)]
@@ -162,6 +187,20 @@ enum NetworkAction {
#[arg(value_enum)]
choice: Option<BroadcastChoice>,
},
/// Offers this device as an exit node in a network, or stops.
///
/// Off unless turned on. While on, other members can send all their
/// internet traffic through this device, which can see it. The firewall
/// and NAT rules are installed by the agent; kernel forwarding is not
/// turned on for you, and is reported when it is off.
#[command(name = "exit-node")]
ExitNode {
/// Exact network name, full id or unique id prefix.
network: String,
/// Offer, or stop offering.
#[arg(value_enum)]
choice: 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
@@ -755,6 +794,7 @@ async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
Command::Join(args) => join_command(args).await,
Command::Network(args) => network_command(args).await,
Command::Dns(args) => dns_command(args).await,
Command::ExitNode(args) => exit_node_command(args).await,
Command::Wipe(args) => wipe(args).await,
}
}
@@ -1544,6 +1584,47 @@ impl tsunagi::ipc::ReportSource for AgentControl {
})
}
fn set_exit_offer(
&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_exit_offer(id, enabled)
.await
.map_err(|err| err.to_string())?;
Ok(enabled)
})
}
fn set_exit_node(
&self,
network_id: String,
peer: Option<String>,
) -> tsunagi::BoxFuture<'_, Result<Option<String>, String>> {
Box::pin(async move {
let id = network_id
.parse()
.map_err(|err| format!("invalid network id: {err}"))?;
let via = peer
.as_deref()
.map(|peer| {
peer.parse::<EndpointId>()
.map_err(|err| format!("`{peer}` is not an endpoint id: {err}"))
})
.transpose()?;
self.agent
.set_exit_via(id, via)
.await
.map_err(|err| err.to_string())?;
Ok(peer)
})
}
fn set_active(
&self,
network_id: String,
@@ -1701,6 +1782,9 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Er
Some(NetworkAction::Broadcast { network, choice }) => {
broadcast_command(&paths, &socket, &network, choice).await
}
Some(NetworkAction::ExitNode { network, choice }) => {
exit_offer_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,
@@ -2000,6 +2084,293 @@ async fn broadcast_command(
Ok(())
}
/// What to run to turn kernel forwarding on, which the agent never does.
const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1";
/// The warning for an exit node whose rules are in place while the kernel is
/// not forwarding, so that nothing passes.
fn forwarding_off_note() -> String {
format!(
"kernel forwarding is OFF, so nothing passes yet: run `{FORWARDING_COMMAND}` \
(and keep it across reboots with `net.ipv4.ip_forward = 1` in \
/etc/sysctl.d/99-tsunagi.conf)"
)
}
/// Offers this device as an exit node in a network, or stops.
async fn exit_offer_command(
paths: &StoragePaths,
socket: &std::path::Path,
wanted: &str,
choice: BroadcastChoice,
) -> Result<(), Box<dyn std::error::Error>> {
let configured = stored_networks(paths);
let network = resolve_network(&configured, wanted)?;
let enabled = matches!(choice, BroadcastChoice::On);
let id = network.network_id.to_string();
if !tsunagi::ipc::is_serving(socket).await {
let storage = tsunagi::storage::Storage::open(paths)?;
storage.set_exit_node(network.network_id, enabled).await?;
storage.release_ownership_lock();
println!(
"exit node {} for `{}` ({}) with the next `tsunagi up`",
if enabled {
"will be offered"
} else {
"will not be offered"
},
network.name,
short(&id, 10)
);
return Ok(());
}
tsunagi::ipc::set_exit_offer(socket, &id, enabled).await?;
println!(
"exit node {} for `{}` ({})",
if enabled { "on" } else { "off" },
network.name,
short(&id, 10)
);
if !enabled {
return Ok(());
}
let status = tsunagi::ipc::request_status(socket).await?;
let Some(report) = status.networks.iter().find(|n| n.network_id == id) else {
return Ok(());
};
match &report.exit.offer_rules {
Some(rules) if rules.ok => println!(" rules {}", rules.detail),
Some(rules) => eprintln!("warning: the rules are not all in place: {}", rules.detail),
None => eprintln!(
"warning: no rules were installed, so this device is not offered yet: the \
network needs an address on the overlay interface first"
),
}
if report.exit.forwarding == Some(false) {
eprintln!("warning: {}", forwarding_off_note());
}
println!(
"\nMembers can now send all their internet traffic through this device, and it \
can see it. `tsunagi network exit-node {wanted} off` stops."
);
Ok(())
}
/// Every connected device that offers to be an exit node, across networks.
fn exit_candidates(
networks: &[tsunagi::ipc::NetworkReport],
) -> Vec<(&tsunagi::ipc::NetworkReport, &tsunagi::ipc::PeerReport)> {
networks
.iter()
.flat_map(|network| {
network
.peers
.iter()
.filter(|peer| peer.exit_node)
.map(move |peer| (network, peer))
})
.collect()
}
/// What to call a peer: its hostname, or a short form of its id.
fn peer_label(peer: &tsunagi::ipc::PeerReport) -> String {
peer.hostname
.clone()
.unwrap_or_else(|| short(&peer.endpoint_id, 12))
}
/// The network a user named, among those the agent reports.
fn select_networks<'a>(
networks: &'a [tsunagi::ipc::NetworkReport],
only: Option<&str>,
) -> Result<Vec<&'a tsunagi::ipc::NetworkReport>, String> {
let Some(only) = only else {
return Ok(networks.iter().collect());
};
let only = only.trim().trim_end_matches('…');
let chosen: Vec<_> = networks
.iter()
.filter(|n| n.name == only || n.network_id == only || n.network_id.starts_with(only))
.collect();
if chosen.is_empty() {
return Err(format!("no network here is called or has the id `{only}`"));
}
Ok(chosen)
}
/// Finds the device a user means among those that offer to be an exit node.
///
/// By hostname, endpoint id or a unique prefix of the id. A name that is in
/// several networks, or on several devices, is an error that lists them: a
/// device that all traffic goes through must not be picked by guessing.
fn resolve_exit_node<'a>(
networks: &'a [tsunagi::ipc::NetworkReport],
wanted: &str,
only: Option<&str>,
) -> Result<
(
&'a tsunagi::ipc::NetworkReport,
&'a tsunagi::ipc::PeerReport,
),
String,
> {
let wanted = wanted.trim().trim_end_matches('…');
let scope = select_networks(networks, only)?;
let named: Vec<_> = scope
.iter()
.flat_map(|network| network.peers.iter().map(move |peer| (*network, peer)))
.filter(|(_, peer)| {
peer.hostname
.as_deref()
.is_some_and(|name| name.eq_ignore_ascii_case(wanted))
|| peer.endpoint_id == wanted
|| (wanted.len() >= 4 && peer.endpoint_id.starts_with(&wanted.to_ascii_lowercase()))
})
.collect();
let offering: Vec<_> = named.iter().filter(|(_, peer)| peer.exit_node).collect();
match offering.as_slice() {
[(network, peer)] => Ok((*network, *peer)),
[] => match named.first() {
Some((network, peer)) => Err(format!(
"`{}` is connected but does not offer to be an exit node. On that device: \
tsunagi network exit-node {} on",
peer_label(peer),
network.name
)),
None => Err(format!(
"no connected device matches `{wanted}`. `tsunagi exit-node` lists the \
ones that offer to be an exit node"
)),
},
several => {
let mut message = format!(
"`{wanted}` matches more than one exit node; say which with --network or a \
longer id:"
);
for (network, peer) in several {
message.push_str(&format!(
"\n {} ({}) in network {}",
peer_label(peer),
short(&peer.endpoint_id, 12),
network.name
));
}
Err(message)
}
}
}
/// `tsunagi exit-node`: list, use or stop using an exit node.
async fn exit_node_command(args: ExitNodeArgs) -> Result<(), Box<dyn std::error::Error>> {
use report::{Health, Report, Row, Section};
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
if !tsunagi::ipc::is_serving(&socket).await {
return Err(
"no agent is running: an exit node is used through the running agent, which \
changes the routes of this device. Start it with `tsunagi up`."
.into(),
);
}
let status = tsunagi::ipc::request_status(&socket).await?;
let scope = select_networks(&status.networks, args.network.as_deref())?;
match args.target.as_deref() {
None => {
let mut section = Section::new("exit nodes");
let mut any = false;
for network in &scope {
for (_, peer) in exit_candidates(std::slice::from_ref(*network)) {
any = true;
let in_use = network.exit.via.as_deref() == Some(peer.endpoint_id.as_str());
let mut detail = format!("network {}", network.name);
if let Some(rtt) = peer.rtt_ms {
detail.push_str(&format!(" · rtt {rtt}ms"));
}
if in_use {
detail.push_str(" · IN USE");
}
section.push(Row::new(
if in_use { Health::Good } else { Health::Info },
peer_label(peer),
detail,
));
}
}
if !any {
section.push(
Row::new(
Health::Info,
"none",
"no connected device offers to be an exit node",
)
.with_note("on that device: tsunagi network exit-node <network> on"),
);
}
let mut out = Report::new();
out.push(section);
print_report("tsunagi exit-node", &out)?;
if any {
println!(
"\n`tsunagi exit-node <name|id>` uses one; `tsunagi exit-node off` stops."
);
}
Ok(())
}
Some(target) if target.eq_ignore_ascii_case("off") => {
let using: Vec<_> = scope.iter().filter(|n| n.exit.via.is_some()).collect();
if using.is_empty() {
println!("no exit node is in use");
return Ok(());
}
for network in using {
tsunagi::ipc::set_exit_node(&socket, &network.network_id, None).await?;
println!(
"stopped using the exit node in `{}`: internet traffic leaves this \
device the ordinary way again",
network.name
);
}
Ok(())
}
Some(target) => {
let (network, peer) =
resolve_exit_node(&status.networks, target, args.network.as_deref())?;
tsunagi::ipc::set_exit_node(&socket, &network.network_id, Some(&peer.endpoint_id))
.await?;
println!(
"all internet traffic from this device now leaves through `{}` (network `{}`)",
peer_label(peer),
network.name
);
let after = tsunagi::ipc::request_status(&socket).await?;
if let Some(exit) = after
.networks
.iter()
.find(|n| n.network_id == network.network_id)
.map(|n| &n.exit)
{
match &exit.client_rules {
Some(rules) if rules.ok => println!(" routes {}", rules.detail),
Some(rules) => {
eprintln!("warning: the routes are not all in place: {}", rules.detail)
}
None => {}
}
}
eprintln!(
"\nThat device can see everything that is not encrypted. If it goes away the \
traffic is dropped, not sent the old way; `tsunagi exit-node off` stops."
);
Ok(())
}
}
}
async fn set_active(
paths: &StoragePaths,
socket: &std::path::Path,
@@ -2670,6 +3041,10 @@ struct MemberRow<'a> {
overlay_address_v4: Option<&'a str>,
tunnel: Option<&'a tsunagi::ipc::OverlayPeerReport>,
failed_dials: u32,
/// It offers to be an exit node.
exit_node: bool,
/// It is the exit node this device sends its traffic through.
exit_in_use: bool,
}
impl MemberRow<'_> {
@@ -2703,6 +3078,8 @@ fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Ve
overlay_address_v4: None,
tunnel: None,
failed_dials: 0,
exit_node: false,
exit_in_use: false,
})
}
@@ -2716,6 +3093,7 @@ fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Ve
for peer in &network.peers {
let row = entry(&mut rows, &peer.endpoint_id);
row.hostname = peer.hostname.as_deref().or(row.hostname);
row.exit_node = peer.exit_node;
row.transport = Some(&peer.transport);
row.rtt_ms = peer.rtt_ms;
}
@@ -2729,6 +3107,10 @@ fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Ve
}
}
for row in rows.values_mut() {
row.exit_in_use = network.exit.via.as_deref() == Some(row.endpoint_id);
}
// This agent is in the roster too — it signs claims like everyone else —
// but it is already the subject of the `device` section.
let mut rows: Vec<MemberRow<'a>> = rows
@@ -2851,6 +3233,9 @@ fn network_section(
),
));
}
for row in exit_rows(network) {
section.push(row);
}
let relayed = network.relay_forwarded + network.relay_sent_via + network.relay_received_via;
if relayed > 0 {
section.push(Row::new(
@@ -2955,6 +3340,87 @@ fn network_section(
section
}
/// The exit-node rows of a network: what this device offers, and what it uses.
///
/// Nothing at all for a network that does neither, which is nearly all of
/// them: an off switch is not worth a line in every report.
fn exit_rows(network: &tsunagi::ipc::NetworkReport) -> Vec<report::Row> {
use report::{Health, Row};
let exit = &network.exit;
let mut rows = Vec::new();
if exit.offering {
let forwarding_off = exit.forwarding == Some(false);
let (rules_ok, detail) = match &exit.offer_rules {
Some(rules) => (rules.ok, rules.detail.clone()),
None => (false, "rules not installed yet".to_string()),
};
let row = Row::new(
if rules_ok && !forwarding_off {
Health::Good
} else {
Health::Degraded
},
"exit node",
format!("offered to members · {detail}"),
);
rows.push(if forwarding_off {
row.with_note(forwarding_off_note())
} else {
row
});
}
if let Some(via) = &exit.via {
let name = exit_name(network, via);
let row = if !exit.via_online {
Row::new(Health::Broken, "using exit", format!("{name} · OFFLINE")).with_note(
"internet traffic is dropped until it returns or you run \
`tsunagi exit-node off`",
)
} else {
match &exit.client_rules {
Some(rules) if rules.ok => Row::new(
Health::Good,
"using exit",
format!("{name} · all internet traffic leaves through it"),
),
Some(rules) => Row::new(
Health::Degraded,
"using exit",
format!("{name} · routes incomplete"),
)
.with_note(rules.detail.clone()),
None => Row::new(
Health::Degraded,
"using exit",
format!("{name} · routes not installed"),
),
}
};
rows.push(row);
}
rows
}
/// What to call the device a network's exit node is, even while it is away.
fn exit_name(network: &tsunagi::ipc::NetworkReport, id: &str) -> String {
network
.peers
.iter()
.find(|peer| peer.endpoint_id == id)
.and_then(|peer| peer.hostname.clone())
.or_else(|| {
network
.members
.iter()
.find(|member| member.endpoint_id == id)
.and_then(|member| member.hostname.clone())
})
.unwrap_or_else(|| short(id, 12))
}
/// One member: connected or not, and what is known either way.
fn member_row(row: &MemberRow<'_>) -> report::Row {
use report::{Health, Row};
@@ -2966,6 +3432,9 @@ fn member_row(row: &MemberRow<'_>) -> report::Row {
if let Some(v4) = row.overlay_address_v4 {
detail = format!("{v4:<15} offline · {v4} still reserved for it");
}
if row.exit_in_use {
detail.push_str(" · your exit node");
}
let out = Row::new(Health::Info, row.label(), detail);
return if row.failed_dials > 0 {
// Attributed to the member it concerns, rather than left as a
@@ -2991,6 +3460,11 @@ fn member_row(row: &MemberRow<'_>) -> report::Row {
if let Some(rtt) = row.rtt_ms {
detail.push_str(&format!(" rtt {rtt}ms"));
}
if row.exit_in_use {
detail.push_str(" · your exit node");
} else if row.exit_node {
detail.push_str(" · exit node");
}
let health = if !direct || (has_overlay && !tunnel_up) {
Health::Degraded
@@ -4276,6 +4750,7 @@ async fn build_report(
};
NetworkReport {
exit: exit_report(network),
broadcast: network.broadcast,
name: network.name.to_string(),
network_id: network.network_id.to_string(),
@@ -4292,6 +4767,7 @@ async fn build_report(
hostname: peer.hostname.clone(),
transport: peer.transport.to_string(),
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
exit_node: peer.exit_node,
})
.collect(),
members: network
@@ -4357,6 +4833,24 @@ async fn build_report(
}
}
/// One network's exit-node settings, as the control interface reports them.
fn exit_report(network: &tsunagi::agent::NetworkStatus) -> tsunagi::ipc::ExitReport {
let rules = |report: &Option<tsunagi::overlay::RuleSetReport>| {
report.as_ref().map(|report| tsunagi::ipc::RuleSetReport {
ok: report.ok,
detail: report.detail.clone(),
})
};
tsunagi::ipc::ExitReport {
offering: network.exit_offer,
offer_rules: rules(&network.exit_rules.offer),
forwarding: network.exit_rules.forwarding,
via: network.exit_via.map(|peer| peer.to_string()),
via_online: network.exit_via_online,
client_rules: rules(&network.exit_rules.client),
}
}
/// The status line for the host rules, saying so when they are bound to
/// another network's address.
fn broadcast_rules_line(
@@ -4636,6 +5130,7 @@ mod status_tests {
/// The situation that prompted this: one peer left and came back.
fn network_after_a_peer_returned() -> NetworkReport {
NetworkReport {
exit: Default::default(),
broadcast: true,
name: "LAB".into(),
network_id: "xa7gyz".into(),
@@ -4649,6 +5144,7 @@ mod status_tests {
hostname: Some("music".into()),
transport: "direct".into(),
rtt_ms: Some(24),
exit_node: false,
}],
members: vec![
MemberReport {
@@ -4912,6 +5408,112 @@ mod status_tests {
assert_eq!(rows[1].endpoint_id, AWAY);
}
fn exit_peer(network: &mut NetworkReport, offers: bool) {
network.peers[0].exit_node = offers;
}
#[test]
fn a_network_that_neither_offers_nor_uses_an_exit_node_says_nothing() {
let network = network_after_a_peer_returned();
let mut out = report::Report::new();
out.push(network_section(&network, OWN, false));
let text = out.render(false);
assert!(!text.contains("exit"), "{text}");
}
#[test]
fn offering_with_forwarding_off_is_degraded_and_says_the_command() {
let mut network = network_after_a_peer_returned();
network.exit.offering = true;
network.exit.offer_rules = Some(tsunagi::ipc::RuleSetReport {
ok: true,
detail: "ok (nat + forward)".into(),
});
network.exit.forwarding = Some(false);
let rows = exit_rows(&network);
assert_eq!(rows.len(), 1);
let mut out = report::Report::new();
out.push(network_section(&network, OWN, false));
let text = out.render(false);
assert_eq!(out.worst(), Health::Degraded, "{text}");
assert!(
text.contains("sudo sysctl -w net.ipv4.ip_forward=1"),
"{text}"
);
assert!(text.contains("ok (nat + forward)"), "{text}");
network.exit.forwarding = Some(true);
let mut out = report::Report::new();
out.push(network_section(&network, OWN, false));
assert_eq!(out.worst(), Health::Good, "{}", out.render(false));
}
#[test]
fn losing_the_exit_node_in_use_is_loud_and_says_the_traffic_is_dropped() {
let mut network = network_after_a_peer_returned();
exit_peer(&mut network, true);
network.exit.via = Some(ONLINE.into());
network.exit.via_online = true;
network.exit.client_rules = Some(tsunagi::ipc::RuleSetReport {
ok: true,
detail: "ok".into(),
});
let mut out = report::Report::new();
out.push(network_section(&network, OWN, false));
let text = out.render(false);
assert_eq!(out.worst(), Health::Good, "{text}");
assert!(
text.contains("music · all internet traffic leaves through it"),
"{text}"
);
assert!(text.contains("your exit node"), "{text}");
network.exit.via_online = false;
let mut out = report::Report::new();
out.push(network_section(&network, OWN, false));
let text = out.render(false);
assert_eq!(out.worst(), Health::Broken, "{text}");
assert!(text.contains("OFFLINE"), "{text}");
assert!(text.contains("dropped until it returns"), "{text}");
assert!(text.contains("tsunagi exit-node off"), "{text}");
}
#[test]
fn an_exit_node_is_resolved_by_name_or_id_and_only_among_those_that_offer() {
let mut network = network_after_a_peer_returned();
let networks = |network: &NetworkReport| vec![network.clone()];
let err = resolve_exit_node(&networks(&network), "music", None).unwrap_err();
assert!(err.contains("does not offer to be an exit node"), "{err}");
let err = resolve_exit_node(&networks(&network), "nobody", None).unwrap_err();
assert!(err.contains("no connected device matches"), "{err}");
exit_peer(&mut network, true);
let all = networks(&network);
for wanted in ["music", "MUSIC", ONLINE, &ONLINE[..8]] {
let (found_network, peer) = resolve_exit_node(&all, wanted, None).unwrap();
assert_eq!(found_network.name, "LAB");
assert_eq!(peer.endpoint_id, ONLINE);
}
assert!(resolve_exit_node(&all, "music", Some("other")).is_err());
assert!(resolve_exit_node(&all, "music", Some("LAB")).is_ok());
}
#[test]
fn the_same_exit_node_in_two_networks_has_to_be_told_apart() {
let mut first = network_after_a_peer_returned();
exit_peer(&mut first, true);
let mut second = first.clone();
second.name = "OTHER".into();
second.network_id = "zzzzzz".into();
let both = vec![first, second];
let err = resolve_exit_node(&both, "music", None).unwrap_err();
assert!(err.contains("--network"), "{err}");
assert!(err.contains("LAB") && err.contains("OTHER"), "{err}");
let (network, _) = resolve_exit_node(&both, "music", Some("OTHER")).unwrap();
assert_eq!(network.name, "OTHER");
}
#[test]
fn an_offline_member_keeps_the_last_name_it_announced() {
let mut network = network_after_a_peer_returned();
@@ -5047,6 +5649,8 @@ mod network_tests {
NetworkSecret::from_bytes(&[secret.as_bytes(), &[0u8; 32]].concat()[..32]).unwrap();
let keys = NetworkKeys::derive(&name, &secret);
StoredNetwork {
exit_node: false,
exit_via: None,
broadcast: true,
network_id: keys.network_id(),
name,
@@ -5268,6 +5872,8 @@ mod network_context_tests {
let secret = NetworkSecret::from_bytes([seed; 32]).unwrap();
let keys = NetworkKeys::derive(&name, &secret);
StoredNetwork {
exit_node: false,
exit_via: None,
broadcast: true,
network_id: keys.network_id(),
name,