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::v1::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::v1::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::v1::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::v1::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::v1::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::v1::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::v1::AegisNetworkHost {
676 mode: aegis_dto::AegisHostMode::Leaf,
677 ssh: Some(aegis_dto::v1::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::v1::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::v1::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::v1::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}