Skip to main content

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}