crates/wg-appliance-core/examples/hub_relay.rs
//! `hub_relay` — the [`HubRelay`] as a process, for a bench that drives guests.
//!
//! ```text
//! hub_relay --key FILE --port N --endpoint IP --subnet CIDR --conf-out FILE [--conf-only]
//! [--peers FILE] [--verdict-out FILE] [--hub-addr IP]
//! ```
//!
//! * `--key FILE`: the hub's 32-byte private key, MINTED on first use (0600) and
//! read after, so the `hub.conf` a medium was composed with stays valid.
//! * `--conf-out FILE`: the site `hub.conf` (ceremony dialect) the medium bakes,
//! `Endpoint = <endpoint>:<port>`, `AllowedIPs = <subnet>`.
//! * `--conf-only`: write the conf and exit (the compose step).
//! * `--peers FILE`: the `[Peer]` list `gunnar control tunnel enrol --hub-peers`
//! appends to; re-read every half second, each new `PublicKey`/`AllowedIPs`
//! pair is listed on the relay (a real hub is told to reload; this one looks).
//! * `--verdict-out FILE`: the verdict, rewritten every half second — one line
//! per peer (`peer <addr> handshakes N rx_bytes N rx_packets N tx_bytes N
//! tx_packets N keepalives N`) and one `totals refused N spoofed N no_route N
//! to_hub N`.
//!
//! Userspace, no root. Runs until killed.
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::time::Duration;
use wg_appliance_core::hubrelay::HubRelay;
use wg_appliance_core::keys::{decode_key, generate_keypair, KEY_LEN};
fn flag(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
}
fn load_or_mint(path: &PathBuf) -> Result<[u8; KEY_LEN], String> {
match std::fs::read(path) {
Ok(b) => b.try_into().map_err(|_| format!("{} does not hold a 32-byte key", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let (private, _) = generate_keypair();
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path).map_err(|e| format!("{}: {e}", path.display()))?;
f.write_all(&private).map_err(|e| e.to_string())?;
Ok(private)
}
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
/// `[Peer]` blocks of a `--hub-peers` file: `(PublicKey, the /32's address)`.
fn read_peers(text: &str) -> Vec<([u8; KEY_LEN], Ipv4Addr)> {
let mut out = Vec::new();
let (mut key, mut addr) = (None, None);
let mut flush = |key: &mut Option<[u8; KEY_LEN]>, addr: &mut Option<Ipv4Addr>| {
if let (Some(k), Some(a)) = (key.take(), addr.take()) {
out.push((k, a));
}
};
for line in text.lines().map(str::trim) {
if line == "[Peer]" {
flush(&mut key, &mut addr);
} else if let Some(v) = line.strip_prefix("PublicKey") {
key = decode_key(v.trim_start_matches([' ', '=']).trim()).ok();
} else if let Some(v) = line.strip_prefix("AllowedIPs") {
addr = v.trim_start_matches([' ', '=']).trim().split('/').next().and_then(|a| a.parse().ok());
}
}
flush(&mut key, &mut addr);
out
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let need = |n: &str| flag(&args, n).unwrap_or_else(|| {
eprintln!("hub_relay: {n} is required");
std::process::exit(2)
});
let key_path = PathBuf::from(need("--key"));
let port: u16 = need("--port").parse().expect("--port N");
let endpoint: Ipv4Addr = need("--endpoint").parse().expect("--endpoint IP");
let subnet = need("--subnet");
let hub_addr: Ipv4Addr = flag(&args, "--hub-addr").unwrap_or_else(|| "10.77.0.1".into()).parse().expect("--hub-addr IP");
let private = load_or_mint(&key_path).unwrap_or_else(|e| {
eprintln!("hub_relay: {e}");
std::process::exit(2)
});
let conf_out = PathBuf::from(need("--conf-out"));
let conf = wg_appliance_core::hubfixture::render_hub_conf(
&wg_appliance_core::keys::encode_key(&wg_appliance_core::keys::public_from_private_bytes(&private)),
endpoint,
port,
&subnet,
5,
None,
);
std::fs::write(&conf_out, &conf).expect("write --conf-out");
if args.iter().any(|a| a == "--conf-only") {
println!("{}", conf_out.display());
return;
}
let relay = HubRelay::spawn(port, private, hub_addr).unwrap_or_else(|e| {
eprintln!("hub_relay: binding udp {port}: {e}");
std::process::exit(2)
});
println!("hub_relay listening udp 0.0.0.0:{} key {} (hub.conf {})", relay.port(), relay.public_b64, conf_out.display());
let peers_file = flag(&args, "--peers").map(PathBuf::from);
let verdict_out = flag(&args, "--verdict-out").map(PathBuf::from);
let mut listed: Vec<[u8; KEY_LEN]> = Vec::new();
loop {
if let Some(p) = &peers_file {
if let Ok(text) = std::fs::read_to_string(p) {
for (k, a) in read_peers(&text) {
if !listed.contains(&k) {
match relay.add_peer(k, a) {
Ok(()) => println!("hub_relay listed {a} {}", wg_appliance_core::keys::encode_key(&k)),
Err(e) => println!("hub_relay refused to list {a}: {e}"),
}
listed.push(k);
}
}
}
}
if let Some(out) = &verdict_out {
let v = relay.verdict();
let mut s = String::new();
for (a, _, c) in &v.peers {
s.push_str(&format!(
"peer {a} handshakes {} rx_bytes {} rx_packets {} tx_bytes {} tx_packets {} keepalives {}\n",
c.handshakes, c.rx_bytes, c.rx_packets, c.tx_bytes, c.tx_packets, c.keepalives
));
}
s.push_str(&format!("totals refused {} spoofed {} no_route {} to_hub {}\n", v.refused, v.dropped_spoofed, v.dropped_no_route, v.to_hub));
let tmp = out.with_extension("tmp");
if std::fs::write(&tmp, s).is_ok() {
let _ = std::fs::rename(&tmp, out);
}
}
std::thread::sleep(Duration::from_millis(500));
}
}
#[cfg(test)]
mod tests {
#[test]
fn the_enrol_peer_file_is_read() {
let (_, pubk) = wg_appliance_core::keys::generate_keypair();
let text = format!("[Peer]\n# gunnar main — registered by `gunnar control tunnel enrol`\nPublicKey = {}\nAllowedIPs = 10.77.0.2/32\n", wg_appliance_core::keys::encode_key(&pubk));
assert_eq!(super::read_peers(&text), vec![(pubk, "10.77.0.2".parse().unwrap())]);
}
}