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}