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. Use #[cfg] blocks rather than cfg!() because
158    // SERVICE_USER itself is gated to linux; cfg!() doesn't gate symbol
159    // resolution and breaks the Windows + macOS builds.
160    #[cfg(target_os = "linux")]
161    let username = Some(SERVICE_USER.to_string());
162    #[cfg(not(target_os = "linux"))]
163    let username: Option<String> = None;
164
165    Ok(ServiceInstallCtx {
166        label: label(),
167        program,
168        args,
169        contents: platform_contents(&working_dir),
170        username,
171        working_directory: Some(working_dir),
172        environment: if environment.is_empty() {
173            None
174        } else {
175            Some(environment)
176        },
177        autostart: true,
178        // `Always` matches the existing standalone systemd template
179        // (install/usepod-agent.service), which operators have been running
180        // since v0.1.0. Clean stops via `service stop` aren't fought by this
181        // policy because the service manager records a stop reason that
182        // suppresses auto-restart on intentional shutdown.
183        restart_policy: RestartPolicy::Always {
184            delay_secs: Some(5),
185        },
186    })
187}
188
189/// Custom unit-file / plist contents per platform. Returning `None` here lets
190/// the service-manager crate generate a sensible default; we override only
191/// where we want hardening or log-capture directives.
192fn platform_contents(_working_dir: &std::path::Path) -> Option<String> {
193    // Linux: keep the existing hardened unit template (ProtectSystem,
194    // NoNewPrivileges, etc.) by emitting our own contents.
195    #[cfg(target_os = "linux")]
196    {
197        let exe = std::env::current_exe()
198            .ok()
199            .map(|p| p.to_string_lossy().into_owned())
200            .unwrap_or_else(|| "/usr/local/bin/usepod-agent".into());
201        return Some(format!(
202            r#"[Unit]
203Description=Use Pod Provider Agent
204Documentation=https://usepod.ai/docs/agent
205After=network-online.target
206Wants=network-online.target
207
208[Service]
209Type=simple
210User={user}
211Group={user}
212WorkingDirectory={wd}
213ExecStart={exe} run
214Restart=always
215RestartSec=5
216
217# --- Hardening (kept in sync with install/usepod-agent.service) -------------
218NoNewPrivileges=true
219ProtectSystem=strict
220ProtectHome=true
221PrivateTmp=true
222ReadWritePaths={wd}
223ProtectKernelTunables=true
224ProtectKernelModules=true
225ProtectControlGroups=true
226RestrictSUIDSGID=true
227LockPersonality=true
228
229[Install]
230WantedBy=multi-user.target
231"#,
232            user = SERVICE_USER,
233            wd = _working_dir.display(),
234            exe = exe,
235        ));
236    }
237
238    // macOS: emit a plist with explicit StandardOutPath / StandardErrorPath
239    // so logs land somewhere operators can `tail -f`. The crate's default
240    // plist doesn't set those, leaving stdout discarded.
241    #[cfg(target_os = "macos")]
242    {
243        let exe = std::env::current_exe()
244            .ok()
245            .map(|p| p.to_string_lossy().into_owned())
246            .unwrap_or_else(|| "/usr/local/bin/usepod-agent".into());
247        let log = default_log_path().to_string_lossy().into_owned();
248        return Some(format!(
249            r#"<?xml version="1.0" encoding="UTF-8"?>
250<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
251<plist version="1.0">
252<dict>
253    <key>Label</key><string>ai.usepod.agent</string>
254    <key>ProgramArguments</key>
255    <array>
256        <string>{exe}</string>
257        <string>run</string>
258    </array>
259    <key>WorkingDirectory</key><string>{wd}</string>
260    <key>RunAtLoad</key><true/>
261    <key>KeepAlive</key>
262    <dict>
263        <key>SuccessfulExit</key><false/>
264    </dict>
265    <key>StandardOutPath</key><string>{log}</string>
266    <key>StandardErrorPath</key><string>{log}</string>
267</dict>
268</plist>
269"#,
270            exe = exe,
271            wd = _working_dir.display(),
272            log = log,
273        ));
274    }
275
276    // Windows: let service-manager generate the default sc.exe install. The
277    // log redirect is handled in-process via USEPOD_AGENT_LOG_FILE rather than
278    // by SCM (which has no stdout-capture).
279    #[cfg(target_os = "windows")]
280    {
281        return None;
282    }
283
284    #[allow(unreachable_code)]
285    None
286}
287
288fn install(opts: InstallOptions) -> Result<()> {
289    require_elevated("install")?;
290    ensure_working_directory()?;
291
292    #[cfg(target_os = "linux")]
293    {
294        ensure_linux_user(SERVICE_USER)?;
295        chown_working_directory(SERVICE_USER, &working_directory())?;
296    }
297
298    let m = manager()?;
299    let ctx = build_install_ctx(&opts)?;
300    m.install(ctx).context("service install failed")?;
301
302    let status_hint = match m.status(ServiceStatusCtx { label: label() }) {
303        Ok(ServiceStatus::Running) => "running",
304        Ok(ServiceStatus::Stopped(_)) => "installed (stopped)",
305        Ok(ServiceStatus::NotInstalled) => "installed (not yet started)",
306        Err(_) => "installed",
307    };
308    println!("✓ usepod-agent service: {status_hint}");
309    println!("  start:  usepod-agent service start");
310    println!("  status: usepod-agent service status");
311    println!("  logs:   usepod-agent service logs -f");
312    Ok(())
313}
314
315fn uninstall() -> Result<()> {
316    require_elevated("uninstall")?;
317    let m = manager()?;
318    // Best-effort stop; if it isn't running, ignore.
319    let _ = m.stop(ServiceStopCtx { label: label() });
320    m.uninstall(ServiceUninstallCtx { label: label() })
321        .context("service uninstall failed")?;
322    println!("✓ usepod-agent service uninstalled");
323    Ok(())
324}
325
326fn start() -> Result<()> {
327    let m = manager()?;
328    m.start(ServiceStartCtx { label: label() })
329        .context("service start failed (try with sudo / as Administrator)")?;
330    println!("✓ usepod-agent service started");
331    Ok(())
332}
333
334fn stop() -> Result<()> {
335    let m = manager()?;
336    m.stop(ServiceStopCtx { label: label() })
337        .context("service stop failed (try with sudo / as Administrator)")?;
338    println!("✓ usepod-agent service stopped");
339    Ok(())
340}
341
342fn status() -> Result<()> {
343    let m = manager()?;
344    match m.status(ServiceStatusCtx { label: label() }) {
345        Ok(ServiceStatus::Running) => {
346            println!("running");
347            Ok(())
348        }
349        Ok(ServiceStatus::Stopped(reason)) => {
350            match reason {
351                Some(r) => println!("stopped: {r}"),
352                None => println!("stopped"),
353            }
354            Ok(())
355        }
356        Ok(ServiceStatus::NotInstalled) => {
357            println!("not installed");
358            std::process::exit(3);
359        }
360        Err(e) => Err(anyhow::Error::from(e).context("service status query failed")),
361    }
362}
363
364fn logs(follow: bool) -> Result<()> {
365    #[cfg(target_os = "linux")]
366    {
367        let mut cmd = ProcCommand::new("journalctl");
368        cmd.arg("-u").arg("usepod-agent");
369        if follow {
370            cmd.arg("-f");
371        } else {
372            cmd.arg("-n").arg("200").arg("--no-pager");
373        }
374        let status = cmd.status().context("failed to invoke journalctl")?;
375        if !status.success() {
376            bail!("journalctl exited with {status}");
377        }
378        return Ok(());
379    }
380
381    #[cfg(any(target_os = "macos", target_os = "windows"))]
382    {
383        let path = default_log_path();
384        if !path.exists() {
385            bail!(
386                "log file does not yet exist at {} (service may not have run yet)",
387                path.display()
388            );
389        }
390        let mut cmd = if cfg!(target_os = "windows") {
391            // No tail on stock Windows; use PowerShell's Get-Content -Wait.
392            let mut c = ProcCommand::new("powershell");
393            c.arg("-NoProfile").arg("-Command");
394            if follow {
395                c.arg(format!("Get-Content -Path '{}' -Wait -Tail 200", path.display()));
396            } else {
397                c.arg(format!("Get-Content -Path '{}' -Tail 200", path.display()));
398            }
399            c
400        } else {
401            let mut c = ProcCommand::new("tail");
402            c.arg("-n").arg("200");
403            if follow {
404                c.arg("-f");
405            }
406            c.arg(&path);
407            c
408        };
409        let status = cmd.status().context("failed to invoke log tailer")?;
410        if !status.success() {
411            bail!("log tailer exited with {status}");
412        }
413        return Ok(());
414    }
415
416    #[allow(unreachable_code)]
417    {
418        bail!("`service logs` not implemented for this platform");
419    }
420}
421
422// --- Helpers ----------------------------------------------------------------
423
424fn require_elevated(action: &str) -> Result<()> {
425    if is_elevated() {
426        return Ok(());
427    }
428    let hint = if cfg!(target_os = "windows") {
429        "re-run from an elevated PowerShell (Run as Administrator)"
430    } else {
431        "re-run with sudo"
432    };
433    bail!("`service {action}` needs root/Administrator privileges; {hint}");
434}
435
436#[cfg(unix)]
437fn is_elevated() -> bool {
438    // SAFETY: getuid is always-success and side-effect-free.
439    unsafe { libc_getuid() == 0 }
440}
441
442#[cfg(unix)]
443unsafe extern "C" {
444    #[link_name = "getuid"]
445    fn libc_getuid() -> u32;
446}
447
448#[cfg(target_os = "windows")]
449fn is_elevated() -> bool {
450    // Heuristic: try to open a handle to the SCM with full access. Fails
451    // for non-admin users. Avoids pulling in the windows-rs crate just for
452    // this check by using the env-driven sentinel `IsElevated` set by some
453    // PowerShell launchers, falling back to attempting a privileged sc.exe
454    // command. Pragmatic and Good Enough.
455    if std::env::var("USEPOD_AGENT_FORCE_ELEVATED").is_ok() {
456        return true;
457    }
458    // `net session` returns success only when running as Administrator and
459    // doesn't require any privileges to attempt. It's the canonical
460    // is-admin check on stock Windows.
461    ProcCommand::new("net")
462        .arg("session")
463        .stdout(std::process::Stdio::null())
464        .stderr(std::process::Stdio::null())
465        .status()
466        .map(|s| s.success())
467        .unwrap_or(false)
468}
469
470fn ensure_working_directory() -> Result<()> {
471    let wd = working_directory();
472    if !wd.exists() {
473        std::fs::create_dir_all(&wd)
474            .with_context(|| format!("could not create {}", wd.display()))?;
475    }
476    Ok(())
477}
478
479#[cfg(target_os = "linux")]
480fn ensure_linux_user(user: &str) -> Result<()> {
481    // If `id usepod` succeeds, the account exists and we're done.
482    let exists = ProcCommand::new("id")
483        .arg(user)
484        .stdout(std::process::Stdio::null())
485        .stderr(std::process::Stdio::null())
486        .status()
487        .map(|s| s.success())
488        .unwrap_or(false);
489    if exists {
490        return Ok(());
491    }
492    let status = ProcCommand::new("useradd")
493        .arg("--system")
494        .arg("--no-create-home")
495        .arg("--shell")
496        .arg("/usr/sbin/nologin")
497        .arg(user)
498        .status()
499        .context("failed to invoke useradd; install shadow-utils or create the user manually")?;
500    if !status.success() {
501        bail!(
502            "useradd exited with {status}; create the `{user}` system user manually then retry"
503        );
504    }
505    Ok(())
506}
507
508#[cfg(target_os = "linux")]
509fn chown_working_directory(user: &str, wd: &std::path::Path) -> Result<()> {
510    let status = ProcCommand::new("chown")
511        .arg("-R")
512        .arg(format!("{user}:{user}"))
513        .arg(wd)
514        .status()
515        .context("failed to invoke chown")?;
516    if !status.success() {
517        bail!("chown exited with {status}");
518    }
519    Ok(())
520}
521
522// Stub on non-Linux so cfg-free callsites compile.
523#[cfg(not(target_os = "linux"))]
524#[allow(dead_code)]
525fn ensure_linux_user(_user: &str) -> Result<()> {
526    Ok(())
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn label_renders_per_platform_correctly() {
535        let l = label();
536        // systemd unit name (script_name)
537        assert_eq!(l.to_script_name(), "usepod-agent");
538        // launchd / SCM name (qualified_name)
539        assert_eq!(l.to_qualified_name(), "ai.usepod.agent");
540    }
541
542    #[test]
543    fn install_ctx_carries_run_subcommand() {
544        let opts = InstallOptions {
545            config: None,
546            log_level: None,
547        };
548        let ctx = build_install_ctx(&opts).expect("ctx builds");
549        assert_eq!(ctx.args.first().map(|s| s.as_os_str()), Some(std::ffi::OsStr::new("run")));
550        assert!(ctx.autostart);
551    }
552
553    #[test]
554    fn install_ctx_propagates_config_and_log_level() {
555        let opts = InstallOptions {
556            config: Some(PathBuf::from("/etc/usepod/agent.toml")),
557            log_level: Some("debug".into()),
558        };
559        let ctx = build_install_ctx(&opts).expect("ctx builds");
560        let args: Vec<String> = ctx
561            .args
562            .iter()
563            .map(|s| s.to_string_lossy().into_owned())
564            .collect();
565        assert_eq!(args, vec!["run", "--config", "/etc/usepod/agent.toml", "--log-level", "debug"]);
566    }
567
568    #[test]
569    fn restart_policy_is_always() {
570        let ctx = build_install_ctx(&InstallOptions {
571            config: None,
572            log_level: None,
573        })
574        .unwrap();
575        assert!(matches!(ctx.restart_policy, RestartPolicy::Always { .. }));
576    }
577}