Skip to main content

provider_agent/
service.rs

1//! `usepod-agent service ...` — install / start / stop / status / uninstall
2//! the agent as a managed system service.
3//!
4//! Cross-platform via the `service-manager` crate:
5//!
6//! - **Linux** — systemd. Unit at `/etc/systemd/system/usepod-agent.service`.
7//!   Runs as a dedicated `usepod` user (created during install if missing).
8//!   Logs go to `journalctl -u usepod-agent` via stderr.
9//! - **macOS** — launchd. Plist at `/Library/LaunchDaemons/ai.usepod.agent.plist`.
10//!   System daemon (requires sudo). Logs at `/var/log/usepod-agent.log`.
11//! - **Windows** — SCM. Service `ai.usepod.agent`, runs as LocalSystem. Logs
12//!   captured via `USEPOD_AGENT_LOG_FILE` (SCM doesn't capture stdout).
13//!
14//! All `install`/`uninstall` paths require elevated privileges. `start`/`stop`/
15//! `status`/`logs` work without elevation on platforms that allow it; on
16//! systemd they need sudo because we install system (not user) units.
17
18use std::ffi::OsString;
19use std::path::PathBuf;
20use std::process::Command as ProcCommand;
21
22use anyhow::{Context, Result, bail};
23use service_manager::{
24    RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceLevel, ServiceManager,
25    ServiceStartCtx, ServiceStatus, ServiceStatusCtx, ServiceStopCtx, ServiceUninstallCtx,
26};
27
28/// Service identity. Platforms render this differently:
29///
30/// - systemd unit name: `usepod-agent.service` (from `to_script_name()`)
31/// - launchd plist: `ai.usepod.agent.plist` (from `to_qualified_name()`)
32/// - Windows SCM service: `ai.usepod.agent`
33pub fn label() -> ServiceLabel {
34    ServiceLabel {
35        qualifier: Some("ai".into()),
36        organization: Some("usepod".into()),
37        application: "agent".into(),
38    }
39}
40
41/// Per-OS default working directory for the service. Created on install.
42pub fn working_directory() -> PathBuf {
43    #[cfg(target_os = "linux")]
44    {
45        PathBuf::from("/var/lib/usepod-agent")
46    }
47    #[cfg(target_os = "macos")]
48    {
49        PathBuf::from("/var/lib/usepod-agent")
50    }
51    #[cfg(target_os = "windows")]
52    {
53        // %ProgramData% is C:\ProgramData on default installs; readable+writable
54        // by LocalSystem and admin without surprises.
55        let pd = std::env::var("ProgramData").unwrap_or_else(|_| "C:\\ProgramData".into());
56        PathBuf::from(pd).join("usepod-agent")
57    }
58}
59
60/// Path the service's stdout/stderr is captured to (Windows + macOS).
61/// On Linux this is unused; logs go through journald.
62pub fn default_log_path() -> PathBuf {
63    #[cfg(target_os = "linux")]
64    {
65        PathBuf::from("/var/log/usepod-agent.log")
66    }
67    #[cfg(target_os = "macos")]
68    {
69        PathBuf::from("/var/log/usepod-agent.log")
70    }
71    #[cfg(target_os = "windows")]
72    {
73        working_directory().join("agent.log")
74    }
75}
76
77/// Linux service user. Created at install if missing.
78#[cfg(target_os = "linux")]
79const SERVICE_USER: &str = "usepod";
80
81#[derive(Debug, Clone)]
82pub struct InstallOptions {
83    /// Optional `--config <path>` to pass through to the running agent.
84    pub config: Option<PathBuf>,
85    /// Optional `--log-level` to pass through (default: `info`).
86    pub log_level: Option<String>,
87}
88
89/// Top-level dispatcher invoked from `main.rs`.
90pub fn run(action: Action) -> Result<()> {
91    match action {
92        Action::Install(opts) => install(opts),
93        Action::Uninstall => uninstall(),
94        Action::Start => start(),
95        Action::Stop => stop(),
96        Action::Restart => {
97            // Best-effort stop, then start. On a fresh install where stop
98            // fails because nothing's running, swallow and proceed.
99            let _ = stop();
100            start()
101        }
102        Action::Status => status(),
103        Action::Logs { follow } => logs(follow),
104    }
105}
106
107#[derive(Debug, Clone)]
108pub enum Action {
109    Install(InstallOptions),
110    Uninstall,
111    Start,
112    Stop,
113    Restart,
114    Status,
115    Logs { follow: bool },
116}
117
118fn manager() -> Result<Box<dyn ServiceManager>> {
119    let mut m = <dyn ServiceManager>::native()
120        .context("could not detect a supported native service manager (systemd / launchd / SCM)")?;
121    // We only operate at the system level — user-level services are out of
122    // scope for this PR. set_level returns Err if the requested level isn't
123    // supported on this platform.
124    m.set_level(ServiceLevel::System)
125        .context("system-level service install not supported by this platform's service manager")?;
126    Ok(m)
127}
128
129fn build_install_ctx(opts: &InstallOptions) -> Result<ServiceInstallCtx> {
130    let program = std::env::current_exe()
131        .context("could not resolve current executable path for service install")?;
132
133    let mut args: Vec<OsString> = vec!["run".into()];
134    if let Some(p) = &opts.config {
135        args.push("--config".into());
136        args.push(p.as_os_str().to_owned());
137    }
138    if let Some(level) = &opts.log_level {
139        args.push("--log-level".into());
140        args.push(level.into());
141    }
142
143    let working_dir = working_directory();
144
145    // Environment: tell the running agent where to send logs when stdout/stderr
146    // aren't captured. On Linux journald handles it; setting this is harmless.
147    let mut environment: Vec<(String, String)> = Vec::new();
148    if cfg!(target_os = "windows") {
149        environment.push((
150            "USEPOD_AGENT_LOG_FILE".into(),
151            default_log_path().to_string_lossy().into_owned(),
152        ));
153    }
154
155    // Username: dedicated user on Linux. macOS + Windows fall back to the
156    // service manager default (root / LocalSystem) — addressed in a follow-up
157    // PR per the plan.
158    let username = if cfg!(target_os = "linux") {
159        Some(SERVICE_USER.to_string())
160    } else {
161        None
162    };
163
164    Ok(ServiceInstallCtx {
165        label: label(),
166        program,
167        args,
168        contents: platform_contents(&working_dir),
169        username,
170        working_directory: Some(working_dir),
171        environment: if environment.is_empty() {
172            None
173        } else {
174            Some(environment)
175        },
176        autostart: true,
177        // `Always` matches the existing standalone systemd template
178        // (install/usepod-agent.service), which operators have been running
179        // since v0.1.0. Clean stops via `service stop` aren't fought by this
180        // policy because the service manager records a stop reason that
181        // suppresses auto-restart on intentional shutdown.
182        restart_policy: RestartPolicy::Always {
183            delay_secs: Some(5),
184        },
185    })
186}
187
188/// Custom unit-file / plist contents per platform. Returning `None` here lets
189/// the service-manager crate generate a sensible default; we override only
190/// where we want hardening or log-capture directives.
191fn platform_contents(_working_dir: &std::path::Path) -> Option<String> {
192    // Linux: keep the existing hardened unit template (ProtectSystem,
193    // NoNewPrivileges, etc.) by emitting our own contents.
194    #[cfg(target_os = "linux")]
195    {
196        let exe = std::env::current_exe()
197            .ok()
198            .map(|p| p.to_string_lossy().into_owned())
199            .unwrap_or_else(|| "/usr/local/bin/usepod-agent".into());
200        return Some(format!(
201            r#"[Unit]
202Description=Use Pod Provider Agent
203Documentation=https://usepod.ai/docs/agent
204After=network-online.target
205Wants=network-online.target
206
207[Service]
208Type=simple
209User={user}
210Group={user}
211WorkingDirectory={wd}
212ExecStart={exe} run
213Restart=always
214RestartSec=5
215
216# --- Hardening (kept in sync with install/usepod-agent.service) -------------
217NoNewPrivileges=true
218ProtectSystem=strict
219ProtectHome=true
220PrivateTmp=true
221ReadWritePaths={wd}
222ProtectKernelTunables=true
223ProtectKernelModules=true
224ProtectControlGroups=true
225RestrictSUIDSGID=true
226LockPersonality=true
227
228[Install]
229WantedBy=multi-user.target
230"#,
231            user = SERVICE_USER,
232            wd = _working_dir.display(),
233            exe = exe,
234        ));
235    }
236
237    // macOS: emit a plist with explicit StandardOutPath / StandardErrorPath
238    // so logs land somewhere operators can `tail -f`. The crate's default
239    // plist doesn't set those, leaving stdout discarded.
240    #[cfg(target_os = "macos")]
241    {
242        let exe = std::env::current_exe()
243            .ok()
244            .map(|p| p.to_string_lossy().into_owned())
245            .unwrap_or_else(|| "/usr/local/bin/usepod-agent".into());
246        let log = default_log_path().to_string_lossy().into_owned();
247        return Some(format!(
248            r#"<?xml version="1.0" encoding="UTF-8"?>
249<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
250<plist version="1.0">
251<dict>
252    <key>Label</key><string>ai.usepod.agent</string>
253    <key>ProgramArguments</key>
254    <array>
255        <string>{exe}</string>
256        <string>run</string>
257    </array>
258    <key>WorkingDirectory</key><string>{wd}</string>
259    <key>RunAtLoad</key><true/>
260    <key>KeepAlive</key>
261    <dict>
262        <key>SuccessfulExit</key><false/>
263    </dict>
264    <key>StandardOutPath</key><string>{log}</string>
265    <key>StandardErrorPath</key><string>{log}</string>
266</dict>
267</plist>
268"#,
269            exe = exe,
270            wd = _working_dir.display(),
271            log = log,
272        ));
273    }
274
275    // Windows: let service-manager generate the default sc.exe install. The
276    // log redirect is handled in-process via USEPOD_AGENT_LOG_FILE rather than
277    // by SCM (which has no stdout-capture).
278    #[cfg(target_os = "windows")]
279    {
280        return None;
281    }
282
283    #[allow(unreachable_code)]
284    None
285}
286
287fn install(opts: InstallOptions) -> Result<()> {
288    require_elevated("install")?;
289    ensure_working_directory()?;
290
291    #[cfg(target_os = "linux")]
292    {
293        ensure_linux_user(SERVICE_USER)?;
294        chown_working_directory(SERVICE_USER, &working_directory())?;
295    }
296
297    let m = manager()?;
298    let ctx = build_install_ctx(&opts)?;
299    m.install(ctx).context("service install failed")?;
300
301    let status_hint = match m.status(ServiceStatusCtx { label: label() }) {
302        Ok(ServiceStatus::Running) => "running",
303        Ok(ServiceStatus::Stopped(_)) => "installed (stopped)",
304        Ok(ServiceStatus::NotInstalled) => "installed (not yet started)",
305        Err(_) => "installed",
306    };
307    println!("✓ usepod-agent service: {status_hint}");
308    println!("  start:  usepod-agent service start");
309    println!("  status: usepod-agent service status");
310    println!("  logs:   usepod-agent service logs -f");
311    Ok(())
312}
313
314fn uninstall() -> Result<()> {
315    require_elevated("uninstall")?;
316    let m = manager()?;
317    // Best-effort stop; if it isn't running, ignore.
318    let _ = m.stop(ServiceStopCtx { label: label() });
319    m.uninstall(ServiceUninstallCtx { label: label() })
320        .context("service uninstall failed")?;
321    println!("✓ usepod-agent service uninstalled");
322    Ok(())
323}
324
325fn start() -> Result<()> {
326    let m = manager()?;
327    m.start(ServiceStartCtx { label: label() })
328        .context("service start failed (try with sudo / as Administrator)")?;
329    println!("✓ usepod-agent service started");
330    Ok(())
331}
332
333fn stop() -> Result<()> {
334    let m = manager()?;
335    m.stop(ServiceStopCtx { label: label() })
336        .context("service stop failed (try with sudo / as Administrator)")?;
337    println!("✓ usepod-agent service stopped");
338    Ok(())
339}
340
341fn status() -> Result<()> {
342    let m = manager()?;
343    match m.status(ServiceStatusCtx { label: label() }) {
344        Ok(ServiceStatus::Running) => {
345            println!("running");
346            Ok(())
347        }
348        Ok(ServiceStatus::Stopped(reason)) => {
349            match reason {
350                Some(r) => println!("stopped: {r}"),
351                None => println!("stopped"),
352            }
353            Ok(())
354        }
355        Ok(ServiceStatus::NotInstalled) => {
356            println!("not installed");
357            std::process::exit(3);
358        }
359        Err(e) => Err(anyhow::Error::from(e).context("service status query failed")),
360    }
361}
362
363fn logs(follow: bool) -> Result<()> {
364    #[cfg(target_os = "linux")]
365    {
366        let mut cmd = ProcCommand::new("journalctl");
367        cmd.arg("-u").arg("usepod-agent");
368        if follow {
369            cmd.arg("-f");
370        } else {
371            cmd.arg("-n").arg("200").arg("--no-pager");
372        }
373        let status = cmd.status().context("failed to invoke journalctl")?;
374        if !status.success() {
375            bail!("journalctl exited with {status}");
376        }
377        return Ok(());
378    }
379
380    #[cfg(any(target_os = "macos", target_os = "windows"))]
381    {
382        let path = default_log_path();
383        if !path.exists() {
384            bail!(
385                "log file does not yet exist at {} (service may not have run yet)",
386                path.display()
387            );
388        }
389        let mut cmd = if cfg!(target_os = "windows") {
390            // No tail on stock Windows; use PowerShell's Get-Content -Wait.
391            let mut c = ProcCommand::new("powershell");
392            c.arg("-NoProfile").arg("-Command");
393            if follow {
394                c.arg(format!("Get-Content -Path '{}' -Wait -Tail 200", path.display()));
395            } else {
396                c.arg(format!("Get-Content -Path '{}' -Tail 200", path.display()));
397            }
398            c
399        } else {
400            let mut c = ProcCommand::new("tail");
401            c.arg("-n").arg("200");
402            if follow {
403                c.arg("-f");
404            }
405            c.arg(&path);
406            c
407        };
408        let status = cmd.status().context("failed to invoke log tailer")?;
409        if !status.success() {
410            bail!("log tailer exited with {status}");
411        }
412        return Ok(());
413    }
414
415    #[allow(unreachable_code)]
416    {
417        bail!("`service logs` not implemented for this platform");
418    }
419}
420
421// --- Helpers ----------------------------------------------------------------
422
423fn require_elevated(action: &str) -> Result<()> {
424    if is_elevated() {
425        return Ok(());
426    }
427    let hint = if cfg!(target_os = "windows") {
428        "re-run from an elevated PowerShell (Run as Administrator)"
429    } else {
430        "re-run with sudo"
431    };
432    bail!("`service {action}` needs root/Administrator privileges; {hint}");
433}
434
435#[cfg(unix)]
436fn is_elevated() -> bool {
437    // SAFETY: getuid is always-success and side-effect-free.
438    unsafe { libc_getuid() == 0 }
439}
440
441#[cfg(unix)]
442unsafe extern "C" {
443    #[link_name = "getuid"]
444    fn libc_getuid() -> u32;
445}
446
447#[cfg(target_os = "windows")]
448fn is_elevated() -> bool {
449    // Heuristic: try to open a handle to the SCM with full access. Fails
450    // for non-admin users. Avoids pulling in the windows-rs crate just for
451    // this check by using the env-driven sentinel `IsElevated` set by some
452    // PowerShell launchers, falling back to attempting a privileged sc.exe
453    // command. Pragmatic and Good Enough.
454    if std::env::var("USEPOD_AGENT_FORCE_ELEVATED").is_ok() {
455        return true;
456    }
457    // `net session` returns success only when running as Administrator and
458    // doesn't require any privileges to attempt. It's the canonical
459    // is-admin check on stock Windows.
460    ProcCommand::new("net")
461        .arg("session")
462        .stdout(std::process::Stdio::null())
463        .stderr(std::process::Stdio::null())
464        .status()
465        .map(|s| s.success())
466        .unwrap_or(false)
467}
468
469fn ensure_working_directory() -> Result<()> {
470    let wd = working_directory();
471    if !wd.exists() {
472        std::fs::create_dir_all(&wd)
473            .with_context(|| format!("could not create {}", wd.display()))?;
474    }
475    Ok(())
476}
477
478#[cfg(target_os = "linux")]
479fn ensure_linux_user(user: &str) -> Result<()> {
480    // If `id usepod` succeeds, the account exists and we're done.
481    let exists = ProcCommand::new("id")
482        .arg(user)
483        .stdout(std::process::Stdio::null())
484        .stderr(std::process::Stdio::null())
485        .status()
486        .map(|s| s.success())
487        .unwrap_or(false);
488    if exists {
489        return Ok(());
490    }
491    let status = ProcCommand::new("useradd")
492        .arg("--system")
493        .arg("--no-create-home")
494        .arg("--shell")
495        .arg("/usr/sbin/nologin")
496        .arg(user)
497        .status()
498        .context("failed to invoke useradd; install shadow-utils or create the user manually")?;
499    if !status.success() {
500        bail!(
501            "useradd exited with {status}; create the `{user}` system user manually then retry"
502        );
503    }
504    Ok(())
505}
506
507#[cfg(target_os = "linux")]
508fn chown_working_directory(user: &str, wd: &std::path::Path) -> Result<()> {
509    let status = ProcCommand::new("chown")
510        .arg("-R")
511        .arg(format!("{user}:{user}"))
512        .arg(wd)
513        .status()
514        .context("failed to invoke chown")?;
515    if !status.success() {
516        bail!("chown exited with {status}");
517    }
518    Ok(())
519}
520
521// Stub on non-Linux so cfg-free callsites compile.
522#[cfg(not(target_os = "linux"))]
523#[allow(dead_code)]
524fn ensure_linux_user(_user: &str) -> Result<()> {
525    Ok(())
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    #[test]
533    fn label_renders_per_platform_correctly() {
534        let l = label();
535        // systemd unit name (script_name)
536        assert_eq!(l.to_script_name(), "usepod-agent");
537        // launchd / SCM name (qualified_name)
538        assert_eq!(l.to_qualified_name(), "ai.usepod.agent");
539    }
540
541    #[test]
542    fn install_ctx_carries_run_subcommand() {
543        let opts = InstallOptions {
544            config: None,
545            log_level: None,
546        };
547        let ctx = build_install_ctx(&opts).expect("ctx builds");
548        assert_eq!(ctx.args.first().map(|s| s.as_os_str()), Some(std::ffi::OsStr::new("run")));
549        assert!(ctx.autostart);
550    }
551
552    #[test]
553    fn install_ctx_propagates_config_and_log_level() {
554        let opts = InstallOptions {
555            config: Some(PathBuf::from("/etc/usepod/agent.toml")),
556            log_level: Some("debug".into()),
557        };
558        let ctx = build_install_ctx(&opts).expect("ctx builds");
559        let args: Vec<String> = ctx
560            .args
561            .iter()
562            .map(|s| s.to_string_lossy().into_owned())
563            .collect();
564        assert_eq!(args, vec!["run", "--config", "/etc/usepod/agent.toml", "--log-level", "debug"]);
565    }
566
567    #[test]
568    fn restart_policy_is_always() {
569        let ctx = build_install_ctx(&InstallOptions {
570            config: None,
571            log_level: None,
572        })
573        .unwrap();
574        assert!(matches!(ctx.restart_policy, RestartPolicy::Always { .. }));
575    }
576}