Skip to main content

rust_web_server/fleet/
mod.rs

1//! Fleet config pull (18.23.0, #80): many instances, one source of truth.
2//!
3//! With `[fleet] source` set, rws fetches its config file from an HTTPS
4//! URL, an S3 object or a git repository every `interval_secs`, checks its
5//! Ed25519 signature (`<source>.sig`) against `public_key`, validates it
6//! like `rws --check`, writes it over the local config file and reloads.
7//! A config that fails any step is refused and the running one stays;
8//! `report_url` gets each outcome. `{node}` in the source is the node name
9//! (`[cluster] node`, else the host name), so each instance can have its
10//! own file.
11//!
12//! The fleet config is the declared layer; runtime changes made through
13//! the admin API ([`crate::cluster`]) are the live one.
14//!
15//! `rws fleet keygen` makes a key pair; `rws fleet sign <file> --key <key>`
16//! writes `<file>.sig`.
17
18use 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/// Where the config comes from.
33#[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    /// `https://…`, `s3://bucket/key`, `git+https://host/repo.git#branch:path`
42    /// (`git+ssh://`, `git+file://` too); `{node}` replaced.
43    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/// `[fleet]` settings.
68#[derive(Debug, Clone)]
69pub struct FleetConfig {
70    pub source: Source,
71    pub interval: Duration,
72    /// Ed25519 public key (32 bytes); `None` only with `insecure = true`.
73    pub public_key: Option<Vec<u8>>,
74    pub report_url: String,
75    pub node: String,
76    /// The local config file written and reloaded.
77    pub config_path: PathBuf,
78    /// Where a git source is cloned.
79    pub state_dir: PathBuf,
80    pub s3_endpoint: String,
81}
82
83/// A public key: base64 of the 32 raw bytes, inline or in a file.
84pub 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    /// `None` without `[fleet] source`.
100    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/// What a fetch brought.
126#[derive(Debug, PartialEq)]
127pub enum Fetched {
128    /// The source says nothing changed (HTTP 304).
129    NotModified,
130    Config { body: Vec<u8>, signature: Option<Vec<u8>> },
131}
132
133/// A signature file: base64, or the 64 raw bytes.
134fn 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
145/// Check `body` against `signature` with `public_key`.
146pub 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
157/// The puller: fetch, verify, check, write, reload, report.
158pub 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/// One round's outcome.
172#[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    /// One round.
268    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    /// `{"source":…,"node":…,"config":…,"last_check":…,"error":…}`.
353    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
377/// The fleet status for the admin API, or `None` without `[fleet]`.
378pub fn status_json() -> Option<String> {
379    global().get().map(Fleet::status_json)
380}
381
382/// The binary's entry point: with `[fleet] source`, pull on a thread.
383pub 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
402// ── rws fleet keygen / sign ──────────────────────────────────────────────────
403
404/// `rws fleet keygen [<prefix>]` / `rws fleet sign <file> --key <file>`:
405/// `Some(exit code)` when `args` (a full argv) asks for one.
406pub 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;