Skip to main content

aegis_tool/
config.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::ops::{Deref, DerefMut};
4#[cfg(unix)]
5use std::os::unix::fs::{MetadataExt, PermissionsExt};
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use aegis_dto::{HostAlias, HostAliases, HostId};
10use anyhow::{Context, Result, bail};
11use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
12use capulus::paths;
13use capulus::store::{atomic_write, ensure_directory, tighten_file_permissions};
14use serde::{Deserialize, Serialize};
15
16pub type CachedNetworkConfig = aegis_dto::protocol::AegisNetworkConfig;
17pub const SHARED_CACHE_PATH: &str = "/var/lib/aegis/cache.json";
18#[cfg(target_os = "linux")]
19pub const AEGIS_AGENT_SOCKET_PATH: &str = "/run/aegis/agent.sock";
20#[cfg(target_os = "macos")]
21pub const AEGIS_AGENT_SOCKET_PATH: &str = "/private/var/run/aegis-agent.sock";
22pub const AGENT_CONTEXT_PATH: &str = "/var/lib/aegis/context.json";
23
24#[derive(Clone, Debug, Deserialize, Serialize)]
25#[serde(deny_unknown_fields)]
26pub(crate) struct AgentContext {
27    pub api_base: String,
28    pub host_id: HostId,
29}
30
31#[derive(Clone, Debug, Deserialize, Serialize)]
32#[serde(deny_unknown_fields)]
33pub struct UserContext {
34    pub api_base: String,
35}
36
37impl UserContext {
38    pub fn load() -> Result<Option<Self>> {
39        match fs::read_to_string(app_dir()?.join("context.toml")) {
40            Ok(raw) => {
41                let context: Self =
42                    toml::from_str(&raw).context("invalid selected Aegis context")?;
43                namespace_endpoint(&context.api_base)?;
44                Ok(Some(context))
45            }
46            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
47            Err(error) => Err(error).context("failed to read selected Aegis context"),
48        }
49    }
50
51    pub fn persist(&self) -> Result<()> {
52        namespace_endpoint(&self.api_base)?;
53        atomic_write(
54            &app_dir()?.join("context.toml"),
55            toml::to_string(self)?.as_bytes(),
56            Some(0o600),
57            Some(0o700),
58        )
59    }
60}
61
62impl AgentContext {
63    pub(crate) fn persist(&self) -> Result<()> {
64        namespace_endpoint(&self.api_base)?;
65        atomic_write(
66            Path::new(AGENT_CONTEXT_PATH),
67            &serde_json::to_vec(self)?,
68            Some(0o644),
69            Some(0o755),
70        )
71    }
72
73    pub(crate) fn load() -> Result<Option<Self>> {
74        match fs::read(AGENT_CONTEXT_PATH) {
75            Ok(raw) => {
76                let context: Self =
77                    serde_json::from_slice(&raw).context("invalid local agent context")?;
78                namespace_endpoint(&context.api_base)?;
79                Ok(Some(context))
80            }
81            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
82            Err(error) => Err(error).context("failed to read local agent context"),
83        }
84    }
85}
86
87pub(crate) fn namespace_endpoint(api_base: &str) -> Result<aegis_dto::namespace::ApiEndpoint> {
88    let endpoint =
89        aegis_dto::namespace::ApiEndpoint::parse(api_base).map_err(anyhow::Error::msg)?;
90    endpoint.require_namespace().map_err(anyhow::Error::msg)?;
91    Ok(endpoint)
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct CachedHost {
96    pub host_id: HostId,
97    pub aliases: HostAliases,
98    #[serde(flatten)]
99    pub host: aegis_dto::protocol::AegisNetworkHost,
100}
101
102impl CachedHost {
103    pub fn alias(&self) -> &aegis_dto::HostAlias {
104        self.aliases.primary()
105    }
106
107    pub fn matches(&self, value: &str) -> bool {
108        value
109            .parse::<HostId>()
110            .is_ok_and(|host_id| host_id == self.host_id)
111            || value
112                .parse::<HostAlias>()
113                .is_ok_and(|alias| self.aliases.contains(&alias))
114    }
115
116    pub fn host_label(&self) -> String {
117        let host = self
118            .internal_ipv4()
119            .or_else(|| self.internal_ipv6())
120            .or_else(|| self.host.wireguard_ipv4())
121            .or(self.host.wireguard_ipv6())
122            .unwrap_or_else(|| self.alias().as_str());
123        let host = if host.contains(':') {
124            format!("[{host}]")
125        } else {
126            host.to_string()
127        };
128        match self.ssh.as_ref() {
129            Some(ssh) => match ssh.port {
130                Some(22) => host,
131                Some(port) => format!("{host}:{port}"),
132                None => host,
133            },
134            None => host,
135        }
136    }
137}
138
139impl Deref for CachedHost {
140    type Target = aegis_dto::protocol::AegisNetworkHost;
141
142    fn deref(&self) -> &Self::Target {
143        &self.host
144    }
145}
146
147impl DerefMut for CachedHost {
148    fn deref_mut(&mut self) -> &mut Self::Target {
149        &mut self.host
150    }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
154pub struct CachedInventory {
155    pub api_base: String,
156    pub hosts: BTreeMap<HostId, aegis_dto::protocol::AegisHost>,
157    pub networks: BTreeMap<String, CachedNetwork>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161pub struct CachedNetwork {
162    pub config: CachedNetworkConfig,
163    pub members: BTreeMap<HostId, aegis_dto::protocol::AegisNetworkMember>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ResolvedNetwork {
168    pub config: CachedNetworkConfig,
169    pub hosts: Vec<CachedHost>,
170}
171
172impl CachedInventory {
173    pub fn resolve_network(&self, network: &str) -> Result<Option<ResolvedNetwork>> {
174        let Some(cached) = self.networks.get(network) else {
175            return Ok(None);
176        };
177        let mut hosts = Vec::with_capacity(cached.members.len());
178        for (host_id, member) in &cached.members {
179            let host = self.hosts.get(host_id).cloned().ok_or_else(|| {
180                anyhow::anyhow!("network member `{network}/{host_id}` has no matching host")
181            })?;
182            if member.aliases != host.aliases {
183                anyhow::bail!("network member `{network}/{host_id}` aliases do not match its host");
184            }
185            hosts.push(CachedHost {
186                host_id: *host_id,
187                aliases: host.aliases.clone(),
188                host: aegis_dto::protocol::AegisNetworkHost::resolve(host, member.clone()),
189            });
190        }
191        Ok(Some(ResolvedNetwork {
192            config: cached.config.clone(),
193            hosts,
194        }))
195    }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199#[serde(deny_unknown_fields)]
200pub struct UserAuthState {
201    pub access_token: String,
202    pub refresh_token: String,
203    pub principal: String,
204    pub access_expires_at_unix: i64,
205    pub refresh_expires_at_unix: i64,
206}
207
208impl UserAuthState {
209    pub fn access_needs_refresh(&self, now_unix: i64, skew_seconds: i64) -> bool {
210        now_unix.saturating_add(skew_seconds) >= self.access_expires_at_unix
211    }
212
213    pub fn refresh_is_expired(&self, now_unix: i64) -> bool {
214        now_unix >= self.refresh_expires_at_unix
215    }
216}
217
218#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
219pub struct AgentAuthConfig {
220    pub refresh_token: String,
221}
222
223#[derive(Debug, Clone, Deserialize, Serialize)]
224#[serde(deny_unknown_fields)]
225pub(crate) struct AgentHostConfigOptions {
226    pub(crate) host_id: HostId,
227    pub(crate) ssh_user: String,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub(crate) port: Option<u16>,
230    pub(crate) host_private_key_path: PathBuf,
231    pub(crate) host_public_key_path: PathBuf,
232    pub(crate) host_certificate_path: PathBuf,
233    pub(crate) client_ca_path: PathBuf,
234    pub(crate) authorized_principals_dir: PathBuf,
235    pub(crate) sshd_dropin_path: PathBuf,
236}
237
238#[derive(Debug, Clone, Deserialize, Serialize)]
239#[serde(deny_unknown_fields)]
240pub(crate) struct AgentBirdConfigOptions {
241    pub(crate) config_path: PathBuf,
242    #[serde(default = "default_agent_bird_service")]
243    pub(crate) service: String,
244}
245
246#[derive(Debug, Clone, Deserialize, Serialize)]
247#[serde(tag = "backend", rename_all = "snake_case", deny_unknown_fields)]
248pub(crate) enum AgentRoutingOptions {
249    Bird(AgentBirdConfigOptions),
250    Babel,
251}
252
253#[derive(Debug, Clone, Serialize)]
254#[serde(tag = "backend", rename_all = "snake_case")]
255pub(crate) enum AgentRoutingConfig {
256    Bird(AgentBirdConfig),
257    Babel,
258}
259
260impl AgentRoutingOptions {
261    fn validate(self) -> Result<AgentRoutingConfig> {
262        match self {
263            Self::Bird(bird) => {
264                require_absolute_agent_config_path("routing.config_path", &bird.config_path)?;
265                if bird.service.is_empty()
266                    || !bird.service.bytes().all(|byte| {
267                        byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'@')
268                    })
269                {
270                    bail!("agent routing.service is not a valid systemd service name");
271                }
272                Ok(AgentRoutingConfig::Bird(AgentBirdConfig {
273                    config_path: bird.config_path,
274                    service: bird.service,
275                }))
276            }
277            Self::Babel => Ok(AgentRoutingConfig::Babel),
278        }
279    }
280}
281
282impl AgentRoutingConfig {
283    pub(crate) fn validate_platform(
284        &self,
285        platform: aegis_dto::platform::HostPlatform,
286    ) -> Result<()> {
287        let macos = platform.operating_system == aegis_dto::platform::OperatingSystem::MacOs;
288        if macos != matches!(self, Self::Babel) {
289            bail!(
290                "routing backend does not match the host platform; use Babel on macOS and BIRD on Linux"
291            );
292        }
293        Ok(())
294    }
295
296    #[cfg(target_os = "linux")]
297    pub(crate) fn bird(&self) -> Result<&AgentBirdConfig> {
298        match self {
299            Self::Bird(bird) => Ok(bird),
300            Self::Babel => bail!("BIRD is not the selected routing backend"),
301        }
302    }
303}
304
305#[derive(Debug, Clone, Deserialize, Serialize)]
306#[serde(deny_unknown_fields)]
307pub(crate) struct AgentConfigOptions {
308    pub(crate) api_base: String,
309    pub(crate) auth: AgentAuthConfig,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub(crate) cache_path: Option<PathBuf>,
312    pub(crate) host: AgentHostConfigOptions,
313    pub(crate) routing: AgentRoutingOptions,
314}
315
316#[derive(Clone, Debug, Serialize)]
317pub(crate) struct AgentHostConfig {
318    pub(crate) host_id: HostId,
319    pub(crate) ssh_user: String,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub(crate) port: Option<u16>,
322    pub(crate) host_private_key_path: PathBuf,
323    pub(crate) host_public_key_path: PathBuf,
324    pub(crate) host_certificate_path: PathBuf,
325    pub(crate) client_ca_path: PathBuf,
326    pub(crate) authorized_principals_dir: PathBuf,
327    pub(crate) sshd_dropin_path: PathBuf,
328}
329
330#[derive(Clone, Debug, Serialize)]
331pub(crate) struct AgentBirdConfig {
332    pub(crate) config_path: PathBuf,
333    pub(crate) service: String,
334}
335
336#[derive(Clone, Debug, Serialize)]
337pub(crate) struct AgentConfig {
338    pub(crate) api_base: String,
339    pub(crate) auth: AgentAuthConfig,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub(crate) cache_path: Option<PathBuf>,
342    pub(crate) host: AgentHostConfig,
343    pub(crate) routing: AgentRoutingConfig,
344}
345
346impl AgentConfig {
347    pub(crate) fn parse_toml(raw: &str) -> Result<Self> {
348        toml::from_str::<AgentConfigOptions>(raw)
349            .context("failed to parse aegis-agent config")?
350            .try_into()
351    }
352}
353
354impl TryFrom<AgentConfigOptions> for AgentConfig {
355    type Error = anyhow::Error;
356
357    fn try_from(raw: AgentConfigOptions) -> Result<Self> {
358        let api_base = raw.api_base.trim();
359        if api_base != raw.api_base || api_base.is_empty() {
360            bail!("agent api_base must be non-empty and contain no surrounding whitespace");
361        }
362        let endpoint = namespace_endpoint(api_base).context("invalid agent api_base")?;
363
364        let refresh_token = raw.auth.refresh_token.trim();
365        if refresh_token.is_empty() || refresh_token != raw.auth.refresh_token {
366            bail!(
367                "agent auth.refresh_token must be non-empty and contain no surrounding whitespace"
368            );
369        }
370
371        if let Some(cache_path) = raw.cache_path.as_ref() {
372            require_absolute_agent_config_path("cache_path", cache_path)?;
373        }
374
375        crate::principal_grants::validate_login_principal(&raw.host.ssh_user)
376            .context("agent host.ssh_user is invalid")?;
377        if raw.host.port == Some(0) {
378            bail!("agent host.port must be between 1 and 65535");
379        }
380        for (field, path) in [
381            (
382                "host.host_private_key_path",
383                &raw.host.host_private_key_path,
384            ),
385            ("host.host_public_key_path", &raw.host.host_public_key_path),
386            (
387                "host.host_certificate_path",
388                &raw.host.host_certificate_path,
389            ),
390            ("host.client_ca_path", &raw.host.client_ca_path),
391            (
392                "host.authorized_principals_dir",
393                &raw.host.authorized_principals_dir,
394            ),
395            ("host.sshd_dropin_path", &raw.host.sshd_dropin_path),
396        ] {
397            require_absolute_agent_config_path(field, path)?;
398        }
399        let host_key_paths = [
400            &raw.host.host_private_key_path,
401            &raw.host.host_public_key_path,
402            &raw.host.host_certificate_path,
403        ];
404        if host_key_paths[0] == host_key_paths[1]
405            || host_key_paths[0] == host_key_paths[2]
406            || host_key_paths[1] == host_key_paths[2]
407        {
408            bail!("agent host key and certificate paths must be distinct");
409        }
410        let routing = raw.routing.validate()?;
411
412        Ok(Self {
413            api_base: endpoint.base_url(),
414            auth: raw.auth,
415            cache_path: raw.cache_path,
416            host: AgentHostConfig {
417                host_id: raw.host.host_id,
418                ssh_user: raw.host.ssh_user,
419                port: raw.host.port,
420                host_private_key_path: raw.host.host_private_key_path,
421                host_public_key_path: raw.host.host_public_key_path,
422                host_certificate_path: raw.host.host_certificate_path,
423                client_ca_path: raw.host.client_ca_path,
424                authorized_principals_dir: raw.host.authorized_principals_dir,
425                sshd_dropin_path: raw.host.sshd_dropin_path,
426            },
427            routing,
428        })
429    }
430}
431
432pub(crate) fn persist_agent_config(path: &Path, config: &AgentConfig) -> Result<()> {
433    atomic_write(
434        path,
435        toml::to_string(config)
436            .context("failed to encode aegis-agent config")?
437            .as_bytes(),
438        Some(0o600),
439        Some(0o755),
440    )
441}
442
443fn require_absolute_agent_config_path(field: &str, path: &Path) -> Result<()> {
444    if !path.is_absolute() {
445        bail!("agent {field} must be an absolute path");
446    }
447    Ok(())
448}
449
450fn default_agent_bird_service() -> String {
451    "bird".to_string()
452}
453
454pub fn agent_refresh_token_env_value(refresh_token: &str) -> String {
455    BASE64_STANDARD.encode(refresh_token)
456}
457
458pub fn agent_refresh_token_from_encoded_value(encoded: &str) -> Result<String> {
459    let raw = BASE64_STANDARD
460        .decode(encoded.trim())
461        .context("failed to decode aegis-agent refresh token")?;
462    let raw = String::from_utf8(raw).context("aegis-agent refresh token is not valid UTF-8")?;
463    let trimmed = raw.trim();
464    if trimmed.is_empty() {
465        anyhow::bail!("aegis-agent refresh token must not be empty");
466    }
467    Ok(trimmed.to_string())
468}
469
470pub fn canonical_saved_api_base_url(value: &str) -> String {
471    let trimmed = value.trim().trim_end_matches('/');
472    if trimmed.is_empty() {
473        return String::new();
474    }
475    trimmed.to_string()
476}
477
478pub fn now_unix() -> i64 {
479    SystemTime::now()
480        .duration_since(UNIX_EPOCH)
481        .map(|duration| duration.as_secs() as i64)
482        .unwrap_or(0)
483}
484
485pub fn resolve_api_base(
486    cli_override: Option<&str>,
487    installed_api_base: Option<&str>,
488) -> Result<String> {
489    let selected = match cli_override.or(installed_api_base) {
490        Some(value) => value.to_owned(),
491        None => UserContext::load()?.context("No Aegis deployment selected. Run `aegis-admin setup`, select an enrollment file, or pass --api-base once.")?.api_base,
492    };
493    Ok(aegis_dto::namespace::ApiEndpoint::parse(&selected)
494        .map_err(anyhow::Error::msg)?
495        .base_url())
496}
497
498pub fn app_dir() -> Result<PathBuf> {
499    Ok(paths::home_dir()?.join(".aegis"))
500}
501
502pub fn locks_dir() -> Result<PathBuf> {
503    Ok(app_dir()?.join("locks"))
504}
505
506pub fn keys_dir() -> Result<PathBuf> {
507    Ok(app_dir()?.join("keys"))
508}
509
510pub fn user_auth_state_path() -> Result<PathBuf> {
511    Ok(app_dir()?.join("auth.toml"))
512}
513
514pub fn scoped_private_key_path(api_base: &str, host_id: &HostId) -> Result<PathBuf> {
515    use sha2::{Digest, Sha256};
516    let endpoint = namespace_endpoint(api_base)?;
517    let directory = keys_dir()?.join(
518        base64::engine::general_purpose::URL_SAFE_NO_PAD
519            .encode(Sha256::digest(endpoint.base_url().as_bytes())),
520    );
521    ensure_directory(&directory, Some(0o700))?;
522    Ok(directory.join(host_id.to_string()))
523}
524
525pub fn load_cached_inventory(path: &Path) -> Result<Option<CachedInventory>> {
526    let endpoint = crate::api::installed_agent_api_base()?;
527    load_cached_inventory_for_endpoint(path, &resolve_api_base(endpoint.as_deref(), None)?)
528}
529
530pub(crate) fn load_cached_inventory_for_endpoint(
531    path: &Path,
532    api_base: &str,
533) -> Result<Option<CachedInventory>> {
534    let expected = namespace_endpoint(api_base)?;
535    let raw = match fs::read(path) {
536        Ok(raw) => raw,
537        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
538        Err(error) => {
539            return Err(error).with_context(|| format!("failed to read {}", path.display()));
540        }
541    };
542    let inventory: CachedInventory = serde_json::from_slice(&raw)
543        .with_context(|| format!("failed to parse {}", path.display()))?;
544    if namespace_endpoint(&inventory.api_base)? != expected {
545        return Ok(None);
546    }
547    Ok(Some(inventory))
548}
549
550pub fn load_all_hosts(path: &Path) -> Result<Vec<CachedHost>> {
551    load_all_hosts_for_network(path, aegis_dto::DEFAULT_AEGIS_NETWORK)
552}
553
554pub fn load_cached_network(path: &Path, network: &str) -> Result<Option<ResolvedNetwork>> {
555    load_cached_inventory(path)?
556        .map(|inventory| inventory.resolve_network(network))
557        .transpose()
558        .map(Option::flatten)
559}
560
561pub fn load_all_hosts_for_network(path: &Path, network: &str) -> Result<Vec<CachedHost>> {
562    Ok(load_cached_network(path, network)?
563        .map(|network| network.hosts)
564        .unwrap_or_default())
565}
566
567pub fn persist_inventory(path: &Path, inventory: &CachedInventory) -> Result<()> {
568    namespace_endpoint(&inventory.api_base)?;
569    let parent = path
570        .parent()
571        .ok_or_else(|| anyhow::anyhow!("{} has no parent directory", path.display()))?;
572    ensure_directory(parent, Some(0o755))?;
573    let raw = serde_json::to_vec_pretty(inventory).context("failed to encode inventory cache")?;
574    match fs::read(path) {
575        Ok(existing) if existing == raw => {
576            #[cfg(unix)]
577            if fs::metadata(path)
578                .with_context(|| format!("failed to inspect {}", path.display()))?
579                .permissions()
580                .mode()
581                & 0o7777
582                != 0o644
583            {
584                tighten_file_permissions(path, 0o644)?;
585            }
586            return Ok(());
587        }
588        Ok(_) => {}
589        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
590        Err(error) => {
591            return Err(error).with_context(|| format!("failed to read {}", path.display()));
592        }
593    }
594    atomic_write(path, &raw, Some(0o644), Some(0o755))
595}
596
597pub fn load_user_auth_state() -> Result<Option<UserAuthState>> {
598    let path = user_auth_state_path()?;
599    if !path.exists() {
600        return Ok(None);
601    }
602    let raw =
603        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
604    toml::from_str(&raw)
605        .with_context(|| format!("failed to parse {}", path.display()))
606        .map(Some)
607}
608
609pub fn persist_user_auth_state(auth_state: &UserAuthState) -> Result<()> {
610    let path = user_auth_state_path()?;
611    #[cfg(unix)]
612    let existing_owner = match fs::metadata(&path) {
613        Ok(metadata) => Some((metadata.uid(), metadata.gid())),
614        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
615        Err(error) => {
616            return Err(error).with_context(|| format!("failed to inspect {}", path.display()));
617        }
618    };
619    let raw = toml::to_string(auth_state).context("failed to encode aegis user auth state")?;
620    let parent = path
621        .parent()
622        .ok_or_else(|| anyhow::anyhow!("{} has no parent directory", path.display()))?;
623    ensure_directory(parent, Some(0o700))?;
624    atomic_write(&path, raw.as_bytes(), Some(0o600), Some(0o700))?;
625    #[cfg(unix)]
626    if unsafe { libc::geteuid() } == 0
627        && let Some((uid, gid)) = existing_owner
628    {
629        std::os::unix::fs::chown(&path, Some(uid), Some(gid))
630            .with_context(|| format!("failed to preserve ownership of {}", path.display()))?;
631    }
632    Ok(())
633}
634
635pub fn ensure_client_dirs() -> Result<()> {
636    for dir in [app_dir()?, keys_dir()?] {
637        ensure_directory(&dir, Some(0o700))?;
638    }
639    Ok(())
640}
641
642#[cfg(test)]
643mod tests {
644    #[test]
645    fn disabled_inbound_ssh_survives_persistence() {
646        let raw = super::AgentHostConfigOptions {
647            host_id: aegis_dto::HostId::new_v4(),
648            ssh_user: "user".into(),
649            port: None,
650            host_private_key_path: "/etc/ssh/ssh_host_ed25519_key".into(),
651            host_public_key_path: "/etc/ssh/ssh_host_ed25519_key.pub".into(),
652            host_certificate_path: "/etc/ssh/aegis-cert.pub".into(),
653            client_ca_path: "/etc/aegis/ca.pub".into(),
654            authorized_principals_dir: "/etc/aegis/principals".into(),
655            sshd_dropin_path: "/etc/ssh/sshd_config.d/90-aegis.conf".into(),
656        };
657        let saved = toml::to_string(&raw).unwrap();
658        let restored: super::AgentHostConfigOptions = toml::from_str(&saved).unwrap();
659        assert_eq!(restored.port, None);
660    }
661
662    use super::{
663        AgentAuthConfig, CachedHost, CachedInventory, persist_inventory, resolve_api_base,
664    };
665    use std::{collections::BTreeMap, fs, os::unix::fs::MetadataExt};
666
667    #[test]
668    fn cached_inventory_is_bound_to_its_api_endpoint() {
669        let dir = tempfile::tempdir().unwrap();
670        let path = dir.path().join("cache.json");
671        let inventory = CachedInventory {
672            api_base: "https://example.test/v2/namespaces/alice".into(),
673            hosts: BTreeMap::new(),
674            networks: BTreeMap::new(),
675        };
676        persist_inventory(&path, &inventory).unwrap();
677        let load = |base| super::load_cached_inventory_for_endpoint(&path, base).unwrap();
678        assert!(load("https://example.test/v2/namespaces/alice/").is_some());
679        assert!(load("https://example.test/v2/namespaces/bob").is_none());
680        assert!(load("https://other.test/v2/namespaces/alice").is_none());
681        assert!(
682            super::load_cached_inventory_for_endpoint(&path, "https://example.test/v2").is_err()
683        );
684        fs::write(
685            &path,
686            r#"{"api_base":"https://example.test/v2","hosts":{},"networks":{}}"#,
687        )
688        .unwrap();
689        assert!(
690            super::load_cached_inventory_for_endpoint(
691                &path,
692                "https://example.test/v2/namespaces/alice"
693            )
694            .is_err()
695        );
696        fs::write(&path, r#"{"hosts":{},"networks":{}}"#).unwrap();
697        assert!(
698            super::load_cached_inventory_for_endpoint(
699                &path,
700                "https://example.test/v2/namespaces/alice"
701            )
702            .is_err()
703        );
704    }
705
706    #[test]
707    fn agent_auth_config_serializes_host_refresh_token() {
708        let raw = toml::to_string(&AgentAuthConfig {
709            refresh_token: "hrt.id.secret".to_string(),
710        })
711        .expect("agent auth config should serialize");
712
713        assert!(raw.contains("refresh_token = \"hrt.id.secret\""));
714    }
715
716    #[test]
717    fn resolve_api_base_prefers_cli_override() {
718        assert_eq!(
719            "https://override.example/v2",
720            resolve_api_base(
721                Some("https://override.example/v2"),
722                Some("https://saved.example/v2")
723            )
724            .unwrap()
725        );
726    }
727
728    #[test]
729    fn cached_host_label_uses_port_only_when_non_default() {
730        let mut host = CachedHost {
731            host_id: "00000000-0000-4000-8000-000000000001"
732                .parse()
733                .expect("host id"),
734            aliases: aegis_dto::HostAliases::new(vec![
735                aegis_dto::HostAlias::parse("alpha").expect("alias"),
736            ])
737            .expect("aliases"),
738            host: aegis_dto::protocol::AegisNetworkHost {
739                platform: aegis_dto::platform::HostPlatform {
740                    operating_system: aegis_dto::platform::OperatingSystem::Ubuntu,
741                    architecture: aegis_dto::platform::Architecture::X86_64,
742                },
743                mode: aegis_dto::AegisHostMode::Leaf,
744                ssh: Some(aegis_dto::protocol::AegisNetworkHostSsh {
745                    port: Some(22),
746                    public_key: Some("ssh-ed25519 AAAA test".to_string()),
747                    internal_principals: vec![
748                        "10.0.0.42".to_string(),
749                        "fd75::2a".to_string(),
750                        "alpha.example.com".to_string(),
751                    ],
752                    external_principals: vec![],
753                }),
754                wireguard: Some(aegis_dto::protocol::AegisNetworkMemberWireGuard {
755                    public_key: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string(),
756                    ipv4: "10.0.0.42".to_string(),
757                    ipv6: "fd75::2a".to_string(),
758                    endpoints: Vec::new(),
759                }),
760                egress: None,
761                internal: None,
762                messages: Vec::new(),
763                agent: None,
764                ssh_lockdown_enabled: false,
765                observed_public_ips: aegis_dto::protocol::AegisObservedPublicIps::default(),
766                transient: false,
767                pending: false,
768                updated_unix: 10,
769            },
770        };
771
772        assert_eq!("10.0.0.42", host.host_label());
773        host.ssh.as_mut().expect("ssh config").port = Some(2200);
774        assert_eq!("10.0.0.42:2200", host.host_label());
775
776        host.internal = Some(aegis_dto::protocol::AegisNetworkMemberInternalAddresses {
777            ipv4: "10.75.0.42".to_string(),
778            ipv6: "fd75::2a".to_string(),
779        });
780        assert_eq!("10.75.0.42:2200", host.host_label());
781        host.internal = None;
782        assert_eq!("10.0.0.42:2200", host.host_label());
783        host.ssh.as_mut().expect("ssh config").port = None;
784        assert_eq!("10.0.0.42", host.host_label());
785    }
786
787    #[test]
788    fn resolve_api_base_keeps_saved_value() {
789        assert_eq!(
790            "https://api.hoek.io/v2",
791            resolve_api_base(None, Some("https://api.hoek.io/v2")).unwrap()
792        );
793    }
794
795    #[test]
796    fn identical_inventory_persistence_keeps_the_existing_inode() {
797        let directory = tempfile::tempdir().expect("temporary directory");
798        let path = directory.path().join("cache.json");
799        let inventory = CachedInventory {
800            api_base: "https://api.hoek.io/v2/namespaces/test".into(),
801            hosts: BTreeMap::new(),
802            networks: BTreeMap::new(),
803        };
804
805        persist_inventory(&path, &inventory).expect("initial persistence");
806        let inode = fs::metadata(&path).expect("initial metadata").ino();
807        persist_inventory(&path, &inventory).expect("identical persistence");
808
809        assert_eq!(inode, fs::metadata(path).expect("final metadata").ino());
810    }
811}