1use std::path::PathBuf;
19use std::sync::{Mutex, OnceLock};
20use std::time::Duration;
21
22use crate::core::base64::Base64;
23use crate::mcp::json_escape;
24
25pub const RWS_CONFIG_FLEET_SOURCE: &str = "RWS_CONFIG_FLEET_SOURCE";
26pub const RWS_CONFIG_FLEET_INTERVAL_SECS: &str = "RWS_CONFIG_FLEET_INTERVAL_SECS";
27pub const RWS_CONFIG_FLEET_PUBLIC_KEY: &str = "RWS_CONFIG_FLEET_PUBLIC_KEY";
28pub const RWS_CONFIG_FLEET_INSECURE: &str = "RWS_CONFIG_FLEET_INSECURE";
29pub const RWS_CONFIG_FLEET_REPORT_URL: &str = "RWS_CONFIG_FLEET_REPORT_URL";
30pub const RWS_CONFIG_FLEET_S3_ENDPOINT: &str = "RWS_CONFIG_FLEET_S3_ENDPOINT";
31
32#[derive(Debug, Clone, PartialEq)]
34pub enum Source {
35 Http(String),
36 S3 { bucket: String, key: String },
37 Git { repo: String, branch: String, path: String },
38}
39
40impl Source {
41 pub fn parse(text: &str, node: &str) -> Result<Source, String> {
44 let text = text.trim().replace("{node}", node);
45 if text.starts_with("https://") || text.starts_with("http://") {
46 return Ok(Source::Http(text));
47 }
48 if let Some(rest) = text.strip_prefix("s3://") {
49 let (bucket, key) = rest.split_once('/').ok_or_else(|| format!("[fleet] source {text:?}: expected s3://bucket/key"))?;
50 if bucket.is_empty() || key.is_empty() {
51 return Err(format!("[fleet] source {text:?}: expected s3://bucket/key"));
52 }
53 return Ok(Source::S3 { bucket: bucket.to_string(), key: key.to_string() });
54 }
55 if let Some(rest) = text.strip_prefix("git+") {
56 let (repo, fragment) = rest.split_once('#').ok_or_else(|| format!("[fleet] source {text:?}: expected git+<url>#<branch>:<path>"))?;
57 let (branch, path) = fragment.split_once(':').ok_or_else(|| format!("[fleet] source {text:?}: expected git+<url>#<branch>:<path>"))?;
58 if branch.is_empty() || path.is_empty() || branch.starts_with('-') {
59 return Err(format!("[fleet] source {text:?}: expected git+<url>#<branch>:<path>"));
60 }
61 return Ok(Source::Git { repo: repo.to_string(), branch: branch.to_string(), path: path.trim_start_matches('/').to_string() });
62 }
63 Err(format!("[fleet] source {text:?}: expected https://…, s3://bucket/key or git+<url>#<branch>:<path>"))
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct FleetConfig {
70 pub source: Source,
71 pub interval: Duration,
72 pub public_key: Option<Vec<u8>>,
74 pub report_url: String,
75 pub node: String,
76 pub config_path: PathBuf,
78 pub state_dir: PathBuf,
80 pub s3_endpoint: String,
81}
82
83pub fn read_public_key(value: &str) -> Result<Vec<u8>, String> {
85 let value = value.trim();
86 let text = if value.len() != 44 && std::path::Path::new(value).is_file() {
87 std::fs::read_to_string(value).map_err(|e| format!("[fleet] public_key {value}: {e}"))?
88 } else {
89 value.to_string()
90 };
91 let key = Base64::decode(text.trim().to_string()).map_err(|e| format!("[fleet] public_key: not base64: {e}"))?;
92 if key.len() != 32 {
93 return Err(format!("[fleet] public_key: an Ed25519 public key is 32 bytes, this one is {}", key.len()));
94 }
95 Ok(key)
96}
97
98impl FleetConfig {
99 pub fn from_env() -> Option<Result<FleetConfig, String>> {
101 let env = |name: &str| std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
102 let source = env(RWS_CONFIG_FLEET_SOURCE)?;
103 Some((|| {
104 let node = crate::admin::access::node_name();
105 let insecure = env(RWS_CONFIG_FLEET_INSECURE).is_some_and(|v| v.eq_ignore_ascii_case("true"));
106 let public_key = match env(RWS_CONFIG_FLEET_PUBLIC_KEY) {
107 Some(k) => Some(read_public_key(&k)?),
108 None if insecure => None,
109 None => return Err("[fleet] needs public_key (or insecure = true to skip signatures)".to_string()),
110 };
111 Ok(FleetConfig {
112 source: Source::parse(&source, &node)?,
113 interval: Duration::from_secs(env(RWS_CONFIG_FLEET_INTERVAL_SECS).and_then(|v| v.parse().ok()).unwrap_or(30).max(5)),
114 public_key,
115 report_url: env(RWS_CONFIG_FLEET_REPORT_URL).unwrap_or_default(),
116 node,
117 config_path: PathBuf::from(env("RWS_CONFIG_FILE").unwrap_or_else(|| "rws.config.toml".to_string())),
118 state_dir: PathBuf::from(".rws/fleet"),
119 s3_endpoint: env(RWS_CONFIG_FLEET_S3_ENDPOINT).unwrap_or_default(),
120 })
121 })())
122 }
123}
124
125#[derive(Debug, PartialEq)]
127pub enum Fetched {
128 NotModified,
130 Config { body: Vec<u8>, signature: Option<Vec<u8>> },
131}
132
133fn signature_bytes(raw: &[u8]) -> Result<Vec<u8>, String> {
135 if raw.len() == 64 {
136 return Ok(raw.to_vec());
137 }
138 let sig = Base64::decode(String::from_utf8_lossy(raw).trim().to_string()).map_err(|e| format!("the signature isn't base64: {e}"))?;
139 if sig.len() != 64 {
140 return Err(format!("an Ed25519 signature is 64 bytes, this one is {}", sig.len()));
141 }
142 Ok(sig)
143}
144
145pub fn verify(body: &[u8], signature: &[u8], public_key: &[u8]) -> Result<(), String> {
147 let sig = signature_bytes(signature)?;
148 aws_lc_rs::signature::UnparsedPublicKey::new(&aws_lc_rs::signature::ED25519, public_key)
149 .verify(body, &sig)
150 .map_err(|_| "the signature doesn't match (wrong key, or the file was changed after signing)".to_string())
151}
152
153fn sha256_hex(bytes: &[u8]) -> String {
154 crate::core::sha256::sha256(bytes).iter().map(|b| format!("{b:02x}")).collect()
155}
156
157pub struct Fleet {
159 config: FleetConfig,
160 state: Mutex<FleetState>,
161}
162
163#[derive(Debug, Default, Clone)]
164struct FleetState {
165 etag: Option<String>,
166 applied: Option<String>,
167 last_check: u64,
168 last_error: Option<String>,
169}
170
171#[derive(Debug, PartialEq)]
173pub enum Outcome {
174 Unchanged,
175 Applied(String),
176 Refused(String),
177}
178
179fn unix_now() -> u64 {
180 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
181}
182
183impl Fleet {
184 pub fn new(config: FleetConfig) -> Fleet {
185 let applied = std::fs::read(&config.config_path).ok().map(|b| sha256_hex(&b));
186 Fleet { config, state: Mutex::new(FleetState { applied, ..FleetState::default() }) }
187 }
188
189 fn fetch(&self) -> Result<Fetched, String> {
190 let want_sig = self.config.public_key.is_some();
191 match &self.config.source {
192 Source::Http(url) => {
193 let etag = self.state.lock().unwrap_or_else(|e| e.into_inner()).etag.clone();
194 let client = crate::http_client::Client::new().timeout_ms(15_000);
195 let mut request = client.get(url);
196 if let Some(tag) = &etag {
197 request = request.header("If-None-Match", tag);
198 }
199 let response = request.send().map_err(|e| format!("fetching {url}: {e}"))?;
200 if response.status() == 304 {
201 return Ok(Fetched::NotModified);
202 }
203 if !response.is_success() {
204 return Err(format!("fetching {url}: HTTP {}", response.status()));
205 }
206 let new_etag = response.header("ETag").map(str::to_string);
207 let body = response.bytes().to_vec();
208 let signature = if want_sig {
209 let sig = client.get(&format!("{url}.sig")).send().map_err(|e| format!("fetching {url}.sig: {e}"))?;
210 if !sig.is_success() {
211 return Err(format!("fetching {url}.sig: HTTP {}", sig.status()));
212 }
213 Some(sig.bytes().to_vec())
214 } else {
215 None
216 };
217 self.state.lock().unwrap_or_else(|e| e.into_inner()).etag = new_etag;
218 Ok(Fetched::Config { body, signature })
219 }
220 #[cfg(feature = "storage-s3")]
221 Source::S3 { bucket, key } => {
222 use crate::storage::Storage;
223 let region = std::env::var("AWS_REGION").or_else(|_| std::env::var("AWS_DEFAULT_REGION")).unwrap_or_else(|_| "us-east-1".to_string());
224 let endpoint = if self.config.s3_endpoint.is_empty() { format!("https://s3.{region}.amazonaws.com") } else { self.config.s3_endpoint.clone() };
225 let s3 = crate::storage::S3Storage::new(crate::storage::S3Config {
226 bucket: bucket.clone(),
227 region,
228 access_key: std::env::var("AWS_ACCESS_KEY_ID").unwrap_or_default(),
229 secret_key: std::env::var("AWS_SECRET_ACCESS_KEY").unwrap_or_default(),
230 endpoint,
231 });
232 let body = s3.get(key).map_err(|e| format!("s3://{bucket}/{key}: {e}"))?;
233 let signature = if want_sig { Some(s3.get(&format!("{key}.sig")).map_err(|e| format!("s3://{bucket}/{key}.sig: {e}"))?) } else { None };
234 Ok(Fetched::Config { body, signature })
235 }
236 #[cfg(not(feature = "storage-s3"))]
237 Source::S3 { .. } => Err("s3:// sources need the storage-s3 feature, which this build doesn't include".to_string()),
238 Source::Git { repo, branch, path } => {
239 let dir = self.config.state_dir.join("git");
240 let git = |args: &[&str]| -> Result<(), String> {
241 let out = std::process::Command::new("git").args(args).output().map_err(|e| format!("running git: {e} (is git installed?)"))?;
242 if out.status.success() {
243 Ok(())
244 } else {
245 Err(format!("git {}: {}", args.first().unwrap_or(&""), String::from_utf8_lossy(&out.stderr).trim()))
246 }
247 };
248 let d = dir.to_string_lossy().to_string();
249 if !dir.join(".git").is_dir() {
250 std::fs::create_dir_all(&self.config.state_dir).map_err(|e| e.to_string())?;
251 git(&["clone", "--quiet", "--depth", "1", "--branch", branch, "--", repo, &d])?;
252 } else {
253 git(&["-C", &d, "fetch", "--quiet", "--depth", "1", "origin", branch])?;
254 git(&["-C", &d, "reset", "--quiet", "--hard", "FETCH_HEAD"])?;
255 }
256 let file = dir.join(path);
257 if !file.canonicalize().is_ok_and(|f| f.starts_with(dir.canonicalize().unwrap_or_default())) {
258 return Err(format!("{path} isn't in the repository"));
259 }
260 let body = std::fs::read(&file).map_err(|e| format!("{path}: {e}"))?;
261 let signature = if want_sig { Some(std::fs::read(dir.join(format!("{path}.sig"))).map_err(|e| format!("{path}.sig: {e}"))?) } else { None };
262 Ok(Fetched::Config { body, signature })
263 }
264 }
265 }
266
267 pub fn tick(&self) -> Outcome {
269 let outcome = match self.fetch() {
270 Ok(Fetched::NotModified) => Outcome::Unchanged,
271 Ok(Fetched::Config { body, signature }) => self.apply(body, signature),
272 Err(e) => Outcome::Refused(e),
273 };
274 {
275 let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
276 state.last_check = unix_now();
277 match &outcome {
278 Outcome::Refused(e) => {
279 if state.last_error.as_deref() != Some(e) {
280 eprintln!("[fleet] {e}; the running config stays");
281 }
282 state.last_error = Some(e.clone());
283 }
284 Outcome::Applied(hash) => {
285 println!("[fleet] applied config {}", &hash[..12]);
286 state.last_error = None;
287 }
288 Outcome::Unchanged => state.last_error = None,
289 }
290 }
291 if !matches!(outcome, Outcome::Unchanged) {
292 self.report(&outcome);
293 }
294 outcome
295 }
296
297 fn apply(&self, body: Vec<u8>, signature: Option<Vec<u8>>) -> Outcome {
298 if let Some(key) = &self.config.public_key {
299 let Some(sig) = signature else { return Outcome::Refused("no signature".into()) };
300 if let Err(e) = verify(&body, &sig, key) {
301 return Outcome::Refused(e);
302 }
303 }
304 let hash = sha256_hex(&body);
305 if self.state.lock().unwrap_or_else(|e| e.into_inner()).applied.as_deref() == Some(hash.as_str()) {
306 return Outcome::Unchanged;
307 }
308 let Ok(text) = String::from_utf8(body.clone()) else { return Outcome::Refused("the config isn't UTF-8".into()) };
309 let report = crate::config_check::check_str(&text);
310 if !report.is_ok() {
311 return Outcome::Refused(format!("the fetched config doesn't pass rws --check: {}", report.errors.join("; ")));
312 }
313 let path = &self.config.config_path;
314 let tmp = path.with_extension("fleet.tmp");
315 if let Err(e) = std::fs::write(&tmp, &body).and_then(|_| std::fs::rename(&tmp, path)) {
316 return Outcome::Refused(format!("writing {}: {e}", path.display()));
317 }
318 self.state.lock().unwrap_or_else(|e| e.into_inner()).applied = Some(hash.clone());
319 if crate::metrics::SERVER_READY.load(std::sync::atomic::Ordering::SeqCst) {
320 let (before, _) = crate::config_reload::last_reload();
321 crate::config_reload::request_reload();
322 let deadline = std::time::Instant::now() + Duration::from_secs(10);
323 while std::time::Instant::now() < deadline {
324 let (count, errors) = crate::config_reload::last_reload();
325 if count > before {
326 if !errors.is_empty() {
327 return Outcome::Refused(format!("the reload reported: {}", errors.join("; ")));
328 }
329 break;
330 }
331 std::thread::sleep(Duration::from_millis(50));
332 }
333 }
334 Outcome::Applied(hash)
335 }
336
337 fn report(&self, outcome: &Outcome) {
338 if self.config.report_url.is_empty() {
339 return;
340 }
341 let (applied, detail) = match outcome {
342 Outcome::Applied(hash) => (true, format!(r#""config":"{hash}""#)),
343 Outcome::Refused(e) => (false, format!(r#""error":"{}""#, json_escape(e))),
344 Outcome::Unchanged => return,
345 };
346 let body = format!(r#"{{"node":"{}","rws":"{}","applied":{applied},{detail},"at":{}}}"#, json_escape(&self.config.node), env!("CARGO_PKG_VERSION"), unix_now());
347 if let Err(e) = crate::http_client::Client::new().timeout_ms(10_000).post(&self.config.report_url).body_json(&body).send() {
348 eprintln!("[fleet] report to {}: {e}", self.config.report_url);
349 }
350 }
351
352 pub fn status_json(&self) -> String {
354 let state = self.state.lock().unwrap_or_else(|e| e.into_inner()).clone();
355 let source = match &self.config.source {
356 Source::Http(u) => u.clone(),
357 Source::S3 { bucket, key } => format!("s3://{bucket}/{key}"),
358 Source::Git { repo, branch, path } => format!("git+{repo}#{branch}:{path}"),
359 };
360 let opt = |v: &Option<String>| v.as_ref().map_or("null".to_string(), |s| format!("\"{}\"", json_escape(s)));
361 format!(
362 r#"{{"source":"{}","node":"{}","config":{},"last_check":{},"error":{}}}"#,
363 json_escape(&source),
364 json_escape(&self.config.node),
365 opt(&state.applied),
366 state.last_check,
367 opt(&state.last_error)
368 )
369 }
370}
371
372fn global() -> &'static OnceLock<Fleet> {
373 static FLEET: OnceLock<Fleet> = OnceLock::new();
374 &FLEET
375}
376
377pub fn status_json() -> Option<String> {
379 global().get().map(Fleet::status_json)
380}
381
382pub fn start_from_env() {
384 let config = match FleetConfig::from_env() {
385 None => return,
386 Some(Ok(c)) => c,
387 Some(Err(e)) => return eprintln!("[fleet] not started: {e}"),
388 };
389 let interval = config.interval;
390 if global().set(Fleet::new(config)).is_err() {
391 return;
392 }
393 println!("[fleet] pulling the config every {}s", interval.as_secs());
394 let _ = std::thread::Builder::new().name("rws-fleet".into()).spawn(move || loop {
395 if let Some(fleet) = global().get() {
396 fleet.tick();
397 }
398 std::thread::sleep(interval);
399 });
400}
401
402pub fn command(args: &[String]) -> Option<i32> {
407 if args.get(1).map(String::as_str) != Some("fleet") {
408 return None;
409 }
410 let rest: Vec<&str> = args[2..].iter().map(String::as_str).collect();
411 let usage = "Usage: rws fleet keygen [<prefix>] (writes <prefix>.key and <prefix>.pub; default fleet)\n rws fleet sign <file> --key <prefix>.key (writes <file>.sig)";
412 let result = match rest.as_slice() {
413 ["keygen"] => keygen("fleet"),
414 ["keygen", prefix] => keygen(prefix),
415 ["sign", file, "--key", key] | ["sign", "--key", key, file] => sign(file, key),
416 _ => {
417 eprintln!("{usage}");
418 return Some(2);
419 }
420 };
421 Some(match result {
422 Ok(message) => {
423 println!("{message}");
424 0
425 }
426 Err(e) => {
427 eprintln!("rws fleet: {e}");
428 1
429 }
430 })
431}
432
433fn keygen(prefix: &str) -> Result<String, String> {
434 use aws_lc_rs::signature::KeyPair;
435 let rng = aws_lc_rs::rand::SystemRandom::new();
436 let pkcs8 = aws_lc_rs::signature::Ed25519KeyPair::generate_pkcs8(&rng).map_err(|_| "key generation failed".to_string())?;
437 let pair = aws_lc_rs::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).map_err(|_| "key generation failed".to_string())?;
438 let private = Base64::encode(pkcs8.as_ref())?;
439 let public = Base64::encode(pair.public_key().as_ref())?;
440 let key_path = format!("{prefix}.key");
441 let mut options = std::fs::OpenOptions::new();
442 options.write(true).create_new(true);
443 #[cfg(unix)]
444 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
445 let mut file = options.open(&key_path).map_err(|e| format!("{key_path}: {e}"))?;
446 std::io::Write::write_all(&mut file, format!("{private}\n").as_bytes()).map_err(|e| e.to_string())?;
447 std::fs::write(format!("{prefix}.pub"), format!("{public}\n")).map_err(|e| format!("{prefix}.pub: {e}"))?;
448 Ok(format!("wrote {key_path} (keep it secret) and {prefix}.pub\npublic_key = \"{public}\""))
449}
450
451fn sign(file: &str, key: &str) -> Result<String, String> {
452 let body = std::fs::read(file).map_err(|e| format!("{file}: {e}"))?;
453 let pkcs8 = Base64::decode(std::fs::read_to_string(key).map_err(|e| format!("{key}: {e}"))?.trim().to_string()).map_err(|e| format!("{key}: not a key from rws fleet keygen: {e}"))?;
454 let pair = aws_lc_rs::signature::Ed25519KeyPair::from_pkcs8(&pkcs8).map_err(|_| format!("{key}: not a key from rws fleet keygen"))?;
455 let report = crate::config_check::check_str_offline(&String::from_utf8_lossy(&body));
456 if !report.is_ok() {
457 return Err(format!("{file} doesn't pass rws --check: {}", report.errors.join("; ")));
458 }
459 let signature = Base64::encode(pair.sign(&body).as_ref())?;
460 std::fs::write(format!("{file}.sig"), format!("{signature}\n")).map_err(|e| format!("{file}.sig: {e}"))?;
461 Ok(format!("wrote {file}.sig"))
462}
463
464#[cfg(test)]
465mod tests;