Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ peers.txt
members/nullnet-server/ui/node_modules/
members/nullnet-server/ui/dist/
members/nullnet-server/certs
members/nullnet-server/grpc-tls
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip
CERT_RENEWAL_DNS_PROPAGATION_SECS=30 # wait after writing the TXT record
```

- the gRPC control channel itself (nullnet-client/nullnet-proxy ↔ nullnet-server) is TLS-only,
authenticated by a private CA. On first boot the server generates its own CA
(`members/nullnet-server/grpc-tls/ca-cert.pem` + `ca-key.pem`, created once and never
regenerated) and signs its leaf cert with it. Copy `ca-cert.pem` to every client/proxy host and
set `CONTROL_SERVICE_CA_CERT` there (see below) if it isn't at the default path — clients pin the
channel to that CA and do full standard chain validation, so only a leaf actually signed by it is
accepted. Because clients trust the stable CA root rather than the leaf, rotating the leaf later
needs no client-side changes. Client authentication (mTLS) remains a further follow-up.

Validation includes hostname matching, so the leaf's SAN must cover whatever host/IP clients use
as `CONTROL_SERVICE_ADDR`. It's derived in this order: `CONTROL_SERVICE_TLS_SAN`
(comma-separated, if set) → the server's own `CONTROL_SERVICE_ADDR` (if set — often already the
same address clients are told to connect to) → `localhost`. Set `CONTROL_SERVICE_TLS_SAN`
explicitly if the server's address isn't in its own `.env` or differs from what clients use.

- service configuration is split per **stack** — one TOML file per stack under
`members/nullnet-server/services/`. The filename (minus `.toml`) is the stack name.
For example, to define a stack called `my-app`, create `services/my-app.toml`:
Expand Down Expand Up @@ -182,7 +197,12 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip
```
CONTROL_SERVICE_ADDR=192.168.1.100
CONTROL_SERVICE_PORT=50051
CONTROL_SERVICE_CA_CERT=./ca-cert.pem # optional, defaults to ./ca-cert.pem
```
`CONTROL_SERVICE_CA_CERT` points at a copy of the server's own `grpc-tls/ca-cert.pem` (see the
server section above), used to pin and authenticate the control channel. If unset it defaults to
`ca-cert.pem` in the working directory; either way, startup fails if the file is missing or isn't
a valid cert.

- run the project as a daemon (from the repo root)
```
Expand All @@ -205,7 +225,12 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip
```
CONTROL_SERVICE_ADDR=192.168.1.100
CONTROL_SERVICE_PORT=50051
CONTROL_SERVICE_CA_CERT=./ca-cert.pem # optional, defaults to ./ca-cert.pem
```
`CONTROL_SERVICE_CA_CERT` points at a copy of the server's own `grpc-tls/ca-cert.pem` (see the
server section above), used to pin and authenticate the control channel. If unset it defaults to
`ca-cert.pem` in the working directory; either way, startup fails if the file is missing or isn't
a valid cert.

> **⚠️ The client attaches a default-deny eBPF firewall to the uplink NIC on startup.** It permits
> only the nullnet control plane (gRPC to the server), data plane (VXLAN to peers), established
Expand Down
9 changes: 9 additions & 0 deletions members/nullnet-client/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,12 @@ pub static CONTROL_SERVICE_PORT: std::sync::LazyLock<u16> = std::sync::LazyLock:

str.parse().unwrap_or(50051)
});

