Skip to main content

fez/protocol/
client.rs

1use crate::error::{FezError, Result};
2use crate::protocol::frame::{read_frame, write_frame, Frame};
3use crate::protocol::message::{Control, DbusCall, DbusResponse, DbusSignal, IncomingControl};
4use crate::transport::Transport;
5use serde_json::{json, Value};
6use std::process::{Child, Stdio};
7use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
8use std::thread;
9use std::time::Duration;
10
11const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
12
13/// Object path of the bridge's superuser controller on the internal bus.
14///
15/// cockpit-bridge exports `SuperuserRoutingRule` at `/superuser` on its
16/// in-process internal bus (`bridge.py`:
17/// `self.internal_bus.export('/superuser', self.superuser_rule)`), with
18/// interface [`SUPERUSER_IFACE`].
19const SUPERUSER_PATH: &str = "/superuser";
20
21/// D-Bus interface of the bridge's superuser controller.
22///
23/// Defined in cockpit's `superuser.py`
24/// (`SuperuserRoutingRule(..., interface='cockpit.Superuser')`). It exposes the
25/// `Bridges` property (`as`, the ordered list of viable escalation mechanisms)
26/// and the `Start(s)` method (start a named mechanism).
27const SUPERUSER_IFACE: &str = "cockpit.Superuser";
28
29/// Guidance returned when privilege escalation to root fails.
30///
31/// fez tries every escalation mechanism the bridge advertises (sudo, polkit)
32/// and only reports this after all of them fail. The common causes: the
33/// standalone bridge ships no superuser bridge definitions (install
34/// `cockpit-system`), sudo wants a password fez does not supply (configure
35/// passwordless sudo), or no polkit rule grants this user the privileged
36/// action. The message names both mechanisms so the operator knows either path
37/// is viable.
38const ESCALATION_REMEDIATION: &str = "fez could not escalate to root: no superuser mechanism succeeded. Install the cockpit-system package (it ships the sudo/pkexec superuser bridge definitions), then either configure passwordless sudo (NOPASSWD) for this user or grant a polkit rule allowing this user the privileged cockpit action, and retry. fez does not supply sudo passwords";
39
40/// A live connection to a spawned bridge process, multiplexing D-Bus and
41/// stream channels over its stdio.
42pub struct BridgeClient {
43    child: Child,
44    stdin: std::process::ChildStdin,
45    rx: Receiver<Frame>,
46    host: String,
47    next_channel: u64,
48    /// Whether a root peer has been brought up via `cockpit.Superuser.Start`.
49    /// Escalation is performed lazily and at most once per connection.
50    escalated: bool,
51}
52
53impl BridgeClient {
54    /// Spawn the bridge via `transport`, perform the init handshake, and return
55    /// a ready client.
56    pub fn connect(transport: &dyn Transport) -> Result<BridgeClient> {
57        let mut cmd = transport.command();
58        let program = cmd.get_program().to_string_lossy().into_owned();
59        cmd.stdin(Stdio::piped())
60            .stdout(Stdio::piped())
61            .stderr(Stdio::piped());
62        let mut child = cmd
63            .spawn()
64            .map_err(|source| FezError::Spawn { program, source })?;
65        let stdin = child.stdin.take().expect("piped stdin");
66        let mut stdout = child.stdout.take().expect("piped stdout");
67
68        let (tx, rx) = mpsc::channel::<Frame>();
69        thread::spawn(move || {
70            while let Ok(Some(frame)) = read_frame(&mut stdout) {
71                if tx.send(frame).is_err() {
72                    break;
73                }
74            }
75        });
76
77        let mut client = BridgeClient {
78            child,
79            stdin,
80            rx,
81            host: transport.host_label(),
82            next_channel: 1,
83            escalated: false,
84        };
85        client.send_control(&Control::Init {
86            version: 1,
87            host: "localhost".into(),
88            // Defer escalation: bring up no root peer at init. fez selects a
89            // working mechanism later via `escalate()` (cockpit.Superuser.Start)
90            // so it can fall through sudo -> polkit instead of pinning sudo.
91            superuser: Some(json!("none")),
92        })?;
93        client.await_init()?;
94        Ok(client)
95    }
96
97    fn send_control(&mut self, c: &Control) -> Result<()> {
98        write_frame(&mut self.stdin, &Frame::control(&c.to_json())).map_err(FezError::Io)
99    }
100
101    fn recv(&self) -> Result<Frame> {
102        match self.rx.recv_timeout(DEFAULT_TIMEOUT) {
103            Ok(f) => Ok(f),
104            Err(RecvTimeoutError::Timeout) => Err(FezError::Timeout),
105            Err(RecvTimeoutError::Disconnected) => Err(FezError::BridgeClosed),
106        }
107    }
108
109    /// Complete the bridge handshake.
110    ///
111    /// Waits for the bridge's `init` reply, which completes the handshake.
112    /// Because we send `init` with `superuser: "none"`, the bridge brings up no
113    /// root peer at init time and runs no superuser negotiation, so it emits no
114    /// `superuser-init-done` (cockpit's `SuperuserRoutingRule.init` is only
115    /// invoked, and only then fires `superuser-init-done`, when init carries a
116    /// `superuser` object). Waiting for that message here would hang against a
117    /// real bridge. Escalation is deferred to [`escalate`], run lazily before
118    /// the first privileged channel open.
119    ///
120    /// [`escalate`]: BridgeClient::escalate
121    fn await_init(&mut self) -> Result<()> {
122        loop {
123            let frame = self.recv()?;
124            if !frame.channel.is_empty() {
125                continue;
126            }
127            let c: IncomingControl =
128                serde_json::from_slice(&frame.payload).map_err(FezError::Decode)?;
129            // The bridge's `init` reply opens the transport. We deferred
130            // escalation (`superuser: "none"`), so there is no further superuser
131            // negotiation to await; the handshake is done.
132            if c.command == "init" {
133                return Ok(());
134            }
135        }
136    }
137
138    fn alloc_channel(&mut self) -> String {
139        let c = format!("c{}", self.next_channel);
140        self.next_channel += 1;
141        c
142    }
143
144    /// Open an unprivileged D-Bus channel to `name` and return its channel id.
145    pub fn dbus_open(&mut self, name: &str) -> Result<String> {
146        self.open_dbus(name, false)
147    }
148
149    /// Open a privileged D-Bus channel (`superuser: "require"`); the bridge
150    /// performs the sudo/polkit escalation and spawns a root peer (Section 5).
151    pub fn dbus_open_privileged(&mut self, name: &str) -> Result<String> {
152        self.open_dbus(name, true)
153    }
154
155    fn open_dbus(&mut self, name: &str, privileged: bool) -> Result<String> {
156        // A privileged channel routes to a root peer, which only exists once we
157        // have escalated. Drive escalation lazily before the first such open;
158        // reads (privileged == false) never escalate.
159        if privileged && !self.escalated {
160            self.escalate()?;
161        }
162        let channel = self.alloc_channel();
163        let mut open = Control::open(&channel, "dbus-json3")
164            .opt("bus", json!("system"))
165            .opt("name", json!(name));
166        if privileged {
167            open = open.opt("superuser", json!("require"));
168        }
169        self.send_control(&open)?;
170        Ok(channel)
171    }
172
173    /// Bring up a root peer by selecting a working escalation mechanism.
174    ///
175    /// With init sent as `superuser: "none"`, no root peer exists until fez
176    /// asks for one. This reads the bridge's advertised mechanisms
177    /// ([`BridgeClient::superuser_bridges`]) and tries each via
178    /// [`BridgeClient::superuser_start`] in order until one succeeds, so a host
179    /// with password-only sudo but a working polkit rule still escalates. The
180    /// `FEZ_ESCALATION` environment variable overrides the default loop:
181    /// `off` disables escalation, and any other value forces that single
182    /// mechanism (no fall-through). Idempotent: a no-op once escalated.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`FezError::AccessDenied`] (exit 11) when no mechanism succeeds,
187    /// when the host advertises none, or when `FEZ_ESCALATION=off`. Propagates
188    /// any non-`Dbus` transport error encountered while talking to the bridge.
189    pub fn escalate(&mut self) -> Result<()> {
190        if self.escalated {
191            return Ok(());
192        }
193        let denied = || FezError::AccessDenied {
194            remediation: ESCALATION_REMEDIATION.into(),
195        };
196        match std::env::var("FEZ_ESCALATION").ok().as_deref() {
197            // Never escalate. Mutations fail; reads are unaffected because they
198            // never call escalate().
199            Some("off") => return Err(denied()),
200            // Force a single named mechanism with no fall-through.
201            Some(name) if !name.is_empty() => {
202                return match self.superuser_start(name) {
203                    Ok(()) => {
204                        self.escalated = true;
205                        Ok(())
206                    }
207                    Err(FezError::Dbus { .. }) => Err(denied()),
208                    Err(e) => Err(e),
209                };
210            }
211            // Empty or unset: default transparent loop below.
212            _ => {}
213        }
214        let names = self.superuser_bridges()?;
215        for name in names {
216            match self.superuser_start(&name) {
217                Ok(()) => {
218                    self.escalated = true;
219                    return Ok(());
220                }
221                // This mechanism could not start (e.g. it needs an unanswerable
222                // credential); try the next advertised one.
223                Err(FezError::Dbus { .. }) => continue,
224                Err(e) => return Err(e),
225            }
226        }
227        Err(denied())
228    }
229
230    /// Open a D-Bus channel to the bridge's internal bus.
231    ///
232    /// The internal bus hosts the bridge's own controllers (notably
233    /// `cockpit.Superuser`). It carries no `name` (the bridge is the peer) and
234    /// is never privileged: the controller decides escalation, it is not itself
235    /// reached through a root peer (cockpit `dbus.py`: `bus == 'internal'`).
236    fn open_dbus_internal(&mut self) -> Result<String> {
237        let channel = self.alloc_channel();
238        let open = Control::open(&channel, "dbus-json3").opt("bus", json!("internal"));
239        self.send_control(&open)?;
240        Ok(channel)
241    }
242
243    /// List the escalation mechanisms the bridge considers viable on this host.
244    ///
245    /// Reads the `cockpit.Superuser` `Bridges` property (signature `as`) over
246    /// the internal bus. The list is the bridge's own ordered, validity-filtered
247    /// set of mechanism names (e.g. `["sudo", "pkexec"]`); an empty list means
248    /// the host has no usable escalation mechanism.
249    ///
250    /// # Errors
251    ///
252    /// Returns [`FezError::Dbus`] if the property read fails, or any transport
253    /// error from opening the internal channel or reading the reply.
254    pub fn superuser_bridges(&mut self) -> Result<Vec<String>> {
255        let channel = self.open_dbus_internal()?;
256        let out = self.dbus_call(
257            &channel,
258            SUPERUSER_PATH,
259            "org.freedesktop.DBus.Properties",
260            "Get",
261            json!([SUPERUSER_IFACE, "Bridges"]),
262        )?;
263        // `dbus_call` returns the out-argument array (`reply[0]`).
264        // `Properties.Get` has a single `v` out-arg, so the `as` value arrives
265        // variant-wrapped: `out = [{"t":"as","v":["sudo",...]}]`. Unwrap the
266        // `{"t","v"}` envelope to reach the array (cockpit-bridge does not
267        // unwrap it for us; treating `out[0]` as the array directly yields an
268        // empty list and a spurious exit-11 deny).
269        let names = out
270            .as_array()
271            .and_then(|args| args.first())
272            .map(variant_value)
273            .and_then(Value::as_array)
274            .map(|arr| {
275                arr.iter()
276                    .filter_map(|v| v.as_str().map(str::to_owned))
277                    .collect()
278            })
279            .unwrap_or_default();
280        Ok(names)
281    }
282
283    /// Ask the bridge to start the named escalation mechanism.
284    ///
285    /// Calls `cockpit.Superuser.Start(name)` over the internal bus. On success
286    /// the bridge has brought up a root peer, and subsequent
287    /// `superuser: "require"` channels route to it. A mechanism that needs a
288    /// credential fez cannot supply surfaces as a D-Bus error, not a hang.
289    ///
290    /// # Errors
291    ///
292    /// Returns [`FezError::Dbus`] when the bridge rejects the start (e.g. the
293    /// mechanism needs an unanswerable credential), or any transport error.
294    pub fn superuser_start(&mut self, name: &str) -> Result<()> {
295        let channel = self.open_dbus_internal()?;
296        self.dbus_call(
297            &channel,
298            SUPERUSER_PATH,
299            SUPERUSER_IFACE,
300            "Start",
301            json!([name]),
302        )?;
303        Ok(())
304    }
305
306    /// Returns the out-argument array (`reply[0]`). Index `[0]` for the first return value.
307    pub fn dbus_call(
308        &mut self,
309        channel: &str,
310        path: &str,
311        iface: &str,
312        method: &str,
313        args: Value,
314    ) -> Result<Value> {
315        let call = DbusCall::new(channel, path, iface, method, args);
316        write_frame(&mut self.stdin, &Frame::new(channel, call.to_json())).map_err(FezError::Io)?;
317        loop {
318            let frame = self.recv()?;
319            if frame.channel.is_empty() {
320                let c: IncomingControl =
321                    serde_json::from_slice(&frame.payload).map_err(FezError::Decode)?;
322                if c.command == "close" && c.channel.as_deref() == Some(channel) {
323                    return Err(close_problem_to_error(c.problem));
324                }
325                continue;
326            }
327            if frame.channel != channel {
328                continue;
329            }
330            let resp: DbusResponse =
331                serde_json::from_slice(&frame.payload).map_err(FezError::Decode)?;
332            if resp.id.as_deref() != Some(&call.id) {
333                continue; // signal/notify or stale; ignore
334            }
335            if let Some(name) = resp.dbus_error_name() {
336                return Err(FezError::Dbus {
337                    name: name.into(),
338                    message: resp.dbus_error_message().unwrap_or_default(),
339                });
340            }
341            return Ok(resp.out_args().cloned().unwrap_or(Value::Null));
342        }
343    }
344
345    /// Send a D-Bus method call on `channel` and collect the signals it emits
346    /// until a `Finished` signal (or a channel close) terminates the stream.
347    ///
348    /// PackageKit transactions report their result as a stream of signals on
349    /// the transaction object path rather than as a method reply, so the
350    /// request/reply [`BridgeClient::dbus_call`] cannot observe them. This sends
351    /// the call, then accumulates every `signal` frame on `channel` whose path
352    /// matches `path`, returning the raw `(member, args)` pairs in arrival
353    /// order. The method-call reply itself (an empty reply) is ignored; only
354    /// signals carry the payload. A `Finished` signal ends collection.
355    ///
356    /// # Errors
357    ///
358    /// Returns [`FezError::BridgeClosed`] / [`FezError::Timeout`] on transport
359    /// failure, [`FezError::Decode`] on a malformed frame, or the mapped close
360    /// problem if the channel closes with an error before `Finished`.
361    pub fn dbus_call_collect(
362        &mut self,
363        channel: &str,
364        path: &str,
365        iface: &str,
366        method: &str,
367        args: Value,
368    ) -> Result<Vec<(String, Vec<Value>)>> {
369        let call = DbusCall::new(channel, path, iface, method, args);
370        write_frame(&mut self.stdin, &Frame::new(channel, call.to_json())).map_err(FezError::Io)?;
371        let mut collected: Vec<(String, Vec<Value>)> = Vec::new();
372        loop {
373            let frame = self.recv()?;
374            if frame.channel.is_empty() {
375                let c: IncomingControl =
376                    serde_json::from_slice(&frame.payload).map_err(FezError::Decode)?;
377                if c.command == "close" && c.channel.as_deref() == Some(channel) {
378                    return Err(close_problem_to_error(c.problem));
379                }
380                continue;
381            }
382            if frame.channel != channel {
383                continue;
384            }
385            // A signal frame? Decode and accumulate; stop on Finished.
386            let sig: DbusSignal =
387                serde_json::from_slice(&frame.payload).map_err(FezError::Decode)?;
388            let Some(member) = sig.member() else {
389                // Not a signal (e.g. the empty method reply); ignore.
390                continue;
391            };
392            if sig.path() != Some(path) {
393                continue; // signal from a different transaction object
394            }
395            let member = member.to_string();
396            let args = sig.args().cloned().unwrap_or_default();
397            let finished = member == "Finished";
398            collected.push((member, args));
399            if finished {
400                return Ok(collected);
401            }
402        }
403    }
404
405    /// Open a `stream` channel running `argv` and buffer its output until `done`.
406    pub fn stream_collect(&mut self, argv: &[&str]) -> Result<Vec<u8>> {
407        let channel = self.alloc_channel();
408        self.send_control(&Control::open(&channel, "stream").opt("spawn", json!(argv)))?;
409        let mut buf = Vec::new();
410        loop {
411            let frame = self.recv()?;
412            if frame.channel == channel {
413                buf.extend_from_slice(&frame.payload);
414            } else if frame.channel.is_empty() {
415                let c: IncomingControl =
416                    serde_json::from_slice(&frame.payload).map_err(FezError::Decode)?;
417                if c.channel.as_deref() == Some(&channel) {
418                    if c.command == "close" && c.problem.is_some() {
419                        return Err(close_problem_to_error(c.problem));
420                    }
421                    if c.command == "done" || c.command == "close" {
422                        return Ok(buf);
423                    }
424                }
425            }
426        }
427    }
428
429    /// Open a `stream` channel and invoke `on_chunk` for each data frame until `done`.
430    pub fn stream_each<F: FnMut(&[u8])>(&mut self, argv: &[&str], mut on_chunk: F) -> Result<()> {
431        let channel = self.alloc_channel();
432        self.send_control(&Control::open(&channel, "stream").opt("spawn", json!(argv)))?;
433        loop {
434            let frame = self.recv()?;
435            if frame.channel == channel {
436                on_chunk(&frame.payload);
437            } else if frame.channel.is_empty() {
438                let c: IncomingControl =
439                    serde_json::from_slice(&frame.payload).map_err(FezError::Decode)?;
440                if c.channel.as_deref() == Some(&channel) {
441                    if c.command == "close" && c.problem.is_some() {
442                        return Err(close_problem_to_error(c.problem));
443                    }
444                    if c.command == "done" || c.command == "close" {
445                        return Ok(());
446                    }
447                }
448            }
449        }
450    }
451
452    /// The host label associated with this connection.
453    pub fn host(&self) -> &str {
454        &self.host
455    }
456}
457
458impl Drop for BridgeClient {
459    fn drop(&mut self) {
460        let _ = self.child.kill();
461        let _ = self.child.wait();
462    }
463}
464
465/// Unwrap a D-Bus variant envelope to its inner value.
466///
467/// cockpit-bridge represents a variant on the wire as `{"t":<sig>,"v":<value>}`
468/// (e.g. a `Properties.Get` out-arg or an `a{sv}` dict value). Return the inner
469/// `v` when present, otherwise the value unchanged, so callers can treat
470/// variant-wrapped and bare values uniformly (same convention as the services
471/// status parser).
472fn variant_value(v: &Value) -> &Value {
473    v.get("v").unwrap_or(v)
474}
475
476/// Convert a channel-close `problem` into the matching [`FezError`].
477///
478/// A privileged channel that the bridge could not escalate closes with
479/// `problem: "access-denied"`; surface that as the dedicated [`FezError::AccessDenied`]
480/// (exit 11, with remediation) instead of a generic channel problem (exit 4),
481/// so privilege failures are distinguishable from missing resources. Any other
482/// problem string keeps the generic [`FezError::Problem`] mapping.
483fn close_problem_to_error(problem: Option<String>) -> FezError {
484    match problem {
485        Some(p) if p == "access-denied" => FezError::AccessDenied {
486            remediation: ESCALATION_REMEDIATION.into(),
487        },
488        Some(p) => FezError::Problem(p),
489        None => FezError::Problem("channel-closed".into()),
490    }
491}