2026-09-21 17:05:24 +01:00
|
|
|
//! The DNS service as the binary actually runs it.
|
|
|
|
|
//!
|
|
|
|
|
//! The zone and the server have their own tests. What this covers is the
|
|
|
|
|
//! wiring between them and the agent, which is where the interesting
|
|
|
|
|
//! mistakes live: choosing an address to listen on, and deciding when to
|
|
|
|
|
//! rebuild the listener. Both were wrong once, and neither was reachable
|
|
|
|
|
//! from a unit test, so this runs the real binary.
|
|
|
|
|
|
|
|
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
|
|
|
|
|
|
use std::net::{SocketAddr, UdpSocket};
|
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
|
|
|
|
use simple_dns::{Name, Packet, QCLASS, RCODE, TYPE, rdata::RData};
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
// A port per test, high enough to need no privileges and fixed so the query
|
|
|
|
|
// knows where to look. Distinct because these tests run in parallel and each
|
|
|
|
|
// starts its own agent.
|
|
|
|
|
const PORT_BINDS: u16 = 15361;
|
|
|
|
|
const PORT_REBIND: u16 = 15362;
|
|
|
|
|
const PORT_REFUSE: u16 = 15363;
|
2026-09-21 23:03:25 +01:00
|
|
|
const PORT_TWO_ZONES: u16 = 15364;
|
|
|
|
|
const PORT_SWITCH: u16 = 15365;
|
2026-09-22 16:08:06 +03:00
|
|
|
const PORT_FLAGS: u16 = 15366;
|
2026-09-21 17:05:24 +01:00
|
|
|
|
|
|
|
|
/// Asks, and returns the raw reply. Raw because a parsed packet borrows
|
|
|
|
|
/// from the bytes it came out of.
|
|
|
|
|
fn query(server: SocketAddr, name: &str, qtype: TYPE) -> Option<Vec<u8>> {
|
|
|
|
|
let mut packet = Packet::new_query(0x2468);
|
|
|
|
|
packet.questions.push(simple_dns::Question::new(
|
|
|
|
|
Name::new(name).unwrap(),
|
|
|
|
|
qtype.into(),
|
|
|
|
|
QCLASS::CLASS(simple_dns::CLASS::IN),
|
|
|
|
|
false,
|
|
|
|
|
));
|
|
|
|
|
let bytes = packet.build_bytes_vec().unwrap();
|
|
|
|
|
|
|
|
|
|
let socket = UdpSocket::bind("127.0.0.1:0").ok()?;
|
|
|
|
|
socket
|
|
|
|
|
.set_read_timeout(Some(Duration::from_millis(500)))
|
|
|
|
|
.ok()?;
|
|
|
|
|
socket.send_to(&bytes, server).ok()?;
|
|
|
|
|
let mut buffer = vec![0u8; 4096];
|
|
|
|
|
let read = socket.recv(&mut buffer).ok()?;
|
|
|
|
|
buffer.truncate(read);
|
|
|
|
|
Packet::parse(&buffer).ok()?;
|
|
|
|
|
Some(buffer)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether a reply carries at least one answer record.
|
|
|
|
|
fn has_answer(reply: &[u8]) -> bool {
|
|
|
|
|
Packet::parse(reply).is_ok_and(|packet| !packet.answers.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Blocks until the server answers, or gives up.
|
|
|
|
|
fn wait_for_answer(server: SocketAddr, name: &str) -> Vec<u8> {
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
|
|
|
loop {
|
|
|
|
|
if let Some(reply) = query(server, name, TYPE::A)
|
|
|
|
|
&& has_answer(&reply)
|
|
|
|
|
{
|
|
|
|
|
return reply;
|
|
|
|
|
}
|
|
|
|
|
assert!(Instant::now() < deadline, "the dns server never answered");
|
|
|
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The agent, running as a real process with its DNS service on.
|
|
|
|
|
struct Running {
|
|
|
|
|
child: std::process::Child,
|
2026-09-21 23:03:25 +01:00
|
|
|
dir: TempDir,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Running {
|
2026-09-22 16:08:06 +03:00
|
|
|
fn wait_ready(&mut self) {
|
|
|
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
|
|
|
.enable_all()
|
|
|
|
|
.build()
|
|
|
|
|
.unwrap();
|
|
|
|
|
let socket = tsunagi::ipc::control_socket_path(&self.dir.path().join("state"));
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
|
|
|
while !runtime.block_on(tsunagi::ipc::is_serving(&socket)) {
|
|
|
|
|
assert!(
|
|
|
|
|
self.child.try_wait().unwrap().is_none(),
|
|
|
|
|
"the agent exited before becoming ready"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
Instant::now() < deadline,
|
|
|
|
|
"the control socket never became ready"
|
|
|
|
|
);
|
|
|
|
|
std::thread::sleep(Duration::from_millis(25));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn restart(&mut self, dns_flag: Option<&str>) {
|
|
|
|
|
self.child.kill().unwrap();
|
|
|
|
|
self.child.wait().unwrap();
|
|
|
|
|
self.child = spawn_child(&self.dir, None, dns_flag);
|
|
|
|
|
self.wait_ready();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 23:03:25 +01:00
|
|
|
/// Runs another `tsunagi` command against this agent's directory.
|
|
|
|
|
fn run(&self, args: &[&str]) -> std::process::Output {
|
|
|
|
|
std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
|
|
|
|
.args(args)
|
|
|
|
|
.arg("--state-dir")
|
|
|
|
|
.arg(self.dir.path().join("state"))
|
|
|
|
|
.arg("--cache-dir")
|
|
|
|
|
.arg(self.dir.path().join("cache"))
|
|
|
|
|
.output()
|
|
|
|
|
.expect("the agent binary runs")
|
|
|
|
|
}
|
2026-09-21 17:05:24 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Drop for Running {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
let _ = self.child.kill();
|
|
|
|
|
let _ = self.child.wait();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 23:03:25 +01:00
|
|
|
/// Starts an agent in one network, whose name is therefore the zone.
|
|
|
|
|
///
|
|
|
|
|
/// There is no separate zone setting: an agent serves a zone per network,
|
|
|
|
|
/// named after it, so the network name is the zone name.
|
2026-09-21 17:05:24 +01:00
|
|
|
fn start(zone: &str, port: u16) -> Running {
|
2026-09-22 00:30:03 +01:00
|
|
|
let agent = start_without_dns_at(zone, Some(port));
|
|
|
|
|
let joined = agent.run(&[
|
|
|
|
|
"join",
|
|
|
|
|
"--network",
|
|
|
|
|
zone,
|
|
|
|
|
"--secret",
|
|
|
|
|
"a-secret-for-the-dns-test",
|
|
|
|
|
]);
|
|
|
|
|
assert!(
|
|
|
|
|
joined.status.success(),
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&joined.stderr)
|
|
|
|
|
);
|
|
|
|
|
agent
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Starts an agent in one network with the resolver off, to switch on later.
|
|
|
|
|
fn start_without_dns(network: &str) -> Running {
|
|
|
|
|
let agent = start_without_dns_at(network, None);
|
|
|
|
|
let joined = agent.run(&[
|
|
|
|
|
"join",
|
|
|
|
|
"--network",
|
|
|
|
|
network,
|
|
|
|
|
"--secret",
|
|
|
|
|
"a-secret-for-the-dns-test",
|
|
|
|
|
]);
|
|
|
|
|
assert!(
|
|
|
|
|
joined.status.success(),
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&joined.stderr)
|
|
|
|
|
);
|
|
|
|
|
agent
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The agent alone: `up` runs it, and what it belongs to is decided after.
|
|
|
|
|
fn start_without_dns_at(_network: &str, dns_port: Option<u16>) -> Running {
|
2026-09-21 17:05:24 +01:00
|
|
|
let dir = TempDir::new().unwrap();
|
2026-09-22 16:08:06 +03:00
|
|
|
let child = spawn_child(&dir, dns_port, dns_port.is_none().then_some("--no-dns"));
|
|
|
|
|
let mut agent = Running { child, dir };
|
|
|
|
|
agent.wait_ready();
|
|
|
|
|
agent
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn spawn_child(
|
|
|
|
|
dir: &TempDir,
|
|
|
|
|
dns_port: Option<u16>,
|
|
|
|
|
dns_flag: Option<&str>,
|
|
|
|
|
) -> std::process::Child {
|
2026-09-22 00:30:03 +01:00
|
|
|
let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"));
|
|
|
|
|
command
|
|
|
|
|
.arg("up")
|
2026-09-21 17:05:24 +01:00
|
|
|
.arg("--state-dir")
|
|
|
|
|
.arg(dir.path().join("state"))
|
|
|
|
|
.arg("--cache-dir")
|
|
|
|
|
.arg(dir.path().join("cache"))
|
|
|
|
|
// No real interface and no internet: this is about the wiring.
|
2026-09-22 00:30:03 +01:00
|
|
|
.args(["--reach", "local", "--no-tun"]);
|
|
|
|
|
if let Some(port) = dns_port {
|
2026-09-22 16:08:06 +03:00
|
|
|
command.args(["--dns-port", &port.to_string()]);
|
2026-09-22 00:30:03 +01:00
|
|
|
}
|
2026-09-22 16:08:06 +03:00
|
|
|
if let Some(flag) = dns_flag {
|
|
|
|
|
command.arg(flag);
|
|
|
|
|
}
|
|
|
|
|
command
|
2026-09-21 23:03:25 +01:00
|
|
|
.args(["--log", "error", "--status-interval", "0"])
|
|
|
|
|
.stdout(std::process::Stdio::null())
|
|
|
|
|
.stderr(std::process::Stdio::null())
|
|
|
|
|
.spawn()
|
2026-09-22 16:08:06 +03:00
|
|
|
.expect("the agent binary starts")
|
2026-09-21 17:05:24 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-09-22 16:08:06 +03:00
|
|
|
fn the_resolver_is_on_by_default_even_without_an_overlay_interface() {
|
2026-09-21 23:03:25 +01:00
|
|
|
// The promise is that the port is served whatever else fails: with
|
|
|
|
|
// `--no-tun` there is no interface to attach a resolver setting to,
|
|
|
|
|
// and the zone is answered on loopback all the same. Getting this
|
2026-09-21 17:05:24 +01:00
|
|
|
// wrong left the feature silently dead.
|
|
|
|
|
let _agent = start("lab.internal", PORT_BINDS);
|
|
|
|
|
let server: SocketAddr = format!("127.0.0.1:{PORT_BINDS}").parse().unwrap();
|
|
|
|
|
|
|
|
|
|
let reply = wait_for_answer(server, &format!("{}.lab.internal", hostname()));
|
|
|
|
|
let answer = Packet::parse(&reply).unwrap();
|
|
|
|
|
assert_eq!(answer.rcode(), RCODE::NoError);
|
|
|
|
|
match &answer.answers[0].rdata {
|
|
|
|
|
RData::A(_) => {}
|
|
|
|
|
other => panic!("expected an A record, got {other:?}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_listener_is_not_rebuilt_on_every_pass() {
|
2026-09-21 23:03:25 +01:00
|
|
|
// The listener is bound once and kept, and the zones are swapped
|
|
|
|
|
// underneath it as networks and members come and go. Rebuilding it on
|
|
|
|
|
// the way past — which an earlier version did on every tick — shut the
|
|
|
|
|
// port for a moment each time.
|
2026-09-21 17:05:24 +01:00
|
|
|
let _agent = start("rebind.internal", PORT_REBIND);
|
|
|
|
|
let server: SocketAddr = format!("127.0.0.1:{PORT_REBIND}").parse().unwrap();
|
|
|
|
|
let name = format!("{}.rebind.internal", hostname());
|
|
|
|
|
wait_for_answer(server, &name);
|
|
|
|
|
|
|
|
|
|
// Long enough to cross several of the supervisor's passes.
|
|
|
|
|
for round in 0..6 {
|
|
|
|
|
std::thread::sleep(Duration::from_millis(900));
|
|
|
|
|
assert!(
|
|
|
|
|
query(server, &name, TYPE::A).is_some_and(|reply| has_answer(&reply)),
|
|
|
|
|
"the server stopped answering on round {round}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_name_outside_the_zone_is_refused_and_never_forwarded() {
|
|
|
|
|
let _agent = start("refuse.internal", PORT_REFUSE);
|
|
|
|
|
let server: SocketAddr = format!("127.0.0.1:{PORT_REFUSE}").parse().unwrap();
|
|
|
|
|
wait_for_answer(server, &format!("{}.refuse.internal", hostname()));
|
|
|
|
|
|
|
|
|
|
let reply = query(server, "example.com", TYPE::A).expect("an answer");
|
|
|
|
|
let answer = Packet::parse(&reply).unwrap();
|
|
|
|
|
assert_eq!(answer.rcode(), RCODE::Refused);
|
|
|
|
|
assert!(answer.answers.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn hostname() -> String {
|
|
|
|
|
tsunagi::agent::system_hostname().unwrap_or_else(|| "unknown".into())
|
|
|
|
|
}
|
2026-09-21 23:03:25 +01:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_network_gets_a_zone_of_its_own() {
|
|
|
|
|
// One agent, one identity, several networks — and a question carries a
|
|
|
|
|
// name, not the network it belongs to. Each network is a zone named
|
|
|
|
|
// after it, and joining one while the agent runs adds its zone without
|
|
|
|
|
// restarting anything.
|
|
|
|
|
let agent = start("first.internal", PORT_TWO_ZONES);
|
|
|
|
|
let server: SocketAddr = format!("127.0.0.1:{PORT_TWO_ZONES}").parse().unwrap();
|
|
|
|
|
let host = hostname();
|
|
|
|
|
wait_for_answer(server, &format!("{host}.first.internal"));
|
|
|
|
|
|
|
|
|
|
let joined = agent.run(&[
|
|
|
|
|
"join",
|
|
|
|
|
"--network",
|
|
|
|
|
"second.internal",
|
|
|
|
|
"--secret",
|
|
|
|
|
"another-secret-for-the-dns-test",
|
|
|
|
|
]);
|
|
|
|
|
assert!(
|
|
|
|
|
joined.status.success(),
|
|
|
|
|
"joining failed: {}",
|
|
|
|
|
String::from_utf8_lossy(&joined.stderr)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The second network's zone answers too, and neither leaks into the
|
|
|
|
|
// other: a member of one is not a name in the other.
|
|
|
|
|
let reply = wait_for_answer(server, &format!("{host}.second.internal"));
|
|
|
|
|
assert_eq!(Packet::parse(&reply).unwrap().rcode(), RCODE::NoError);
|
|
|
|
|
let first = wait_for_answer(server, &format!("{host}.first.internal"));
|
|
|
|
|
assert_eq!(Packet::parse(&first).unwrap().rcode(), RCODE::NoError);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_resolver_can_be_switched_on_and_off_while_the_agent_runs() {
|
2026-09-22 16:08:06 +03:00
|
|
|
// An intentional --no-dns can be reversed at runtime without restarting.
|
2026-09-21 23:03:25 +01:00
|
|
|
let agent = start_without_dns("switch.internal");
|
|
|
|
|
let server: SocketAddr = format!("127.0.0.1:{PORT_SWITCH}").parse().unwrap();
|
|
|
|
|
let host = hostname();
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
query(server, &format!("{host}.switch.internal"), TYPE::A).is_none(),
|
|
|
|
|
"nothing should be answering yet"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let on = agent.run(&["dns", "on", "--port", &PORT_SWITCH.to_string()]);
|
|
|
|
|
assert!(
|
|
|
|
|
on.status.success(),
|
|
|
|
|
"dns on failed: {}",
|
|
|
|
|
String::from_utf8_lossy(&on.stderr)
|
|
|
|
|
);
|
|
|
|
|
wait_for_answer(server, &format!("{host}.switch.internal"));
|
|
|
|
|
|
|
|
|
|
let off = agent.run(&["dns", "off"]);
|
|
|
|
|
assert!(off.status.success());
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
|
|
|
while query(server, &format!("{host}.switch.internal"), TYPE::A).is_some() {
|
|
|
|
|
assert!(
|
|
|
|
|
Instant::now() < deadline,
|
|
|
|
|
"it kept answering after `dns off`"
|
|
|
|
|
);
|
|
|
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-22 16:08:06 +03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn explicit_dns_choices_override_the_saved_setting_and_survive_restart() {
|
|
|
|
|
let mut agent = start("flags.internal", PORT_FLAGS);
|
|
|
|
|
let server = format!("127.0.0.1:{PORT_FLAGS}").parse().unwrap();
|
|
|
|
|
let name = format!("{}.flags.internal", hostname());
|
|
|
|
|
wait_for_answer(server, &name);
|
|
|
|
|
|
|
|
|
|
for flag in [Some("--no-dns"), None] {
|
|
|
|
|
agent.restart(flag);
|
|
|
|
|
let state = agent.run(&["dns"]);
|
|
|
|
|
assert!(state.status.success());
|
|
|
|
|
assert!(String::from_utf8_lossy(&state.stdout).contains("not serving"));
|
|
|
|
|
assert!(query(server, &name, TYPE::A).is_none());
|
|
|
|
|
}
|
|
|
|
|
agent.restart(Some("--dns"));
|
|
|
|
|
wait_for_answer(server, &name);
|
|
|
|
|
agent.restart(None);
|
|
|
|
|
wait_for_answer(server, &name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn opposing_dns_flags_are_rejected() {
|
|
|
|
|
let result = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
|
|
|
|
.args(["up", "--dns", "--no-dns"])
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(!result.status.success());
|
|
|
|
|
assert!(String::from_utf8_lossy(&result.stderr).contains("cannot be used with"));
|
|
|
|
|
}
|