git.delta.rocks / remowt / refs/commits / 960ee4bca68c

difftreelog

feat remowt-askpass, remowt-editor

qprrsuurYaroslav Bolyukin2026-01-25parent: #5f64fb1.patch.diff
in: trunk

5 files changed

modifiedcmds/remowt-agent/Cargo.tomldiffbeforeafterboth
4edition = "2021"4edition = "2021"
55
6[dependencies]6[dependencies]
7anyhow = "1.0.86"7anyhow.workspace = true
8bifrostlink.workspace = true8bifrostlink.workspace = true
9bifrostlink-ports.workspace = true9bifrostlink-ports.workspace = true
10clap = { version = "4.5.13", features = ["derive"] }10clap = { workspace = true, features = ["derive"] }
11futures = "0.3.30"11futures.workspace = true
12futures-util = "0.3.30"12futures-util.workspace = true
13nix = "0.29.0"13nix.workspace = true
14polkit-shared = { version = "0.1.0", path = "../../crates/polkit-shared" }14polkit-shared.workspace = true
15rand = "0.8.5"15rand.workspace = true
16remowt-link-shared = { version = "0.1.0", path = "../../crates/remowt-link-shared" }16remowt-link-shared.workspace = true
17remowt-pty.workspace = true
17serde = { version = "1.0.204", features = ["derive"] }18serde = { workspace = true, features = ["derive"] }
19tempfile.workspace = true
18tokio = { version = "1.39.2", features = ["rt-multi-thread", "fs", "macros"] }20tokio = { workspace = true, features = [
21 "rt",
22 "fs",
23 "macros",
24 "net",
25 "io-util",
26 "time",
27 "process",
28] }
19tokio-util = { version = "0.7.11", features = ["codec"] }29tokio-util = { workspace = true, features = ["codec"] }
20tracing = "0.1.40"30tracing.workspace = true
21tracing-subscriber = "0.3.18"31tracing-subscriber.workspace = true
22ui-prompt = { version = "0.1.0", path = "../../crates/ui-prompt" }32ui-prompt.workspace = true
23uuid = { version = "1.10.0", features = ["v4"] }33uuid = { workspace = true, features = ["v4"] }
24zbus = { version = "4.4.0", features = ["tokio"] }34zbus = { workspace = true, features = ["tokio"] }
25zbus_polkit = { version = "4.0.0", features = ["tokio"] }35zbus_polkit = { workspace = true, features = ["tokio"] }
2636
addedcmds/remowt-agent/src/askpass.rsdiffbeforeafterboth
--- /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<BifConfig>,
+) -> 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(())
+}
addedcmds/remowt-agent/src/bus.rsdiffbeforeafterboth
--- /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<PrivateBus> {
+	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,
+	})
+}
addedcmds/remowt-agent/src/editor.rsdiffbeforeafterboth
--- /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<BifConfig>,
+}
+
+#[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<BifConfig>,
+) -> 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())
+}
modifiedcmds/remowt-agent/src/main.rsdiffbeforeafterboth
--- 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<H> {
+struct Agent<H, P> {
 	tasks: Arc<Mutex<HashMap<String, AbortHandle>>>,
 	helper: H,
+	prompter: P,
 }
-impl<H> Agent<H> {
-	fn new(helper: H) -> Self {
+impl<H, P> Agent<H, P> {
+	fn new(helper: H, prompter: P) -> Self {
 		Agent {
 			tasks: Arc::new(Mutex::new(HashMap::new())),
 			helper,
+			prompter,
 		}
 	}
 }
 
 #[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
-impl<H> Agent<H>
+impl<H, P> Agent<H, P>
 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<String, String>,
 		cookie: String,
 		identities: Vec<Identity>,
@@ -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<String> =
@@ -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<PathBuf>,
+		/// 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::<Address, remowt_link_shared::Error>::new(Address::Agent);
-    rpc.add_direct(Address::User, port, bifrostlink::Rtt(0));
-    Ok(())
+async fn main_real_agent(path: Option<PathBuf>, privileged: bool) -> anyhow::Result<()> {
+	let address = if privileged {
+		Address::AgentPrivileged
+	} else {
+		Address::Agent
+	};
+	let mut rpc = Rpc::<BifConfig>::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<H, P>(conn: &Connection, agent: Agent<H, P>) -> 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<Subject> {
 	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);
+	}
 }