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}