Skip to main content

wist_contracts/
enrollment.rs

1//! Agent enrollment / identity **domain** types.
2//!
3//! 跨进程 seam 的**报文**(`EnrollmentRequest` / `EnrollmentEnvelope` / `EnrollmentOutcome` /
4//! `EnrollmentStatus` / `CredentialRenewal` / `CredentialRenewed`)已迁到独立 seam crate
5//! `wist-api::enrollment`。这里保留的是被多条 seam 复用的**领域类型**(如 `HostProfile` 同时
6//! 用在 `agent/enroll` 与 `agent/status`),故留在两侧共同底座。
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
11#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Identity")]
12#[serde(deny_unknown_fields)]
13pub struct HostProfile {
14    pub node_id: String,
15    /// 系统主机名:`HOSTNAME` → `COMPUTERNAME` → `/etc/hostname` → `gethostname`;
16    /// 都拿不到时才用占位名 `local-host`(macOS 上没有 `/etc/hostname`,靠最后那个系统调用兜底)。
17    pub hostname: String,
18    pub os: String,
19    pub arch: String,
20    pub machine_id: String,
21    pub cloud_instance_id: Option<String>,
22    pub k8s_node_uid: Option<String>,
23    /// 有信息量的本机网卡地址,形如 `en0 192.168.3.178/24`(网卡名 + 地址/掩码)。
24    /// 采集侧已滤掉 IPv6 链路本地(`fe80::/10`)/ 回环 / IPv4 自分配;空表 = 还没上报过或确实没有。
25    /// 多网卡主机不会再把十几条噪声地址带上管理面。
26    pub ip_addresses: Vec<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
30#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Identity")]
31#[serde(deny_unknown_fields)]
32pub struct AgentIdentity {
33    pub agent_id: String,
34    pub instance_id: String,
35    pub tenant_id: String,
36    pub environment_id: String,
37    pub node_id: String,
38    pub issued_at: String,
39    pub expires_at: Option<String>,
40    pub status: AgentIdentityStatus,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
44#[jumo(kind = "state", domain = "Control", module = "Control.Agent.Identity")]
45pub enum AgentIdentityStatus {
46    #[serde(rename = "active")]
47    Active,
48    #[serde(rename = "revoked")]
49    Revoked,
50    #[serde(rename = "expired")]
51    Expired,
52    #[serde(rename = "renewal_required")]
53    RenewalRequired,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
57#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Identity")]
58#[serde(deny_unknown_fields)]
59pub struct CredentialBundle {
60    pub credential_id: String,
61    pub agent_id: String,
62    pub instance_id: String,
63    /// 客户端证书(PEM)。mTLS 是 agent 与网关之间的**唯一**凭据路径(bearer 已删),
64    /// 所以这里是必填:注册/续期都必须换回一张证书。
65    pub certificate: String,
66    pub private_key_ref: Option<String>,
67    pub ca_bundle: Option<String>,
68    pub issued_at: String,
69    pub not_before: Option<String>,
70    pub not_after: Option<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct InitialConfig {
76    pub schema_version: String,
77    pub mode: String,
78    pub gateway_endpoint: String,
79    pub policy_version: String,
80    pub telemetry_output: Option<String>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct PolicyBinding {
86    pub agent_id: String,
87    pub policy_id: String,
88    pub policy_version: String,
89    pub bound_at: String,
90}
91
92#[cfg(test)]
93mod tests {
94    use super::{CredentialBundle, HostProfile};
95
96    /// 契约收口(2026-09-30):agent 的凭据包**只剩客户端证书** —— 线上不再有 `bearer_token` /
97    /// `auth_scheme`,而 `certificate` 是必填。带旧字段的老报文一律拒(`deny_unknown_fields`)。
98    #[test]
99    fn credential_bundle_carries_only_the_client_certificate() {
100        let bundle = CredentialBundle {
101            credential_id: "cred-1".to_string(),
102            agent_id: "agent-1".to_string(),
103            instance_id: "inst-1".to_string(),
104            certificate: "CERT".to_string(),
105            private_key_ref: None,
106            ca_bundle: None,
107            issued_at: "2026-09-30T00:00:00Z".to_string(),
108            not_before: None,
109            not_after: None,
110        };
111        let json = serde_json::to_string(&bundle).expect("encode");
112        assert!(!json.contains("bearer_token"), "{json}");
113        assert!(!json.contains("auth_scheme"), "{json}");
114
115        // 带旧字段的老报文解不了(字段已从契约里删掉)。
116        let legacy = r#"{"credential_id":"c","agent_id":"a","instance_id":"i",\
117            "auth_scheme":"bearer","bearer_token":"wic_x","certificate":"CERT",\
118            "private_key_ref":null,"ca_bundle":null,"issued_at":"t",\
119            "not_before":null,"not_after":null}"#;
120        assert!(serde_json::from_str::<CredentialBundle>(legacy).is_err());
121
122        // certificate 必填:缺了就解不了(mTLS 是唯一凭据路径,没有它就没有凭据)。
123        let no_certificate = r#"{"credential_id":"c","agent_id":"a","instance_id":"i",\
124            "private_key_ref":null,"ca_bundle":null,"issued_at":"t",\
125            "not_before":null,"not_after":null}"#;
126        assert!(serde_json::from_str::<CredentialBundle>(no_certificate).is_err());
127    }
128
129    #[test]
130    fn host_profile_round_trips_and_rejects_unknown_fields() {
131        let profile = HostProfile {
132            node_id: "node-1".to_string(),
133            hostname: "host-1".to_string(),
134            os: "macos".to_string(),
135            arch: "arm64".to_string(),
136            machine_id: "mid-1".to_string(),
137            cloud_instance_id: None,
138            k8s_node_uid: None,
139            ip_addresses: vec!["10.0.0.1".to_string()],
140        };
141        let json = serde_json::to_string(&profile).expect("encode");
142        let back: HostProfile = serde_json::from_str(&json).expect("decode");
143        assert_eq!(back, profile);
144
145        let mutated = json.replacen('{', "{\"extra\":1,", 1);
146        assert!(serde_json::from_str::<HostProfile>(&mutated).is_err());
147    }
148}