/// Path to the control server's private CA root (its `grpc-tls/ca-cert.pem`
/// — generated automatically on the server, copy it here) — pins the control
/// channel to that CA for full standard chain validation. Defaults to
/// `ca-cert.pem` (relative to the working directory) if unset; the connection
/// fails at startup if the file is missing or not a valid cert.
pub static CONTROL_SERVICE_CA_CERT: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
std::env::var("CONTROL_SERVICE_CA_CERT").unwrap_or_else(|_| "ca-cert.pem".to_string())
});
11 changes: 6 additions & 5 deletions members/nullnet-client/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::commands::{
RtNetLinkHandle, cleanup_network, find_ethernet_interface, find_ethernet_ip, setup_br0,
};
use crate::control_channel::control_channel;
use crate::env::{CONTROL_SERVICE_ADDR, CONTROL_SERVICE_PORT};
use crate::env::{CONTROL_SERVICE_ADDR, CONTROL_SERVICE_CA_CERT, CONTROL_SERVICE_PORT};
use crate::forward::receive::receive;
use crate::forward::send::send;
use crate::host_mappings::HostMappingsState;
Expand All @@ -20,6 +20,7 @@ use nullnet_grpc_lib::nullnet_grpc::{
};
use nullnet_liberror::{Error, ErrorHandler, Location, location};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use std::{panic, process};
Expand Down Expand Up @@ -280,10 +281,10 @@ fn resolve_server_ip() -> Option<std::net::Ipv4Addr> {
async fn grpc_init() -> Result<NullnetGrpcInterface, Error> {
let host = CONTROL_SERVICE_ADDR.to_string();
let port = *CONTROL_SERVICE_PORT;

let server = NullnetGrpcInterface::new(&host, port, false)
.await
.handle_err(location!())?;
let server =
NullnetGrpcInterface::new(&host, port, Path::new(CONTROL_SERVICE_CA_CERT.as_str()))
.await
.handle_err(location!())?;

Ok(server)
}
Expand Down
5 changes: 3 additions & 2 deletions members/nullnet-grpc-lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ keywords = ["firewall", "network", "application", "centralized", "monitor"]
categories = ["network-programming"]

[dependencies]
tonic = { workspace = true, features = ["_tls-any", "tls-native-roots"] }
tonic = { workspace = true, features = ["_tls-any"] }
prost.workspace = true
tonic-prost.workspace = true
tokio.workspace = true
tokio = { workspace = true, features = ["fs"] }
tokio-rustls = "0.26"
serde = { workspace = true, features = ["derive"] }

[build-dependencies]
Expand Down
96 changes: 96 additions & 0 deletions members/nullnet-grpc-lib/src/control_tls_verifier.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! Certificate verifier for the gRPC control channel: standard WebPKI chain
//! validation against a single pinned CA root — the server's own private CA
//! (see `nullnet-server`'s `grpc_tls.rs`). Only a leaf actually signed by
//! that CA, for the right host, passes.
use std::sync::Arc;
use tokio_rustls::rustls::client::WebPkiServerVerifier;
use tokio_rustls::rustls::client::danger::{
HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier,
};
use tokio_rustls::rustls::crypto::{
CryptoProvider, verify_tls12_signature, verify_tls13_signature,
};
use tokio_rustls::rustls::pki_types::pem::PemObject;
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::{DigitallySignedStruct, Error, RootCertStore, SignatureScheme};

/// The process's default crypto provider, falling back to aws-lc-rs (the
/// backend `tokio-rustls`'s default features pull in) if nothing has
/// installed one yet.
fn crypto_provider() -> Arc<CryptoProvider> {
CryptoProvider::get_default()
.cloned()
.unwrap_or_else(|| Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider()))
}

#[derive(Debug)]
pub(crate) struct PinnedCa {
webpki: Arc<WebPkiServerVerifier>,
provider: Arc<CryptoProvider>,
}

impl PinnedCa {
/// `ca_cert_pem`: the server's private CA root, PEM-encoded (its
/// `grpc-tls/ca-cert.pem`) — the only cert this verifier trusts.
pub(crate) fn verifier(ca_cert_pem: &[u8]) -> Result<Arc<dyn ServerCertVerifier>, String> {
let provider = crypto_provider();

let ca_cert = CertificateDer::from_pem_slice(ca_cert_pem).map_err(|e| e.to_string())?;
let mut roots = RootCertStore::empty();
roots.add(ca_cert).map_err(|e| e.to_string())?;

let webpki = WebPkiServerVerifier::builder_with_provider(Arc::new(roots), provider.clone())
.build()
.map_err(|e| e.to_string())?;

Ok(Arc::new(Self { webpki, provider }))
}
}

impl ServerCertVerifier for PinnedCa {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
server_name: &ServerName<'_>,
ocsp_response: &[u8],
now: UnixTime,
) -> Result<ServerCertVerified, Error> {
self.webpki
.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
}

fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}

fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}

fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.provider
.signature_verification_algorithms
.supported_schemes()
}
}
25 changes: 15 additions & 10 deletions members/nullnet-grpc-lib/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
mod control_tls_verifier;
mod proto;

use crate::control_tls_verifier::PinnedCa;
use crate::nullnet_grpc::nullnet_grpc_client::NullnetGrpcClient;
use crate::nullnet_grpc::{
AgentEvent, BackendTriggerRequest, CertBundle, EgressDestinationEntry, EgressDestinationReport,
EgressPolicyCheck, EgressTriggerRequest, Empty, IngressPolicyCheck, MsgId, NetMessage, NetType,
PortMappingBundle, ProxyRequest, ServiceReport, ServicesListResponse, Upstream,
};
pub use proto::*;
use std::path::Path;
use tokio::sync::mpsc;
use tonic::Request;
pub use tonic::Streaming;
Expand All @@ -19,25 +22,27 @@ pub struct NullnetGrpcInterface {
}

