Skip to main content

auc_tool/
system.rs

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