#![cfg(unix)]
use hatchery_c2::enroll_service::{EnrollConfig, PeerProvisioner, PublicInfo};
use hatchery_c2::enrollment::TunnelPool;
use hatchery_c2::protocol::NodeId;
use hatchery_c2::{C2Config, C2NodeConfig, C2Running};
use hatchery_enroll::{NodeIdentity, NodeKind};
use hatchery_node::enroll_client;
use hatchery_node::protocol::WorkspaceId;
use hatchery_node::{NodeServer, NodeServerConfig, WorkspaceConfig};
use std::os::unix::fs::DirBuilderExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::{sleep, timeout};
const API_TOKEN: &str = "operator-api-token-for-test";
#[derive(Default)]
struct Recording(Mutex<Vec<String>>);
impl PeerProvisioner for Recording {
fn add_peer(&self, key: &str, ip: &str) -> std::io::Result<()> {
self.0.lock().unwrap().push(format!("add {key} {ip}"));
Ok(())
}
fn remove_peer(&self, key: &str) -> std::io::Result<()> {
self.0.lock().unwrap().push(format!("remove {key}"));
Ok(())
}
}
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
struct Dir(PathBuf);
impl Dir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("hatchery-enroll-e2e-{}-{}", std::process::id(), free_port()));
std::fs::DirBuilder::new().mode(0o700).create(&path).unwrap();
Self(path)
}
fn p(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for Dir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
async fn http(addr: std::net::SocketAddr, method: &str, path: &str, bearer: Option<&str>, body: &str) -> (u16, String) {
let mut stream = TcpStream::connect(addr).await.unwrap();
let auth = bearer.map(|t| format!("Authorization: Bearer {t}\r\n")).unwrap_or_default();
let request = format!("{method} {path} HTTP/1.1\r\nHost: x\r\n{auth}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
stream.write_all(request.as_bytes()).await.unwrap();
let mut raw = Vec::new();
stream.read_to_end(&mut raw).await.unwrap();
let text = String::from_utf8_lossy(&raw).into_owned();
let status = text.split_whitespace().nth(1).unwrap().parse().unwrap();
let body = text.split("\r\n\r\n").nth(1).unwrap_or_default().to_owned();
(status, body)
}
struct Ports {
api: std::net::SocketAddr,
enroll: std::net::SocketAddr,
call_home: std::net::SocketAddr,
}
fn start_config(dir: &Dir, ports: &Ports, provisioner: Arc<Recording>, with_token_node: bool) -> C2Config {
let nodes = if with_token_node {
vec![C2NodeConfig::new(NodeId::new("tok1").unwrap(), "accept", "token-node-secret-token").unwrap()]
} else {
Vec::new()
};
let mut enroll = EnrollConfig::new(ports.enroll);
enroll.registry_path = Some(dir.p("registry.json"));
enroll.pool = Some(TunnelPool { c2_ip: "10.79.0.1".parse().unwrap(), prefix: 24 });
enroll.provisioner = provisioner;
enroll.public = PublicInfo {
c2_tunnel_ip: Some("10.79.0.1".into()),
c2_wg_public_key: Some("c2-public-key-placeholder".into()),
c2_wg_endpoint: Some("c2.example:51890".into()),
c2_call_home: Some(ports.call_home.to_string()),
};
C2Config::new(ports.api, API_TOKEN, nodes)
.unwrap()
.with_control_endpoint(dir.p("c2.sock").to_string_lossy().into_owned())
.unwrap()
.with_node_listen(ports.call_home)
.unwrap()
.with_enrollment(enroll)
}
fn node_server(dir: &Dir, id: &str, identity: Option<Arc<NodeIdentity>>, call_home: std::net::SocketAddr, token: &str) -> NodeServer {
let mut config = NodeServerConfig::new(
dir.p(&format!("{id}.sock")).to_string_lossy().into_owned(),
token,
NodeId::new(id).unwrap(),
[WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), std::env::current_dir().unwrap()).unwrap()],
)
.unwrap()
.with_call_home(call_home)
.unwrap();
if let Some(identity) = identity {
config = config.with_call_home_identity(identity);
}
NodeServer::new_fixture(config).unwrap()
}
async fn is_online(api: std::net::SocketAddr, id: &str) -> bool {
let (status, body) = http(api, "GET", "/status", Some(API_TOKEN), "").await;
if status != 200 {
return false;
}
let value: serde_json::Value = serde_json::from_str(&body).unwrap();
value["nodes"][id]["transport"].as_str().is_some_and(|t| t.eq_ignore_ascii_case("online"))
}
async fn wait_online(api: std::net::SocketAddr, id: &str, want: bool, secs: u64) {
timeout(Duration::from_secs(secs), async {
while is_online(api, id).await != want {
sleep(Duration::from_millis(100)).await;
}
})
.await
.unwrap_or_else(|_| panic!("node {id} did not become online={want}"));
}
async fn code(api: std::net::SocketAddr) -> String {
let (status, body) = http(api, "POST", "/v1/enroll/codes", Some(API_TOKEN), "{}").await;
assert_eq!(status, 200);
serde_json::from_str::<serde_json::Value>(&body).unwrap()["code"].as_str().unwrap().to_owned()
}
async fn record_status(api: std::net::SocketAddr, id: &str) -> Option<String> {
let (status, body) = http(api, "GET", "/v1/enroll/nodes", Some(API_TOKEN), "").await;
assert_eq!(status, 200);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
v["nodes"].as_array().unwrap().iter().find(|n| n["node_id"] == id).map(|n| n["status"].as_str().unwrap().to_owned())
}
async fn stop(shutdown: hatchery_node::NodeShutdownHandle, task: tokio::task::JoinHandle<Result<(), hatchery_node::NodeServerError>>) {
shutdown.request_shutdown().await.unwrap();
let _ = timeout(Duration::from_secs(5), task).await;
}
use hatchery_c2_client::{connect_local, C2Client};
use hatchery_node::protocol::NodeRequest;
fn start_with_policy(dir: &Dir, ports: &Ports, prov: Arc<Recording>, policy: &Path) -> C2Config {
let mut config = start_config(dir, ports, prov, false);
if let Some(enroll) = config.enrollment.as_mut() {
enroll.policy_path = Some(policy.to_owned());
}
config
}
async fn snapshot_verdict(api: std::net::SocketAddr, control: &hatchery_c2_client::C2ControlHandle, id: &str) -> String {
let client = C2Client::new(api, API_TOKEN).unwrap().with_deadline(Duration::from_secs(2));
let status = client.status().await.unwrap();
let node_id = NodeId::new(id).unwrap();
let cursor = status.nodes[&node_id].cursor.expect("online node has a cursor");
let route = hatchery_c2::protocol::NodeRoute { node_id, expected_incarnation_id: cursor.incarnation_id };
format!("{:?}", control.request(route, NodeRequest::Snapshot).await)
}
async fn inventory_visible(api: std::net::SocketAddr, id: &str) -> bool {
let (_, body) = http(api, "GET", "/status", Some(API_TOKEN), "").await;
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
!v["nodes"][id]["inventory"].is_null()
}
async fn allows(api: std::net::SocketAddr, id: &str, cap: &str) -> bool {
let (s, body) = http(api, "POST", &format!("/v1/enroll/nodes/{id}/allows"), Some(API_TOKEN), &format!(r#"{{"capability":"{cap}"}}"#)).await;
assert_eq!(s, 200, "{body}");
serde_json::from_str::<serde_json::Value>(&body).unwrap()["allowed"].as_bool().unwrap()
}
async fn set_status(api: std::net::SocketAddr, id: &str, status: &str) -> u16 {
http(api, "POST", &format!("/v1/enroll/nodes/{id}/status"), Some(API_TOKEN), &format!(r#"{{"status":"{status}"}}"#)).await.0
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn policy_is_enforced_per_status_reloaded_and_survives_restart() {
let dir = Dir::new();
let ports = Ports {
api: format!("127.0.0.1:{}", free_port()).parse().unwrap(),
enroll: format!("127.0.0.1:{}", free_port()).parse().unwrap(),
call_home: format!("127.0.0.1:{}", free_port()).parse().unwrap(),
};
let api = ports.api;
let policy_file = dir.p("policy.json");
std::fs::write(&policy_file, "{}").unwrap();
let c2 = C2Running::start(start_with_policy(&dir, &ports, Arc::new(Recording::default()), &policy_file)).await.unwrap();
let keys = dir.p("keys-a");
let one = code(api).await;
enroll_client::enroll(ports.enroll, "pola", NodeKind::Qemu, Some(&one), &keys).await.unwrap();
let identity = Arc::new(NodeIdentity::load_or_create(&keys).unwrap());
let node = node_server(&dir, "pola", Some(Arc::clone(&identity)), ports.call_home, "local-only-token-pola");
let (shutdown, task) = (node.shutdown_handle(), tokio::spawn(node.run()));
wait_online(api, "pola", true, 30).await;
let (control, _events) = timeout(Duration::from_secs(5), connect_local(&dir.p("c2.sock").to_string_lossy(), API_TOKEN)).await.unwrap().unwrap();
assert!(allows(api, "pola", "heartbeat").await);
for cap in ["attach", "tasks", "tools"] {
assert!(!allows(api, "pola", cap).await, "neutral must not hold {cap}");
}
assert!(!inventory_visible(api, "pola").await, "neutral shows liveness only");
let verdict = snapshot_verdict(api, &control, "pola").await;
assert!(verdict.contains("RequestForbidden"), "neutral request must be forbidden: {verdict}");
assert_eq!(set_status(api, "pola", "member").await, 200);
assert!(allows(api, "pola", "attach").await && !allows(api, "pola", "tasks").await);
timeout(Duration::from_secs(10), async { while !inventory_visible(api, "pola").await { sleep(Duration::from_millis(100)).await } }).await.unwrap();
let verdict = snapshot_verdict(api, &control, "pola").await;
assert!(verdict.contains("Snapshot"), "member may attach: {verdict}");
assert_eq!(set_status(api, "pola", "trusted").await, 200);
assert!(allows(api, "pola", "tasks").await && allows(api, "pola", "tools").await);
assert_eq!(set_status(api, "pola", "suspended").await, 200);
wait_online(api, "pola", false, 30).await;
assert!(!allows(api, "pola", "heartbeat").await);
sleep(Duration::from_secs(3)).await;
assert!(!is_online(api, "pola").await, "a suspended node cannot call home");
assert_eq!(set_status(api, "pola", "member").await, 200);
wait_online(api, "pola", true, 40).await;
std::fs::write(&policy_file, r#"{"member":{"mailbox":true,"rooms":["general"],"capabilities":["heartbeat","mail"]}}"#).unwrap();
let (s, _) = http(api, "POST", "/v1/enroll/policy/reload", Some(API_TOKEN), "").await;
assert_eq!(s, 200);
assert!(!allows(api, "pola", "attach").await);
assert!(is_online(api, "pola").await);
timeout(Duration::from_secs(10), async { while inventory_visible(api, "pola").await { sleep(Duration::from_millis(100)).await } }).await.unwrap();
let verdict = snapshot_verdict(api, &control, "pola").await;
assert!(verdict.contains("RequestForbidden"), "{verdict}");
std::fs::write(&policy_file, r#"{"member":{"capabilities":["atach"]}}"#).unwrap();
let (s, body) = http(api, "POST", "/v1/enroll/policy/reload", Some(API_TOKEN), "").await;
assert_eq!(s, 409, "{body}");
assert!(!allows(api, "pola", "attach").await && allows(api, "pola", "mail").await);
std::fs::write(&policy_file, r#"{"member":{"capabilities":["mail"]}}"#).unwrap();
assert_eq!(http(api, "POST", "/v1/enroll/policy/reload", Some(API_TOKEN), "").await.0, 200);
wait_online(api, "pola", false, 30).await;
drop(c2);
sleep(Duration::from_millis(500)).await;
let c2 = C2Running::start(start_with_policy(&dir, &ports, Arc::new(Recording::default()), &policy_file)).await.unwrap();
assert_eq!(record_status(api, "pola").await.as_deref(), Some("member"));
assert!(!allows(api, "pola", "heartbeat").await, "policy file is read again at start");
sleep(Duration::from_secs(4)).await;
assert!(!is_online(api, "pola").await);
std::fs::write(&policy_file, "{}").unwrap();
assert_eq!(http(api, "POST", "/v1/enroll/policy/reload", Some(API_TOKEN), "").await.0, 200);
wait_online(api, "pola", true, 40).await;
assert_eq!(http(api, "POST", "/v1/enroll/nodes/pola/revoke", Some(API_TOKEN), "").await.0, 200);
wait_online(api, "pola", false, 30).await;
assert!(!allows(api, "pola", "heartbeat").await);
assert_eq!(set_status(api, "pola", "member").await, 409);
assert_eq!(http(api, "GET", "/v1/enroll/policy", None, "").await.0, 401);
stop(shutdown, task).await;
drop(c2);
}