Fixec join CLI

This commit is contained in:
ab
2026-09-22 16:32:39 +03:00
parent 686c74a3bd
commit d1a0eca723
5 changed files with 151 additions and 26 deletions
+12 -6
View File
@@ -480,12 +480,18 @@ The original IP packet, including its DF bit, is preserved. See
| `sudo tsunagi up` | root | everything works, nothing is isolated | | `sudo tsunagi up` | root | everything works, nothing is isolated |
| `--no-tun` | ordinary user, no capabilities | tunnels run and handshake, traffic never reaches the OS | | `--no-tun` | ordinary user, no capabilities | tunnels run and handshake, traffic never reaches the OS |
**Not implemented yet.** macOS and Windows have no provisioner: both need On Windows, put `wintun.dll` beside the executable and start `tsunagi up`
real platform work — `utun` and `SystemConfiguration` on one, the IP Helper from PowerShell or Command Prompt opened with **Run as administrator**.
API and a Wintun adapter on the other. There the agent says so and `--no-tun` Run commands controlling that agent (`join`, `status`, `dns`, `network`)
is the way to run it; the control plane and the tunnels are unaffected. The as the same Windows user with the same elevation. If Windows denies access
decision logic that says *what* to change is shared and tested on every to the agent's control pipe, the command reports a permission error instead
platform, so only the execution is left to write. of claiming the agent is absent or trying to edit its locked state.
Creating a TUN without sufficient privileges also explains how to restart
the agent with the required permissions.
**Not implemented yet.** macOS has no provisioner; `--no-tun` is the way to
run it. The control plane and tunnels are unaffected. The decision logic
that says *what* to change is shared and tested on every platform.
## Checks ## Checks
+2 -8
View File
@@ -2256,14 +2256,8 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
// Nothing running is an ordinary answer to "what is running", not // Nothing running is an ordinary answer to "what is running", not
// a fault; a socket that will not answer is a fault. // a fault; a socket that will not answer is a fault.
agent.push(if *socket_present { agent.push(if *socket_present {
// The version check in the framing names a mismatch only for Row::new(Health::Degraded, "running", "not answering")
// whichever side is newer. An older agent reading a newer .with_note(format!("{why} · the rest was read from the store"))
// request just drops the connection, so the hint has to be
// offered rather than asserted.
Row::new(Health::Degraded, "running", "not answering").with_note(format!(
"{why} · it may be an older build: restart it with this binary. \
The rest was read from the store"
))
} else { } else {
Row::new(Health::Info, "running", "no") Row::new(Health::Info, "running", "no")
.with_note(format!("{why} · the rest was read from the store")) .with_note(format!("{why} · the rest was read from the store"))
+2
View File
@@ -584,6 +584,8 @@ async fn exchange(path: &Path, request: &Request, within: Duration) -> Result<Re
/// ///
/// Used to tell a running agent from a leftover: on Unix a socket file with /// Used to tell a running agent from a leftover: on Unix a socket file with
/// nothing behind it, on Windows nothing at all. /// nothing behind it, on Windows nothing at all.
/// A Windows pipe that denies access still counts as present; the subsequent
/// request reports the permission error instead of attempting an offline edit.
pub async fn is_serving(path: impl AsRef<Path>) -> bool { pub async fn is_serving(path: impl AsRef<Path>) -> bool {
transport::probe(path.as_ref()).await transport::probe(path.as_ref()).await
} }
+58 -5
View File
@@ -25,6 +25,8 @@
//! other unprivileged users, the default is the practical equivalent of the //! other unprivileged users, the default is the practical equivalent of the
//! owner-only Unix socket. There is no leftover to clean up: a pipe exists only //! owner-only Unix socket. There is no leftover to clean up: a pipe exists only
//! while its server does. //! while its server does.
//! Commands must run as the same Windows user with the same elevation as the
//! agent. A permission denial is reported as such, never as an absent agent.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -41,8 +43,8 @@ pub use super::{
request_status, set_active, set_dns, set_hostname, request_status, set_active, set_dns, set_hostname,
}; };
/// `ERROR_ACCESS_DENIED`: what creating the first pipe instance returns when /// `ERROR_ACCESS_DENIED`: creating a first instance finds an existing pipe,
/// one already exists, so another agent already owns the name. /// or a client cannot access an existing pipe with its current privileges.
const ERROR_ACCESS_DENIED: i32 = 5; const ERROR_ACCESS_DENIED: i32 = 5;
/// `ERROR_PIPE_BUSY`: every instance is serving a client right now. A server /// `ERROR_PIPE_BUSY`: every instance is serving a client right now. A server
/// is there; the client only has to wait for a free instance. /// is there; the client only has to wait for a free instance.
@@ -177,6 +179,20 @@ pub(crate) async fn connect(path: &Path) -> Result<NamedPipeClient> {
Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => { Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => {
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
} }
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
return Err(Error::Io {
path: path.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"the agent's control pipe exists, but Windows denied access. \
Run this command as the same Windows user and with the same \
elevation as `tsunagi up` (use an administrator terminal if \
the agent is elevated). Windows error: {err}"
),
),
});
}
Err(source) => { Err(source) => {
return Err(Error::Io { return Err(Error::Io {
path: path.to_path_buf(), path: path.to_path_buf(),
@@ -189,13 +205,17 @@ pub(crate) async fn connect(path: &Path) -> Result<NamedPipeClient> {
/// Whether an agent is serving the pipe for `path`. /// Whether an agent is serving the pipe for `path`.
/// ///
/// A busy pipe still means a server is there; only a name nothing has created /// A busy or inaccessible pipe still means a server is there. In particular,
/// counts as not serving. /// access denied must not send a mutating command down the offline path,
/// where it would only produce a misleading state-directory lock error.
pub(crate) async fn probe(path: &Path) -> bool { pub(crate) async fn probe(path: &Path) -> bool {
let name = pipe_name(path); let name = pipe_name(path);
match ClientOptions::new().open(&name) { match ClientOptions::new().open(&name) {
Ok(_) => true, Ok(_) => true,
Err(err) => err.raw_os_error() == Some(ERROR_PIPE_BUSY), Err(err) => {
err.raw_os_error() == Some(ERROR_PIPE_BUSY)
|| err.kind() == std::io::ErrorKind::PermissionDenied
}
} }
} }
@@ -248,6 +268,39 @@ mod tests {
control.shutdown().await; control.shutdown().await;
} }
#[tokio::test]
async fn an_inaccessible_agent_is_not_mistaken_for_an_absent_one() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent.sock");
// An outbound-only pipe denies the client's duplex open even when
// both processes have the same privileges. This exercises the same
// Windows error as a client unable to access an elevated agent,
// without requiring elevation or changing any ACLs.
let _server = ServerOptions::new()
.first_pipe_instance(true)
.access_inbound(false)
.create(pipe_name(&path))
.unwrap();
assert!(is_serving(&path).await, "access denied is not absence");
for error in [
request_status(&path).await.unwrap_err(),
join_network(&path, "test", "local-ipc-test-secret")
.await
.unwrap_err(),
set_dns(&path, false, None).await.unwrap_err(),
] {
let Error::Io { source, .. } = error else {
panic!("expected an access error, got {error}");
};
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
let message = source.to_string();
assert!(message.contains("same Windows user"), "{message}");
assert!(message.contains("administrator"), "{message}");
assert!(!message.contains("local-ipc-test-secret"), "{message}");
}
}
#[tokio::test] #[tokio::test]
async fn a_silent_agent_is_reported_rather_than_waited_out() { async fn a_silent_agent_is_reported_rather_than_waited_out() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+77 -7
View File
@@ -401,13 +401,7 @@ mod system {
// applies those to the interface separately, the same way on every // applies those to the interface separately, the same way on every
// platform, so the plan stays the single source of what it carries. // platform, so the plan stays the single source of what it carries.
let device = tun::create_as_async(&config).map_err(|err| { let device = tun::create_as_async(&config).map_err(|err| open_error(&request.name, err))?;
OverlayError::Unavailable(format!(
"cannot create the TUN interface `{}`: {err}. {}",
request.name,
open_hint()
))
})?;
let (reader, writer) = tokio::io::split(device); let (reader, writer) = tokio::io::split(device);
Ok(Arc::new(SystemTun { Ok(Arc::new(SystemTun {
@@ -418,6 +412,44 @@ mod system {
}) as Arc<dyn TunDevice>) }) as Arc<dyn TunDevice>)
} }
fn open_error(name: &str, error: tun::Error) -> OverlayError {
// Wintun preserves the Windows code in its I/O variant. Normalize it
// before testing permissions; matching localized error text would
// miss the same failure on another Windows language.
#[cfg(target_os = "windows")]
let error = match error {
tun::Error::WintunError(error) => tun::Error::Io(error.into()),
error => error,
};
let denied = match &error {
tun::Error::Io(error) => {
error.kind() == std::io::ErrorKind::PermissionDenied
|| (cfg!(target_os = "windows")
&& matches!(error.raw_os_error(), Some(5 | 740 | 1314)))
}
_ => false,
};
if denied {
let hint = if cfg!(target_os = "windows") {
"Open PowerShell or Command Prompt with 'Run as administrator' and \
start `tsunagi up` there."
} else if cfg!(target_os = "linux") {
"Start `tsunagi up` as root or grant CAP_NET_ADMIN with \
`sudo setcap cap_net_admin+p /path/to/tsunagi`."
} else {
"Start `tsunagi up` with the privileges required to create a TUN interface."
};
return OverlayError::Unavailable(format!(
"permission denied while creating the TUN interface `{name}`. \
{hint} OS error: {error}"
));
}
OverlayError::Unavailable(format!(
"cannot create the TUN interface `{name}`: {error}. {}",
open_hint()
))
}
/// The platform-appropriate tail of a "cannot create the interface" error: /// The platform-appropriate tail of a "cannot create the interface" error:
/// what privilege it needs and the fallback that always works. /// what privilege it needs and the fallback that always works.
fn open_hint() -> &'static str { fn open_hint() -> &'static str {
@@ -437,4 +469,42 @@ mod system {
"Run with `--no-tun` to keep the tunnels off the operating system." "Run with `--no-tun` to keep the tunnels off the operating system."
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_tun_permission_error_explains_how_to_start_the_agent() {
let error = tun::Error::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
let message = open_error("tsun0", error).to_string();
assert!(message.contains("permission denied"), "{message}");
assert!(message.contains("tsun0"), "{message}");
assert!(message.contains("tsunagi up"), "{message}");
#[cfg(target_os = "windows")]
assert!(message.contains("Run as administrator"), "{message}");
#[cfg(target_os = "linux")]
assert!(message.contains("CAP_NET_ADMIN"), "{message}");
}
#[cfg(target_os = "windows")]
#[test]
fn wintun_access_and_elevation_errors_get_the_permission_hint() {
for code in [5, 740, 1314] {
let error = tun::Error::WintunError(std::io::Error::from_raw_os_error(code).into());
let message = open_error("tsun0", error).to_string();
assert!(message.contains("permission denied"), "{message}");
assert!(message.contains("Run as administrator"), "{message}");
assert!(message.contains(&format!("os error {code}")), "{message}");
assert!(!message.contains("wintun.dll"), "{message}");
}
}
#[test]
fn other_tun_errors_keep_their_cause_without_claiming_access_was_denied() {
let message = open_error("tsun0", tun::Error::InvalidName).to_string();
assert!(message.contains("invalid device tun name"), "{message}");
assert!(!message.contains("permission denied"), "{message}");
}
}
} }