Answer DNS over both families, and only for our own interface
Questions now arrive over IPv4 or IPv6, whichever the resolver uses. The server opens one listener per family — the overlay address or `127.0.0.1`, and `[::1]` — and all of them are published to systemd-resolved in one call, which is what that call requires: sending them one at a time leaves only the last. A family that cannot be bound, IPv6 switched off in the kernel for instance, no longer stops the other from answering. The answers stay IPv4, because that is what the overlay is. A listening address is disposable, unlike an address a member holds in signed state, so serving one family over the overlay and the other over loopback costs nothing and loses nothing. `--no-tun` was also configuring the host. An in-memory interface has a name and an MTU and nothing else, but everything downstream read that name as a host interface: the resolver setting was pushed onto whatever else on the host happened to be called `tsun0` — which, with two agents on one machine, is another agent's live interface. A factory now says whether what it creates is on the host, and the resolver setting goes only to an interface the agent created. For the same reason the complaint that "the allocated address is not on any interface" no longer fires under `--no-tun`, where there was never going to be one; it had people looking for something that had removed their address. The status line says `tsun0 (in memory, --no-tun)` rather than printing an address beside a name the operating system does not have. That distinction also corrected a test that used the in-memory interface as a stand-in for an unconfigured host interface. They are not the same case, so the test now uses a factory that claims the host and puts nothing there — a provisioner that reported a success it did not achieve — and a second test covers `--no-tun` being an arrangement rather than a fault. An in-memory device now reports end of stream when it is destroyed. It never did, so the packet loop reading it could not end, and since shutdown became bounded that cost every `--no-tun` agent the full five-second grace before the loop was aborted instead of finishing. And `status` says when there is no local resolver at all. Its absence is the answer to "why does this name not resolve?", and leaving the section out made a report with DNS switched off look exactly like one where it was running. The local control protocol is 7: `DnsReport` carries a list of listening addresses and `OverlayReport` says whether the interface is on the host. Verified against the running systemd-resolved: it takes `127.0.0.1:5354 [::1]:5354` on one link in a single call, and forward and reverse questions for a peer's name are answered identically over both transports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,9 @@ use tsunagi::agent::Event;
|
||||
use tsunagi::dataplane::IpPlugin;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkId, NetworkName, NetworkSecret};
|
||||
use tsunagi::overlay::{MemoryTun, MemoryTunFactory};
|
||||
use tsunagi::overlay::{
|
||||
MemoryTun, MemoryTunFactory, OverlayError, TunDevice, TunFactory, TunRequest,
|
||||
};
|
||||
use tsunagi::state::Ipv4Range;
|
||||
use tsunagi::testing::{config_with, network, settle, wait_event, wait_for_peers, wait_until};
|
||||
use tsunagi::{Agent, NetworkStatus};
|
||||
@@ -27,6 +29,45 @@ use tsunagi_wg_quic::{
|
||||
WIREGUARD_PROTOCOL, WgAnnouncement, WgSecretKey, WireguardConfig, WireguardPlugin,
|
||||
};
|
||||
|
||||
/// A factory that claims the host and puts nothing on it.
|
||||
///
|
||||
/// This is the case the missing-address report exists for: a provisioner
|
||||
/// that returned success without achieving it, or something outside that
|
||||
/// removed the address afterwards. An in-memory interface is *not* that
|
||||
/// case — it has no host side at all, and treating the two as one is what
|
||||
/// had the agent reporting a fault about `--no-tun` working as intended,
|
||||
/// and configuring host interfaces it never created.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PretendHostTuns(MemoryTunFactory);
|
||||
|
||||
impl TunFactory for PretendHostTuns {
|
||||
fn name(&self) -> &str {
|
||||
"pretend-host"
|
||||
}
|
||||
|
||||
fn on_host(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> tsunagi::BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>> {
|
||||
self.0.create(request)
|
||||
}
|
||||
|
||||
fn reconfigure<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> tsunagi::BoxFuture<'a, Result<(), OverlayError>> {
|
||||
self.0.reconfigure(request)
|
||||
}
|
||||
|
||||
fn destroy<'a>(&'a self, name: &'a str) -> tsunagi::BoxFuture<'a, ()> {
|
||||
self.0.destroy(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// An agent with a WireGuard plugin backed by an in-memory packet interface.
|
||||
struct WgAgent {
|
||||
dir: TempDir,
|
||||
@@ -121,6 +162,35 @@ impl WgAgent {
|
||||
(agent, plugin, tuns)
|
||||
}
|
||||
|
||||
/// An agent whose interface claims to be on the host but is not.
|
||||
///
|
||||
/// Used only by the missing-address test: everywhere else the in-memory
|
||||
/// interface is honest about what it is.
|
||||
async fn spawn_pretending_host(discovery: &SharedMemoryDiscovery, tag: &str) -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let tuns = MemoryTunFactory::new();
|
||||
let plugin = WireguardPlugin::open(
|
||||
WireguardConfig::new(dir.path().join("wireguard"))
|
||||
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let agent = Agent::spawn(
|
||||
config_with(dir.path(), discovery)
|
||||
.with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE))
|
||||
.with_interface(Arc::new(PretendHostTuns(tuns.clone())), tag, 1280)
|
||||
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
Self {
|
||||
dir,
|
||||
agent,
|
||||
plugin,
|
||||
tuns,
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_id(&self) -> EndpointId {
|
||||
self.agent.endpoint_id()
|
||||
}
|
||||
@@ -445,12 +515,13 @@ async fn an_allocated_address_missing_from_the_host_is_reported() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("wg-missing-address");
|
||||
|
||||
// The in-memory interface never carries the address, which is exactly
|
||||
// the situation of a real interface the operator has not configured yet.
|
||||
// Left unsaid, packets leave with the wrong source and every peer drops
|
||||
// them, which looks like a broken network rather than a missing command.
|
||||
let a = WgAgent::spawn(&discovery, "ta").await;
|
||||
let b = WgAgent::spawn(&discovery, "tb").await;
|
||||
// An interface that says it is on the host and never carries the
|
||||
// address: a provisioner that reported a success it did not achieve, or
|
||||
// something outside that took the address away. Left unsaid, packets
|
||||
// leave with the wrong source and every peer drops them, which looks
|
||||
// like a broken network rather than a missing command.
|
||||
let a = WgAgent::spawn_pretending_host(&discovery, "ta").await;
|
||||
let b = WgAgent::spawn_pretending_host(&discovery, "tb").await;
|
||||
|
||||
let mut events = a.agent.subscribe();
|
||||
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
@@ -484,6 +555,40 @@ async fn an_allocated_address_missing_from_the_host_is_reported() {
|
||||
b.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_interface_that_is_only_in_memory_is_not_reported_as_a_fault() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("wg-memory-interface");
|
||||
|
||||
// `--no-tun` is an arrangement, not a failure: the tunnels run and the
|
||||
// packets move between agents, and nothing was ever going to put an
|
||||
// address on a host interface that does not exist. Complaining about it
|
||||
// sent people looking for something that had removed their address.
|
||||
let a = WgAgent::spawn(&discovery, "ta").await;
|
||||
let b = WgAgent::spawn(&discovery, "tb").await;
|
||||
|
||||
let mut events = a.agent.subscribe();
|
||||
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
a.wait_for_tunnels(network_id, 1).await;
|
||||
// An address was allocated, so the report had every other reason to fire.
|
||||
let address = a.overlay(network_id).await;
|
||||
assert!(tsunagi::state::DEFAULT_IPV4_RANGE.contains(address));
|
||||
|
||||
settle().await;
|
||||
let complaint = std::iter::from_fn(|| events.try_recv().ok()).find(
|
||||
|event| matches!(event, Event::PluginError { reason, .. } if reason.contains("not on any")),
|
||||
);
|
||||
assert!(complaint.is_none(), "unexpected: {complaint:?}");
|
||||
|
||||
// And the interface says plainly what it is, which is what keeps the
|
||||
// resolver setting off a host interface of the same name.
|
||||
assert!(!a.agent.overlay().unwrap().on_host);
|
||||
|
||||
a.shutdown().await;
|
||||
b.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_address_is_kept_across_a_restart() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
|
||||
Reference in New Issue
Block a user