Skip to main content

auc_tool/
system.rs

1use std::fs;
2use std::os::unix::fs::{FileTypeExt, MetadataExt};
3use std::path::Path;
4use std::process::{Command, Output};
5use std::sync::Arc;
6use std::time::Duration;
7
8use anyhow::{Context, Result, anyhow, bail};
9use capulus::managed::{
10    BuildArtifacts, JobId, RedeployCoordinator, SystemInstallation, SystemUninstallation,
11    UnixAccount,
12};
13use serde::{Deserialize, Serialize};
14
15use crate::product::{ACCESS_GROUP, managed_product, wait_until_healthy};
16use crate::vault::Vault;
17
18const ACCESS_POLICY_PATH: &str = "/var/lib/auc/access-policy.json";
19const ACCESS_POLICY_SCHEMA: u16 = 1;
20const COMMAND_OUTPUT_LIMIT: usize = 64 * 1024;
21
22pub async fn install(operator_uid: u32) -> Result<()> {
23    require_root()?;
24    validate_audit_login(operator_uid)?;
25    let operator = UnixAccount::by_uid(operator_uid)?;
26    operator.validate_interactive()?;
27    ensure_uhid_device()?;
28    let product = managed_product()?;
29    let artifacts = BuildArtifacts::from_installed_program(&product)?;
30    Vault::open().context("failed to initialize or validate the auc vault")?;
31    let mut access = AccessGroupSetup::prepare(operator)?;
32    let mut installation =
33        match SystemInstallation::prepare(&product, JobId::random(), &artifacts).await {
34            Ok(installation) => installation,
35            Err(error) => {
36                access.rollback()?;
37                return Err(error.context("failed to prepare the auc system installation"));
38            }
39        };
40    if let Err(error) = installation.commit_files() {
41        return Err(rollback_installation(&mut installation, &mut access, error).await);
42    }
43    if let Err(error) = installation.activate().await {
44        return Err(rollback_installation(&mut installation, &mut access, error).await);
45    }
46    let version = product.version().clone();
47    let health =
48        tokio::task::spawn_blocking(move || wait_until_healthy(&version, Duration::from_secs(60)))
49            .await
50            .context("auc installation health task panicked")?;
51    if let Err(error) = health {
52        return Err(rollback_installation(&mut installation, &mut access, error).await);
53    }
54    if let Err(error) = access.commit() {
55        return Err(rollback_installation(&mut installation, &mut access, error).await);
56    }
57    if let Err(error) = installation.finalize() {
58        if installation.acceptance_committed() {
59            access.finish();
60            return Err(error.context(
61                "auc is installed and healthy, but committed installation cleanup failed",
62            ));
63        }
64        return Err(rollback_installation(&mut installation, &mut access, error).await);
65    }
66    access.finish();
67    Ok(())
68}
69
70pub async fn uninstall(operator_uid: u32, purge_vault: bool) -> Result<()> {
71    require_root()?;
72    validate_audit_login(operator_uid)?;
73    let operator = UnixAccount::by_uid(operator_uid)?;
74    operator.validate_interactive()?;
75    let policy = read_access_policy()?.ok_or_else(|| anyhow!("auc access policy is missing"))?;
76    if !policy.operator_uids.contains(&operator_uid) || !user_has_group(&operator)? {
77        bail!("the invoking user is not an authorized auc operator");
78    }
79    let product = Arc::new(managed_product()?);
80    if RedeployCoordinator::new(Arc::clone(&product))?
81        .reconciled_active()
82        .await?
83        .is_some_and(|job| !job.phase.is_terminal())
84    {
85        bail!("auc cannot be uninstalled while a redeploy is active");
86    }
87    ensure_no_pending_presence()?;
88    let mut uninstallation = SystemUninstallation::prepare(&product, JobId::random()).await?;
89    if let Err(error) = uninstallation.deactivate().await {
90        return Err(rollback_uninstallation(&mut uninstallation, error).await);
91    }
92    if let Err(error) = uninstallation.remove_files() {
93        return Err(rollback_uninstallation(&mut uninstallation, error).await);
94    }
95    if let Err(error) = uninstallation.finalize().await {
96        if uninstallation.removal_committed() {
97            return Err(error.context(
98                "auc system files were removed, but committed uninstall cleanup is incomplete",
99            ));
100        }
101        return Err(rollback_uninstallation(&mut uninstallation, error).await);
102    }
103    if purge_vault {
104        Vault::purge().context("auc system files were removed, but vault destruction failed")?;
105        checked_command(
106            "/usr/sbin/groupdel",
107            &[ACCESS_GROUP],
108            "remove the auc access group",
109        )
110        .context("auc and its vault were removed, but the access group remains")?;
111    }
112    Ok(())
113}
114
115async fn rollback_uninstallation(
116    uninstallation: &mut SystemUninstallation,
117    cause: anyhow::Error,
118) -> anyhow::Error {
119    match uninstallation.rollback().await {
120        Ok(()) => cause.context("auc uninstall failed and the installation was restored"),
121        Err(rollback) => anyhow!(
122            "auc uninstall failed: {cause:#}; restoring the installation also failed: {rollback:#}"
123        ),
124    }
125}
126
127fn ensure_no_pending_presence() -> Result<()> {
128    use crate::application::{ApplicationClient, ApplicationRequest, ApplicationResponse};
129    use crate::product::APPLICATION_SOCKET_PATH;
130
131    match ApplicationClient::new(APPLICATION_SOCKET_PATH).request(ApplicationRequest::Status)? {
132        ApplicationResponse::Status(status) if status.pending_touch => {
133            bail!("auc cannot be uninstalled while a presence request is pending")
134        }
135        ApplicationResponse::Status(_) => Ok(()),
136        _ => bail!("auc-agent returned the wrong status response before uninstall"),
137    }
138}
139
140async fn rollback_installation(
141    installation: &mut SystemInstallation,
142    access: &mut AccessGroupSetup,
143    cause: anyhow::Error,
144) -> anyhow::Error {
145    let installation_rollback = installation.rollback().await;
146    let access_rollback = access.rollback();
147    match (installation_rollback, access_rollback) {
148        (Ok(()), Ok(())) => cause.context("auc installation failed and was rolled back"),
149        (system, group) => anyhow!(
150            "auc installation failed: {cause:#}; system rollback: {}; access-group rollback: {}",
151            outcome(&system),
152            outcome(&group),
153        ),
154    }
155}
156
157fn outcome(result: &Result<()>) -> String {
158    match result {
159        Ok(()) => "succeeded".to_string(),
160        Err(error) => format!("failed: {error:#}"),
161    }
162}
163
164fn validate_audit_login(operator_uid: u32) -> Result<()> {
165    let value = fs::read_to_string("/proc/self/loginuid")
166        .context("failed to read the kernel audit login UID")?;
167    let login_uid = value
168        .trim()
169        .parse::<u32>()
170        .context("kernel audit login UID is malformed")?;
171    if login_uid == u32::MAX {
172        bail!("kernel audit login UID is unset; run auc system install from a real login session");
173    }
174    if login_uid != operator_uid {
175        bail!("requested auc operator does not match the kernel audit login UID");
176    }
177    Ok(())
178}
179
180fn ensure_uhid_device() -> Result<()> {
181    let path = Path::new("/dev/uhid");
182    if fs::symlink_metadata(path).is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound) {
183        checked_command(
184            "/usr/sbin/modprobe",
185            &["uhid"],
186            "load the Linux UHID kernel module",
187        )?;
188    }
189    let metadata = fs::symlink_metadata(path).context("Linux did not expose /dev/uhid")?;
190    if !metadata.file_type().is_char_device() {
191        bail!("/dev/uhid is not a real character device");
192    }
193    Ok(())
194}
195
196struct AccessGroupSetup {
197    operator: UnixAccount,
198    policy: AccessPolicy,
199    group_created: bool,
200    member_added: bool,
201    policy_written: bool,
202    finished: bool,
203}
204
205impl AccessGroupSetup {
206    fn prepare(operator: UnixAccount) -> Result<Self> {
207        let existing_policy = read_access_policy()?;
208        let group_exists = command_success("/usr/bin/getent", &["group", ACCESS_GROUP])?;
209        let group_created = match (existing_policy.is_some(), group_exists) {
210            (true, true) => false,
211            (true, false) => bail!("auc access policy exists but its Unix group is missing"),
212            (false, true) => bail!("refusing to take ownership of a pre-existing auc Unix group"),
213            (false, false) => {
214                checked_command(
215                    "/usr/sbin/groupadd",
216                    &["--system", ACCESS_GROUP],
217                    "create the auc access group",
218                )?;
219                true
220            }
221        };
222        let policy = existing_policy.unwrap_or(AccessPolicy {
223            schema: ACCESS_POLICY_SCHEMA,
224            operator_uids: Vec::new(),
225        });
226        policy.validate()?;
227        Ok(Self {
228            operator,
229            policy,
230            group_created,
231            member_added: false,
232            policy_written: false,
233            finished: false,
234        })
235    }
236
237    fn commit(&mut self) -> Result<()> {
238        if !self.policy.operator_uids.contains(&self.operator.uid) {
239            checked_command(
240                "/usr/sbin/usermod",
241                &["--append", "--groups", ACCESS_GROUP, &self.operator.name],
242                "add the selected user to the auc access group",
243            )?;
244            self.member_added = true;
245            self.policy.operator_uids.push(self.operator.uid);
246            self.policy.operator_uids.sort_unstable();
247        }
248        if !user_has_group(&self.operator)? {
249            bail!("selected auc operator did not acquire access-group membership");
250        }
251        capulus::store::atomic_write(
252            Path::new(ACCESS_POLICY_PATH),
253            &serde_json::to_vec(&self.policy)?,
254            Some(0o600),
255            None,
256        )?;
257        self.policy_written = true;
258        Ok(())
259    }
260
261    fn rollback(&mut self) -> Result<()> {
262        let mut errors = Vec::new();
263        if self.policy_written {
264            if self.group_created {
265                if let Err(error) = fs::remove_file(ACCESS_POLICY_PATH)
266                    && error.kind() != std::io::ErrorKind::NotFound
267                {
268                    errors.push(anyhow!(error).context("remove new auc access policy"));
269                }
270            } else if self.member_added {
271                self.policy
272                    .operator_uids
273                    .retain(|uid| *uid != self.operator.uid);
274                if let Err(error) = capulus::store::atomic_write(
275                    Path::new(ACCESS_POLICY_PATH),
276                    &serde_json::to_vec(&self.policy)?,
277                    Some(0o600),
278                    None,
279                ) {
280                    errors.push(error.context("restore auc access policy"));
281                }
282            }
283            self.policy_written = false;
284        }
285        if self.group_created {
286            if let Err(error) = checked_command(
287                "/usr/sbin/groupdel",
288                &[ACCESS_GROUP],
289                "remove the new auc access group",
290            ) {
291                errors.push(error);
292            }
293            self.group_created = false;
294            self.member_added = false;
295        } else if self.member_added {
296            if let Err(error) = checked_command(
297                "/usr/bin/gpasswd",
298                &["--delete", &self.operator.name, ACCESS_GROUP],
299                "restore auc access-group membership",
300            ) {
301                errors.push(error);
302            }
303            self.member_added = false;
304        }
305        if errors.is_empty() {
306            Ok(())
307        } else {
308            bail!(
309                "{}",
310                errors
311                    .into_iter()
312                    .map(|error| format!("{error:#}"))
313                    .collect::<Vec<_>>()
314                    .join("; ")
315            )
316        }
317    }
318
319    fn finish(&mut self) {
320        self.finished = true;
321    }
322}
323
324impl Drop for AccessGroupSetup {
325    fn drop(&mut self) {
326        if !self.finished {
327            let _ = self.rollback();
328        }
329    }
330}
331
332#[derive(Deserialize, Serialize)]
333#[serde(deny_unknown_fields)]
334struct AccessPolicy {
335    schema: u16,
336    operator_uids: Vec<u32>,
337}
338
339impl AccessPolicy {
340    fn validate(&self) -> Result<()> {
341        if self.schema != ACCESS_POLICY_SCHEMA
342            || self.operator_uids.len() > 64
343            || self.operator_uids.contains(&0)
344            || self
345                .operator_uids
346                .windows(2)
347                .any(|window| window[0] >= window[1])
348        {
349            bail!("auc access policy is invalid");
350        }
351        Ok(())
352    }
353}
354
355fn read_access_policy() -> Result<Option<AccessPolicy>> {
356    let path = Path::new(ACCESS_POLICY_PATH);
357    let metadata = match fs::symlink_metadata(path) {
358        Ok(metadata) => metadata,
359        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
360        Err(error) => return Err(error.into()),
361    };
362    if !metadata.file_type().is_file()
363        || metadata.uid() != 0
364        || metadata.gid() != 0
365        || metadata.mode() & 0o7777 != 0o600
366        || metadata.len() > 16 * 1024
367    {
368        bail!("auc access policy failed ownership, type, mode, or size validation");
369    }
370    let policy: AccessPolicy = serde_json::from_slice(&fs::read(path)?)?;
371    policy.validate()?;
372    Ok(Some(policy))
373}
374
375pub(crate) fn operator_is_authorized(uid: u32) -> Result<bool> {
376    Ok(
377        read_access_policy()?
378            .is_some_and(|policy| policy.operator_uids.binary_search(&uid).is_ok()),
379    )
380}
381
382fn user_has_group(account: &UnixAccount) -> Result<bool> {
383    let output = checked_output(
384        "/usr/bin/id",
385        &["--name", "--groups", &account.name],
386        "inspect auc access-group membership",
387    )?;
388    Ok(String::from_utf8(output.stdout)?
389        .split_whitespace()
390        .any(|group| group == ACCESS_GROUP))
391}
392
393fn command_success(program: &str, arguments: &[&str]) -> Result<bool> {
394    let output = bounded_command(program, arguments).output()?;
395    ensure_bounded_output(&output)?;
396    match output.status.code() {
397        Some(0) => Ok(true),
398        Some(2) => Ok(false),
399        _ => bail!("{} failed: {}", program, output_detail(&output)),
400    }
401}
402
403fn checked_command(program: &str, arguments: &[&str], action: &str) -> Result<()> {
404    checked_output(program, arguments, action).map(|_| ())
405}
406
407fn checked_output(program: &str, arguments: &[&str], action: &str) -> Result<Output> {
408    let output = bounded_command(program, arguments)
409        .output()
410        .with_context(|| format!("failed to {action}"))?;
411    ensure_bounded_output(&output)?;
412    if !output.status.success() {
413        bail!("failed to {action}: {}", output_detail(&output));
414    }
415    Ok(output)
416}
417
418fn bounded_command(program: &str, arguments: &[&str]) -> Command {
419    let mut command = Command::new("/usr/bin/timeout");
420    command
421        .env_clear()
422        .env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin")
423        .env("LANG", "C.UTF-8")
424        .args(["--signal=TERM", "--kill-after=2s", "15s", "--", program])
425        .args(arguments);
426    command
427}
428
429fn ensure_bounded_output(output: &Output) -> Result<()> {
430    if output.stdout.len() > COMMAND_OUTPUT_LIMIT || output.stderr.len() > COMMAND_OUTPUT_LIMIT {
431        bail!("system account command output exceeded its safety limit");
432    }
433    Ok(())
434}
435
436fn output_detail(output: &Output) -> String {
437    String::from_utf8_lossy(if output.stderr.is_empty() {
438        &output.stdout
439    } else {
440        &output.stderr
441    })
442    .trim()
443    .to_string()
444}
445
446fn require_root() -> Result<()> {
447    if rustix::process::geteuid().is_root() {
448        Ok(())
449    } else {
450        bail!("auc system installation requires root")
451    }
452}