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