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, services: &[String]) -> 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 =
167 match manager_proxy.list_unit_files_by_patterns(vec![], services.iter().map(|s| s.to_string()).collect()).await {
168 Ok(files) => {
169 info!("get_unit_files: got {} {:?} unit files", files.len(), unit_scope);
170 files
171 },
172 Err(e) => {
173 error!("get_unit_files: list_unit_files_by_patterns failed for {:?}: {:?}", unit_scope, e);
174 if is_root && unit_scope == UnitScope::User {
175 info!("get_unit_files: ignoring user scope error because we're root");
176 vec![]
177 } else {
178 return Err(e.into());
179 }
180 },
181 };
182
183 let services = unit_files
184 .into_iter()
185 .filter_map(|(path, state)| {
186 let rust_path = std::path::Path::new(&path);
187 let file_name = rust_path.file_name()?.to_str()?;
188 Some(UnitFile { name: file_name.to_string(), scope: unit_scope, enablement_state: state, path })
189 })
190 .collect::<Vec<_>>();
191 ret.extend(services);
192 }
193
194 info!("Loaded {} unit files in {:?}", ret.len(), start.elapsed());
195 Ok(ret)
196}
197
198pub async fn get_all_services(scope: Scope, services: &[String]) -> Result<Vec<UnitWithStatus>> {
200 let start = std::time::Instant::now();
201
202 let mut units = vec![];
203
204 let is_root = nix::unistd::geteuid().is_root();
205
206 match scope {
207 Scope::Global => {
208 let system_units = get_services(UnitScope::Global, services).await?;
209 units.extend(system_units);
210 },
211 Scope::User => {
212 let user_units = get_services(UnitScope::User, services).await?;
213 units.extend(user_units);
214 },
215 Scope::All => {
216 let (system_units, user_units) =
217 tokio::join!(get_services(UnitScope::Global, services), get_services(UnitScope::User, services));
218 units.extend(system_units?);
219
220 if let Ok(user_units) = user_units {
222 units.extend(user_units);
223 } else if is_root {
224 error!("Failed to get user units, ignoring because we're running as root")
225 } else {
226 user_units?;
227 }
228 },
229 }
230
231 units.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
233
234 info!("Loaded systemd services in {:?}", start.elapsed());
235
236 Ok(units)
237}
238
239async fn get_services(scope: UnitScope, services: &[String]) -> Result<Vec<UnitWithStatus>, anyhow::Error> {
240 let connection = get_connection(scope).await?;
241 let manager_proxy = ManagerProxy::new(&connection).await?;
242 let units = manager_proxy.list_units_by_patterns(vec![], services.to_vec()).await?;
243 let units: Vec<_> = units.into_iter().map(|u| to_unit_status(u, scope)).collect();
244 Ok(units)
245}
246
247pub fn get_unit_file_location(service: &UnitId) -> Result<String> {
248 let mut args = vec!["--quiet", "show", "-P", "FragmentPath"];
250 args.push(&service.name);
251
252 if service.scope == UnitScope::User {
253 args.insert(0, "--user");
254 }
255
256 let output = Command::new("systemctl").args(&args).output()?;
257
258 if output.status.success() {
259 let path = str::from_utf8(&output.stdout)?.trim();
260 if path.is_empty() {
261 bail!("No unit file found for {}", service.name);
262 }
263 Ok(path.trim().to_string())
264 } else {
265 let stderr = String::from_utf8(output.stderr)?;
266 bail!(stderr);
267 }
268}
269
270pub async fn start_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
271 async fn start_service(service: UnitId) -> Result<()> {
272 let connection = get_connection(service.scope).await?;
273 let manager_proxy = ManagerProxy::new(&connection).await?;
274 manager_proxy.start_unit(service.name.clone(), "replace".into()).await?;
275 Ok(())
276 }
277
278 tokio::select! {
280 _ = cancel_token.cancelled() => {
281 anyhow::bail!("cancelled");
282 }
283 result = start_service(service) => {
284 result
285 }
286 }
287}
288
289pub async fn stop_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
290 async fn stop_service(service: UnitId) -> Result<()> {
291 let connection = get_connection(service.scope).await?;
292 let manager_proxy = ManagerProxy::new(&connection).await?;
293 manager_proxy.stop_unit(service.name, "replace".into()).await?;
294 Ok(())
295 }
296
297 tokio::select! {
299 _ = cancel_token.cancelled() => {
300 anyhow::bail!("cancelled");
301 }
302 result = stop_service(service) => {
303 result
304 }
305 }
306}
307
308pub async fn reload(scope: UnitScope, cancel_token: CancellationToken) -> Result<()> {
309 async fn reload_(scope: UnitScope) -> Result<()> {
310 let connection = get_connection(scope).await?;
311 let manager_proxy: ManagerProxy<'_> = ManagerProxy::new(&connection).await?;
312 let error_message = match scope {
313 UnitScope::Global => "Failed to reload units, probably because superuser permissions are needed. Try running `sudo systemctl daemon-reload`",
314 UnitScope::User => "Failed to reload units. Try running `systemctl --user daemon-reload`",
315 };
316 manager_proxy.reload().await.context(error_message)?;
317 Ok(())
318 }
319
320 tokio::select! {
322 _ = cancel_token.cancelled() => {
323 anyhow::bail!("cancelled");
324 }
325 result = reload_(scope) => {
326 result
327 }
328 }
329}
330
331async fn get_connection(scope: UnitScope) -> Result<Connection, anyhow::Error> {
332 match scope {
333 UnitScope::Global => Ok(Connection::system().await?),
334 UnitScope::User => Ok(Connection::session().await?),
335 }
336}
337
338pub async fn restart_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
339 async fn restart(service: UnitId) -> Result<()> {
340 let connection = get_connection(service.scope).await?;
341 let manager_proxy = ManagerProxy::new(&connection).await?;
342 manager_proxy.restart_unit(service.name, "replace".into()).await?;
343 Ok(())
344 }
345
346 tokio::select! {
348 _ = cancel_token.cancelled() => {
349 anyhow::bail!("cancelled");
351 }
352 result = restart(service) => {
353 result
354 }
355 }
356}
357
358pub async fn enable_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
359 async fn enable(service: UnitId) -> Result<()> {
360 let connection = get_connection(service.scope).await?;
361 let manager_proxy = ManagerProxy::new(&connection).await?;
362 let files = vec![service.name];
363 let (_, changes) = manager_proxy.enable_unit_files(files, false, false).await?;
364
365 for (change_type, name, destination) in changes {
366 info!("{}: {} -> {}", change_type, name, destination);
367 }
368 manager_proxy.reload().await?;
370 Ok(())
371 }
372
373 tokio::select! {
374 _ = cancel_token.cancelled() => {
375 anyhow::bail!("cancelled");
376 }
377 result = enable(service) => {
378 result
379 }
380 }
381}
382
383pub async fn disable_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
384 async fn disable(service: UnitId) -> Result<()> {
385 let connection = get_connection(service.scope).await?;
386 let manager_proxy = ManagerProxy::new(&connection).await?;
387 let files = vec![service.name];
388 let changes = manager_proxy.disable_unit_files(files, false).await?;
389
390 for (change_type, name, destination) in changes {
391 info!("{}: {} -> {}", change_type, name, destination);
392 }
393 manager_proxy.reload().await?;
394 Ok(())
395 }
396
397 tokio::select! {
398 _ = cancel_token.cancelled() => {
399 anyhow::bail!("cancelled");
400 }
401 result = disable(service) => {
402 result
403 }
404 }
405}
406
407pub async fn sleep_test(_service: String, cancel_token: CancellationToken) -> Result<()> {
409 tokio::select! {
411 _ = cancel_token.cancelled() => {
412 anyhow::bail!("cancelled");
414 }
415 _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {
416 Ok(())
417 }
418 }
419}
420
421pub async fn kill_service(service: UnitId, signal: String, cancel_token: CancellationToken) -> Result<()> {
422 async fn kill(service: UnitId, signal: String) -> Result<()> {
423 let mut args = vec!["kill", "--signal", &signal];
424 if service.scope == UnitScope::User {
425 args.push("--user");
426 }
427 args.push(&service.name);
428
429 let output = Command::new("systemctl").args(&args).output()?;
430
431 if output.status.success() {
432 info!("Successfully sent signal {} to srvice {}", signal, service.name);
433 Ok(())
434 } else {
435 let stderr = String::from_utf8(output.stderr)?;
436 bail!("Failed to send signal {} to service {}: {}", signal, service.name, stderr);
437 }
438 }
439
440 tokio::select! {
441 _ = cancel_token.cancelled() => {
442 bail!("cancelled");
443 }
444 result = kill(service, signal) => {
445 result
446 }
447 }
448}
449
450#[proxy(
453 interface = "org.freedesktop.systemd1.Manager",
454 default_service = "org.freedesktop.systemd1",
455 default_path = "/org/freedesktop/systemd1",
456 gen_blocking = false
457)]
458pub trait Manager {
459 #[zbus(name = "StartUnit")]
461 fn start_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
462
463 #[zbus(name = "StopUnit")]
465 fn stop_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
466
467 #[zbus(name = "ReloadUnit")]
469 fn reload_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
470
471 #[zbus(name = "RestartUnit")]
473 fn restart_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
474
475 #[zbus(name = "EnableUnitFiles")]
477 fn enable_unit_files(
478 &self,
479 files: Vec<String>,
480 runtime: bool,
481 force: bool,
482 ) -> zbus::Result<(bool, Vec<(String, String, String)>)>;
483
484 #[zbus(name = "DisableUnitFiles")]
486 fn disable_unit_files(&self, files: Vec<String>, runtime: bool) -> zbus::Result<Vec<(String, String, String)>>;
487
488 #[zbus(name = "ListUnits")]
490 fn list_units(
491 &self,
492 ) -> zbus::Result<
493 Vec<(
494 String,
495 String,
496 String,
497 String,
498 String,
499 String,
500 zvariant::OwnedObjectPath,
501 u32,
502 String,
503 zvariant::OwnedObjectPath,
504 )>,
505 >;
506
507 #[zbus(name = "ListUnitsByPatterns")]
509 fn list_units_by_patterns(
510 &self,
511 states: Vec<String>,
512 patterns: Vec<String>,
513 ) -> zbus::Result<
514 Vec<(
515 String,
516 String,
517 String,
518 String,
519 String,
520 String,
521 zvariant::OwnedObjectPath,
522 u32,
523 String,
524 zvariant::OwnedObjectPath,
525 )>,
526 >;
527
528 #[zbus(name = "Reload")]
530 fn reload(&self) -> zbus::Result<()>;
531
532 #[zbus(name = "ListUnitFilesByPatterns")]
534 fn list_unit_files_by_patterns(
535 &self,
536 states: Vec<String>,
537 patterns: Vec<String>,
538 ) -> zbus::Result<Vec<(String, String)>>;
539}
540
541#[proxy(
544 interface = "org.freedesktop.systemd1.Unit",
545 default_service = "org.freedesktop.systemd1",
546 assume_defaults = false,
547 gen_blocking = false
548)]
549pub trait Unit {
550 #[zbus(property)]
552 fn active_state(&self) -> zbus::Result<String>;
553
554 #[zbus(property)]
556 fn load_state(&self) -> zbus::Result<String>;
557
558 #[zbus(property)]
560 fn unit_file_state(&self) -> zbus::Result<String>;
561}
562
563#[proxy(
566 interface = "org.freedesktop.systemd1.Service",
567 default_service = "org.freedesktop.systemd1",
568 assume_defaults = false,
569 gen_blocking = false
570)]
571trait Service {
572 #[zbus(property, name = "MainPID")]
574 fn main_pid(&self) -> zbus::Result<u32>;
575}
576
577pub async fn get_active_state(connection: &Connection, full_service_name: &str) -> String {
587 let object_path = get_unit_path(full_service_name);
588
589 match zvariant::ObjectPath::try_from(object_path) {
590 Ok(path) => {
591 let unit_proxy = UnitProxy::new(connection, path).await.unwrap();
592 unit_proxy.active_state().await.unwrap_or("invalid-unit-path".into())
593 },
594 Err(_) => "invalid-unit-path".to_string(),
595 }
596}
597
598pub async fn get_unit_file_state(connection: &Connection, full_service_name: &str) -> String {
608 let object_path = get_unit_path(full_service_name);
609
610 match zvariant::ObjectPath::try_from(object_path) {
611 Ok(path) => {
612 let unit_proxy = UnitProxy::new(connection, path).await.unwrap();
613 unit_proxy.unit_file_state().await.unwrap_or("invalid-unit-path".into())
614 },
615 Err(_) => "invalid-unit-path".to_string(),
616 }
617}
618
619pub async fn get_main_pid(connection: &Connection, full_service_name: &str) -> Result<u32, zbus::Error> {
627 let object_path = get_unit_path(full_service_name);
628
629 let validated_object_path = zvariant::ObjectPath::try_from(object_path).unwrap();
630
631 let service_proxy = ServiceProxy::new(connection, validated_object_path).await.unwrap();
632 service_proxy.main_pid().await
633}
634
635fn encode_as_dbus_object_path(input_string: &str) -> String {
642 input_string
643 .chars()
644 .map(|c| if c.is_ascii_alphanumeric() || c == '/' || c == '_' { c.to_string() } else { format!("_{:x}", c as u32) })
645 .collect()
646}
647
648pub fn get_unit_path(full_service_name: &str) -> String {
655 format!("/org/freedesktop/systemd1/unit/{}", encode_as_dbus_object_path(full_service_name))
656}
657
658#[derive(Debug, Clone)]
660pub enum LogDiagnostic {
661 NeverRun { unit_name: String },
663 JournalInaccessible { error: String },
665 PermissionDenied { error: String },
667 NoLogsRecorded { unit_name: String },
669 JournalctlError { stderr: String },
671}
672
673impl LogDiagnostic {
674 pub fn message(&self) -> String {
676 match self {
677 Self::NeverRun { unit_name } => format!("No logs: {} has never been started", unit_name),
678 Self::JournalInaccessible { error } => {
679 format!("Cannot access journal: {}\n\nCheck that systemd-journald is running", error)
680 },
681 Self::PermissionDenied { error } => format!("Permission denied: {}\n\nTry: sudo systemctl-tui", error),
682 Self::NoLogsRecorded { unit_name } => {
683 format!("No logs recorded for {} (unit has run but produced no journal output)", unit_name)
684 },
685 Self::JournalctlError { stderr } => format!("journalctl error: {}", stderr),
686 }
687 }
688}
689
690pub fn check_unit_has_run(unit: &UnitId) -> bool {
692 let mut args = vec!["show", "-P", "ActiveEnterTimestampMonotonic"];
693 if unit.scope == UnitScope::User {
694 args.insert(0, "--user");
695 }
696 args.push(&unit.name);
697
698 Command::new("systemctl")
699 .args(&args)
700 .output()
701 .ok()
702 .and_then(
703 |output| if output.status.success() { std::str::from_utf8(&output.stdout).ok().map(String::from) } else { None },
704 )
705 .map(|s| s.trim().parse::<u64>().unwrap_or(0) > 0)
706 .unwrap_or(false)
707}
708
709fn can_access_journal(scope: UnitScope) -> Result<(), String> {
711 let mut args = vec!["--lines=1", "--quiet"];
712 if scope == UnitScope::User {
713 args.push("--user");
714 }
715
716 match Command::new("journalctl").args(&args).output() {
717 Ok(output) => {
718 if output.status.success() {
719 Ok(())
720 } else {
721 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
722 }
723 },
724 Err(e) => Err(e.to_string()),
725 }
726}
727
728pub fn parse_journalctl_error(stderr: &str) -> LogDiagnostic {
730 let stderr_lower = stderr.to_lowercase();
731
732 if stderr_lower.contains("permission denied") || stderr_lower.contains("access denied") {
733 LogDiagnostic::PermissionDenied { error: stderr.trim().to_string() }
734 } else if stderr_lower.contains("no such file") || stderr_lower.contains("failed to open") {
735 LogDiagnostic::JournalInaccessible { error: stderr.trim().to_string() }
736 } else {
737 LogDiagnostic::JournalctlError { stderr: stderr.trim().to_string() }
738 }
739}
740
741pub fn diagnose_missing_logs(unit: &UnitId) -> LogDiagnostic {
743 if !check_unit_has_run(unit) {
745 return LogDiagnostic::NeverRun { unit_name: unit.name.clone() };
746 }
747
748 if let Err(error) = can_access_journal(unit.scope) {
750 return parse_journalctl_error(&error);
751 }
752
753 LogDiagnostic::NoLogsRecorded { unit_name: unit.name.clone() }
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760
761 #[test]
762 fn test_get_unit_path() {
763 assert_eq!(get_unit_path("test.service"), "/org/freedesktop/systemd1/unit/test_2eservice");
764 }
765
766 #[test]
767 fn test_encode_as_dbus_object_path() {
768 assert_eq!(encode_as_dbus_object_path("test.service"), "test_2eservice");
769 assert_eq!(encode_as_dbus_object_path("test-with-hyphen.service"), "test_2dwith_2dhyphen_2eservice");
770 }
771
772 #[test]
773 fn test_parse_journalctl_error_permission() {
774 let diagnostic = parse_journalctl_error("Failed to get journal access: Permission denied");
775 assert!(matches!(diagnostic, LogDiagnostic::PermissionDenied { .. }));
776 }
777
778 #[test]
779 fn test_parse_journalctl_error_no_file() {
780 let diagnostic = parse_journalctl_error("No such file or directory");
781 assert!(matches!(diagnostic, LogDiagnostic::JournalInaccessible { .. }));
782 }
783
784 #[test]
785 fn test_parse_journalctl_error_generic() {
786 let diagnostic = parse_journalctl_error("Something unexpected happened");
787 assert!(matches!(diagnostic, LogDiagnostic::JournalctlError { .. }));
788 }
789}