1use core::str;
4use std::process::Command;
5
6use anyhow::{bail, Context, Result};
7use log::error;
8use tokio_util::sync::CancellationToken;
9use tracing::info;
10use zbus::{proxy, zvariant, Connection};
11
12#[derive(Debug, Clone)]
13pub struct UnitWithStatus {
14 pub name: String, pub scope: UnitScope, pub description: String, pub file_path: Option<Result<String, String>>, pub load_state: String, pub activation_state: String,
24 pub sub_state: String,
26
27 pub enablement_state: Option<String>,
30 }
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum UnitScope {
40 Global,
41 User,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
46pub struct UnitId {
47 pub name: String,
48 pub scope: UnitScope,
49}
50
51impl UnitWithStatus {
52 pub fn is_active(&self) -> bool {
53 self.activation_state == "active"
54 }
55
56 pub fn is_failed(&self) -> bool {
57 self.activation_state == "failed"
58 }
59
60 pub fn is_not_found(&self) -> bool {
61 self.load_state == "not-found"
62 }
63
64 pub fn is_enabled(&self) -> bool {
65 self.load_state == "loaded" && self.activation_state == "active"
66 }
67
68 pub fn short_name(&self) -> &str {
69 if self.name.ends_with(".service") {
70 &self.name[..self.name.len() - 8]
71 } else {
72 &self.name
73 }
74 }
75
76 pub fn id(&self) -> UnitId {
78 UnitId { name: self.name.clone(), scope: self.scope }
79 }
80
81 pub fn update(&mut self, other: UnitWithStatus) {
83 self.description = other.description;
84 self.load_state = other.load_state;
85 self.activation_state = other.activation_state;
86 self.sub_state = other.sub_state;
87 }
88}
89
90type RawUnit =
91 (String, String, String, String, String, String, zvariant::OwnedObjectPath, u32, String, zvariant::OwnedObjectPath);
92
93fn to_unit_status(raw_unit: RawUnit, scope: UnitScope) -> UnitWithStatus {
94 let (name, description, load_state, active_state, sub_state, _followed, _path, _job_id, _job_type, _job_path) =
95 raw_unit;
96
97 UnitWithStatus {
98 name,
99 scope,
100 description,
101 file_path: None,
102 enablement_state: None,
103 load_state,
104 activation_state: active_state,
105 sub_state,
106 }
107}
108
109#[derive(Clone, Copy, Default, Debug)]
111pub enum Scope {
112 Global,
113 User,
114 #[default]
115 All,
116}
117
118#[derive(Debug, Clone)]
120pub struct UnitFile {
121 pub name: String,
122 pub scope: UnitScope,
123 pub enablement_state: String,
124 pub path: String,
125}
126
127impl UnitFile {
128 pub fn id(&self) -> UnitId {
129 UnitId { name: self.name.clone(), scope: self.scope }
130 }
131}
132
133pub async fn get_unit_files(scope: Scope) -> Result<Vec<UnitFile>> {
136 let start = std::time::Instant::now();
137
138 let mut unit_scopes = vec![];
139 match scope {
140 Scope::Global => unit_scopes.push(UnitScope::Global),
141 Scope::User => unit_scopes.push(UnitScope::User),
142 Scope::All => {
143 unit_scopes.push(UnitScope::Global);
144 unit_scopes.push(UnitScope::User);
145 },
146 }
147
148 let mut ret = vec![];
149 let is_root = nix::unistd::geteuid().is_root();
150 info!("get_unit_files: is_root={}, scope={:?}", is_root, scope);
151
152 for unit_scope in unit_scopes {
153 info!("get_unit_files: fetching {:?} unit files", unit_scope);
154 let connection = match get_connection(unit_scope).await {
155 Ok(conn) => conn,
156 Err(e) => {
157 error!("get_unit_files: failed to get {:?} connection: {:?}", unit_scope, e);
158 if is_root && unit_scope == UnitScope::User {
159 info!("get_unit_files: skipping user scope because we're root");
160 continue;
161 }
162 return Err(e);
163 },
164 };
165 let manager_proxy = ManagerProxy::new(&connection).await?;
166 let unit_files = match manager_proxy.list_unit_files_by_patterns(vec![], vec!["*.service".into()]).await {
167 Ok(files) => {
168 info!("get_unit_files: got {} {:?} unit files", files.len(), unit_scope);
169 files
170 },
171 Err(e) => {
172 error!("get_unit_files: list_unit_files_by_patterns failed for {:?}: {:?}", unit_scope, e);
173 if is_root && unit_scope == UnitScope::User {
174 info!("get_unit_files: ignoring user scope error because we're root");
175 vec![]
176 } else {
177 return Err(e.into());
178 }
179 },
180 };
181
182 let services = unit_files
183 .into_iter()
184 .filter_map(|(path, state)| {
185 let rust_path = std::path::Path::new(&path);
186 let file_name = rust_path.file_name()?.to_str()?;
187 Some(UnitFile { name: file_name.to_string(), scope: unit_scope, enablement_state: state, path })
188 })
189 .collect::<Vec<_>>();
190 ret.extend(services);
191 }
192
193 info!("Loaded {} unit files in {:?}", ret.len(), start.elapsed());
194 Ok(ret)
195}
196
197pub async fn get_all_services(scope: Scope, services: &[String]) -> Result<Vec<UnitWithStatus>> {
199 let start = std::time::Instant::now();
200
201 let mut units = vec![];
202
203 let is_root = nix::unistd::geteuid().is_root();
204
205 match scope {
206 Scope::Global => {
207 let system_units = get_services(UnitScope::Global, services).await?;
208 units.extend(system_units);
209 },
210 Scope::User => {
211 let user_units = get_services(UnitScope::User, services).await?;
212 units.extend(user_units);
213 },
214 Scope::All => {
215 let (system_units, user_units) =
216 tokio::join!(get_services(UnitScope::Global, services), get_services(UnitScope::User, services));
217 units.extend(system_units?);
218
219 if let Ok(user_units) = user_units {
221 units.extend(user_units);
222 } else if is_root {
223 error!("Failed to get user units, ignoring because we're running as root")
224 } else {
225 user_units?;
226 }
227 },
228 }
229
230 units.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
232
233 info!("Loaded systemd services in {:?}", start.elapsed());
234
235 Ok(units)
236}
237
238async fn get_services(scope: UnitScope, services: &[String]) -> Result<Vec<UnitWithStatus>, anyhow::Error> {
239 let connection = get_connection(scope).await?;
240 let manager_proxy = ManagerProxy::new(&connection).await?;
241 let units = manager_proxy.list_units_by_patterns(vec![], services.to_vec()).await?;
242 let units: Vec<_> = units.into_iter().map(|u| to_unit_status(u, scope)).collect();
243 Ok(units)
244}
245
246pub fn get_unit_file_location(service: &UnitId) -> Result<String> {
247 let mut args = vec!["--quiet", "show", "-P", "FragmentPath"];
249 args.push(&service.name);
250
251 if service.scope == UnitScope::User {
252 args.insert(0, "--user");
253 }
254
255 let output = Command::new("systemctl").args(&args).output()?;
256
257 if output.status.success() {
258 let path = str::from_utf8(&output.stdout)?.trim();
259 if path.is_empty() {
260 bail!("No unit file found for {}", service.name);
261 }
262 Ok(path.trim().to_string())
263 } else {
264 let stderr = String::from_utf8(output.stderr)?;
265 bail!(stderr);
266 }
267}
268
269pub async fn start_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
270 async fn start_service(service: UnitId) -> Result<()> {
271 let connection = get_connection(service.scope).await?;
272 let manager_proxy = ManagerProxy::new(&connection).await?;
273 manager_proxy.start_unit(service.name.clone(), "replace".into()).await?;
274 Ok(())
275 }
276
277 tokio::select! {
279 _ = cancel_token.cancelled() => {
280 anyhow::bail!("cancelled");
281 }
282 result = start_service(service) => {
283 result
284 }
285 }
286}
287
288pub async fn stop_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
289 async fn stop_service(service: UnitId) -> Result<()> {
290 let connection = get_connection(service.scope).await?;
291 let manager_proxy = ManagerProxy::new(&connection).await?;
292 manager_proxy.stop_unit(service.name, "replace".into()).await?;
293 Ok(())
294 }
295
296 tokio::select! {
298 _ = cancel_token.cancelled() => {
299 anyhow::bail!("cancelled");
300 }
301 result = stop_service(service) => {
302 result
303 }
304 }
305}
306
307pub async fn reload(scope: UnitScope, cancel_token: CancellationToken) -> Result<()> {
308 async fn reload_(scope: UnitScope) -> Result<()> {
309 let connection = get_connection(scope).await?;
310 let manager_proxy: ManagerProxy<'_> = ManagerProxy::new(&connection).await?;
311 let error_message = match scope {
312 UnitScope::Global => "Failed to reload units, probably because superuser permissions are needed. Try running `sudo systemctl daemon-reload`",
313 UnitScope::User => "Failed to reload units. Try running `systemctl --user daemon-reload`",
314 };
315 manager_proxy.reload().await.context(error_message)?;
316 Ok(())
317 }
318
319 tokio::select! {
321 _ = cancel_token.cancelled() => {
322 anyhow::bail!("cancelled");
323 }
324 result = reload_(scope) => {
325 result
326 }
327 }
328}
329
330async fn get_connection(scope: UnitScope) -> Result<Connection, anyhow::Error> {
331 match scope {
332 UnitScope::Global => Ok(Connection::system().await?),
333 UnitScope::User => Ok(Connection::session().await?),
334 }
335}
336
337pub async fn restart_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
338 async fn restart(service: UnitId) -> Result<()> {
339 let connection = get_connection(service.scope).await?;
340 let manager_proxy = ManagerProxy::new(&connection).await?;
341 manager_proxy.restart_unit(service.name, "replace".into()).await?;
342 Ok(())
343 }
344
345 tokio::select! {
347 _ = cancel_token.cancelled() => {
348 anyhow::bail!("cancelled");
350 }
351 result = restart(service) => {
352 result
353 }
354 }
355}
356
357pub async fn enable_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
358 async fn enable(service: UnitId) -> Result<()> {
359 let connection = get_connection(service.scope).await?;
360 let manager_proxy = ManagerProxy::new(&connection).await?;
361 let files = vec![service.name];
362 let (_, changes) = manager_proxy.enable_unit_files(files, false, false).await?;
363
364 for (change_type, name, destination) in changes {
365 info!("{}: {} -> {}", change_type, name, destination);
366 }
367 manager_proxy.reload().await?;
369 Ok(())
370 }
371
372 tokio::select! {
373 _ = cancel_token.cancelled() => {
374 anyhow::bail!("cancelled");
375 }
376 result = enable(service) => {
377 result
378 }
379 }
380}
381
382pub async fn disable_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
383 async fn disable(service: UnitId) -> Result<()> {
384 let connection = get_connection(service.scope).await?;
385 let manager_proxy = ManagerProxy::new(&connection).await?;
386 let files = vec![service.name];
387 let changes = manager_proxy.disable_unit_files(files, false).await?;
388
389 for (change_type, name, destination) in changes {
390 info!("{}: {} -> {}", change_type, name, destination);
391 }
392 manager_proxy.reload().await?;
393 Ok(())
394 }
395
396 tokio::select! {
397 _ = cancel_token.cancelled() => {
398 anyhow::bail!("cancelled");
399 }
400 result = disable(service) => {
401 result
402 }
403 }
404}
405
406pub async fn sleep_test(_service: String, cancel_token: CancellationToken) -> Result<()> {
408 tokio::select! {
410 _ = cancel_token.cancelled() => {
411 anyhow::bail!("cancelled");
413 }
414 _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {
415 Ok(())
416 }
417 }
418}
419
420pub async fn kill_service(service: UnitId, signal: String, cancel_token: CancellationToken) -> Result<()> {
421 async fn kill(service: UnitId, signal: String) -> Result<()> {
422 let mut args = vec!["kill", "--signal", &signal];
423 if service.scope == UnitScope::User {
424 args.push("--user");
425 }
426 args.push(&service.name);
427
428 let output = Command::new("systemctl").args(&args).output()?;
429
430 if output.status.success() {
431 info!("Successfully sent signal {} to srvice {}", signal, service.name);
432 Ok(())
433 } else {
434 let stderr = String::from_utf8(output.stderr)?;
435 bail!("Failed to send signal {} to service {}: {}", signal, service.name, stderr);
436 }
437 }
438
439 tokio::select! {
440 _ = cancel_token.cancelled() => {
441 bail!("cancelled");
442 }
443 result = kill(service, signal) => {
444 result
445 }
446 }
447}
448
449#[proxy(
452 interface = "org.freedesktop.systemd1.Manager",
453 default_service = "org.freedesktop.systemd1",
454 default_path = "/org/freedesktop/systemd1",
455 gen_blocking = false
456)]
457pub trait Manager {
458 #[zbus(name = "StartUnit")]
460 fn start_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
461
462 #[zbus(name = "StopUnit")]
464 fn stop_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
465
466 #[zbus(name = "ReloadUnit")]
468 fn reload_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
469
470 #[zbus(name = "RestartUnit")]
472 fn restart_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
473
474 #[zbus(name = "EnableUnitFiles")]
476 fn enable_unit_files(
477 &self,
478 files: Vec<String>,
479 runtime: bool,
480 force: bool,
481 ) -> zbus::Result<(bool, Vec<(String, String, String)>)>;
482
483 #[zbus(name = "DisableUnitFiles")]
485 fn disable_unit_files(&self, files: Vec<String>, runtime: bool) -> zbus::Result<Vec<(String, String, String)>>;
486
487 #[zbus(name = "ListUnits")]
489 fn list_units(
490 &self,
491 ) -> zbus::Result<
492 Vec<(
493 String,
494 String,
495 String,
496 String,
497 String,
498 String,
499 zvariant::OwnedObjectPath,
500 u32,
501 String,
502 zvariant::OwnedObjectPath,
503 )>,
504 >;
505
506 #[zbus(name = "ListUnitsByPatterns")]
508 fn list_units_by_patterns(
509 &self,
510 states: Vec<String>,
511 patterns: Vec<String>,
512 ) -> zbus::Result<
513 Vec<(
514 String,
515 String,
516 String,
517 String,
518 String,
519 String,
520 zvariant::OwnedObjectPath,
521 u32,
522 String,
523 zvariant::OwnedObjectPath,
524 )>,
525 >;
526
527 #[zbus(name = "Reload")]
529 fn reload(&self) -> zbus::Result<()>;
530
531 #[zbus(name = "ListUnitFilesByPatterns")]
533 fn list_unit_files_by_patterns(
534 &self,
535 states: Vec<String>,
536 patterns: Vec<String>,
537 ) -> zbus::Result<Vec<(String, String)>>;
538}
539
540#[proxy(
543 interface = "org.freedesktop.systemd1.Unit",
544 default_service = "org.freedesktop.systemd1",
545 assume_defaults = false,
546 gen_blocking = false
547)]
548pub trait Unit {
549 #[zbus(property)]
551 fn active_state(&self) -> zbus::Result<String>;
552
553 #[zbus(property)]
555 fn load_state(&self) -> zbus::Result<String>;
556
557 #[zbus(property)]
559 fn unit_file_state(&self) -> zbus::Result<String>;
560}
561
562#[proxy(
565 interface = "org.freedesktop.systemd1.Service",
566 default_service = "org.freedesktop.systemd1",
567 assume_defaults = false,
568 gen_blocking = false
569)]
570trait Service {
571 #[zbus(property, name = "MainPID")]
573 fn main_pid(&self) -> zbus::Result<u32>;
574}
575
576pub async fn get_active_state(connection: &Connection, full_service_name: &str) -> String {
586 let object_path = get_unit_path(full_service_name);
587
588 match zvariant::ObjectPath::try_from(object_path) {
589 Ok(path) => {
590 let unit_proxy = UnitProxy::new(connection, path).await.unwrap();
591 unit_proxy.active_state().await.unwrap_or("invalid-unit-path".into())
592 },
593 Err(_) => "invalid-unit-path".to_string(),
594 }
595}
596
597pub async fn get_unit_file_state(connection: &Connection, full_service_name: &str) -> String {
607 let object_path = get_unit_path(full_service_name);
608
609 match zvariant::ObjectPath::try_from(object_path) {
610 Ok(path) => {
611 let unit_proxy = UnitProxy::new(connection, path).await.unwrap();
612 unit_proxy.unit_file_state().await.unwrap_or("invalid-unit-path".into())
613 },
614 Err(_) => "invalid-unit-path".to_string(),
615 }
616}
617
618pub async fn get_main_pid(connection: &Connection, full_service_name: &str) -> Result<u32, zbus::Error> {
626 let object_path = get_unit_path(full_service_name);
627
628 let validated_object_path = zvariant::ObjectPath::try_from(object_path).unwrap();
629
630 let service_proxy = ServiceProxy::new(connection, validated_object_path).await.unwrap();
631 service_proxy.main_pid().await
632}
633
634fn encode_as_dbus_object_path(input_string: &str) -> String {
641 input_string
642 .chars()
643 .map(|c| if c.is_ascii_alphanumeric() || c == '/' || c == '_' { c.to_string() } else { format!("_{:x}", c as u32) })
644 .collect()
645}
646
647pub fn get_unit_path(full_service_name: &str) -> String {
654 format!("/org/freedesktop/systemd1/unit/{}", encode_as_dbus_object_path(full_service_name))
655}
656
657#[derive(Debug, Clone)]
659pub enum LogDiagnostic {
660 NeverRun { unit_name: String },
662 JournalInaccessible { error: String },
664 PermissionDenied { error: String },
666 NoLogsRecorded { unit_name: String },
668 JournalctlError { stderr: String },
670}
671
672impl LogDiagnostic {
673 pub fn message(&self) -> String {
675 match self {
676 Self::NeverRun { unit_name } => format!("No logs: {} has never been started", unit_name),
677 Self::JournalInaccessible { error } => {
678 format!("Cannot access journal: {}\n\nCheck that systemd-journald is running", error)
679 },
680 Self::PermissionDenied { error } => format!("Permission denied: {}\n\nTry: sudo systemctl-tui", error),
681 Self::NoLogsRecorded { unit_name } => {
682 format!("No logs recorded for {} (unit has run but produced no journal output)", unit_name)
683 },
684 Self::JournalctlError { stderr } => format!("journalctl error: {}", stderr),
685 }
686 }
687}
688
689pub fn check_unit_has_run(unit: &UnitId) -> bool {
691 let mut args = vec!["show", "-P", "ActiveEnterTimestampMonotonic"];
692 if unit.scope == UnitScope::User {
693 args.insert(0, "--user");
694 }
695 args.push(&unit.name);
696
697 Command::new("systemctl")
698 .args(&args)
699 .output()
700 .ok()
701 .and_then(
702 |output| if output.status.success() { std::str::from_utf8(&output.stdout).ok().map(String::from) } else { None },
703 )
704 .map(|s| s.trim().parse::<u64>().unwrap_or(0) > 0)
705 .unwrap_or(false)
706}
707
708fn can_access_journal(scope: UnitScope) -> Result<(), String> {
710 let mut args = vec!["--lines=1", "--quiet"];
711 if scope == UnitScope::User {
712 args.push("--user");
713 }
714
715 match Command::new("journalctl").args(&args).output() {
716 Ok(output) => {
717 if output.status.success() {
718 Ok(())
719 } else {
720 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
721 }
722 },
723 Err(e) => Err(e.to_string()),
724 }
725}
726
727pub fn parse_journalctl_error(stderr: &str) -> LogDiagnostic {
729 let stderr_lower = stderr.to_lowercase();
730
731 if stderr_lower.contains("permission denied") || stderr_lower.contains("access denied") {
732 LogDiagnostic::PermissionDenied { error: stderr.trim().to_string() }
733 } else if stderr_lower.contains("no such file") || stderr_lower.contains("failed to open") {
734 LogDiagnostic::JournalInaccessible { error: stderr.trim().to_string() }
735 } else {
736 LogDiagnostic::JournalctlError { stderr: stderr.trim().to_string() }
737 }
738}
739
740pub fn diagnose_missing_logs(unit: &UnitId) -> LogDiagnostic {
742 if !check_unit_has_run(unit) {
744 return LogDiagnostic::NeverRun { unit_name: unit.name.clone() };
745 }
746
747 if let Err(error) = can_access_journal(unit.scope) {
749 return parse_journalctl_error(&error);
750 }
751
752 LogDiagnostic::NoLogsRecorded { unit_name: unit.name.clone() }
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759
760 #[test]
761 fn test_get_unit_path() {
762 assert_eq!(get_unit_path("test.service"), "/org/freedesktop/systemd1/unit/test_2eservice");
763 }
764
765 #[test]
766 fn test_encode_as_dbus_object_path() {
767 assert_eq!(encode_as_dbus_object_path("test.service"), "test_2eservice");
768 assert_eq!(encode_as_dbus_object_path("test-with-hyphen.service"), "test_2dwith_2dhyphen_2eservice");
769 }
770
771 #[test]
772 fn test_parse_journalctl_error_permission() {
773 let diagnostic = parse_journalctl_error("Failed to get journal access: Permission denied");
774 assert!(matches!(diagnostic, LogDiagnostic::PermissionDenied { .. }));
775 }
776
777 #[test]
778 fn test_parse_journalctl_error_no_file() {
779 let diagnostic = parse_journalctl_error("No such file or directory");
780 assert!(matches!(diagnostic, LogDiagnostic::JournalInaccessible { .. }));
781 }
782
783 #[test]
784 fn test_parse_journalctl_error_generic() {
785 let diagnostic = parse_journalctl_error("Something unexpected happened");
786 assert!(matches!(diagnostic, LogDiagnostic::JournalctlError { .. }));
787 }
788}