use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use gunnar_core::ssh_key::PrivateKey;
use gunnar_grpc::{EventBus, ServerConfig, ServiceContext};
use gunnar_proto::bootstrap_client::BootstrapClient;
use gunnar_server::stores::ServerStores;
use gunnar_server::twin;
use gunnar_server::CoreControlPlane;
fn free_addr() -> SocketAddr {
let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
l.local_addr().expect("addr")
}
fn wait<T>(what: &str, within: Duration, mut probe: impl FnMut() -> Option<T>) -> T {
let t0 = Instant::now();
loop {
if let Some(v) = probe() {
return v;
}
assert!(t0.elapsed() < within, "{what}: not within {within:?}");
std::thread::sleep(Duration::from_millis(25));
}
}
struct Served {
rt: tokio::runtime::Runtime,
addr: SocketAddr,
}
fn serve(stores: &Arc<ServerStores>) -> Served {
let plane: Arc<dyn gunnar_grpc::ControlPlane> =
Arc::new(CoreControlPlane::new(Arc::clone(stores)));
let ctx = ServiceContext::new(plane, Arc::new(EventBus::new(16)), ServerConfig::default());
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("rt");
let addr = free_addr();
rt.spawn(async move {
let _ = gunnar_grpc::router(ctx, || 0).serve(addr).await;
});
wait("the box listens", Duration::from_secs(10), || {
std::net::TcpStream::connect(addr).ok()
});
Served { rt, addr }
}
fn with_credential<T>(message: T, credential: Option<String>) -> tonic::Request<T> {
let mut request = tonic::Request::new(message);
if let Some(value) = credential {
request.metadata_mut().insert(
gunnar_grpc::CREDENTIAL_METADATA_KEY,
value.parse().expect("a legal header"),
);
}
request
}
impl Served {
fn client(&self) -> BootstrapClient<tonic::transport::Channel> {
let _in = self.rt.enter();
let channel = tonic::transport::Channel::from_shared(format!("http://{}", self.addr))
.expect("url")
.connect_lazy();
BootstrapClient::new(channel)
}
fn become_twin(
&self,
primary: &str,
credential: Option<String>,
) -> Result<gunnar_proto::BecomeTwinResponse, tonic::Status> {
let mut c = self.client();
let req = with_credential(
gunnar_proto::BecomeTwinRequest {
primary: primary.to_string(),
},
credential,
);
self.rt
.block_on(async move { c.become_twin(req).await.map(|r| r.into_inner()) })
}
fn install(
&self,
credential: Option<String>,
) -> Result<gunnar_proto::InstallTwinCertificateResponse, tonic::Status> {
let mut c = self.client();
let req = with_credential(
gunnar_proto::InstallTwinCertificateRequest {
cert_pem: "-----BEGIN CERTIFICATE-----\nnot-one\n-----END CERTIFICATE-----\n"
.into(),
ca_pem: "-----BEGIN CERTIFICATE-----\nnot-one-either\n-----END CERTIFICATE-----\n"
.into(),
replication_port: 50053,
replication_host: "10.13.0.2".into(),
},
credential,
);
self.rt.block_on(async move {
c.install_twin_certificate(req)
.await
.map(|r| r.into_inner())
})
}
}
fn key() -> (PrivateKey, String) {
let pem = gunnar_core::mint_ed25519().expect("mint");
let key = PrivateKey::from_openssh(std::str::from_utf8(&pem).unwrap()).expect("parse");
let line = key.public_key().to_openssh().expect("public line");
(key, line)
}
fn credential(key: &PrivateKey, method: &str) -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let now = twin::now_ms();
let nonce = format!(
"bootstrap-credential-test-{}-{}-{now}",
std::process::id(),
N.fetch_add(1, Ordering::SeqCst)
);
gunnar_grpc::sign_credential_for(key, now, &nonce, method)
.expect("sign")
.to_header_value()
}
fn feed(root: &std::path::Path, admin: &str) {
std::fs::create_dir_all(root.join("pairing")).unwrap();
let text = format!(
"estate = \"bootstrap-credential-test\"\nrole = \"twin\"\npeers = []\n\n[me]\nname = \"twin\"\noverlay = \"10.13.0.3\"\n\n\
[feed]\nadmin_pubkey = \"{admin}\"\n"
);
std::fs::write(root.join("pairing/joined.toml"), text).unwrap();
}
fn fresh_fed() -> (tempfile::TempDir, Arc<ServerStores>, PrivateKey) {
let fd = tempfile::tempdir().unwrap();
let stores = Arc::new(ServerStores::open(fd.path()).unwrap());
let (operator, line) = key();
feed(fd.path(), &line);
(fd, stores, operator)
}
#[test]
fn become_twin_without_a_credential_is_refused_and_the_box_stays_its_own() {
let (fd, stores, _operator) = fresh_fed();
let served = serve(&stores);
let refused = served
.become_twin("10.66.6.6:50051", None)
.expect_err("no credential, no twin");
assert_eq!(refused.code(), tonic::Code::Unauthenticated, "{refused:?}");
assert!(
refused.message().contains("Bootstrap.BecomeTwin"),
"the refusal names the door: {refused:?}"
);
assert!(
!twin::is_twin_root(fd.path()),
"the box did not become anybody's twin"
);
assert!(!stores.is_twin());
}
#[test]
fn become_twin_from_a_key_the_box_was_not_fed_is_refused() {
let (fd, stores, _operator) = fresh_fed();
let served = serve(&stores);
let (stranger, _) = key();
let refused = served
.become_twin(
"10.66.6.6:50051",
Some(credential(&stranger, "Bootstrap.BecomeTwin")),
)
.expect_err("a stranger");
assert_eq!(refused.code(), tonic::Code::Unauthenticated, "{refused:?}");
assert!(!twin::is_twin_root(fd.path()));
}
#[test]
fn a_restore_credential_does_not_make_a_twin() {
let (fd, stores, operator) = fresh_fed();
let served = serve(&stores);
let refused = served
.become_twin(
"10.66.6.6:50051",
Some(credential(&operator, "Bootstrap.Restore")),
)
.expect_err("bound elsewhere");
assert_eq!(refused.code(), tonic::Code::Unauthenticated, "{refused:?}");
assert!(!twin::is_twin_root(fd.path()));
}
#[test]
fn become_twin_from_the_fed_key_is_taken() {
let (fd, stores, operator) = fresh_fed();
let served = serve(&stores);
let ok = served
.become_twin(
"10.13.0.2:50051",
Some(credential(&operator, "Bootstrap.BecomeTwin")),
)
.expect("the operator makes the twin");
assert!(!ok.twin_public_key.is_empty());
assert!(twin::is_twin_root(fd.path()));
}
#[test]
fn install_twin_certificate_without_a_credential_is_refused_before_anything_is_pinned() {
let (fd, stores, _operator) = fresh_fed();
let served = serve(&stores);
let refused = served.install(None).expect_err("no credential, no pin");
assert_eq!(refused.code(), tonic::Code::Unauthenticated, "{refused:?}");
assert!(
refused
.message()
.contains("Bootstrap.InstallTwinCertificate"),
"{refused:?}"
);
let (stranger, _) = key();
let refused = served
.install(Some(credential(
&stranger,
"Bootstrap.InstallTwinCertificate",
)))
.expect_err("a stranger");
assert_eq!(refused.code(), tonic::Code::Unauthenticated, "{refused:?}");
let _ = fd;
}
#[test]
fn an_unfed_box_refuses_both_by_name() {
let fd = tempfile::tempdir().unwrap();
let stores = Arc::new(ServerStores::open(fd.path()).unwrap());
let served = serve(&stores);
let (someone, _) = key();
let refused = served
.become_twin(
"10.13.0.2:50051",
Some(credential(&someone, "Bootstrap.BecomeTwin")),
)
.expect_err("unfed");
assert_eq!(refused.code(), tonic::Code::PermissionDenied, "{refused:?}");
assert!(
refused.message().contains("[bootstrap-no-key]"),
"{refused:?}"
);
let refused = served
.install(Some(credential(
&someone,
"Bootstrap.InstallTwinCertificate",
)))
.expect_err("unfed");
assert_eq!(refused.code(), tonic::Code::PermissionDenied, "{refused:?}");
assert!(
refused.message().contains("[bootstrap-no-key]"),
"{refused:?}"
);
assert!(!twin::is_twin_root(fd.path()));
}