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}