feat(agent): extend config contract for enrollment (SPEC-016 Phase B item 2)
Add enrollment_key + site_code to EmbeddedConfig and the resolved Config alongside the existing labels, and add department/device_type label fields (SPEC-007 AgentStatus parity). The legacy api_key is retained but made optional/defaulted so a SPEC-016 site installer can carry only the enrollment credentials; existing pre-enrollment installers still parse. The enrollment fields are #[serde(skip)] on Config so they are never written to the on-disk TOML (install-time material only); apply_enrollment_env layers them from GURUCONNECT_ENROLLMENT_KEY / GURUCONNECT_SITE_CODE on the file and env load paths. The embedded path carries them from the install blob. Config delivery itself (signed wrapper) is Phase C and unchanged here. Add Config::https_base() deriving the REST API base (https://host[:port]) from the wss:// server_url so the enroll client and the persistent transport share one authority. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,18 +16,39 @@ use uuid::Uuid;
|
|||||||
const MAGIC_MARKER: &[u8] = b"GURUCONFIG";
|
const MAGIC_MARKER: &[u8] = b"GURUCONFIG";
|
||||||
|
|
||||||
/// Embedded configuration data (appended to executable)
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct EmbeddedConfig {
|
pub struct EmbeddedConfig {
|
||||||
/// Server WebSocket URL
|
/// Server WebSocket URL
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
/// API key for authentication
|
/// DEPRECATED shared/legacy API key for authentication. Optional — a
|
||||||
pub api_key: String,
|
/// SPEC-016 site installer omits it and enrolls for a per-machine `cak_`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// Per-site code identifying which site this installer enrolls into.
|
||||||
|
#[serde(default)]
|
||||||
|
pub site_code: Option<String>,
|
||||||
/// Company/organization name
|
/// Company/organization name
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub company: Option<String>,
|
pub company: Option<String>,
|
||||||
/// Site/location name
|
/// Site/location name
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub site: Option<String>,
|
pub site: Option<String>,
|
||||||
|
/// Department label (reserved — SPEC-007 AgentStatus parity).
|
||||||
|
#[serde(default)]
|
||||||
|
pub department: Option<String>,
|
||||||
|
/// Device-type label (reserved — SPEC-007 AgentStatus parity).
|
||||||
|
#[serde(default)]
|
||||||
|
pub device_type: Option<String>,
|
||||||
/// Tags for categorization
|
/// Tags for categorization
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
@@ -52,9 +73,28 @@ pub struct Config {
|
|||||||
/// Server WebSocket URL (e.g., wss://connect.example.com/ws)
|
/// Server WebSocket URL (e.g., wss://connect.example.com/ws)
|
||||||
pub server_url: String,
|
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,
|
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<String>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
|
||||||
/// Unique agent identifier (generated on first run)
|
/// Unique agent identifier (generated on first run)
|
||||||
#[serde(default = "generate_agent_id")]
|
#[serde(default = "generate_agent_id")]
|
||||||
pub agent_id: String,
|
pub agent_id: String,
|
||||||
@@ -70,6 +110,14 @@ pub struct Config {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub site: Option<String>,
|
pub site: Option<String>,
|
||||||
|
|
||||||
|
/// Department label (reserved — SPEC-007 AgentStatus parity).
|
||||||
|
#[serde(default)]
|
||||||
|
pub department: Option<String>,
|
||||||
|
|
||||||
|
/// Device-type label (reserved — SPEC-007 AgentStatus parity).
|
||||||
|
#[serde(default)]
|
||||||
|
pub device_type: Option<String>,
|
||||||
|
|
||||||
/// Tags for categorization (from embedded config)
|
/// Tags for categorization (from embedded config)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
@@ -91,6 +139,25 @@ fn generate_agent_id() -> String {
|
|||||||
Uuid::new_v4().to_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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CaptureConfig {
|
pub struct CaptureConfig {
|
||||||
/// Target frames per second (1-60)
|
/// Target frames per second (1-60)
|
||||||
@@ -317,18 +384,26 @@ impl Config {
|
|||||||
info!("Using embedded configuration");
|
info!("Using embedded configuration");
|
||||||
let config = Config {
|
let config = Config {
|
||||||
server_url: embedded.server_url,
|
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(),
|
agent_id: generate_agent_id(),
|
||||||
hostname_override: None,
|
hostname_override: None,
|
||||||
company: embedded.company,
|
company: embedded.company,
|
||||||
site: embedded.site,
|
site: embedded.site,
|
||||||
|
department: embedded.department,
|
||||||
|
device_type: embedded.device_type,
|
||||||
tags: embedded.tags,
|
tags: embedded.tags,
|
||||||
support_code: None,
|
support_code: None,
|
||||||
capture: CaptureConfig::default(),
|
capture: CaptureConfig::default(),
|
||||||
encoding: EncodingConfig::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();
|
let _ = config.save();
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
}
|
}
|
||||||
@@ -349,8 +424,12 @@ impl Config {
|
|||||||
let _ = config.save();
|
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;
|
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);
|
return Ok(config);
|
||||||
}
|
}
|
||||||
@@ -365,18 +444,23 @@ impl Config {
|
|||||||
let agent_id =
|
let agent_id =
|
||||||
std::env::var("GURUCONNECT_AGENT_ID").unwrap_or_else(|_| generate_agent_id());
|
std::env::var("GURUCONNECT_AGENT_ID").unwrap_or_else(|_| generate_agent_id());
|
||||||
|
|
||||||
let config = Config {
|
let mut config = Config {
|
||||||
server_url,
|
server_url,
|
||||||
api_key,
|
api_key,
|
||||||
|
enrollment_key: None,
|
||||||
|
site_code: None,
|
||||||
agent_id,
|
agent_id,
|
||||||
hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(),
|
hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(),
|
||||||
company: None,
|
company: None,
|
||||||
site: None,
|
site: None,
|
||||||
|
department: None,
|
||||||
|
device_type: None,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
support_code: None,
|
support_code: None,
|
||||||
capture: CaptureConfig::default(),
|
capture: CaptureConfig::default(),
|
||||||
encoding: EncodingConfig::default(),
|
encoding: EncodingConfig::default(),
|
||||||
};
|
};
|
||||||
|
apply_enrollment_env(&mut config);
|
||||||
|
|
||||||
// Save config with generated agent_id for persistence
|
// Save config with generated agent_id for persistence
|
||||||
let _ = config.save();
|
let _ = config.save();
|
||||||
@@ -384,6 +468,34 @@ impl Config {
|
|||||||
Ok(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<String> {
|
||||||
|
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
|
/// Get the configuration file path
|
||||||
fn config_path() -> PathBuf {
|
fn config_path() -> PathBuf {
|
||||||
// Check for config in current directory first
|
// Check for config in current directory first
|
||||||
|
|||||||
Reference in New Issue
Block a user