Fixec join CLI
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user