#![doc(html_logo_url = "https://gunnar.rs/download/gunnar.webp", html_favicon_url = "https://gunnar.rs/download/favicon.svg")]
pub mod attest;
pub mod auth;
pub mod bus;
pub mod client_signer;
pub const TWIN_GROUP: &str = "twin";
pub const REPAIR_PRINCIPAL: &str = "repair";
pub const REPAIR_METHODS: &[&str] = &["Mirror.LogHead", "Mirror.LogFetch", "Mirror.SnapshotFetch", "Mirror.SetRole", "Mirror.DemoteReplication", "Access.WhoAmI"];
pub const PRIMARY_GROWING: &str = "[primary-growing]";
pub const TWIN_TOO_SMALL: &str = "[twin-too-small]";
pub mod config;
pub mod grow_journal;
pub mod heartbeat;
pub mod machine_enrolment;
pub mod offload;
pub mod clans;
pub mod quiet;
pub mod retention;
pub mod twin_owed;
pub mod selling;
pub mod seam;
pub mod repo_door;
pub mod security_key;
pub mod enterprise;
pub mod service;
pub mod sso;
pub mod topology;
mod status;
pub mod trace;
pub use nornir_auth_webauthn::ceremony as webauthn;
use std::sync::Arc;
pub use attest::attestation_json_line;
pub const BILLING_METHODS: &[&str] = &[
"Entitlement.Set",
"Entitlement.List",
"Admin.Capacity",
"Admin.Health",
"Admin.GrowStatus",
"Admin.GrowStart",
"Admin.GrowResume",
"Usage.ReadAll",
"UsageBatch.ReadMany",
"Accounts.Purge",
"Accounts.EnrollEmployee",
];
pub use auth::{
method_name_from_path, sign_credential, sign_credential_for, Accepted, Access, AuthFailure, SignError,
AuthGate, AuthMode, CalledMethod, Caller, Credential, ReplayGuard, UnboundCredentialLog,
UnboundCredentialReport, UnboundSighting, ANONYMOUS_PRINCIPAL_ID, AUDIT_TARGET,
CREDENTIAL_METADATA_KEY, CREDENTIAL_SCHEME, CREDENTIAL_SCHEME_V1,
DEFAULT_UNBOUND_LOG_CAPACITY, KEY_ENROLLING, UNAUTHENTICATED_METHODS, UNIMPLEMENTED_METHODS, UNKNOWN_METHOD,
};
pub use bus::{BusStats, EventBus, EventFilter, EventStream};
pub use config::{ServerConfig, AUTHENTICATION_ENABLED, DEFAULT_BIND, DEFAULT_EVENT_BUFFER};
pub use seam::{
BootstrapInvite, BootstrapState, CertificateIdentity, ControlError, ControlPlane, ControlResult,
EmployeeAnswer, EmployeeAsk, Enrolled, FirstBoot, Identity, MemoryControlPlane, MergedRef, NewRepo,
OperatorClaim, RegisteredKey, StoredCredential, NewInvite, InviteCheck, WebLoginCommit, WebLoginOutcome,
MachineApproval, MachineApproved, PrincipalSeen, RestoreIntake,
WebRegistered, WebRegistration, TransferGate,
TransferHold, TransferPermit, TransferRefused,
};
pub use service::{
AccessService, AccountsService, AdminService, BootstrapService, EnterpriseService, EventsService,
ForgeService, Listing, MirrorService,
PolicyService, PullsService, RefsService, ReposService, ServiceContext, SsoService, UsageBatchService, UsageService,
WebhooksService, BROWSE_STILL_ANSWERS,
};
#[cfg(feature = "metered")]
pub use service::{BillingService, EntitlementService, SealedService};
pub use service::ClansService;
pub use trace::{
render_request_id, RequestIdSource, RequestSpan, RequestSpanLayer, REQUEST_ID_METADATA_KEY,
};
use gunnar_proto::access_server::AccessServer;
use gunnar_proto::accounts_server::AccountsServer;
use gunnar_proto::enterprise_server::EnterpriseServer;
use gunnar_proto::sso_server::SsoServer;
use gunnar_proto::usage_server::UsageServer;
use gunnar_proto::usage_batch_server::UsageBatchServer;
#[cfg(feature = "metered")]
use gunnar_proto::billing_server::BillingServer;
#[cfg(feature = "metered")]
use gunnar_proto::entitlement_server::EntitlementServer;
#[cfg(feature = "metered")]
use gunnar_proto::sealed_server::SealedServer;
use gunnar_proto::admin_server::AdminServer;
use gunnar_proto::bootstrap_server::BootstrapServer;
use gunnar_proto::events_server::EventsServer;
use gunnar_proto::forge_server::ForgeServer;
use gunnar_proto::mirror_server::MirrorServer;
use gunnar_proto::policy_server::PolicyServer;
use gunnar_proto::pull_requests_server::PullRequestsServer;
use gunnar_proto::refs_server::RefsServer;
use gunnar_proto::webhooks_server::WebhooksServer;
use gunnar_proto::repos_server::ReposServer;
use gunnar_proto::clans_server::ClansServer;
pub fn warn_authentication_disabled(config: &ServerConfig) {
if config.authentication_enabled() {
tracing::info!(
bind = %config.bind,
loopback = config.is_loopback_bind(),
"gRPC authentication is ON: every control-plane call needs an sshsig credential \
from a key in gunnar's ACL key table, and a grant from the same ACL the SSH \
listener uses."
);
return;
}
tracing::warn!(
bind = %config.bind,
loopback = config.is_loopback_bind(),
"gRPC AUTHENTICATION IS OFF for this listener (the local-development escape hatch). \
Every control-plane mutation is served to any caller that can reach this port: \
principals, keys, grants, branch protection, repo create/delete. Git push/fetch are \
NOT affected: they run on the separate SSH listener with publickey auth, so an open \
gRPC port cannot push code. Do not deploy this."
);
if !config.is_loopback_bind() {
tracing::error!(
bind = %config.bind,
"…and this listener is NOT on loopback. The unauthenticated control plane is \
reachable from the network."
);
}
}
pub type GunnarRouter = tonic::transport::server::Router<
tower_layer::Stack<
offload::OffloadLayer,
tower_layer::Stack<RequestSpanLayer, tower_layer::Identity>,
>,
>;
#[derive(Debug, Clone)]
pub struct Trust {
pub client_ca: Vec<u8>,
pub cert: Vec<u8>,
pub key: Vec<u8>,
}
impl Trust {
pub fn from_files(
client_ca: &std::path::Path,
cert: &std::path::Path,
key: &std::path::Path,
) -> std::io::Result<Self> {
Ok(Trust {
client_ca: std::fs::read(client_ca)?,
cert: std::fs::read(cert)?,
key: std::fs::read(key)?,
})
}
pub fn server_tls(&self) -> tonic::transport::ServerTlsConfig {
tonic::transport::ServerTlsConfig::new()
.identity(tonic::transport::Identity::from_pem(&self.cert, &self.key))
.client_ca_root(tonic::transport::Certificate::from_pem(&self.client_ca))
}
}
pub fn peer_certificate_der<T>(request: &tonic::Request<T>) -> Option<Vec<u8>> {
let certs = request.peer_certs()?;
Some(certs.first()?.as_ref().to_vec())
}
pub fn router(ctx: ServiceContext, request_ids: RequestIdSource) -> GunnarRouter {
router_with_trust(ctx, request_ids, None).expect("a router without TLS cannot fail to build")
}
pub fn ensure_crypto_provider() {
let _ = tls13_only_provider().install_default();
}
pub fn tls13_only_provider() -> rustls::crypto::CryptoProvider {
let mut provider = rustls::crypto::ring::default_provider();
provider.cipher_suites.retain(|suite| suite.version().version == rustls::ProtocolVersion::TLSv1_3);
provider.cipher_suites.sort_by_key(|suite| bulk_rank(suite.suite()));
provider
}
fn bulk_rank(suite: rustls::CipherSuite) -> u8 {
let accelerated = aes_is_accelerated();
match suite {
rustls::CipherSuite::TLS13_AES_128_GCM_SHA256 => u8::from(!accelerated),
rustls::CipherSuite::TLS13_CHACHA20_POLY1305_SHA256 => u8::from(accelerated),
_ => 2,
}
}
pub fn aes_is_accelerated() -> bool {
#[cfg(target_arch = "x86_64")]
{
std::arch::is_x86_feature_detected!("aes") && std::arch::is_x86_feature_detected!("pclmulqdq")
}
#[cfg(not(target_arch = "x86_64"))]
{
false
}
}
pub fn preferred_bulk_suite() -> &'static str {
if aes_is_accelerated() {
"TLS13_AES_128_GCM_SHA256 (AES-NI present; WireGuard's ChaCha20 underneath runs on the other units)"
} else {
"TLS13_CHACHA20_POLY1305_SHA256 (NO AES-NI on this CPU; software AES-GCM would be the slower half)"
}
}
pub fn non_tls13_suites() -> Option<Vec<String>> {
let provider = rustls::crypto::CryptoProvider::get_default()?;
Some(
provider
.cipher_suites
.iter()
.filter(|suite| suite.version().version != rustls::ProtocolVersion::TLSv1_3)
.map(|suite| format!("{:?}", suite.suite()))
.collect(),
)
}
pub fn refuse_unless_tls13_only() -> anyhow::Result<()> {
match non_tls13_suites() {
None => anyhow::bail!(
"no rustls crypto provider is installed in this process, so nothing decides which \
cipher suites a handshake may use. `ensure_crypto_provider()` must run before any \
TLS listener is built or any TLS channel is dialled."
),
Some(offending) if !offending.is_empty() => anyhow::bail!(
"this process's rustls provider offers {} cipher suite(s) BELOW TLS 1.3 ({}), so a \
peer could negotiate TLS 1.2 on a link the owner ruled is TLS 1.3 only. Some other \
crate installed its provider before `ensure_crypto_provider()` did — that call has \
to happen first, in the binary's own start-up, not on the first handshake.",
offending.len(),
offending.join(", ")
),
Some(_) => Ok(()),
}
}
pub fn router_with_trust(
ctx: ServiceContext,
request_ids: RequestIdSource,
trust: Option<&Trust>,
) -> anyhow::Result<GunnarRouter> {
let gate = AuthGate::new(Arc::clone(&ctx.control), ctx.config.auth)
.with_replay_guard(Arc::clone(&ctx.replay))
.with_registered_certificate_required(ctx.config.require_registered_client_certificate)
.with_unbound_log(Arc::clone(&ctx.unbound));
let builder = tonic::transport::Server::builder();
let builder = match trust {
Some(trust) => {
ensure_crypto_provider();
refuse_unless_tls13_only()?;
builder.tls_config(trust.server_tls())?
}
None => builder,
};
let router = builder
.layer(RequestSpanLayer::new(request_ids))
.layer(offload::OffloadLayer::new(ctx.handlers.clone()))
.add_service(ReposServer::with_interceptor(
ReposService::new(ctx.clone()),
gate.clone(),
))
.add_service(AccountsServer::with_interceptor(
AccountsService::new(ctx.clone()),
gate.clone(),
))
.add_service(ClansServer::with_interceptor(
ClansService::new(ctx.clone()),
gate.clone(),
))
.add_service(RefsServer::with_interceptor(
RefsService::new(ctx.clone()),
gate.clone(),
))
.add_service(AccessServer::with_interceptor(
AccessService::new(ctx.clone()),
gate.clone(),
))
.add_service(EnterpriseServer::with_interceptor(
EnterpriseService::new(ctx.clone()),
gate.clone(),
))
.add_service(SsoServer::with_interceptor(
SsoService::new(ctx.clone()),
gate.clone(),
))
.add_service(PolicyServer::with_interceptor(
PolicyService::new(ctx.clone()),
gate.clone(),
))
.add_service(EventsServer::with_interceptor(
EventsService::new(ctx.clone()),
gate.clone(),
))
.add_service(AdminServer::with_interceptor(
AdminService::new(ctx.clone()),
gate.clone(),
))
.add_service(MirrorServer::with_interceptor(
MirrorService::new(ctx.clone()),
gate.clone(),
))
.add_service(UsageServer::with_interceptor(
UsageService::new(ctx.clone()),
gate.clone(),
))
.add_service(UsageBatchServer::with_interceptor(
UsageBatchService::new(ctx.clone()),
gate.clone(),
))
.add_service(WebhooksServer::with_interceptor(
WebhooksService::new(ctx.clone()),
gate.clone(),
))
.add_service(PullRequestsServer::with_interceptor(
PullsService::new(ctx.clone()),
gate.clone(),
))
.add_service(ForgeServer::with_interceptor(
ForgeService::new(ctx.clone()),
gate.clone(),
));
#[cfg(feature = "metered")]
let router = router.add_service(EntitlementServer::with_interceptor(
EntitlementService::new(ctx.clone()),
gate.clone(),
));
#[cfg(feature = "metered")]
let router = router.add_service(BillingServer::with_interceptor(
BillingService::new(ctx.clone()),
gate.clone(),
));
#[cfg(feature = "metered")]
let router = router.add_service(SealedServer::with_interceptor(
SealedService::new(ctx.clone()),
gate.clone(),
));
drop(gate);
Ok(router
.add_service(BootstrapServer::new(BootstrapService::new(ctx))))
}
pub async fn serve(
config: ServerConfig,
control: Arc<dyn ControlPlane>,
bus: Arc<EventBus>,
request_ids: RequestIdSource,
) -> anyhow::Result<()> {
warn_authentication_disabled(&config);
let bind = config.bind;
let ctx = ServiceContext::new(control, bus, config);
tracing::info!(%bind, "gunnar gRPC control plane listening");
router(ctx, request_ids).serve(bind).await?;
Ok(())
}
pub async fn serve_with_shutdown<F>(
config: ServerConfig,
control: Arc<dyn ControlPlane>,
bus: Arc<EventBus>,
request_ids: RequestIdSource,
shutdown: F,
) -> anyhow::Result<()>
where
F: std::future::Future<Output = ()> + Send + 'static,
{
warn_authentication_disabled(&config);
let bind = config.bind;
let ctx = ServiceContext::new(control, bus, config);
tracing::info!(%bind, "gunnar gRPC control plane listening");
router(ctx, request_ids)
.serve_with_shutdown(bind, shutdown)
.await?;
Ok(())
}
#[cfg(test)]
mod trust_tests {
use super::Trust;
#[test]
fn the_trust_config_is_built_from_all_three_parts() {
let t = Trust {
client_ca: b"ca".to_vec(),
cert: b"cert".to_vec(),
key: b"key".to_vec(),
};
assert!(
!t.client_ca.is_empty(),
"a mutual listener needs a client CA"
);
assert!(!t.cert.is_empty() && !t.key.is_empty(), "and an identity");
let _ = t.server_tls();
}
#[test]
fn from_files_reads_all_three_and_keeps_them_distinct() {
let dir = tempfile::tempdir().expect("tempdir");
for (name, body) in [("ca", "CA"), ("cert", "CERT"), ("key", "KEY")] {
std::fs::write(dir.path().join(name), body).expect("write");
}
let t = Trust::from_files(
&dir.path().join("ca"),
&dir.path().join("cert"),
&dir.path().join("key"),
)
.expect("reading");
assert_eq!(t.client_ca, b"CA", "the client CA must be the ca file");
assert_eq!(t.cert, b"CERT", "the identity must be the cert file");
assert_eq!(t.key, b"KEY", "the key must be the key file");
}
}