wowlab_sentinel/http/services/
nodes.rs1use async_trait::async_trait;
4use uuid::Uuid;
5use wowlab_centrifuge::TokenError;
6use wowlab_common::{
7 NodePublicKey,
8 node_http::{
9 NodeRegistrationAccessRule, NodeRegistrationRequest, NodeRegistrationResponse,
10 NodeTokenResponse, NodeUnlinkResponse,
11 },
12};
13use wowlab_types::sensitive::Sensitive;
14
15use crate::ExposeSecret;
16
17const DEFAULT_CORE_COUNT: i32 = 4;
18
19#[derive(Debug, thiserror::Error)]
20pub(in crate::http) enum NodeOperationError {
21 #[error("invalid token")]
22 InvalidToken {
23 #[source]
24 source: Option<sqlx::Error>,
25 },
26 #[error("failed to create node")]
27 CreateNode {
28 #[source]
29 source: sqlx::Error,
30 },
31 #[error("token generation failed")]
32 TokenGeneration(#[source] TokenIssueError),
33 #[error("node not found or not claimed")]
34 NodeNotClaimed {
35 #[source]
36 source: Option<sqlx::Error>,
37 },
38 #[error("node not found")]
39 NodeNotFound,
40 #[error("database error")]
41 Database(#[source] sqlx::Error),
42}
43
44#[derive(Debug, thiserror::Error)]
45pub(in crate::http) enum TokenIssueError {
46 #[error(transparent)]
47 Centrifuge(#[from] TokenError),
48 #[cfg(test)]
49 #[error("{0}")]
50 Test(&'static str),
51}
52
53#[derive(Debug)]
54pub(in crate::http) struct NewNode<'a> {
55 public_key: &'a str,
56 user_id: Uuid,
57 name: &'a str,
58 total_cores: i32,
59 max_parallel: i32,
60 platform: &'a str,
61 version: Option<&'a str>,
62}
63
64#[async_trait]
65pub(in crate::http) trait NodeRepository: Send + Sync {
66 async fn user_by_claim(&self, token_claim: &str) -> Result<Option<Uuid>, sqlx::Error>;
67 async fn exists(&self, public_key: &str) -> Result<bool, sqlx::Error>;
68 async fn update_user(&self, public_key: &str, user_id: Uuid) -> Result<(), sqlx::Error>;
69 async fn insert(&self, node: NewNode<'_>) -> Result<(), sqlx::Error>;
70 async fn upsert_permission(
71 &self,
72 public_key: &str,
73 rule: &NodeRegistrationAccessRule,
74 ) -> Result<(), sqlx::Error>;
75 async fn is_claimed(&self, public_key: &str) -> Result<bool, sqlx::Error>;
76 async fn delete(&self, public_key: &str) -> Result<bool, sqlx::Error>;
77}
78
79pub(in crate::http) trait BeaconTokenIssuer: Send + Sync {
80 fn issue(&self, public_key: &NodePublicKey) -> Result<Sensitive<String>, TokenIssueError>;
81}
82
83pub(in crate::http) struct SqlNodeRepository<'a> {
84 pool: &'a sqlx::PgPool,
85}
86
87#[async_trait]
88impl NodeRepository for SqlNodeRepository<'_> {
89 async fn user_by_claim(&self, token_claim: &str) -> Result<Option<Uuid>, sqlx::Error> {
90 sqlx::query_file_scalar!("queries/nodes_get_user_by_token_claim.sql", token_claim)
91 .fetch_optional(self.pool)
92 .await
93 }
94
95 async fn exists(&self, public_key: &str) -> Result<bool, sqlx::Error> {
96 Ok(
97 sqlx::query_file_scalar!("queries/nodes_check_exists.sql", public_key)
98 .fetch_optional(self.pool)
99 .await?
100 .is_some(),
101 )
102 }
103
104 async fn update_user(&self, public_key: &str, user_id: Uuid) -> Result<(), sqlx::Error> {
105 sqlx::query_file!("queries/nodes_update_user.sql", user_id, public_key)
106 .execute(self.pool)
107 .await?;
108
109 Ok(())
110 }
111
112 async fn insert(&self, node: NewNode<'_>) -> Result<(), sqlx::Error> {
113 sqlx::query_file!(
114 "queries/nodes_insert.sql",
115 node.public_key,
116 node.user_id,
117 node.name,
118 node.total_cores,
119 node.max_parallel,
120 node.platform,
121 node.version,
122 )
123 .execute(self.pool)
124 .await?;
125
126 Ok(())
127 }
128
129 async fn upsert_permission(
130 &self,
131 public_key: &str,
132 rule: &NodeRegistrationAccessRule,
133 ) -> Result<(), sqlx::Error> {
134 let target_id = rule.target_id.as_deref().unwrap_or("");
135
136 sqlx::query_file!(
137 "queries/nodes_upsert_permission.sql",
138 public_key,
139 &rule.access_type,
140 target_id,
141 )
142 .execute(self.pool)
143 .await?;
144
145 Ok(())
146 }
147
148 async fn is_claimed(&self, public_key: &str) -> Result<bool, sqlx::Error> {
149 Ok(
150 sqlx::query_file_scalar!("queries/nodes_check_claimed.sql", public_key)
151 .fetch_optional(self.pool)
152 .await?
153 .unwrap_or(false),
154 )
155 }
156
157 async fn delete(&self, public_key: &str) -> Result<bool, sqlx::Error> {
158 let result = sqlx::query_file!("queries/nodes_delete.sql", public_key)
159 .execute(self.pool)
160 .await?;
161
162 Ok(result.rows_affected() > 0)
163 }
164}
165
166pub(in crate::http) struct CentrifugeTokenIssuer<'a> {
167 secret: &'a str,
168}
169
170impl BeaconTokenIssuer for CentrifugeTokenIssuer<'_> {
171 fn issue(&self, public_key: &NodePublicKey) -> Result<Sensitive<String>, TokenIssueError> {
172 Ok(wowlab_centrifuge::generate_token(
173 &public_key.to_string(),
174 self.secret,
175 )?)
176 }
177}
178
179pub(in crate::http) struct NodeOperations<R, T> {
180 repository: R,
181 token_issuer: T,
182}
183
184pub(in crate::http) fn node_operations(
185 state: &crate::state::ServerState,
186) -> NodeOperations<SqlNodeRepository<'_>, CentrifugeTokenIssuer<'_>> {
187 NodeOperations {
188 repository: SqlNodeRepository {
189 pool: state.dbs.get::<crate::http::HttpDb>(),
190 },
191 token_issuer: CentrifugeTokenIssuer {
192 secret: state.config.centrifugo_token_secret.expose_secret(),
193 },
194 }
195}
196
197impl<R, T> NodeOperations<R, T>
198where
199 R: NodeRepository,
200 T: BeaconTokenIssuer,
201{
202 pub(in crate::http) async fn register(
203 &self,
204 public_key: &NodePublicKey,
205 request: &NodeRegistrationRequest,
206 ) -> Result<NodeRegistrationResponse, NodeOperationError> {
207 let user_id = match self.repository.user_by_claim(&request.token_claim).await {
208 Ok(Some(user_id)) => user_id,
209 Ok(None) => return Err(NodeOperationError::InvalidToken { source: None }),
210 Err(source) => {
211 return Err(NodeOperationError::InvalidToken {
212 source: Some(source),
213 });
214 }
215 };
216 let public_key_text = public_key.to_string();
217
218 if self
219 .repository
220 .exists(&public_key_text)
221 .await
222 .unwrap_or(false)
223 {
224 if let Err(error) = self.repository.update_user(&public_key_text, user_id).await {
225 tracing::error!(%error, public_key = %public_key, "Failed to update node user_id");
226 }
227
228 return Ok(NodeRegistrationResponse {
229 beacon_token: self
230 .token_issuer
231 .issue(public_key)
232 .ok()
233 .map(Sensitive::into_inner),
234 });
235 }
236
237 let total_cores = request.total_cores.unwrap_or(DEFAULT_CORE_COUNT);
238 let node = NewNode {
239 public_key: &public_key_text,
240 user_id,
241 name: request.hostname.as_deref().unwrap_or("WowLab Node"),
242 total_cores,
243 max_parallel: request.enabled_cores.unwrap_or(total_cores),
244 platform: request.platform.as_deref().unwrap_or("unknown"),
245 version: request.version.as_deref(),
246 };
247
248 if let Err(source) = self.repository.insert(node).await {
249 tracing::error!(error = %source, "Failed to insert node");
250
251 return Err(NodeOperationError::CreateNode { source });
252 }
253
254 let mut permission_failures = 0usize;
255
256 for rule in &request.access_rules {
257 if self
258 .repository
259 .upsert_permission(&public_key_text, rule)
260 .await
261 .is_err()
262 {
263 permission_failures += 1;
264 }
265 }
266
267 if permission_failures > 0 {
268 tracing::error!(permission_failures, "Failed to insert node permissions");
269 }
270
271 Ok(NodeRegistrationResponse {
272 beacon_token: self
273 .token_issuer
274 .issue(public_key)
275 .ok()
276 .map(Sensitive::into_inner),
277 })
278 }
279
280 pub(in crate::http) async fn refresh_token(
281 &self,
282 public_key: &NodePublicKey,
283 ) -> Result<NodeTokenResponse, NodeOperationError> {
284 let public_key_text = public_key.to_string();
285
286 match self.repository.is_claimed(&public_key_text).await {
287 Ok(true) => {}
288 Ok(false) => return Err(NodeOperationError::NodeNotClaimed { source: None }),
289 Err(source) => {
290 return Err(NodeOperationError::NodeNotClaimed {
291 source: Some(source),
292 });
293 }
294 }
295
296 let token = match self.token_issuer.issue(public_key) {
297 Ok(token) => token,
298 Err(source) => {
299 tracing::error!("Failed to generate beacon token");
300
301 return Err(NodeOperationError::TokenGeneration(source));
302 }
303 };
304
305 Ok(NodeTokenResponse::new(token.into_inner()))
306 }
307
308 pub(in crate::http) async fn unlink(
309 &self,
310 public_key: &NodePublicKey,
311 ) -> Result<NodeUnlinkResponse, NodeOperationError> {
312 let public_key_text = public_key.to_string();
313
314 match self.repository.delete(&public_key_text).await {
315 Ok(true) => {
316 tracing::info!(public_key = %public_key, "Node unlinked");
317
318 Ok(NodeUnlinkResponse::success())
319 }
320 Ok(false) => Err(NodeOperationError::NodeNotFound),
321 Err(source) => {
322 tracing::error!(error = %source, "Failed to delete node");
323
324 Err(NodeOperationError::Database(source))
325 }
326 }
327 }
328}
329
330impl From<TokenIssueError> for NodeOperationError {
331 fn from(error: TokenIssueError) -> Self {
332 Self::TokenGeneration(error)
333 }
334}
335
336#[cfg(test)]
337mod tests;