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}