Skip to main content

aperion_shield/orgmode/
state.rs

1//! On-disk org-mode enrollment record.
2//!
3//! Persisted at `~/.aperion-shield/orgmode.json` with mode 0600. Stores
4//! everything `aperion-shield` needs to continue talking to Smartflow
5//! across restarts: the virtual key (treat as bearer secret), device id,
6//! policy group, and the smartflow base URL.
7
8use std::path::PathBuf;
9
10use anyhow::{anyhow, Context};
11use serde::{Deserialize, Serialize};
12
13/// Filename relative to the user's `~/.aperion-shield/` directory.
14pub const ORG_STATE_FILE: &str = "orgmode.json";
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct OrgState {
18    /// Base URL of the Smartflow control plane, e.g.
19    /// `https://smartflow.langsmart.app`. Used for every REST call.
20    pub smartflow_url: String,
21
22    /// Virtual key issued by `enterprise_device_api::token_enroll`.
23    /// Sent as `Authorization: Bearer <vkey>` on every request.
24    pub vkey: String,
25
26    /// Server-assigned device id (uuid v4).
27    pub device_id: String,
28
29    /// Policy group this device is bound to. Used to fetch the right
30    /// shieldset from `/api/enterprise/shield/shieldset/<group>`.
31    pub policy_group: String,
32
33    /// Original enrolling user email (informational; the dashboard
34    /// shows it in the fleet view).
35    #[serde(default)]
36    pub owner_email: Option<String>,
37
38    /// RFC 3339 timestamp of when this device was enrolled.
39    pub enrolled_at: String,
40
41    /// Device platform string sent at enrollment time -- "macos",
42    /// "linux", or "windows". Drives policy group resolution on the
43    /// server.
44    pub platform: String,
45
46    /// Friendly device name shown in the fleet view. Defaults to the
47    /// machine's hostname.
48    pub device_name: String,
49
50    /// Hashed device fingerprint -- prevents the server from issuing
51    /// two records for the same physical machine if the user re-enrolls.
52    pub device_fingerprint: String,
53}
54
55impl OrgState {
56    /// Resolve `~/.aperion-shield/orgmode.json`. Honour the
57    /// `APERION_SHIELD_HOME` env override so tests don't write into
58    /// the real user home.
59    pub fn default_path() -> anyhow::Result<PathBuf> {
60        let dir = if let Ok(custom) = std::env::var("APERION_SHIELD_HOME") {
61            PathBuf::from(custom)
62        } else {
63            let mut home = dirs::home_dir()
64                .ok_or_else(|| anyhow!("could not resolve home directory"))?;
65            home.push(".aperion-shield");
66            home
67        };
68        std::fs::create_dir_all(&dir).context("create ~/.aperion-shield/")?;
69        Ok(dir.join(ORG_STATE_FILE))
70    }
71
72    /// Load if present; `Ok(None)` means "not enrolled" (the normal
73    /// standalone path).
74    pub fn load() -> anyhow::Result<Option<Self>> {
75        let path = Self::default_path()?;
76        if !path.exists() {
77            return Ok(None);
78        }
79        let raw = std::fs::read_to_string(&path)
80            .with_context(|| format!("read {}", path.display()))?;
81        let state: OrgState =
82            serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
83        Ok(Some(state))
84    }
85
86    /// Persist atomically with mode 0600 on Unix.
87    pub fn save(&self) -> anyhow::Result<()> {
88        let path = Self::default_path()?;
89        let tmp = path.with_extension("json.tmp");
90        let json = serde_json::to_string_pretty(self)?;
91        std::fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?;
92        #[cfg(unix)]
93        {
94            use std::os::unix::fs::PermissionsExt;
95            let mut perms = std::fs::metadata(&tmp)?.permissions();
96            perms.set_mode(0o600);
97            std::fs::set_permissions(&tmp, perms)?;
98        }
99        std::fs::rename(&tmp, &path).with_context(|| format!("rename {}", path.display()))?;
100        Ok(())
101    }
102
103    /// Remove the on-disk file. Used by `aperion-shield disenroll`.
104    pub fn remove() -> anyhow::Result<()> {
105        let path = Self::default_path()?;
106        if path.exists() {
107            std::fs::remove_file(&path)
108                .with_context(|| format!("remove {}", path.display()))?;
109        }
110        Ok(())
111    }
112
113    /// Derive a fingerprint that's stable across re-enrolls on the
114    /// same physical machine but doesn't leak anything sensitive.
115    /// SHA-256 of `<hostname>|<os-name>|<machine-id-if-available>`.
116    pub fn fingerprint() -> String {
117        use sha2::{Digest, Sha256};
118        let hostname = hostname_string();
119        let os = std::env::consts::OS.to_string();
120        let machine_id = machine_id_string();
121        let mut hasher = Sha256::new();
122        hasher.update(format!("{}|{}|{}", hostname, os, machine_id).as_bytes());
123        hex::encode(hasher.finalize())
124    }
125}
126
127fn hostname_string() -> String {
128    // Fallback chain: HOSTNAME env, then uname-style read, then "unknown".
129    std::env::var("HOSTNAME")
130        .ok()
131        .or_else(|| std::env::var("COMPUTERNAME").ok())
132        .or_else(|| {
133            std::process::Command::new("hostname")
134                .output()
135                .ok()
136                .and_then(|o| String::from_utf8(o.stdout).ok())
137                .map(|s| s.trim().to_string())
138                .filter(|s| !s.is_empty())
139        })
140        .unwrap_or_else(|| "unknown".to_string())
141}
142
143fn machine_id_string() -> String {
144    // Best-effort. On Linux /etc/machine-id is universal; on macOS we
145    // hash IOPlatformUUID; on Windows we use the registry's MachineGuid
146    // (skip Windows for now -- not deployed).
147    if let Ok(s) = std::fs::read_to_string("/etc/machine-id") {
148        return s.trim().to_string();
149    }
150    if cfg!(target_os = "macos") {
151        if let Ok(out) = std::process::Command::new("ioreg")
152            .args(["-rd1", "-c", "IOPlatformExpertDevice"])
153            .output()
154        {
155            if let Ok(s) = String::from_utf8(out.stdout) {
156                for line in s.lines() {
157                    if let Some(idx) = line.find("IOPlatformUUID") {
158                        if let Some(uuid) = line[idx..].split('"').nth(3) {
159                            return uuid.to_string();
160                        }
161                    }
162                }
163            }
164        }
165    }
166    "unknown".to_string()
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn fingerprint_is_stable() {
175        let a = OrgState::fingerprint();
176        let b = OrgState::fingerprint();
177        assert_eq!(a, b);
178        assert_eq!(a.len(), 64);
179    }
180}