fix(client): reuse existing relay host route

A crashed or forcibly killed Windows client can leave the scoped relay host
route behind. The next run should still be allowed to start when Windows says
that the exact route row already exists, because that route already protects the
relay path from TAP default-route takeover.

Handle ERROR_OBJECT_ALREADY_EXISTS from CreateIpForwardEntry2 as a successful
borrowed pin. Routes created by the current client are still deleted on Drop;
pre-existing routes are left alone so we do not remove administrator-managed or
stale routes that this process did not create. The client startup log now marks
whether the route was created or already existed, and the README and MVP test
guide explain the behavior.

Test Plan:
All cargo commands used these environment variables:
RUSTUP_HOME=/tmp/softlan-vpn-rustup
CARGO_HOME=/tmp/softlan-vpn-cargo

- cargo fmt --check
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- cargo check -p lanparty-client-route --tests
  --target x86_64-pc-windows-gnu
- cargo check -p lanparty-client-route --tests
  --target x86_64-pc-windows-msvc
- cargo clippy -p lanparty-client-route --tests
  --target x86_64-pc-windows-gnu -- -D warnings
- cargo clippy -p lanparty-client-route --tests
  --target x86_64-pc-windows-msvc -- -D warnings
- git diff --check

Known limitation: full lanparty-client-win Windows cross-check is still blocked
on this Linux host by the external ring toolchain setup. The GNU target lacks
x86_64-w64-mingw32-gcc, and the MSVC target lacks lib.exe/MSVC environment.

Refs: PLAN.md route-protection requirement
This commit is contained in:
2026-05-22 04:40:05 +02:00
parent 0784e73f30
commit abc75831cb
4 changed files with 60 additions and 13 deletions
+42 -7
View File
@@ -7,7 +7,7 @@ use std::{
use anyhow::{Context, Result, bail};
use windows_sys::Win32::{
Foundation::ERROR_SUCCESS,
Foundation::{ERROR_OBJECT_ALREADY_EXISTS, ERROR_SUCCESS},
NetworkManagement::{
IpHelper::{
ConvertInterfaceGuidToLuid, ConvertInterfaceLuidToIndex, CreateIpForwardEntry2,
@@ -439,25 +439,39 @@ pub fn pin_relay_route(route: &RouteSnapshot) -> Result<PinnedRelayRoute> {
// valid host destination, next hop, and interface identity.
CreateIpForwardEntry2(&pinned.row)
};
windows_status(status).with_context(|| {
let disposition = route_create_disposition(status).with_context(|| {
format!(
"failed to pin relay route to {} via interface index {}",
route.destination(),
route.interface_index()
)
})?;
pinned.active = true;
pinned.delete_on_drop = disposition == RouteCreateDisposition::Created;
Ok(pinned)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RouteCreateDisposition {
Created,
AlreadyExists,
}
fn route_create_disposition(status: u32) -> io::Result<RouteCreateDisposition> {
match status {
ERROR_SUCCESS => Ok(RouteCreateDisposition::Created),
ERROR_OBJECT_ALREADY_EXISTS => Ok(RouteCreateDisposition::AlreadyExists),
_ => Err(io::Error::from_raw_os_error(status as i32)),
}
}
pub struct PinnedRelayRoute {
row: MIB_IPFORWARD_ROW2,
destination: IpAddr,
next_hop: Option<IpAddr>,
interface_index: u32,
interface_luid: u64,
active: bool,
delete_on_drop: bool,
}
impl PinnedRelayRoute {
@@ -480,6 +494,11 @@ impl PinnedRelayRoute {
pub const fn interface_luid(&self) -> u64 {
self.interface_luid
}
#[must_use]
pub const fn created_by_client(&self) -> bool {
self.delete_on_drop
}
}
impl fmt::Debug for PinnedRelayRoute {
@@ -489,14 +508,14 @@ impl fmt::Debug for PinnedRelayRoute {
.field("next_hop", &self.next_hop)
.field("interface_index", &self.interface_index)
.field("interface_luid", &self.interface_luid)
.field("active", &self.active)
.field("delete_on_drop", &self.delete_on_drop)
.finish_non_exhaustive()
}
}
impl Drop for PinnedRelayRoute {
fn drop(&mut self) {
if !self.active {
if !self.delete_on_drop {
return;
}
@@ -532,7 +551,7 @@ fn pinned_route_row(route: &RouteSnapshot) -> PinnedRelayRoute {
next_hop,
interface_index: route.interface_index(),
interface_luid: route.interface_luid(),
active: false,
delete_on_drop: false,
}
}
@@ -685,6 +704,22 @@ mod tests {
assert_eq!(pinned.row.SitePrefixLength, 32);
assert_eq!(pinned.row.Metric, 0);
assert_eq!(pinned.row.Protocol, RouteProtocolNetMgmt);
assert!(!pinned.created_by_client());
}
#[test]
fn classifies_route_create_statuses() {
use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER;
assert_eq!(
route_create_disposition(ERROR_SUCCESS).unwrap(),
RouteCreateDisposition::Created
);
assert_eq!(
route_create_disposition(ERROR_OBJECT_ALREADY_EXISTS).unwrap(),
RouteCreateDisposition::AlreadyExists
);
assert!(route_create_disposition(ERROR_INVALID_PARAMETER).is_err());
}
#[test]