> = LazyLock::new(|| {
+ [
+ // pam ssh agent auth
+ "SSH_AUTH_SOCK",
+ // ssh itself provides this when running PAM
+ "SSH_AUTH_INFO_0",
+ // contains user which ran sudo
+ "SUDO_USER",
+ ]
+ .into_iter()
+ .collect()
+});
+
+struct Conversation(P);
+impl Conversation {
+ fn prompt_inner(&self, echo: bool, prompt: &CStr) -> Result {
+ trace!("do prompt");
+ let out = self
+ .0
+ .prompt_text(echo, &prompt.to_string_lossy(), "PAM prompt request", &[])
+ .map_err(|e| {
+ trace!("prompt error: {e}");
+ ErrorCode::CONV_ERR
+ })?;
+ CString::new(out).map_err(|_| ErrorCode::CONV_AGAIN)
+ }
+ fn text_inner(&self, error: bool, msg: &CStr) {
+ trace!("do text");
+ let msg = msg.to_string_lossy();
+ let _ = self.0.display_text(error, &msg, &[]);
+ }
+}
+impl ConversationHandler for Conversation {
+ fn prompt_echo_on(&mut self, prompt: &CStr) -> Result {
+ self.prompt_inner(true, prompt)
+ }
+
+ fn prompt_echo_off(&mut self, prompt: &CStr) -> Result {
+ self.prompt_inner(false, prompt)
+ }
+
+ fn text_info(&mut self, msg: &CStr) {
+ self.text_inner(false, msg)
+ }
+
+ fn error_msg(&mut self, msg: &CStr) {
+ self.text_inner(true, msg)
+ }
+
+ fn radio_prompt(&mut self, prompt: &CStr) -> Result {
+ let prompt = prompt.to_string_lossy();
+ let result = self
+ .0
+ .prompt_radio(&prompt, "PAM prompt request", &[])
+ .map_err(|_| ErrorCode::CONV_ERR)?;
+ Ok(result)
+ }
+}
+
+#[proxy(
+ default_service = "org.freedesktop.DBus",
+ default_path = "/org/freedesktop/DBus"
+)]
+trait DBus {
+ fn get_connection_credentials(&self, body: &str) -> zbus::Result>;
+}
+
+#[interface(name = "lach.PolkitHelper")]
+impl Helper {
+ async fn init_conversation(
+ &self,
+ request: BackendRequest,
+ #[zbus(header)] hdr: Header<'_>,
+ ) -> fdo::Result<()> {
+ let Some(sender) = hdr.sender().map(|v| v.to_owned()) else {
+ trace!("missing sender");
+ return Err(fdo::Error::AuthFailed("missing sender".to_owned()));
+ };
+
+ let dbus = DBusProxy::new(&self.connection).await?;
+
+ // TOCTOU: sender might be already disconnected, and there might be another
+ // user with different user id here, but does it matters?
+ let reply = dbus.get_connection_credentials(&sender).await?;
+ let connection_uid: u32 = (&reply["UnixUserID"]).try_into().unwrap();
+
+ let identity = request.identity.clone();
+ let blocking_connection = self.blocking_connection.clone();
+ let thread_result: fdo::Result<()> = block_in_place(move || {
+ trace!("find user");
+ let Some(identity_uid) = identity.uid() else {
+ return Err(fdo::Error::AuthFailed("can't process identity".to_owned()));
+ };
+ let user = User::from_uid(identity_uid)
+ .map_err(|_| fdo::Error::AuthFailed("error querying user".to_owned()))?
+ .ok_or_else(|| fdo::Error::AuthFailed("uid not found".to_owned()))?;
+
+ let responder = DbusPrompterProxyBlocking::new(
+ &blocking_connection,
+ sender,
+ request.prompter_path,
+ )?;
+ let conversation = Conversation(responder);
+ trace!("run context for {}", &user.name);
+ let mut ctx = Context::new(
+ // TODO: Should another scope be used?
+ "login",
+ Some(&user.name),
+ conversation,
+ )
+ .map_err(|_| fdo::Error::Failed("pam context init failed".to_owned()))?;
+
+ trace!("fill env");
+ for (k, v) in request.environment {
+ if k.contains('=') || !ALLOWED_ENVIRONMENT.contains(k.as_str()) {
+ continue;
+ }
+ let _ = ctx.putenv(format!("{k}={v}"));
+ }
+
+ trace!("authenticate");
+ ctx.authenticate(Flag::NONE)
+ .map_err(|_| fdo::Error::AuthFailed("pam authentication failed".to_owned()))?;
+
+ trace!("acct mgmt");
+ ctx.acct_mgmt(Flag::NONE)
+ .map_err(|_| fdo::Error::AuthFailed("pam acct mgmt failed".to_owned()))?;
+
+ Ok(())
+ });
+
+ thread_result?;
+
+ trace!("respond");
+ let proxy = zbus_polkit::policykit1::AuthorityProxy::new(&self.connection).await?;
+
+ let identity_details = request
+ .identity
+ .details
+ .iter()
+ .map(|(k, v)| (k.as_str(), (**v).try_clone().expect("success")))
+ .collect::>();
+ proxy
+ .authentication_agent_response2(
+ connection_uid,
+ &request.cookie,
+ &zbus_polkit::policykit1::Identity {
+ identity_kind: &request.identity.kind,
+ identity_details: &identity_details,
+ },
+ )
+ .await?;
+ Ok(())
+ }
+}
+
+const OBJ_PATH: &str = "/lach/PolkitHelper";
+
+#[derive(Parser)]
+struct Opts {
+ /// Not recommended: start as a session connection, then use escalation
+ /// to respond to polkit requests.
+ #[arg(long)]
+ session: bool,
+}
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ tracing_subscriber::fmt::init();
+ let opts = Opts::parse();
+ let connection = if opts.session {
+ Connection::session().await
+ } else {
+ Connection::system().await
+ }
+ .context("failed to open connection")?;
+
+ let session = opts.session;
+ let blocking_connection: anyhow::Result = spawn_blocking(move || {
+ Ok(if session {
+ blocking::Connection::session()?
+ } else {
+ blocking::Connection::system()?
+ })
+ })
+ .await?;
+ let blocking_connection = blocking_connection.context("failed to open blocking connection")?;
+
+ if opts.session {
+ setuid(Uid::from_raw(0))
+ .context("polkit-backend needs to be suid if run in session mode")?;
+ }
+
+ connection
+ .object_server()
+ .at(
+ OBJ_PATH,
+ Helper {
+ connection: connection.clone(),
+ blocking_connection,
+ },
+ )
+ .await
+ .context("failed listen path")?;
+
+ connection
+ .request_name("lach.polkit.helper1")
+ .await
+ .context("failed to request name")?;
+
+ pending().await
+}
--- /dev/null
+++ b/remowt/cmds/remowt-agent/Cargo.toml
@@ -0,0 +1,35 @@
+[package]
+name = "remowt-agent"
+description = "remowt on-host agent serving fs/pty/systemd endpoints over bifrostlink"
+version.workspace = true
+edition = "2021"
+license.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+bifrostlink.workspace = true
+bifrostlink-ports.workspace = true
+clap = { workspace = true, features = ["derive"] }
+futures.workspace = true
+nix.workspace = true
+remowt-polkit-shared.workspace = true
+remowt-link-shared.workspace = true
+remowt-plugin.workspace = true
+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
+remowt-ui-prompt.workspace = true
+uuid = { workspace = true, features = ["v4"] }
+zbus = { workspace = true, features = ["tokio"] }
+zbus_polkit = { workspace = true, features = ["tokio"] }
+remowt-endpoints.workspace = true
--- /dev/null
+++ b/remowt/cmds/remowt-agent/src/askpass.rs
@@ -0,0 +1,51 @@
+use std::borrow::Cow;
+use std::io::Write as _;
+
+use anyhow::Context as _;
+use remowt_ui_prompt::dbus::{DbusPrompterInterface, DbusPrompterProxy, BUS_NAME, PROMPTER_PATH};
+use remowt_ui_prompt::{Prompter, Source};
+use tracing::debug;
+use zbus::Connection;
+
+pub async fn serve(conn: &Connection, prompter: P) -> anyhow::Result<()>
+where
+ P: Prompter + 'static,
+{
+ conn.object_server()
+ .at(PROMPTER_PATH, DbusPrompterInterface(prompter))
+ .await?;
+ match conn.request_name(BUS_NAME).await {
+ Ok(()) => {}
+ Err(zbus::Error::NameTaken) => {
+ debug!("{BUS_NAME} already owned, chaining to upstream");
+ }
+ Err(e) => return Err(e.into()),
+ }
+ 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/remowt/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/remowt/cmds/remowt-agent/src/editor.rs
@@ -0,0 +1,158 @@
+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(())
+ }
+
+ async fn forward_tcp(&self, addr: String) -> fdo::Result {
+ let local = self
+ .editor
+ .expose_tcp(addr)
+ .await
+ .map_err(|e| fdo::Error::Failed(format!("requesting tcp forward on the User: {e}")))?
+ .map_err(|e| fdo::Error::Failed(format!("tcp forward failed: {e}")))?;
+ Ok(local)
+ }
+
+ async fn forward_udp(&self, addr: String) -> fdo::Result {
+ let local = self
+ .editor
+ .expose_udp(addr)
+ .await
+ .map_err(|e| fdo::Error::Failed(format!("requesting udp forward on the User: {e}")))?
+ .map_err(|e| fdo::Error::Failed(format!("udp forward failed: {e}")))?;
+ Ok(local)
+ }
+}
+
+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<()>;
+ async fn forward_tcp(&self, addr: &str) -> fdo::Result;
+ async fn forward_udp(&self, addr: &str) -> fdo::Result;
+}
+
+pub async fn forward(udp: bool, addr: String) -> anyhow::Result<()> {
+ 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 local = if udp {
+ proxy.forward_udp(&addr).await?
+ } else {
+ proxy.forward_tcp(&addr).await?
+ };
+ println!("{local}");
+ Ok(())
+}
+
+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(())
+}
+
+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())
+}
--- /dev/null
+++ b/remowt/cmds/remowt-agent/src/helper/dbus.rs
@@ -0,0 +1,81 @@
+use std::collections::HashMap;
+use std::marker::PhantomData;
+
+use remowt_polkit_shared::{BackendRequest, Identity};
+use remowt_ui_prompt::dbus::DbusPrompterInterface;
+use remowt_ui_prompt::Prompter;
+use zbus::Connection;
+
+use crate::PolkitHelperProxy;
+
+use super::Helper;
+
+struct TemporaryPrompterInterface {
+ connection: Connection,
+ path: String,
+ _marker: PhantomData,
+}
+impl TemporaryPrompterInterface {
+ async fn new(connection: Connection, prompter: P) -> Self {
+ let path = format!(
+ "/remowt/prompters/{}",
+ uuid::Uuid::new_v4().to_string().replace("-", "_")
+ );
+ let _ = connection
+ .object_server()
+ .at(path.clone(), DbusPrompterInterface(prompter))
+ .await;
+ Self {
+ connection,
+ path,
+ _marker: PhantomData,
+ }
+ }
+}
+impl Drop for TemporaryPrompterInterface {
+ fn drop(&mut self) {
+ // Removal is async because of async RwLock used inside...
+ // We should not care about its reuse
+ let connection = self.connection.clone();
+ let path = std::mem::take(&mut self.path);
+ tokio::spawn(async move {
+ let _ = connection
+ .object_server()
+ .remove::, String>(path)
+ .await;
+ });
+ }
+}
+
+#[derive(Clone)]
+pub struct DbusHelper {
+ connection: Connection,
+ helper: PolkitHelperProxy<'static>,
+}
+impl DbusHelper {
+ pub async fn new(connection: Connection) -> zbus::Result {
+ let helper = PolkitHelperProxy::new(&connection).await?;
+ Ok(Self { connection, helper })
+ }
+}
+impl Helper for DbusHelper {
+ async fn help_me(
+ &self,
+ cookie: &str,
+ prompter: P,
+ identity: Identity,
+ ) -> anyhow::Result<()> {
+ let prompter = TemporaryPrompterInterface::new(self.connection.clone(), prompter).await;
+ self.helper
+ .init_conversation(
+ BackendRequest {
+ cookie: cookie.to_owned(),
+ environment: HashMap::new(),
+ prompter_path: prompter.path.clone(),
+ identity,
+ }, // cookie.to_owned(), HashMap::new(), prompter.path.clone()
+ )
+ .await?;
+ Ok(())
+ }
+}
--- /dev/null
+++ b/remowt/cmds/remowt-agent/src/helper/mod.rs
@@ -0,0 +1,21 @@
+use futures::Future;
+use remowt_polkit_shared::Identity;
+use remowt_ui_prompt::Prompter;
+
+mod dbus;
+mod protocol;
+mod socket;
+mod suid;
+
+pub use dbus::DbusHelper;
+pub use socket::SocketHelper;
+pub use suid::SuidHelper;
+
+pub trait Helper {
+ fn help_me(
+ &self,
+ cookie: &str,
+ prompt: P,
+ identity: Identity,
+ ) -> impl Future