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";
|
||||
|
||||
/// 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<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
|
||||
#[serde(default)]
|
||||
pub company: Option<String>,
|
||||
/// Site/location name
|
||||
#[serde(default)]
|
||||
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
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
@@ -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<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)
|
||||
#[serde(default = "generate_agent_id")]
|
||||
pub agent_id: String,
|
||||
@@ -70,6 +110,14 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
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)
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
@@ -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<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
|
||||
fn config_path() -> PathBuf {
|
||||
// Check for config in current directory first
|
||||
|
||||
Reference in New Issue
Block a user