Skip to main content

wowlab_node/
config.rs

1// #t(file: rust_hardcoded_url) config module with env var defaults for URLs
2
3use ed25519_dalek::{Signer, SigningKey};
4use rand::Rng;
5use wowlab_common::{EnvLoader, NodePublicKey, ProjectIdentity, config_dir, time};
6use wowlab_fs::path::{Path, PathBuf};
7use wowlab_parsers::{AccessControl, build_sign_message};
8use wowlab_types::sensitive::Sensitive;
9
10use crate::sentinel::{RequestSigner, SignedHeaders};
11
12const ED25519_KEY_BYTES: usize = 32;
13
14const DEFAULT_SENTINEL_URL: &str = "https://sentinel.wowlab.gg";
15const DEFAULT_BEACON_URL: &str = "wss://beacon.wowlab.gg";
16// Anon keys are public-by-design; real access is gated by Row-Level Security.
17const DEFAULT_SUPABASE_URL: &str = "https://api.wowlab.gg";
18const DEFAULT_ANON_KEY: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFtbHp6aWZzanNuanJxb3FyZ2x5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjIzOTUyMTYsImV4cCI6MjA3Nzk3MTIxNn0.I8sbS5AgEzLzD2h5FXcIBZCCchHnbnVn3EufN61WMoM";
19
20#[derive(Clone, Debug)]
21pub(crate) struct NodeKeyStore {
22    path: Option<PathBuf>,
23}
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub(crate) enum IdentityCleanupOutcome {
27    Removed,
28    AlreadyAbsent,
29}
30
31/// Failure to load startup configuration or establish the persisted node identity.
32#[derive(Debug, thiserror::Error)]
33#[error("{kind}")]
34pub struct NodeConfigError {
35    #[source]
36    kind: NodeConfigErrorKind,
37}
38
39#[derive(Debug, thiserror::Error)]
40enum NodeConfigErrorKind {
41    #[error("node identity directory is unavailable; set NODE_CONFIG_DIR")]
42    IdentityDirectoryUnavailable,
43    #[error("failed to establish node identity at {path}: {source}")]
44    IdentityIo {
45        path: PathBuf,
46        #[source]
47        source: wowlab_fs::error::Error,
48    },
49    #[error("node identity at {path} has {actual} bytes; expected {expected}")]
50    InvalidIdentityLength {
51        path: PathBuf,
52        actual: usize,
53        expected: usize,
54    },
55}
56
57impl NodeConfigError {
58    fn identity_directory_unavailable() -> Self {
59        Self {
60            kind: NodeConfigErrorKind::IdentityDirectoryUnavailable,
61        }
62    }
63
64    fn identity_io(path: &Path, source: wowlab_fs::error::Error) -> Self {
65        Self {
66            kind: NodeConfigErrorKind::IdentityIo {
67                path: path.to_path_buf(),
68                source,
69            },
70        }
71    }
72
73    fn invalid_identity_length(path: &Path, actual: usize) -> Self {
74        Self {
75            kind: NodeConfigErrorKind::InvalidIdentityLength {
76                path: path.to_path_buf(),
77                actual,
78                expected: ED25519_KEY_BYTES,
79            },
80        }
81    }
82}
83
84impl NodeKeyStore {
85    fn discover() -> Self {
86        Self {
87            path: config_dir(
88                "NODE",
89                ProjectIdentity {
90                    qualifier: "gg",
91                    organization: "wowlab",
92                    application: "wowlab-node",
93                },
94            )
95            .map(|directory| directory.join("keypair")),
96        }
97    }
98
99    #[cfg(test)]
100    pub(crate) fn at(path: PathBuf) -> Self {
101        Self { path: Some(path) }
102    }
103
104    pub(crate) fn delete(&self) -> Result<IdentityCleanupOutcome, wowlab_fs::error::Error> {
105        let Some(path) = self.path.as_ref() else {
106            return Ok(IdentityCleanupOutcome::AlreadyAbsent);
107        };
108
109        let removed = wowlab_fs::directory::remove_file_if_exists(path)?;
110
111        Ok(if removed {
112            IdentityCleanupOutcome::Removed
113        } else {
114            IdentityCleanupOutcome::AlreadyAbsent
115        })
116    }
117
118    fn load_or_create(&self) -> Result<(SigningKey, NodePublicKey), NodeConfigError> {
119        let path = self
120            .path
121            .as_deref()
122            .ok_or_else(NodeConfigError::identity_directory_unavailable)?;
123        let contents = wowlab_fs::private_file::load_or_create_with(path, || {
124            let mut key_bytes = [0_u8; ED25519_KEY_BYTES];
125
126            rand::rng().fill_bytes(&mut key_bytes);
127
128            key_bytes.to_vec()
129        })
130        .map_err(|source| NodeConfigError::identity_io(path, source))?;
131        let key_bytes: [u8; ED25519_KEY_BYTES] =
132            contents.try_into().map_err(|contents: Vec<u8>| {
133                NodeConfigError::invalid_identity_length(path, contents.len())
134            })?;
135        let signing_key = SigningKey::from_bytes(&key_bytes);
136        let public_key = NodePublicKey::from(signing_key.verifying_key());
137
138        Ok((signing_key, public_key))
139    }
140}
141
142/// Configuration loaded from environment variables for the simulation node.
143#[derive(Clone)]
144pub struct NodeConfig {
145    key_store: NodeKeyStore,
146    signing_key: SigningKey,
147    pub public_key: NodePublicKey,
148
149    pub sentinel_url: String,
150    pub beacon_url: String,
151    pub supabase_url: String,
152    pub supabase_anon_key: String,
153    pub game_data_cache_dir: Option<PathBuf>,
154
155    pub name: String,
156    pub total_cores: i32,
157    pub enabled_cores: i32,
158    pub token_claim: Option<Sensitive<String>>,
159    pub access: AccessControl,
160}
161
162impl NodeConfig {
163    /// Load configuration and establish the persisted signing identity.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error when the identity cannot be loaded or persisted atomically.
168    pub fn load() -> Result<Self, NodeConfigError> {
169        let env = EnvLoader::new("NODE");
170        let key_store = NodeKeyStore::discover();
171        let (signing_key, public_key) = key_store.load_or_create()?;
172
173        Ok(Self {
174            key_store,
175            signing_key,
176            public_key,
177            sentinel_url: env.get_or("SENTINEL_URL", DEFAULT_SENTINEL_URL),
178            beacon_url: env.get_or("BEACON_URL", DEFAULT_BEACON_URL),
179            supabase_url: env.get_or("SUPABASE_URL", DEFAULT_SUPABASE_URL),
180            supabase_anon_key: env.get_or("SUPABASE_ANON_KEY", DEFAULT_ANON_KEY),
181            game_data_cache_dir: env.get("CACHE_DIR").map(PathBuf::from).or_else(|| {
182                config_dir(
183                    "NODE",
184                    ProjectIdentity {
185                        qualifier: "gg",
186                        organization: "wowlab",
187                        application: "wowlab-node",
188                    },
189                )
190                .map(|directory| directory.join("game-data"))
191            }),
192            name: env.get("NAME").unwrap_or_else(crate::claim::default_name),
193            total_cores: env
194                .get("TOTAL_CORES")
195                .and_then(|s| s.parse().ok())
196                .unwrap_or_else(crate::claim::total_cores),
197            enabled_cores: env
198                .get("ENABLED_CORES")
199                .and_then(|s| s.parse().ok())
200                .unwrap_or_else(crate::claim::default_enabled_cores),
201            token_claim: env.get("CLAIM_TOKEN").map(Sensitive::new),
202            access: env
203                .get("ACCESS")
204                .and_then(|s| s.parse().ok())
205                .unwrap_or_default(),
206        })
207    }
208
209    pub(crate) fn key_store(&self) -> NodeKeyStore {
210        self.key_store.clone()
211    }
212}
213
214impl std::fmt::Debug for NodeConfig {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        f.debug_struct("NodeConfig")
217            .field("signing_key", &"[redacted]")
218            .field("public_key", &self.public_key)
219            .field("sentinel_url", &self.sentinel_url)
220            .field("beacon_url", &self.beacon_url)
221            .field("supabase_url", &self.supabase_url)
222            .field("game_data_cache_dir", &self.game_data_cache_dir)
223            .field("name", &self.name)
224            .field("total_cores", &self.total_cores)
225            .field("enabled_cores", &self.enabled_cores)
226            .field("token_claim", &self.token_claim)
227            .field("access", &self.access)
228            .finish_non_exhaustive()
229    }
230}
231
232impl RequestSigner for NodeConfig {
233    fn sign_request(&self, method: &str, host: &str, path: &str, body: &[u8]) -> SignedHeaders {
234        use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as BASE64};
235
236        let timestamp = time::unix_timestamp_secs();
237
238        let message = build_sign_message(timestamp, method, host, path, body);
239        let signature = self.signing_key.sign(message.as_bytes());
240
241        SignedHeaders {
242            key: self.public_key.to_string(),
243            signature: BASE64.encode(signature.to_bytes()),
244            timestamp: timestamp.to_string(),
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as BASE64};
252    use ed25519_dalek::{Signature, Verifier};
253    use googletest::prelude::*;
254
255    use super::*;
256
257    fn test_config() -> NodeConfig {
258        let signing_key = SigningKey::from_bytes(&[7; ED25519_KEY_BYTES]);
259        let public_key = NodePublicKey::from(signing_key.verifying_key());
260
261        NodeConfig {
262            key_store: NodeKeyStore { path: None },
263            signing_key,
264            public_key,
265            sentinel_url: "https://sentinel.example".to_string(),
266            beacon_url: "wss://beacon.example".to_string(),
267            supabase_url: "https://database.example".to_string(),
268            supabase_anon_key: "private-test-anon-value".to_string(),
269            game_data_cache_dir: None,
270            name: "test-node".to_string(),
271            total_cores: 8,
272            enabled_cores: 4,
273            token_claim: Some(Sensitive::new("private-test-claim".to_string())),
274            access: AccessControl::default(),
275        }
276    }
277
278    #[gtest]
279    fn request_signature_verifies_exact_canonical_message() -> Result<()> {
280        let config = test_config();
281        let body = [0_u8, 1, 255];
282
283        let headers = config.sign_request("POST", "sentinel.example", "/chunks/complete", &body);
284
285        let timestamp = headers.timestamp.parse().or_fail()?;
286        let message = build_sign_message(
287            timestamp,
288            "POST",
289            "sentinel.example",
290            "/chunks/complete",
291            &body,
292        );
293        let signature_bytes = BASE64.decode(&headers.signature).or_fail()?;
294        let signature = Signature::from_slice(&signature_bytes).or_fail()?;
295
296        config
297            .signing_key
298            .verifying_key()
299            .verify(message.as_bytes(), &signature)
300            .or_fail()?;
301        verify_that!(headers.signature, not(contains_substring("=")))?;
302
303        verify_that!(headers.key, eq(&config.public_key.to_string()))
304    }
305
306    #[gtest]
307    fn debug_output_redacts_all_credentials() -> Result<()> {
308        let config = test_config();
309
310        let debug = format!("{config:?}");
311
312        verify_that!(debug, contains_substring("[redacted]"))?;
313        verify_that!(debug, not(contains_substring("private-test-claim")))?;
314        verify_that!(debug, not(contains_substring("private-test-anon-value")))?;
315
316        verify_that!(
317            debug,
318            not(contains_substring(BASE64.encode([7_u8; ED25519_KEY_BYTES])))
319        )
320    }
321
322    #[gtest]
323    fn persisted_identity_roundtrips_without_temporary_files() -> Result<()> {
324        let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
325        let path = directory.path().join("keypair");
326        let store = NodeKeyStore::at(path.clone());
327
328        let (created, created_public) = store.load_or_create().or_fail()?;
329        let (loaded, loaded_public) = store.load_or_create().or_fail()?;
330        let entries = wowlab_fs::directory::entries(directory.path()).or_fail()?;
331
332        verify_that!(loaded.to_bytes(), eq(created.to_bytes()))?;
333        verify_that!(loaded_public, eq(&created_public))?;
334        verify_that!(entries.len(), eq(1))?;
335
336        verify_that!(entries[0].path(), eq(&*path))
337    }
338
339    #[gtest]
340    fn malformed_identity_is_rejected_without_being_replaced() -> Result<()> {
341        let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
342        let path = directory.path().join("keypair");
343        let malformed = b"not-an-ed25519-key";
344
345        wowlab_fs::file::write_bytes(&path, malformed).or_fail()?;
346        let store = NodeKeyStore::at(path.clone());
347
348        verify_that!(store.load_or_create(), err(anything()))?;
349
350        verify_that!(
351            wowlab_fs::file::read_bytes(&path).or_fail()?.as_slice(),
352            eq(malformed)
353        )
354    }
355}