diff --git a/agent/src/config.rs b/agent/src/config.rs index a3824d4..0b38e2d 100644 --- a/agent/src/config.rs +++ b/agent/src/config.rs @@ -16,18 +16,39 @@ use uuid::Uuid; const MAGIC_MARKER: &[u8] = b"GURUCONFIG"; /// Embedded configuration data (appended to executable) +/// +/// SPEC-016 Phase B: a managed-install config now carries the per-site +/// `enrollment_key` + `site_code` so the agent can self-register on first run. +/// The legacy `api_key` is retained (defaulted) for backward-compat with older +/// pre-enrollment installers; a fresh site installer carries only the enrollment +/// credentials and the agent obtains its per-machine `cak_` via `/api/enroll`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EmbeddedConfig { /// Server WebSocket URL pub server_url: String, - /// API key for authentication - pub api_key: String, + /// DEPRECATED shared/legacy API key for authentication. Optional — a + /// SPEC-016 site installer omits it and enrolls for a per-machine `cak_`. + #[serde(default)] + pub api_key: Option, + /// Per-site enrollment key (`cek_`), the low-sensitivity registration gate + /// (SPEC-016 §Security). Presented to `/api/enroll`; never logged. + #[serde(default)] + pub enrollment_key: Option, + /// Per-site code identifying which site this installer enrolls into. + #[serde(default)] + pub site_code: Option, /// Company/organization name #[serde(default)] pub company: Option, /// Site/location name #[serde(default)] pub site: Option, + /// Department label (reserved — SPEC-007 AgentStatus parity). + #[serde(default)] + pub department: Option, + /// Device-type label (reserved — SPEC-007 AgentStatus parity). + #[serde(default)] + pub device_type: Option, /// Tags for categorization #[serde(default)] pub tags: Vec, @@ -52,9 +73,28 @@ pub struct Config { /// Server WebSocket URL (e.g., wss://connect.example.com/ws) pub server_url: String, - /// Agent API key for authentication + /// Operating credential used to authenticate the persistent WS connection. + /// + /// SPEC-016 Phase B: the AUTHORITATIVE credential is a per-machine `cak_` + /// obtained at first-run enrollment and stored encrypted at rest (see + /// [`crate::credential_store`]); it is loaded into this field before connect. + /// A non-empty value carried in config is the DEPRECATED shared/legacy + /// `api_key`, kept only for transition compatibility. Empty means "not yet + /// enrolled / no credential" — the run-mode wiring must enroll first. + #[serde(default)] pub api_key: String, + /// Per-site enrollment key (`cek_`) — present only for a not-yet-enrolled + /// managed install. Never persisted to the on-disk TOML (it is install-time + /// material, delivered by the site wrapper); never logged. + #[serde(skip)] + pub enrollment_key: Option, + + /// Per-site code identifying which site to enroll into (paired with + /// `enrollment_key`). Not persisted to the on-disk TOML. + #[serde(skip)] + pub site_code: Option, + /// Unique agent identifier (generated on first run) #[serde(default = "generate_agent_id")] pub agent_id: String, @@ -70,6 +110,14 @@ pub struct Config { #[serde(default)] pub site: Option, + /// Department label (reserved — SPEC-007 AgentStatus parity). + #[serde(default)] + pub department: Option, + + /// Device-type label (reserved — SPEC-007 AgentStatus parity). + #[serde(default)] + pub device_type: Option, + /// Tags for categorization (from embedded config) #[serde(default)] pub tags: Vec, @@ -91,6 +139,25 @@ fn generate_agent_id() -> String { Uuid::new_v4().to_string() } +/// Layer SPEC-016 enrollment material from the environment onto a `Config`. +/// +/// `GURUCONNECT_ENROLLMENT_KEY` / `GURUCONNECT_SITE_CODE` only OVERRIDE when set +/// and non-empty, so embedded/install-time values already present on the config +/// are preserved. Used by the file and env load paths (the embedded path already +/// carries these from the install blob). +fn apply_enrollment_env(config: &mut Config) { + if let Ok(v) = std::env::var("GURUCONNECT_ENROLLMENT_KEY") { + if !v.is_empty() { + config.enrollment_key = Some(v); + } + } + if let Ok(v) = std::env::var("GURUCONNECT_SITE_CODE") { + if !v.is_empty() { + config.site_code = Some(v); + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CaptureConfig { /// Target frames per second (1-60) @@ -317,18 +384,26 @@ impl Config { info!("Using embedded configuration"); let config = Config { server_url: embedded.server_url, - api_key: embedded.api_key, + // Legacy/shared api_key if the installer carried one; empty + // otherwise (the SPEC-016 path enrolls for a per-machine cak_). + api_key: embedded.api_key.unwrap_or_default(), + enrollment_key: embedded.enrollment_key, + site_code: embedded.site_code, agent_id: generate_agent_id(), hostname_override: None, company: embedded.company, site: embedded.site, + department: embedded.department, + device_type: embedded.device_type, tags: embedded.tags, support_code: None, capture: CaptureConfig::default(), encoding: EncodingConfig::default(), }; - // Save to file for persistence (so agent_id is preserved) + // Save to file for persistence (so agent_id is preserved). The + // #[serde(skip)] enrollment fields are intentionally NOT written to + // the on-disk TOML — they are install-time material only. let _ = config.save(); return Ok(config); } @@ -349,8 +424,12 @@ impl Config { let _ = config.save(); } - // support_code is always None when loading from file (set via CLI) + // support_code is always None when loading from file (set via CLI). config.support_code = None; + // The enrollment fields are #[serde(skip)], so a file never carries + // them; layer them in from the environment for testing / a + // file-delivered managed install that supplies them out-of-band. + apply_enrollment_env(&mut config); return Ok(config); } @@ -365,18 +444,23 @@ impl Config { let agent_id = std::env::var("GURUCONNECT_AGENT_ID").unwrap_or_else(|_| generate_agent_id()); - let config = Config { + let mut config = Config { server_url, api_key, + enrollment_key: None, + site_code: None, agent_id, hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(), company: None, site: None, + department: None, + device_type: None, tags: Vec::new(), support_code: None, capture: CaptureConfig::default(), encoding: EncodingConfig::default(), }; + apply_enrollment_env(&mut config); // Save config with generated agent_id for persistence let _ = config.save(); @@ -384,6 +468,34 @@ impl Config { Ok(config) } + /// Derive the HTTPS API base (e.g. `https://connect.example.com`) from the + /// agent's WebSocket `server_url` (e.g. `wss://connect.example.com/ws/agent`). + /// + /// `/api/enroll` is REST/HTTPS while the persistent transport is `wss`, so we + /// reuse the same host/authority and swap scheme + drop the WS path. Mapping: + /// `wss` -> `https`, `ws` -> `http` (dev). Returns an error if `server_url` + /// has no parseable host. + pub fn https_base(&self) -> Result { + let parsed = url::Url::parse(&self.server_url) + .with_context(|| format!("invalid server_url: {}", self.server_url))?; + let scheme = match parsed.scheme() { + "wss" | "https" => "https", + "ws" | "http" => "http", + other => { + return Err(anyhow!( + "unsupported server_url scheme '{other}' (expected ws/wss)" + )) + } + }; + let host = parsed + .host_str() + .ok_or_else(|| anyhow!("server_url has no host: {}", self.server_url))?; + Ok(match parsed.port() { + Some(port) => format!("{scheme}://{host}:{port}"), + None => format!("{scheme}://{host}"), + }) + } + /// Get the configuration file path fn config_path() -> PathBuf { // Check for config in current directory first