use super::*;
use crate::test_env::TempPath;
use std::io::{Read, Write};
use std::sync::Arc;
const GOOD: &str = "port = 7979\n[[route]]\n[route.match]\npath = \"/*\"\n[route.action]\ntype = \"respond\"\nstatus = 200\nbody = \"fleet\"\n";
const BAD: &str = "port = 7979\n[[route]]\n[route.match]\npath = \"/*\"\n[route.action]\ntype = \"proxy\"\nupstream = \"nowhere\"\n";
fn keypair() -> (aws_lc_rs::signature::Ed25519KeyPair, Vec<u8>) {
use aws_lc_rs::signature::KeyPair;
let pkcs8 = aws_lc_rs::signature::Ed25519KeyPair::generate_pkcs8(&aws_lc_rs::rand::SystemRandom::new()).unwrap();
let pair = aws_lc_rs::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let public = pair.public_key().as_ref().to_vec();
(pair, public)
}
fn sig(pair: &aws_lc_rs::signature::Ed25519KeyPair, body: &str) -> String {
Base64::encode(pair.sign(body.as_bytes()).as_ref()).unwrap()
}
fn server(files: Arc<Mutex<HashMap<String, (String, String)>>>, reports: Arc<Mutex<Vec<String>>>) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut s) = stream else { continue };
let mut buf = vec![0u8; 16384];
let n = s.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]).to_string();
let path = req.split_whitespace().nth(1).unwrap_or("/").to_string();
if req.starts_with("POST") {
reports.lock().unwrap().push(req.split("\r\n\r\n").nth(1).unwrap_or("").to_string());
let _ = s.write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n");
continue;
}
let files = files.lock().unwrap();
let reply = match files.get(&path) {
Some((_, etag)) if req.to_ascii_lowercase().contains(&format!("if-none-match: {etag}")) => "HTTP/1.1 304 Not Modified\r\nConnection: close\r\n\r\n".to_string(),
Some((body, etag)) => format!("HTTP/1.1 200 OK\r\nETag: {etag}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()),
None => "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string(),
};
let _ = s.write_all(reply.as_bytes());
}
});
format!("http://{addr}")
}
use std::collections::HashMap;
fn fleet(source: Source, public_key: Option<Vec<u8>>, report_url: String, path: &TempPath) -> Fleet {
Fleet::new(FleetConfig {
source,
interval: Duration::from_secs(30),
public_key,
report_url,
node: "web-1".into(),
config_path: path.to_path_buf(),
state_dir: path.with_extension("state"),
s3_endpoint: String::new(),
})
}
#[test]
fn sources_parse() {
assert_eq!(Source::parse("https://cfg.example/rws/{node}.toml", "web-7").unwrap(), Source::Http("https://cfg.example/rws/web-7.toml".into()));
assert_eq!(Source::parse("s3://ops-config/rws/prod.toml", "n").unwrap(), Source::S3 { bucket: "ops-config".into(), key: "rws/prod.toml".into() });
assert_eq!(
Source::parse("git+https://git.example/ops.git#main:rws/{node}.toml", "web-1").unwrap(),
Source::Git { repo: "https://git.example/ops.git".into(), branch: "main".into(), path: "rws/web-1.toml".into() }
);
assert!(Source::parse("ftp://x/y", "n").is_err());
assert!(Source::parse("git+https://x.git#-c:x", "n").is_err(), "a branch can't be an option");
assert!(Source::parse("s3://bucket", "n").is_err());
}
#[test]
fn signatures_verify_only_with_the_right_key_and_body() {
let (pair, public) = keypair();
let s = sig(&pair, GOOD);
assert!(verify(GOOD.as_bytes(), s.as_bytes(), &public).is_ok());
assert!(verify(BAD.as_bytes(), s.as_bytes(), &public).is_err());
let (_, other) = keypair();
assert!(verify(GOOD.as_bytes(), s.as_bytes(), &other).is_err());
assert!(verify(GOOD.as_bytes(), b"bm90IGEgc2lnbmF0dXJl", &public).is_err());
assert_eq!(read_public_key(&Base64::encode(&public).unwrap()).unwrap(), public);
assert!(read_public_key("c2hvcnQ=").unwrap_err().contains("32 bytes"));
}
#[test]
fn a_signed_config_is_applied_then_not_refetched_and_bad_ones_are_refused_and_reported() {
let (pair, public) = keypair();
let files = Arc::new(Mutex::new(HashMap::new()));
let reports = Arc::new(Mutex::new(Vec::new()));
files.lock().unwrap().insert("/web-1.toml".to_string(), (GOOD.to_string(), "\"v1\"".to_string()));
files.lock().unwrap().insert("/web-1.toml.sig".to_string(), (sig(&pair, GOOD), "\"s1\"".to_string()));
let base = server(Arc::clone(&files), Arc::clone(&reports));
let path = TempPath::new("fleet-applied.toml");
std::fs::write(&*path, "port = 7979\n").unwrap();
let f = fleet(Source::Http(format!("{base}/web-1.toml")), Some(public), format!("{base}/report"), &path);
let outcome = f.tick();
assert!(matches!(outcome, Outcome::Applied(_)), "{outcome:?}");
assert_eq!(std::fs::read_to_string(&*path).unwrap(), GOOD);
assert_eq!(f.tick(), Outcome::Unchanged, "ETag: nothing refetched");
assert!(f.status_json().contains(r#""error":null"#));
files.lock().unwrap().insert("/web-1.toml".to_string(), (format!("{GOOD}# changed\n"), "\"v2\"".to_string()));
let outcome = f.tick();
assert!(matches!(&outcome, Outcome::Refused(e) if e.contains("signature")), "{outcome:?}");
assert_eq!(std::fs::read_to_string(&*path).unwrap(), GOOD, "the running config stays");
files.lock().unwrap().insert("/web-1.toml".to_string(), (BAD.to_string(), "\"v3\"".to_string()));
files.lock().unwrap().insert("/web-1.toml.sig".to_string(), (sig(&pair, BAD), "\"s3\"".to_string()));
let outcome = f.tick();
assert!(matches!(&outcome, Outcome::Refused(e) if e.contains("rws --check")), "{outcome:?}");
assert!(f.status_json().contains("rws --check"));
let reports = reports.lock().unwrap();
assert!(reports.iter().any(|r| r.contains(r#""applied":true"#) && r.contains(r#""node":"web-1""#)), "{reports:?}");
assert!(reports.iter().any(|r| r.contains(r#""applied":false"#) && r.contains("signature")), "{reports:?}");
}
#[test]
fn insecure_sources_need_no_signature() {
let files = Arc::new(Mutex::new(HashMap::new()));
files.lock().unwrap().insert("/c.toml".to_string(), (GOOD.to_string(), "\"a\"".to_string()));
let base = server(files, Arc::new(Mutex::new(Vec::new())));
let path = TempPath::new("fleet-insecure.toml");
let f = fleet(Source::Http(format!("{base}/c.toml")), None, String::new(), &path);
assert!(matches!(f.tick(), Outcome::Applied(_)));
}
#[test]
fn a_git_source_is_cloned_and_pulled() {
if std::process::Command::new("git").arg("--version").output().is_err() {
return;
}
let (pair, public) = keypair();
let repo = TempPath::new("fleet-git-repo");
std::fs::create_dir_all(repo.join("rws")).unwrap();
let git = |args: &[&str]| assert!(std::process::Command::new("git").arg("-C").arg(repo.as_str()).args(args).env("GIT_AUTHOR_NAME", "t").env("GIT_AUTHOR_EMAIL", "t@t").env("GIT_COMMITTER_NAME", "t").env("GIT_COMMITTER_EMAIL", "t@t").output().unwrap().status.success());
git(&["init", "-q", "-b", "main"]);
std::fs::write(repo.join("rws/web-1.toml"), GOOD).unwrap();
std::fs::write(repo.join("rws/web-1.toml.sig"), sig(&pair, GOOD)).unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "one"]);
let path = TempPath::new("fleet-git.toml");
let f = fleet(Source::Git { repo: format!("file://{}", repo.as_str()), branch: "main".into(), path: "rws/web-1.toml".into() }, Some(public), String::new(), &path);
assert!(matches!(f.tick(), Outcome::Applied(_)));
let second = GOOD.replace("fleet", "fleet-2");
std::fs::write(repo.join("rws/web-1.toml"), &second).unwrap();
std::fs::write(repo.join("rws/web-1.toml.sig"), sig(&pair, &second)).unwrap();
git(&["commit", "-q", "-am", "two"]);
assert!(matches!(f.tick(), Outcome::Applied(_)));
assert_eq!(std::fs::read_to_string(&*path).unwrap(), second);
std::fs::remove_dir_all(&*repo).ok();
std::fs::remove_dir_all(path.with_extension("state")).ok();
}
#[test]
fn keygen_and_sign_make_files_verify_accepts() {
let dir = TempPath::new("fleet-keys");
std::fs::create_dir_all(&*dir).unwrap();
let prefix = dir.join("ops").to_string_lossy().to_string();
let argv = |words: &[&str]| words.iter().map(|w| w.to_string()).collect::<Vec<_>>();
assert_eq!(command(&argv(&["rws", "fleet", "keygen", &prefix])), Some(0));
assert_eq!(command(&argv(&["rws", "fleet", "keygen", &prefix])), Some(1), "an existing key isn't overwritten");
let config = dir.join("rws.toml");
std::fs::write(&config, GOOD).unwrap();
let key = format!("{prefix}.key");
assert_eq!(command(&argv(&["rws", "fleet", "sign", config.to_str().unwrap(), "--key", &key])), Some(0));
let public = read_public_key(&format!("{prefix}.pub")).unwrap();
assert!(verify(GOOD.as_bytes(), &std::fs::read(format!("{}.sig", config.display())).unwrap(), &public).is_ok());
std::fs::write(&config, BAD).unwrap();
assert_eq!(command(&argv(&["rws", "fleet", "sign", config.to_str().unwrap(), "--key", &key])), Some(1), "a config that fails --check isn't signed");
assert_eq!(command(&argv(&["rws", "fleet"])), Some(2));
assert_eq!(command(&argv(&["rws", "serve"])), None);
std::fs::remove_dir_all(&*dir).ok();
}