impl NullnetGrpcInterface {
/// Connects over TLS, trusting *only* `ca_cert` (PEM) — the server's
/// private CA root — for full standard chain validation, including
/// hostname matching (see `control_tls_verifier::PinnedCa`).
#[allow(clippy::missing_errors_doc)]
pub async fn new(host: &str, port: u16, tls: bool) -> Result<Self, String> {
let protocol = if tls { "https" } else { "http" };
pub async fn new(host: &str, port: u16, ca_cert: &Path) -> Result<Self, String> {
let ca_cert_pem = tokio::fs::read(ca_cert)
.await
.map_err(|e| format!("failed to read CA cert at {}: {e}", ca_cert.display()))?;
let verifier = PinnedCa::verifier(&ca_cert_pem)?;

// Keepalive so a dead/unreachable server breaks open streams within
// ~30s (PING every 10s, drop if unacked for 20s) instead of hanging.
// Consumers rely on the stream erroring out to exit and be restarted.
let mut endpoint = Channel::from_shared(format!("{protocol}://{host}:{port}"))
let endpoint = Channel::from_shared(format!("https://{host}:{port}"))
.map_err(|e| e.to_string())?
.connect_timeout(std::time::Duration::from_secs(10))
.http2_keep_alive_interval(std::time::Duration::from_secs(10))
.keep_alive_timeout(std::time::Duration::from_secs(20))
.keep_alive_while_idle(true);

if tls {
endpoint = endpoint
.tls_config(ClientTlsConfig::new().with_native_roots())
.map_err(|e| e.to_string())?;
}
.keep_alive_while_idle(true)
.tls_config_with_verifier(ClientTlsConfig::new(), verifier)
.map_err(|e| e.to_string())?;

loop {
if let Ok(channel) = endpoint.connect().await {
Expand Down
9 changes: 9 additions & 0 deletions members/nullnet-proxy/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,12 @@ pub static CONTROL_SERVICE_PORT: std::sync::LazyLock<u16> = std::sync::LazyLock:

str.parse().unwrap_or(50051)
});

/// Path to the control server's private CA root (its `grpc-tls/ca-cert.pem`
/// — generated automatically on the server, copy it here) — pins the control
/// channel to that CA for full standard chain validation. Defaults to
/// `ca-cert.pem` (relative to the working directory) if unset; the connection
/// fails at startup if the file is missing or not a valid cert.
pub static CONTROL_SERVICE_CA_CERT: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
std::env::var("CONTROL_SERVICE_CA_CERT").unwrap_or_else(|_| "ca-cert.pem".to_string())
});
15 changes: 6 additions & 9 deletions members/nullnet-proxy/src/nullnet_proxy.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::env::{CONTROL_SERVICE_ADDR, CONTROL_SERVICE_PORT};
use crate::env::{CONTROL_SERVICE_ADDR, CONTROL_SERVICE_CA_CERT, CONTROL_SERVICE_PORT};
use crate::tls::CertStore;
use arc_swap::ArcSwap;
use nullnet_grpc_lib::NullnetGrpcInterface;
Expand All @@ -7,6 +7,7 @@ use nullnet_grpc_lib::nullnet_grpc::{
};
use nullnet_liberror::{Error, ErrorHandler, Location, location};
use std::net::{IpAddr, SocketAddr};
use std::path::Path;
use std::sync::Arc;

#[derive(Clone)]
Expand All @@ -20,14 +21,10 @@ impl NullnetProxy {
pub async fn new(certs: Arc<ArcSwap<CertStore>>) -> Result<Self, Error> {
let host = CONTROL_SERVICE_ADDR.to_string();
let port = *CONTROL_SERVICE_PORT;

// TODO(grpc-tls): connecting with tls = false. Cert private keys are
// delivered over this channel via WatchCertificates, so enable TLS
// (tls = true) once the control service serves gRPC over TLS, so keys
// never travel in the clear.
let server = NullnetGrpcInterface::new(&host, port, false)
.await
.handle_err(location!())?;
let server =
NullnetGrpcInterface::new(&host, port, Path::new(CONTROL_SERVICE_CA_CERT.as_str()))
.await
.handle_err(location!())?;

Ok(Self {
server,
Expand Down
2 changes: 1 addition & 1 deletion members/nullnet-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ rust-embed = { version = "8", features = ["axum"] }
mime_guess = "2"
aes-gcm = "0.10"
base64 = "0.22"
x509-parser = "0.16"
x509-parser = { version = "0.16", features = ["verify"] }
# ACME (Let's Encrypt) cert issuance via DNS-01, ported from ../routix
anyhow = "1.0"
instant-acme = { version = "0.8", features = ["hyper-rustls"] }
Expand Down
Loading