trusty_console/service.rs
1//! Handler for `trusty-console service` (macOS launchd integration).
2//!
3//! Why: `stable_set()` in trusty-installer marks trusty-console
4//! `ManageStrategy::Launchd`, so `tctl start trusty-console` calls
5//! `launchctl bootstrap … com.trusty.trusty-console.plist` — but no code path
6//! in the repository ever WROTE that plist, so a fresh-machine `tctl start`
7//! hard-failed for the console daemon (#2557). This subcommand supplies the
8//! missing launchd install/uninstall/status/logs mechanics, mirroring the
9//! `trusty-search` / `trusty-analyze` `service` pattern.
10//!
11//! Note (design decision, umbrella #2555 open question): whether the console
12//! SHOULD be launchd-managed or process-managed (like trusty-mpm's own
13//! `start`/`stop` verbs) is an owner decision left open. The console exposes a
14//! real long-lived HTTP daemon (`trusty-console serve`, graceful SIGTERM
15//! shutdown), which launchd supervises correctly, and `stable_set()` already
16//! classifies it `Launchd`; this module makes that existing classification
17//! FUNCTION rather than deciding it is the right lifecycle model. If the owner
18//! later chooses process-management, the console would need its own
19//! `start`/`stop` verbs and the stable-set strategy would flip to `OwnVerb`.
20//!
21//! What: on macOS routes `ServiceAction` to a single launchd operation via the
22//! shared `trusty_common::launchd` module; the agent runs `trusty-console
23//! serve` (the dashboard HTTP daemon). On non-macOS the entry point returns a
24//! clear error.
25//! Test: `service_label_matches_tctl_convention` and `serve_args_are_serve`
26//! (macOS-only, pure) pin the load-bearing label + args; install/uninstall are
27//! side-effecting `launchctl` calls exercised manually (never in tests).
28
29use anyhow::Result;
30use clap::Subcommand;
31
32/// Subcommand actions for `trusty-console service`.
33///
34/// Why: launchd keeps the long-lived console dashboard daemon alive on macOS;
35/// wrapping the plist mechanics in `service` subcommands gives `tctl` a stable
36/// `trusty-console service install` hook and spares operators hand-editing XML.
37/// What: each variant maps to one launchd operation (or `tail -F` for Logs).
38/// Test: `cargo run -p trusty-console -- service --help` lists the four
39/// actions; on Linux any action returns Err with the platform message.
40#[derive(Debug, Clone, Subcommand)]
41pub enum ServiceAction {
42 /// Install the LaunchAgent plist and load it.
43 Install,
44 /// Unload the LaunchAgent and remove the plist.
45 Uninstall,
46 /// Show launchd status for the agent.
47 Status,
48 /// Tail the launchd stdout / stderr logs.
49 Logs,
50}
51
52/// Reverse-DNS label for the LaunchAgent.
53///
54/// Why: this MUST equal the label `tctl start`/`tctl stop` targets, or those
55/// commands drive a launchd job that `service install` never created. Making
56/// both read the same registry constant is what turns "must equal" from a
57/// comment into a fact.
58///
59/// #4868: was the literal `"com.trusty.trusty-console"` while the unit launchd
60/// actually has loaded is `com.trusty.console`, so `service status` queried a
61/// label that does not exist — the same divergence that broke trusty-search.
62/// What: the `Label` key value and the `<label>.plist` base name.
63/// Test: `service_label_matches_tctl_convention`.
64#[cfg(target_os = "macos")]
65pub const LAUNCHD_LABEL: &str = trusty_common::launchd_labels::CONSOLE;
66
67/// Dispatch a `trusty-console service <action>` invocation.
68///
69/// Why: launchd is macOS-specific; on other platforms we return a clear error.
70/// What: macOS routes to install / uninstall / status / logs. Non-macOS bails.
71/// Test: on Linux every action returns Err; the macOS paths are side-effecting
72/// and validated manually.
73pub fn run_service_action(action: &ServiceAction) -> Result<()> {
74 #[cfg(target_os = "macos")]
75 {
76 match action {
77 ServiceAction::Install => service_install(),
78 ServiceAction::Uninstall => service_uninstall(),
79 ServiceAction::Status => service_status(),
80 ServiceAction::Logs => service_logs(),
81 }
82 }
83 #[cfg(not(target_os = "macos"))]
84 {
85 let _ = action;
86 anyhow::bail!(
87 "`trusty-console service` is only supported on macOS — \
88 use your distro's service manager (systemd, OpenRC, etc.) directly."
89 );
90 }
91}
92
93/// The daemon serve args embedded in the launchd plist.
94///
95/// Why: the launchd agent must start the dashboard HTTP daemon, which is
96/// `trusty-console serve`.
97/// What: returns `["serve"]`.
98/// Test: `serve_args_are_serve`.
99#[cfg(target_os = "macos")]
100fn serve_args() -> Vec<String> {
101 vec!["serve".to_string()]
102}
103
104/// Resolve the log directory for the console launchd agent.
105///
106/// Why: align with the other trusty-* daemons (`~/.trusty-<name>/logs`).
107/// What: returns `~/.trusty-console/logs`, creating it on demand.
108/// Test: side-effecting; exercised transitively by `service install`.
109#[cfg(target_os = "macos")]
110fn launchd_log_dir() -> Result<std::path::PathBuf> {
111 let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve $HOME"))?;
112 let dir = home.join(".trusty-console").join("logs");
113 std::fs::create_dir_all(&dir)?;
114 Ok(dir)
115}
116
117/// Build the shared `LaunchdConfig` for the console daemon.
118///
119/// Why: install / uninstall / status all need the same label, exe path, args,
120/// and log directory; building it once keeps them in agreement.
121/// What: resolves the current executable and log dir and returns a
122/// `LaunchdConfig` that runs `trusty-console serve`, kept alive always with a
123/// 10-second restart throttle and a seeded daemon `PATH` (#1298 — the console
124/// shells out to `tailscale`).
125/// Test: side-effecting resolution; exercised transitively by every macOS
126/// `service` subcommand.
127#[cfg(target_os = "macos")]
128fn launchd_config() -> Result<trusty_common::launchd::LaunchdConfig> {
129 use trusty_common::launchd::{KeepAlive, LaunchdConfig};
130
131 let exe = std::env::current_exe()
132 .map_err(|e| anyhow::anyhow!("could not resolve current exe: {e}"))?;
133 let log_dir = launchd_log_dir()?;
134 Ok(LaunchdConfig {
135 label: LAUNCHD_LABEL.to_string(),
136 exe_path: exe,
137 args: serve_args(),
138 log_dir,
139 keep_alive: KeepAlive::Always,
140 throttle_interval: 10,
141 env_vars: Vec::new(),
142 fd_limit: None,
143 working_directory: None,
144 }
145 .with_daemon_path())
146}
147
148/// Install the LaunchAgent and load it.
149///
150/// #4868: console's label genuinely CHANGES with this fix
151/// (`com.trusty.trusty-console` → `com.trusty.console`), which makes eviction
152/// mandatory rather than tidy. A bare `install()` + `bootstrap()` on a host that
153/// ran the older installer would leave the old unit loaded AND add the new one —
154/// two console daemons on one port, the exact #2938 condition. Routing through
155/// `install_and_activate` boots the old label out first, skips the reload when
156/// nothing changed, and rolls back rather than leaving the dashboard down.
157#[cfg(target_os = "macos")]
158fn service_install() -> Result<()> {
159 let cfg = launchd_config()?;
160 let plist_path = cfg.plist_path()?;
161 let outcome = cfg
162 .install_and_activate(trusty_common::launchd_labels::legacy_labels_for(
163 LAUNCHD_LABEL,
164 ))
165 .map_err(|e| anyhow::anyhow!("install LaunchAgent: {e}"))?;
166 for label in outcome.evicted() {
167 println!(
168 "[warn] Evicted the stale LaunchAgent {label} — it named this daemon under an old label."
169 );
170 }
171 let domain = format!("gui/{}", trusty_common::launchd::current_uid());
172 if matches!(
173 outcome,
174 trusty_common::launchd_activate::Activation::AlreadyCurrent { .. }
175 ) {
176 println!(
177 "[ok] {LAUNCHD_LABEL} is already loaded in {domain} with this exact unit — left running."
178 );
179 println!(
180 " Logs: {}\n Status: trusty-console service status",
181 cfg.log_dir.display()
182 );
183 return Ok(());
184 }
185 println!("[ok] Wrote LaunchAgent plist: {}", plist_path.display());
186 println!(
187 "[ok] trusty-console service installed and started ({LAUNCHD_LABEL} loaded into {domain})."
188 );
189 println!(
190 " Logs: {}\n Status: trusty-console service status",
191 cfg.log_dir.display()
192 );
193 Ok(())
194}
195
196#[cfg(target_os = "macos")]
197fn service_uninstall() -> Result<()> {
198 let cfg = launchd_config()?;
199 let plist_path = cfg.plist_path()?;
200 // #4868: a host that never ran the migrating install still has the unit
201 // under its old label. Removing only the canonical plist printed "nothing
202 // to do" while leaving that one loaded.
203 // #6290: `evict_legacy` reports only the labels that WERE evicted, so a
204 // failed bootout or plist deletion vanished silently here. An uninstall
205 // installs nothing, so the fail-soft justification that covers an install's
206 // legacy eviction does not apply — the operator has to be told.
207 for e in cfg.evict_legacy_detailed(trusty_common::launchd_labels::legacy_labels_for(
208 LAUNCHD_LABEL,
209 )) {
210 use trusty_common::launchd_labels::EvictionOutcome;
211 match &e.outcome {
212 EvictionOutcome::Evicted => {
213 println!(
214 "[ok] Unloaded and removed the stale LaunchAgent {}",
215 e.label
216 );
217 }
218 EvictionOutcome::Failed(why) => {
219 eprintln!(
220 "[!!] Could not clear the stale LaunchAgent {}: {why}",
221 e.label
222 );
223 }
224 // `EvictionOutcome` is `#[non_exhaustive]`; Absent and any future
225 // outcome are silent here — only a failure needs the operator.
226 _ => {}
227 }
228 }
229 if plist_path.exists() {
230 let _ = cfg.bootout();
231 std::fs::remove_file(&plist_path)
232 .map_err(|e| anyhow::anyhow!("remove {}: {e}", plist_path.display()))?;
233 println!(
234 "[ok] trusty-console service uninstalled ({} removed).",
235 plist_path.display()
236 );
237 } else {
238 println!(
239 "[skip] {} not installed — nothing to do",
240 plist_path.display()
241 );
242 }
243 Ok(())
244}
245
246#[cfg(target_os = "macos")]
247fn service_status() -> Result<()> {
248 let uid = trusty_common::launchd::current_uid();
249 let target = format!("gui/{uid}/{LAUNCHD_LABEL}");
250 let output = std::process::Command::new("launchctl")
251 .args(["print", &target])
252 .output()
253 .map_err(|e| anyhow::anyhow!("launchctl print failed: {e}"))?;
254 if output.status.success() {
255 println!("{}", String::from_utf8_lossy(&output.stdout));
256 Ok(())
257 } else {
258 eprintln!(" Install with: trusty-console service install");
259 anyhow::bail!(
260 "{target} is not loaded ({})",
261 String::from_utf8_lossy(&output.stderr).trim()
262 );
263 }
264}
265
266#[cfg(target_os = "macos")]
267fn service_logs() -> Result<()> {
268 let log_dir = launchd_log_dir()?;
269 let stdout_log = log_dir.join("stdout.log");
270 let stderr_log = log_dir.join("stderr.log");
271 if !stdout_log.exists() && !stderr_log.exists() {
272 eprintln!(
273 "[skip] No logs at {} yet — start the service first.",
274 log_dir.display()
275 );
276 return Ok(());
277 }
278 let status = std::process::Command::new("tail")
279 .arg("-F")
280 .arg(&stdout_log)
281 .arg(&stderr_log)
282 .status()
283 .map_err(|e| anyhow::anyhow!("tail failed: {e}"))?;
284 if !status.success() {
285 anyhow::bail!("tail exited with {status}");
286 }
287 Ok(())
288}
289
290#[cfg(all(test, target_os = "macos"))]
291mod tests {
292 use super::*;
293
294 /// Why: the label is a cross-crate contract — `tctl` resolves it through
295 /// `plist_label_for` and bootstraps THAT plist, so drift silently breaks
296 /// `tctl start trusty-console`. #4868: asserting against a re-typed literal
297 /// is what made the old version of this test agree with the wrong answer
298 /// (`com.trusty.trusty-console`, while launchd has `com.trusty.console`);
299 /// it now asserts against the registry both sides read.
300 /// What: the constant equals the canonical registry label, and is NOT the
301 /// pre-#4868 full-name form.
302 /// Test: this is the test.
303 #[test]
304 fn service_label_matches_tctl_convention() {
305 assert_eq!(LAUNCHD_LABEL, trusty_common::launchd_labels::CONSOLE);
306 assert_ne!(
307 LAUNCHD_LABEL, "com.trusty.trusty-console",
308 "the full-name form is a legacy alias, not a unit launchd has"
309 );
310 }
311
312 /// Why: the launchd agent must start the dashboard HTTP daemon, i.e.
313 /// `trusty-console serve`.
314 /// What: asserts the embedded args are exactly `["serve"]`.
315 /// Test: this is the test.
316 #[test]
317 fn serve_args_are_serve() {
318 assert_eq!(serve_args(), vec!["serve".to_string()]);
319 }
320
321 /// Cross-crate port-uniqueness contract (#2566, extended by #2573).
322 ///
323 /// Why: trusty-review's original `DEFAULT_PORT` (7880) silently collided
324 /// with trusty-mpm's live `DEFAULT_DAEMON_ADDR`, crash-looping a launchd
325 /// agent on install. This mirrors that fix's guard for the console's own
326 /// default (`crate::DEFAULT_PORT`), pointer-commented to each sibling's
327 /// real source constant, so a future edit here that reintroduces a
328 /// collision fails this test instead of shipping a crash-loop. #2573
329 /// extended this table to also cover trusty-embedderd's `--http` mode
330 /// default, which the original table omitted because it is a manual/
331 /// dev-run listener rather than a `tctl`-managed daemon.
332 /// What: asserts `crate::DEFAULT_PORT` is absent from the known-sibling
333 /// ports list.
334 /// Test: this is the test.
335 #[test]
336 fn default_port_does_not_collide_with_known_siblings() {
337 // (binary, port, source-of-truth pointer)
338 //
339 // #6277 / #6287 / #6286: trusty-review, trusty-analyze and trusty-memory
340 // have NO ROW. None binds a TCP port any more — all three serve a Unix
341 // socket (ADR-0032), so 7891, 7879 and 7070 are not reserved by
342 // anything and listing them would forbid a future daemon a free port.
343 let known_siblings: &[(&str, u16, &str)] = &[
344 (
345 "trusty-search",
346 7878,
347 "trusty-search/src/service/constants.rs::DEFAULT_PORT",
348 ),
349 (
350 "trusty-mpm",
351 7880,
352 "trusty-mpm/src/core/discovery.rs::DEFAULT_DAEMON_ADDR",
353 ),
354 (
355 "trusty-embedderd",
356 7890,
357 "trusty-embedderd/src/lib.rs::Args::http_addr (--http default_value, manual/dev-run only)",
358 ),
359 (
360 // #3331: trusty-agents joined the proxied-sibling set; its API
361 // server default port must not collide with the console's.
362 "trusty-agents",
363 8080,
364 "trusty-agents/src/runtime/mode_dispatch.rs (--port default 8080)",
365 ),
366 // #6288: trusty-mpm's supervisor has NO ROW. It stopped binding
367 // 7881 for a `/metrics` + `/health` listener nothing read and
368 // publishes to `~/.trusty-mpm/supervisor-metrics.json` instead, so
369 // 7881 is not reserved by anything and listing it would forbid a
370 // future daemon a free port. #3364 (below) is why the row existed.
371 (
372 // #3364: trusty-code's own default HTTP port, which previously
373 // reused 7881 and collided with the supervisor's listener,
374 // since retired.
375 "trusty-code",
376 7882,
377 "trusty-code/src/serve/mod.rs::DEFAULT_HTTP_PORT",
378 ),
379 ];
380 for (binary, port, source) in known_siblings {
381 assert_ne!(
382 crate::DEFAULT_PORT,
383 *port,
384 "trusty-console DEFAULT_PORT {} collides with {binary}'s {port} ({source})",
385 crate::DEFAULT_PORT
386 );
387 }
388 }
389}