koi_certmesh/
core_enroll.rs1use super::*;
7
8impl CertmeshCore {
9 pub async fn enroll(
14 &self,
15 request: &protocol::JoinRequest,
16 ) -> Result<protocol::JoinResponse, CertmeshError> {
17 let hostname = &request.hostname;
18 validate_hostname(hostname)?;
19 const MAX_EXTRA_SANS: usize = 16;
24 let mut sans = vec![hostname.clone(), format!("{hostname}.local")];
25 for extra in request.sans.iter().take(MAX_EXTRA_SANS) {
26 if extra.parse::<std::net::IpAddr>().is_err() {
27 validate_hostname(extra)?;
28 }
29 if !sans.contains(extra) {
30 sans.push(extra.clone());
31 }
32 }
33
34 let ca_guard = self.state.ca.lock().await;
35 let ca = ca_guard.as_ref().ok_or_else(|| {
36 if self.state.paths.is_ca_initialized() {
37 CertmeshError::CaLocked
38 } else {
39 CertmeshError::CaNotInitialized
40 }
41 })?;
42
43 let roster = self.state.roster.lock().await;
44 let auth_guard = self.state.auth.lock().await;
45 let auth_state = auth_guard.as_ref();
50 let challenge_guard = self.state.pending_challenge.lock().await;
51 let challenge = challenge_guard
52 .as_ref()
53 .cloned()
54 .unwrap_or(koi_crypto::auth::AuthChallenge::Totp);
55 let mut rate_limiter = self.state.rate_limiter.lock().await;
56 let requires_approval = roster.requires_approval();
57 let fallback_operator = roster.metadata.operator.clone();
58 drop(roster);
59
60 let approved_by = if requires_approval {
61 request_approval(&self.state, hostname, requires_approval).await?
62 } else {
63 fallback_operator
64 };
65
66 let result = self
71 .state
72 .commit_roster(|roster| {
73 enrollment::process_enrollment(
74 ca,
75 roster,
76 auth_state,
77 &challenge,
78 &mut rate_limiter,
79 request,
80 hostname,
81 &sans,
82 approved_by,
83 &self.state.paths,
84 )
85 })
86 .await;
87
88 let limiter_snapshot = rate_limiter.clone();
93 drop(rate_limiter);
94 if let Err(e) = persist_rate_limiter(&self.state.paths, &limiter_snapshot) {
95 tracing::warn!(error = %e, "Could not persist rate-limiter state");
96 }
97
98 let (response, _issued) = result?;
99
100 let _ = self.state.event_tx.send(CertmeshEvent::MemberJoined {
101 hostname: response.hostname.clone(),
102 fingerprint: response.ca_fingerprint.clone(),
103 });
104
105 Ok(response)
106 }
107
108 pub async fn self_enroll(&self) -> Result<SelfEnrollment, CertmeshError> {
120 let hostname = hostname::get()
121 .ok()
122 .and_then(|os| os.into_string().ok())
123 .unwrap_or_else(|| "unknown".to_string());
124
125 validate_hostname(&hostname)?;
127
128 let sans = vec![
129 hostname.clone(),
130 format!("{hostname}.local"),
131 "localhost".to_string(),
132 "127.0.0.1".to_string(),
133 ];
134
135 let policy = {
137 let roster = self.state.roster.lock().await;
138 roster.metadata.policy.clone()
139 };
140
141 {
145 let cert_dir = self.state.paths.certs_dir().join(&hostname);
146 let on_disk = (
147 std::fs::read_to_string(cert_dir.join("cert.pem")).ok(),
148 std::fs::read_to_string(cert_dir.join("key.pem")).ok(),
149 );
150 if let (Some(cert_pem), Some(key_pem)) = on_disk {
151 let due = leaf_not_after_utc(&cert_pem)
152 .map(|na| {
153 chrono::Utc::now()
154 + chrono::Duration::days(i64::from(policy.renew_threshold_days))
155 >= na
156 })
157 .unwrap_or(true); if !due {
159 let ca_guard = self.state.ca.lock().await;
160 let ca = ca_guard.as_ref().ok_or_else(|| {
161 if self.state.paths.is_ca_initialized() {
162 CertmeshError::CaLocked
163 } else {
164 CertmeshError::CaNotInitialized
165 }
166 })?;
167 let ca_cert_pem = ca.cert_pem.clone();
168 drop(ca_guard);
169 tracing::debug!(hostname = %hostname, "already self-enrolled, reusing existing cert");
170 return Ok(SelfEnrollment {
171 cert_pem,
172 key_pem,
173 ca_cert_pem,
174 });
175 }
176 tracing::info!(hostname = %hostname, "CA self-cert within renewal threshold; re-issuing");
177 }
178 }
179
180 let ca_guard = self.state.ca.lock().await;
182 let ca = ca_guard.as_ref().ok_or_else(|| {
183 if self.state.paths.is_ca_initialized() {
184 CertmeshError::CaLocked
185 } else {
186 CertmeshError::CaNotInitialized
187 }
188 })?;
189 let issued = ca::issue_certificate(ca, &hostname, &sans, policy.leaf_lifetime_days)?;
190 let ca_cert_pem = ca.cert_pem.clone();
191 drop(ca_guard);
192
193 let cert_path = self.state.paths.certs_dir().join(&hostname);
195 let issued_clone = issued.clone();
196 let cert_dir = tokio::task::spawn_blocking(move || {
197 certfiles::write_cert_files_to(&cert_path, &issued_clone)
198 })
199 .await
200 .map_err(|e| CertmeshError::Internal(format!("cert write task: {e}")))??;
201
202 if let Err(e) = self
206 .state
207 .commit_roster(|roster| {
208 if let Some(member) = roster.find_member_mut(&hostname) {
209 member.cert_fingerprint = issued.fingerprint.clone();
210 member.cert_expires = issued.expires;
211 member.cert_path = cert_dir.display().to_string();
212 } else {
213 roster.members.push(roster::RosterMember {
214 hostname: hostname.clone(),
215 role: roster::MemberRole::Primary,
216 enrolled_at: chrono::Utc::now(),
217 enrolled_by: Some("self-enrollment".to_string()),
218 cert_fingerprint: issued.fingerprint.clone(),
219 cert_expires: issued.expires,
220 cert_sans: sans.clone(),
221 cert_path: cert_dir.display().to_string(),
222 status: roster::MemberStatus::Active,
223 reload_hook: None,
224 last_seen: Some(chrono::Utc::now()),
225 pinned_ca_fingerprint: None,
226 proxy_entries: Vec::new(),
227 });
228 }
229 Ok(())
230 })
231 .await
232 {
233 tracing::warn!(error = %e, "Failed to save roster after self-enrollment");
234 }
235
236 tracing::info!(hostname = %hostname, "Daemon self-enrolled as certmesh member");
237
238 let _ = audit::append_entry_to(
241 &self.state.paths.audit_log_path(),
242 "self_enroll",
243 &[
244 ("hostname", hostname.as_str()),
245 ("fingerprint", issued.fingerprint.as_str()),
246 ],
247 );
248
249 let _ = self.state.event_tx.send(CertmeshEvent::MemberJoined {
250 hostname,
251 fingerprint: issued.fingerprint,
252 });
253
254 self.state.republish_posture();
256
257 Ok(SelfEnrollment {
258 cert_pem: issued.cert_pem,
259 key_pem: issued.key_pem,
260 ca_cert_pem,
261 })
262 }
263}