--- a/cmds/remowt-agent/Cargo.toml +++ b/cmds/remowt-agent/Cargo.toml @@ -4,22 +4,32 @@ edition = "2021" [dependencies] -anyhow = "1.0.86" +anyhow.workspace = true bifrostlink.workspace = true bifrostlink-ports.workspace = true -clap = { version = "4.5.13", features = ["derive"] } -futures = "0.3.30" -futures-util = "0.3.30" -nix = "0.29.0" -polkit-shared = { version = "0.1.0", path = "../../crates/polkit-shared" } -rand = "0.8.5" -remowt-link-shared = { version = "0.1.0", path = "../../crates/remowt-link-shared" } -serde = { version = "1.0.204", features = ["derive"] } -tokio = { version = "1.39.2", features = ["rt-multi-thread", "fs", "macros"] } -tokio-util = { version = "0.7.11", features = ["codec"] } -tracing = "0.1.40" -tracing-subscriber = "0.3.18" -ui-prompt = { version = "0.1.0", path = "../../crates/ui-prompt" } -uuid = { version = "1.10.0", features = ["v4"] } -zbus = { version = "4.4.0", features = ["tokio"] } -zbus_polkit = { version = "4.0.0", features = ["tokio"] } +clap = { workspace = true, features = ["derive"] } +futures.workspace = true +futures-util.workspace = true +nix.workspace = true +polkit-shared.workspace = true +rand.workspace = true +remowt-link-shared.workspace = true +remowt-pty.workspace = true +serde = { workspace = true, features = ["derive"] } +tempfile.workspace = true +tokio = { workspace = true, features = [ + "rt", + "fs", + "macros", + "net", + "io-util", + "time", + "process", +] } +tokio-util = { workspace = true, features = ["codec"] } +tracing.workspace = true +tracing-subscriber.workspace = true +ui-prompt.workspace = true +uuid = { workspace = true, features = ["v4"] } +zbus = { workspace = true, features = ["tokio"] } +zbus_polkit = { workspace = true, features = ["tokio"] } --- /dev/null +++ b/cmds/remowt-agent/src/askpass.rs @@ -0,0 +1,50 @@ +use std::borrow::Cow; +use std::io::Write as _; + +use anyhow::Context as _; +use ui_prompt::bifrost::PromptEndpointsClient; +use ui_prompt::dbus::{DbusPrompterInterface, DbusPrompterProxy}; +use ui_prompt::Source; +use zbus::Connection; + +use remowt_link_shared::BifConfig; + +const BUS_NAME: &str = "lach.RemowtAskpass"; +const PROMPTER_PATH: &str = "/lach/Askpass"; + +pub async fn serve( + conn: &Connection, + prompter: PromptEndpointsClient, +) -> anyhow::Result<()> { + conn.object_server() + .at(PROMPTER_PATH, DbusPrompterInterface(prompter)) + .await?; + conn.request_name(BUS_NAME).await?; + Ok(()) +} + +pub async fn ask(prompt: &str, description: String) -> anyhow::Result<()> { + let conn = Connection::session() + .await + .context("connecting to the session bus (DBUS_SESSION_BUS_ADDRESS)")?; + let proxy = DbusPrompterProxy::builder(&conn) + .destination(BUS_NAME)? + .path(PROMPTER_PATH)? + .build() + .await?; + + let password = proxy + .prompt_text( + false, + prompt, + &description, + &[Source(Cow::Borrowed("remowt-askpass"))], + ) + .await?; + + let mut out = std::io::stdout().lock(); + out.write_all(password.as_bytes())?; + out.write_all(b"\n")?; + out.flush()?; + Ok(()) +} --- /dev/null +++ b/cmds/remowt-agent/src/bus.rs @@ -0,0 +1,40 @@ +use std::process::Stdio; + +use anyhow::Context as _; +use futures::StreamExt as _; +use tokio::process::{Child, Command}; +use tokio_util::codec::{FramedRead, LinesCodec}; +use zbus::Connection; + +pub struct PrivateBus { + pub address: String, + pub conn: Connection, + _child: Child, +} + +pub async fn spawn() -> anyhow::Result { + let mut child = Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address"]) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .context("spawning dbus-daemon for the private bus")?; + + let stdout = child.stdout.take().expect("piped"); + let address = FramedRead::new(stdout, LinesCodec::new()) + .next() + .await + .context("dbus-daemon exited before printing its address")? + .context("reading dbus-daemon address")?; + + let conn = zbus::connection::Builder::address(address.as_str())? + .build() + .await + .context("connecting to the private bus")?; + + Ok(PrivateBus { + address, + conn, + _child: child, + }) +} --- /dev/null +++ b/cmds/remowt-agent/src/editor.rs @@ -0,0 +1,119 @@ +use std::env::{current_dir, temp_dir}; +use std::path::Path; +use std::time::Duration; +use std::{fs, io}; + +use anyhow::{bail, Context as _}; +use nix::libc; +use remowt_link_shared::editor::EditorEndpointsClient; +use tokio::process::Command; +use zbus::{fdo, interface, proxy, Connection}; + +use remowt_link_shared::BifConfig; + +const BUS_NAME: &str = "lach.RemowtEditor"; +const SERVICE_PATH: &str = "/lach/Editor"; + +pub struct EditorService { + editor: EditorEndpointsClient, +} + +#[interface(name = "lach.RemowtEditor")] +impl EditorService { + /// Attach the User's GUI to the nvim server at `socket_path` (on the remote), + /// blocking until the user is done. + async fn edit(&self, socket_path: String) -> fdo::Result<()> { + self.editor + .open_editor(socket_path) + .await + .map_err(|e| fdo::Error::Failed(format!("requesting editor on the User: {e}")))? + .map_err(|e| fdo::Error::Failed(format!("editor failed: {e}")))?; + Ok(()) + } +} + +pub async fn serve( + conn: &Connection, + editor: EditorEndpointsClient, +) -> anyhow::Result<()> { + conn.object_server() + .at(SERVICE_PATH, EditorService { editor }) + .await?; + conn.request_name(BUS_NAME).await?; + Ok(()) +} + +#[proxy(interface = "lach.RemowtEditor")] +trait RemowtEditor { + async fn edit(&self, socket_path: &str) -> fdo::Result<()>; +} + +pub async fn edit(path: String) -> anyhow::Result<()> { + let path = Path::new(&path); + let abs = if path.is_absolute() { + path.to_path_buf() + } else { + current_dir()?.join(path) + }; + + let sock = temp_dir().join(format!("remowt-nvim-{}.sock", uuid::Uuid::new_v4())); + let sock_str = sock + .to_str() + .context("temp socket path is not utf-8")? + .to_owned(); + + let mut child = Command::new("nvim"); + child + .arg("--headless") + .arg("--listen") + .arg(&sock) + .arg("--") + .arg(&abs) + .kill_on_drop(true); + // SAFETY: only an async-signal-safe `prctl` call. + unsafe { + child.pre_exec(|| { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = child.spawn().context("spawning nvim")?; + + wait_for_socket(&sock) + .await + .context("nvim did not start its server")?; + + let conn = Connection::session() + .await + .context("connecting to the session bus (DBUS_SESSION_BUS_ADDRESS)")?; + let proxy = RemowtEditorProxy::builder(&conn) + .destination(BUS_NAME)? + .path(SERVICE_PATH)? + .build() + .await?; + let result = proxy.edit(&sock_str).await; + + if tokio::time::timeout(Duration::from_secs(2), child.wait()) + .await + .is_err() + { + let _ = child.kill().await; + } + let _ = fs::remove_file(&sock); + + result?; + Ok(()) +} + +/// Poll for `path` to appear (nvim creating its listen socket), up to ~10s. +async fn wait_for_socket(path: &Path) -> anyhow::Result<()> { + for _ in 0..200 { + if tokio::fs::try_exists(path).await.unwrap_or(false) { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + bail!("timed out waiting for {}", path.display()) +} --- a/cmds/remowt-agent/src/main.rs +++ b/cmds/remowt-agent/src/main.rs @@ -1,29 +1,36 @@ use std::borrow::Cow; use std::collections::{BTreeMap, HashMap}; -use std::future; -use std::io::{stdout, Write}; +use std::fs::Permissions; +use std::future::pending; +use std::os::unix::fs::PermissionsExt as _; use std::path::PathBuf; use std::sync::{Arc, Mutex, OnceLock}; -use bifrostlink::{AddressT, Rpc}; +use bifrostlink::declarative::RemoteEndpoints; +use bifrostlink::Rpc; +use bifrostlink_ports::stdio::from_stdio; use bifrostlink_ports::unix_socket::from_socket; use clap::Parser; use polkit_shared::{emphasize, BackendRequest, Identity, PidDisplay}; -use remowt_link_shared::Address; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use remowt_link_shared::editor::EditorEndpointsClient; +use remowt_link_shared::{Address, BifConfig, Fs, Pty, Systemd}; +use tokio::fs; use tokio::net::UnixStream; -use tokio::runtime::Runtime; +use tokio::runtime::Builder; use tokio::task::AbortHandle; use tracing::{info, trace}; -use ui_prompt::rofi::RofiPrompter; +use ui_prompt::bifrost::PromptEndpointsClient; use ui_prompt::{PrependSourcePrompter, Prompter, Source}; use zbus::fdo; use zbus::zvariant::{OwnedValue, Str}; use zbus::{interface, proxy, Connection}; use zbus_polkit::policykit1::Subject; -use self::helper::{Helper, SuidHelper}; +use self::helper::{Helper, SocketHelper, SuidHelper}; +pub mod askpass; +pub mod bus; +pub mod editor; pub mod helper; struct CancelTaskOnDrop { @@ -44,23 +51,26 @@ } } -struct Agent { +struct Agent { tasks: Arc>>, helper: H, + prompter: P, } -impl Agent { - fn new(helper: H) -> Self { +impl Agent { + fn new(helper: H, prompter: P) -> Self { Agent { tasks: Arc::new(Mutex::new(HashMap::new())), helper, + prompter, } } } #[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")] -impl Agent +impl Agent where H: Helper + Clone + Send + Sync + 'static, + P: Prompter + Clone + Send + Sync + 'static, { /// BeginAuthentication method #[allow(clippy::too_many_arguments)] @@ -68,7 +78,7 @@ &self, action_id: String, message: String, - icon_name: String, + _icon_name: String, mut details: BTreeMap, cookie: String, identities: Vec, @@ -78,6 +88,7 @@ let _cancel_guard = Arc::new(OnceLock::new()); let task = { let helper = self.helper.clone(); + let prompter = self.prompter.clone(); let cookie = cookie.clone(); let _cancel_guard = _cancel_guard.clone(); tokio::task::spawn(async move { @@ -103,7 +114,7 @@ let mut prompter = PrependSourcePrompter { source: vec![Source(Cow::Borrowed("polkit agent"))], description: description.clone(), - prompter: RofiPrompter, + prompter, }; let identity_displays: Vec = @@ -194,76 +205,171 @@ #[derive(Parser)] enum Opts { - Agent, AskPass { + prompt: String, description: String, }, + Editor { + /// Argument to nvim + path: String, + }, RealAgent { #[arg(long)] - path: PathBuf, + path: Option, + /// Expect own address to be AgentPrivileged, skip installing polkit agent + #[arg(long)] + privileged: bool, }, } fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt::init(); + // Log to stderr: `privileged-agent` uses stdout as the bifrost transport, + // so anything written there would corrupt the stream. + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .init(); let opts = Opts::parse(); - let runtime = Runtime::new()?; + let runtime = Builder::new_current_thread().enable_all().build()?; match opts { - Opts::Agent => { - // TODO: Setup env, directories with various things... - runtime.block_on(main_agent()) - } - Opts::AskPass { description } => runtime.block_on(main_askpass(description)), - Opts::RealAgent { path } => runtime.block_on(main_real_agent(path)), + Opts::AskPass { + prompt, + description, + } => runtime.block_on(askpass::ask(&prompt, description)), + Opts::Editor { path } => runtime.block_on(editor::edit(path)), + Opts::RealAgent { path, privileged } => runtime.block_on(main_real_agent(path, privileged)), } } -async fn main_real_agent(path: PathBuf) -> anyhow::Result<()> { - let mut stream = UnixStream::connect(path).await?; - stream.write_all(b"REMOWT_HELLO\0").await?; - let mut buf = [0u8; 12]; - stream.read_exact(&mut buf).await?; - assert_eq!(&buf, b"REMOWT_EHLO\0"); - let port = from_socket(stream); - let rpc = Rpc::::new(Address::Agent); - rpc.add_direct(Address::User, port, bifrostlink::Rtt(0)); - Ok(()) +async fn main_real_agent(path: Option, privileged: bool) -> anyhow::Result<()> { + let address = if privileged { + Address::AgentPrivileged + } else { + Address::Agent + }; + let mut rpc = Rpc::::new(address); + + Fs::new().register_endpoints(&mut rpc); + Systemd.register_endpoints(&mut rpc); + Pty::new().register_endpoints(&mut rpc); + + let user_prompter = PromptEndpointsClient::wrap(rpc.remote(Address::User)); + let editor_client = EditorEndpointsClient::wrap(rpc.remote(Address::User)); + + let bus = bus::spawn().await?; + askpass::serve(&bus.conn, user_prompter.clone()).await?; + editor::serve(&bus.conn, editor_client).await?; + + let helpers = tempfile::Builder::new().prefix("remowt-path.").tempdir()?; + let exe = std::env::current_exe()?; + let askpass_helper = helpers.path().join("remowt-askpass"); + let editor_helper = helpers.path().join("remowt-editor"); + { + let script = format!( + "#!/bin/sh\nexec {} ask-pass \"password\" \"$1\"\n", + sh_quote(&exe.to_string_lossy()) + ); + fs::write(&askpass_helper, script).await?; + fs::set_permissions(&askpass_helper, Permissions::from_mode(0o755)).await?; + } + { + let script = format!( + "#!/bin/sh\nexec {} editor \"$1\"\n", + sh_quote(&exe.to_string_lossy()) + ); + fs::write(&editor_helper, script).await?; + fs::set_permissions(&editor_helper, Permissions::from_mode(0o755)).await?; + } + + // Safety: Hoping tokio own threads won't read any of those... + unsafe { + prepend_path(helpers.path()); + std::env::set_var("SUDO_ASKPASS", &askpass_helper); + std::env::set_var("SSH_ASKPASS", &askpass_helper); + std::env::set_var("SSH_ASKPASS_REQUIRE", "force"); + std::env::set_var("EDITOR", &editor_helper); + std::env::set_var("VISUAL", &editor_helper); + std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &bus.address); + } + + let port = match path { + Some(path) => from_socket(UnixStream::connect(path).await?), + None => from_stdio(), + }; + rpc.add_direct(Address::User, port, bifrostlink::Rtt(0)); + + let polkit_conn = if !privileged { + // The unprivileged agent doubles as a polkit authentication agent so + // `run0` (e.g. our own elevation) routes its prompt to the User over + // bifrost instead of failing on a tty-less session. + let conn = Connection::system().await?; + let helper = SocketHelper { + fallback: SuidHelper, + }; + register_auth_agent(&conn, Agent::new(helper, user_prompter)).await?; + Some(conn) + } else { + None + }; + + let _keep_alive = (bus, helpers, polkit_conn); + pending().await } -async fn main_agent() -> anyhow::Result<()> { - trace!("started"); - let conn = Connection::system().await?; +async fn register_auth_agent(conn: &Connection, agent: Agent) -> anyhow::Result<()> +where + H: Helper + Clone + Send + Sync + 'static, + P: Prompter + Clone + Send + Sync + 'static, +{ + let proxy = zbus_polkit::policykit1::AuthorityProxy::new(conn).await?; + conn.object_server().at(OBJ_PATH, agent).await?; - let proxy = zbus_polkit::policykit1::AuthorityProxy::new(&conn).await?; - conn.object_server() - .at(OBJ_PATH, Agent::new(SuidHelper)) + let subject = auth_agent_subject()?; + proxy + .register_authentication_agent(&subject, "C", OBJ_PATH) .await?; + info!(kind = subject.subject_kind, "registered polkit agent"); + Ok(()) +} - let session_id = std::env::var("XDG_SESSION_ID")?; +fn auth_agent_subject() -> anyhow::Result { let mut details = HashMap::new(); - let val: OwnedValue = { - let wrapped: Str<'_> = session_id.into(); - wrapped.into() - }; - details.insert("session-id".to_string(), val); - proxy - .register_authentication_agent( - &Subject { - subject_kind: "unix-session".to_string(), - subject_details: details, - }, - "C", - OBJ_PATH, - ) - .await?; - future::pending().await + if let Ok(session_id) = std::env::var("XDG_SESSION_ID") { + let val: OwnedValue = Str::from(session_id).into(); + details.insert("session-id".to_string(), val); + return Ok(Subject { + subject_kind: "unix-session".to_string(), + subject_details: details, + }); + } + + details.insert("pid".to_string(), OwnedValue::from(std::process::id())); + Ok(Subject { + subject_kind: "unix-process".to_string(), + subject_details: details, + }) +} + +fn sh_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) } -async fn main_askpass(description: String) -> anyhow::Result<()> { - let password = RofiPrompter - .prompt_text(false, &description, "SSH password request", &[]) - .await?; - stdout().lock().write_all(password.as_bytes())?; - future::pending().await +/// Prepend `dir` to the process `PATH`. +/// +/// # SAFETY +/// +/// Same as `set_var` +unsafe fn prepend_path(dir: &std::path::Path) { + let value = match std::env::var_os("PATH") { + Some(existing) => { + let mut v = dir.as_os_str().to_owned(); + v.push(":"); + v.push(existing); + v + } + None => dir.as_os_str().to_owned(), + }; + unsafe { + std::env::set_var("PATH", value); + } }