Skip to main content

basil_core/transport/
grpc_server.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! tonic server wiring for the broker gRPC API.
6
7use std::future::Future;
8use std::io;
9use std::os::unix::fs::PermissionsExt as _;
10use std::path::Path;
11use std::sync::Arc;
12
13use basil_proto::broker::v1::admin_service_server::AdminServiceServer;
14use basil_proto::broker::v1::aead_service_server::AeadServiceServer;
15use basil_proto::broker::v1::invocation_service_server::InvocationServiceServer;
16use basil_proto::broker::v1::minting_service_server::MintingServiceServer;
17use basil_proto::broker::v1::nats_service_server::NatsServiceServer;
18use basil_proto::broker::v1::secret_service_server::SecretServiceServer;
19use basil_proto::broker::v1::signing_service_server::SigningServiceServer;
20use basil_proto::envoy::service::secret::v3::secret_discovery_service_server::SecretDiscoveryServiceServer;
21use basil_proto::spiffe::spiffe_workload_api_server::SpiffeWorkloadApiServer;
22use tokio::net::UnixListener;
23use tokio::signal::unix::{SignalKind, signal};
24use tokio_stream::wrappers::UnixListenerStream;
25use tonic::transport::Server;
26use tracing::{info, warn};
27
28use crate::grpc::BrokerGrpc;
29use crate::sds::EnvoySdsGrpc;
30use crate::service::broker::InvocationRuntimeConfig;
31use crate::spiffe::SpiffeWorkloadGrpc;
32use crate::state::BrokerState;
33
34/// Default Unix socket mode: owner read/write only.
35pub const DEFAULT_SOCKET_MODE: u32 = 0o600;
36
37/// Runtime configuration for the gRPC listener.
38#[derive(Debug, Clone)]
39pub struct ServerConfig {
40    /// Path to bind the listening Unix socket at.
41    pub socket_path: String,
42    /// File mode to apply to the listening Unix socket after bind.
43    pub socket_mode: u32,
44    /// Group name or numeric gid to apply to the listening Unix socket.
45    pub socket_group: Option<String>,
46    /// Runtime settings for the sealed invocation service.
47    pub invocation: InvocationRuntimeConfig,
48}
49
50/// Bind a Unix socket and serve the broker gRPC services until shutdown.
51///
52/// Registers all broker services, the SPIFFE Workload API, and Envoy SDS on one
53/// tonic server.
54pub async fn run(config: ServerConfig, state: Arc<BrokerState>) -> std::io::Result<()> {
55    serve_with_shutdown(config, state, shutdown_signal()).await
56}
57
58async fn serve_with_shutdown(
59    config: ServerConfig,
60    state: Arc<BrokerState>,
61    shutdown: impl Future<Output = ()>,
62) -> std::io::Result<()> {
63    let path = config.socket_path;
64    if Path::new(&path).exists() {
65        std::fs::remove_file(&path)?;
66        warn!(%path, "removed stale socket");
67    }
68
69    let listener = bind_restricted(&path)?;
70    apply_socket_permissions(&path, config.socket_mode, config.socket_group.as_deref())?;
71
72    info!(
73        %path,
74        mode = %format_socket_mode(config.socket_mode),
75        group = ?config.socket_group,
76        backend = state.backend_label(),
77        "basil gRPC agent listening"
78    );
79    let incoming = UnixListenerStream::new(listener);
80    let broker = BrokerGrpc::new_with_invocation_config(state.clone(), config.invocation);
81
82    let server = Server::builder()
83        .add_service(InvocationServiceServer::new(broker.clone()))
84        .add_service(SigningServiceServer::new(broker.clone()))
85        .add_service(AeadServiceServer::new(broker.clone()))
86        .add_service(SecretServiceServer::new(broker.clone()))
87        .add_service(MintingServiceServer::new(broker.clone()))
88        .add_service(NatsServiceServer::new(broker.clone()))
89        .add_service(AdminServiceServer::new(broker));
90    let server = server
91        .add_service(SpiffeWorkloadApiServer::new(SpiffeWorkloadGrpc::new(
92            state.clone(),
93        )))
94        .add_service(SecretDiscoveryServiceServer::new(EnvoySdsGrpc::new(state)));
95    let result = server
96        .serve_with_incoming_shutdown(incoming, shutdown)
97        .await;
98
99    if let Err(e) = std::fs::remove_file(&path)
100        && e.kind() != std::io::ErrorKind::NotFound
101    {
102        warn!(?e, %path, "could not remove socket on shutdown");
103    }
104
105    result.map_err(std::io::Error::other)
106}
107
108/// Bind the listening Unix socket with the process umask tightened to `0o177`,
109/// so the socket node is created owner-only no matter how loose the inherited
110/// umask is. The listen backlog is live from `bind`, so the mode must be
111/// restrictive *at creation*; the later [`apply_socket_permissions`] can only
112/// widen it to the configured mode/group (never leaves a permissive window).
113fn bind_restricted(path: &str) -> io::Result<UnixListener> {
114    let inherited = rustix::process::umask(rustix::fs::Mode::from_raw_mode(0o177));
115    let listener = UnixListener::bind(path);
116    rustix::process::umask(inherited);
117    listener
118}
119
120fn apply_socket_permissions(path: &str, mode: u32, group: Option<&str>) -> io::Result<()> {
121    if let Some(group) = group {
122        let gid = resolve_group(group)?;
123        std::os::unix::fs::chown(path, None, Some(gid))?;
124    }
125    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
126}
127
128fn resolve_group(group: &str) -> io::Result<u32> {
129    if let Ok(gid) = group.parse::<u32>() {
130        return Ok(gid);
131    }
132    resolve_group_from(group, "/etc/group")
133}
134
135fn resolve_group_from(group: &str, group_file: impl AsRef<Path>) -> io::Result<u32> {
136    let body = std::fs::read_to_string(group_file)?;
137    for line in body.lines() {
138        let mut fields = line.split(':');
139        let Some(name) = fields.next() else {
140            continue;
141        };
142        if name != group {
143            continue;
144        }
145        let _passwd = fields.next();
146        let Some(gid) = fields.next() else {
147            break;
148        };
149        return gid.parse::<u32>().map_err(|err| {
150            io::Error::new(
151                io::ErrorKind::InvalidData,
152                format!("group `{group}` has invalid gid `{gid}`: {err}"),
153            )
154        });
155    }
156    Err(io::Error::new(
157        io::ErrorKind::NotFound,
158        format!("group `{group}` not found"),
159    ))
160}
161
162fn format_socket_mode(mode: u32) -> String {
163    format!("{mode:04o}")
164}
165
166async fn shutdown_signal() {
167    let mut int = signal(SignalKind::interrupt()).ok();
168    let mut quit = signal(SignalKind::quit()).ok();
169    let mut term = signal(SignalKind::terminate()).ok();
170
171    tokio::select! {
172        () = async {
173            if let Some(sig) = int.as_mut() {
174                sig.recv().await;
175            } else {
176                std::future::pending::<()>().await;
177            }
178        } => {}
179        () = async {
180            if let Some(sig) = quit.as_mut() {
181                sig.recv().await;
182            } else {
183                std::future::pending::<()>().await;
184            }
185        } => {}
186        () = async {
187            if let Some(sig) = term.as_mut() {
188                sig.recv().await;
189            } else {
190                std::future::pending::<()>().await;
191            }
192        } => {}
193    }
194}
195
196#[cfg(test)]
197#[allow(clippy::significant_drop_tightening)]
198mod tests {
199    use std::collections::BTreeMap;
200    use std::path::{Path, PathBuf};
201    use std::sync::Arc;
202    use std::time::Duration;
203
204    use async_trait::async_trait;
205    use basil::Client;
206    use basil_proto::KeyType;
207    use basil_proto::broker::v1::SealedRequest;
208    use basil_proto::broker::v1::StatusRequest;
209    use basil_proto::broker::v1::admin_service_client::AdminServiceClient;
210    use basil_proto::broker::v1::invocation_service_client::InvocationServiceClient;
211    use basil_proto::envoy::service::discovery::v3::DiscoveryRequest;
212    use basil_proto::envoy::service::secret::v3::secret_discovery_service_client::SecretDiscoveryServiceClient;
213    use basil_proto::spiffe::X509BundlesRequest;
214    use basil_proto::spiffe::spiffe_workload_api_client::SpiffeWorkloadApiClient;
215    use hyper_util::rt::TokioIo;
216    use tokio::net::UnixStream;
217    use tokio::sync::oneshot;
218    use tonic::Code;
219    use tonic::Request;
220    use tonic::metadata::MetadataValue;
221    use tonic::transport::{Channel, Endpoint, Uri};
222    use tower::service_fn;
223
224    use super::*;
225    use crate::backend::{Backend, BackendError, NewKey};
226    use crate::catalog::load;
227    use crate::manager::BackendManager;
228
229    struct DummyBackend;
230
231    #[async_trait]
232    impl Backend for DummyBackend {
233        fn kind(&self) -> &'static str {
234            "dummy"
235        }
236
237        async fn new_key(&self, key_type: KeyType) -> Result<NewKey, BackendError> {
238            let _ = key_type;
239            Err(BackendError::Unsupported("new_key"))
240        }
241
242        async fn public_key(&self, key_id: &str) -> Result<Vec<u8>, BackendError> {
243            let _ = key_id;
244            Err(BackendError::Unsupported("public_key"))
245        }
246
247        async fn sign(&self, key_id: &str, message: &[u8]) -> Result<Vec<u8>, BackendError> {
248            let _ = (key_id, message);
249            Err(BackendError::Unsupported("sign"))
250        }
251
252        async fn verify(
253            &self,
254            key_id: &str,
255            message: &[u8],
256            signature: &[u8],
257        ) -> Result<bool, BackendError> {
258            let _ = (key_id, message, signature);
259            Err(BackendError::Unsupported("verify"))
260        }
261    }
262
263    fn state() -> Arc<BrokerState> {
264        let catalog = r#"{
265          "schemaVersion": 1,
266          "backends": { "bao": { "kind": "vault", "addr": "https://127.0.0.1:8200" } },
267          "keys": {}
268        }"#;
269        // These tests exercise transport wiring over a real UDS, so the peer uid
270        // the kernel reports is this test process's own; register it as a policy
271        // subject so the `status` canary RPC (which requires a resolved subject)
272        // answers.
273        let uid = rustix::process::getuid().as_raw();
274        let policy = format!(
275            r#"{{
276              "schemaVersion": 2,
277              "subjects": {{ "test.peer": {{ "allOf": [ {{ "kind": "unix", "uid": {uid} }} ] }} }},
278              "roles": {{}},
279              "rules": [],
280              "config": {{
281                "names": {{ "users": {{ "{uid}": "test-peer" }}, "groups": {{}} }},
282                "memberships": {{ "{uid}": [{uid}] }}
283              }}
284            }}"#
285        );
286        let (catalog, policy, config, warnings) = load(catalog, &policy).expect("fixture loads");
287        assert!(warnings.is_empty());
288        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
289        backends.insert("bao".to_string(), Box::new(DummyBackend));
290        let manager = BackendManager::new(catalog.clone(), backends).expect("manager builds");
291        Arc::new(BrokerState::new(catalog, policy, config, manager, "dummy"))
292    }
293
294    async fn spawn_server(socket: PathBuf) -> oneshot::Sender<()> {
295        let (tx, rx) = oneshot::channel();
296        let config = ServerConfig {
297            socket_path: socket.to_string_lossy().into_owned(),
298            socket_mode: DEFAULT_SOCKET_MODE,
299            socket_group: None,
300            invocation: InvocationRuntimeConfig::default(),
301        };
302        tokio::spawn(async move {
303            serve_with_shutdown(config, state(), async {
304                let _ = rx.await;
305            })
306            .await
307            .expect("server exits cleanly");
308        });
309        wait_for_socket(&socket).await;
310        tx
311    }
312
313    async fn wait_for_socket(socket: &Path) {
314        for _ in 0..100 {
315            if socket.exists() {
316                return;
317            }
318            tokio::time::sleep(Duration::from_millis(10)).await;
319        }
320        panic!("server socket did not appear: {}", socket.display());
321    }
322
323    fn socket_path(name: &str) -> PathBuf {
324        std::env::temp_dir().join(format!("basil-{name}-{}.sock", uuid::Uuid::new_v4()))
325    }
326
327    #[tokio::test]
328    async fn socket_is_owner_only_at_bind_even_under_a_loose_umask() {
329        // Loosen the process umask: without the tightened bind the socket node
330        // would be group/world-accessible for the instant before the explicit
331        // chmod, with the listen backlog already live.
332        let inherited = rustix::process::umask(rustix::fs::Mode::empty());
333        let socket = socket_path("umask");
334        let listener = bind_restricted(&socket.to_string_lossy()).expect("binds");
335        rustix::process::umask(inherited);
336
337        let mode = std::fs::metadata(&socket)
338            .expect("socket metadata")
339            .permissions()
340            .mode()
341            & 0o777;
342        assert_eq!(mode, 0o600, "socket must be owner-only at bind");
343        drop(listener);
344        let _ = std::fs::remove_file(&socket);
345    }
346
347    #[test]
348    fn group_resolution_accepts_numeric_gid_and_group_file_name() {
349        assert_eq!(resolve_group("4242").expect("numeric gid"), 4242);
350        let path = std::env::temp_dir().join(format!(
351            "basil-group-test-{}-{}",
352            std::process::id(),
353            uuid::Uuid::new_v4()
354        ));
355        std::fs::write(&path, "root:x:0:\nbasil-edge:x:9876:edge\n").expect("write group fixture");
356        assert_eq!(
357            resolve_group_from("basil-edge", &path).expect("named group"),
358            9876
359        );
360        let err = resolve_group_from("missing", &path).expect_err("missing group");
361        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
362        std::fs::remove_file(path).expect("remove group fixture");
363    }
364
365    #[tokio::test]
366    async fn configured_socket_mode_is_applied_before_serving() {
367        let socket = socket_path("mode");
368        let (tx, rx) = oneshot::channel();
369        let config = ServerConfig {
370            socket_path: socket.to_string_lossy().into_owned(),
371            socket_mode: 0o660,
372            socket_group: None,
373            invocation: InvocationRuntimeConfig::default(),
374        };
375        tokio::spawn(async move {
376            serve_with_shutdown(config, state(), async {
377                let _ = rx.await;
378            })
379            .await
380            .expect("server exits cleanly");
381        });
382        wait_for_socket(&socket).await;
383        let mode = std::fs::metadata(&socket)
384            .expect("stat socket")
385            .permissions()
386            .mode()
387            & 0o777;
388        assert_eq!(mode, 0o660);
389        let _ = tx.send(());
390    }
391
392    #[test]
393    fn active_runtime_sources_have_no_legacy_json_wire_symbols() {
394        let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
395        let banned = [
396            ["json", "_codec"].concat(),
397            ["Client", "Request"].concat(),
398            ["Client", "Response"].concat(),
399            ["core", "::", "handler"].concat(),
400            ["core", "::", "server"].concat(),
401        ];
402        let mut stack = vec![src];
403        while let Some(path) = stack.pop() {
404            for entry in std::fs::read_dir(&path).expect("source directory readable") {
405                let entry = entry.expect("source entry readable");
406                let path = entry.path();
407                if path.is_dir() {
408                    stack.push(path);
409                    continue;
410                }
411                if path.extension().and_then(std::ffi::OsStr::to_str) != Some("rs") {
412                    continue;
413                }
414                let source = std::fs::read_to_string(&path).expect("source file readable");
415                for needle in &banned {
416                    assert!(
417                        !source.contains(needle),
418                        "legacy JSON wire symbol `{needle}` remains in {}",
419                        path.display()
420                    );
421                }
422            }
423        }
424    }
425
426    async fn uds_channel(path: &Path) -> Channel {
427        let path = path.to_path_buf();
428        Endpoint::try_from("http://[::]:50051")
429            .expect("endpoint")
430            .connect_with_connector(service_fn(move |_: Uri| {
431                let path = path.clone();
432                async move { UnixStream::connect(path).await.map(TokioIo::new) }
433            }))
434            .await
435            .expect("connect")
436    }
437
438    #[tokio::test]
439    async fn broker_grpc_serves_status_on_unix_socket() {
440        let socket = socket_path("broker-only");
441        let shutdown = spawn_server(socket.clone()).await;
442        {
443            let mut client = Client::connect(socket.to_str().expect("utf8 path"))
444                .await
445                .expect("broker client connects");
446            let status = client.status().await.expect("status");
447            assert_eq!(status.backend, "dummy");
448            assert_eq!(status.protocol, 1);
449        }
450        let _ = shutdown.send(());
451    }
452
453    #[tokio::test]
454    async fn invocation_service_is_registered_but_disabled_by_default() {
455        let socket = socket_path("invocation-disabled");
456        let shutdown = spawn_server(socket.clone()).await;
457
458        let channel = uds_channel(&socket).await;
459        let mut invocation = InvocationServiceClient::new(channel);
460        let status = invocation
461            .invoke(SealedRequest::default())
462            .await
463            .expect_err("invocation is disabled by default");
464        assert_eq!(status.code(), Code::FailedPrecondition);
465
466        let mut broker = Client::connect(socket.to_str().expect("utf8 path"))
467            .await
468            .expect("broker client still connects");
469        let status = broker
470            .status()
471            .await
472            .expect("typed status remains available");
473        assert_eq!(status.protocol, 1);
474
475        let _ = shutdown.send(());
476    }
477    #[tokio::test]
478    async fn broker_and_spiffe_services_share_one_unix_socket() {
479        let socket = socket_path("broker-spiffe");
480        let shutdown = spawn_server(socket.clone()).await;
481
482        {
483            let mut broker = Client::connect(socket.to_str().expect("utf8 path"))
484                .await
485                .expect("broker client connects");
486            let status = broker.status().await.expect("status");
487            assert_eq!(status.backend, "dummy");
488            assert_eq!(status.protocol, 1);
489        }
490
491        {
492            let channel = uds_channel(&socket).await;
493            let mut broker = AdminServiceClient::new(channel.clone());
494            let mut request = Request::new(StatusRequest {});
495            request
496                .metadata_mut()
497                .insert("workload.spiffe.io", "true".parse().expect("metadata"));
498            let status = broker
499                .status(request)
500                .await
501                .expect("broker RPC ignores Workload API metadata")
502                .into_inner();
503            assert_eq!(status.backend, "dummy");
504            assert_eq!(status.protocol, 1);
505        }
506
507        {
508            let channel = uds_channel(&socket).await;
509            let mut spiffe = SpiffeWorkloadApiClient::new(channel);
510            let status = spiffe
511                .fetch_x509_bundles(X509BundlesRequest {})
512                .await
513                .expect_err("registered SPIFFE service rejects missing workload header");
514            assert_eq!(status.code(), Code::InvalidArgument);
515            assert_eq!(
516                status.message(),
517                "SPIFFE Workload API requests require workload.spiffe.io=true"
518            );
519        }
520
521        {
522            let channel = uds_channel(&socket).await;
523            let mut spiffe = SpiffeWorkloadApiClient::new(channel);
524            let mut request = Request::new(X509BundlesRequest {});
525            request
526                .metadata_mut()
527                .append("workload.spiffe.io", "true".parse().expect("metadata"));
528            request
529                .metadata_mut()
530                .append("workload.spiffe.io", "false".parse().expect("metadata"));
531            let status = spiffe
532                .fetch_x509_bundles(request)
533                .await
534                .expect_err("duplicate Workload API metadata is fail-closed");
535            assert_eq!(status.code(), Code::InvalidArgument);
536        }
537
538        {
539            let channel = uds_channel(&socket).await;
540            let mut spiffe = SpiffeWorkloadApiClient::new(channel);
541            let mut request = Request::new(X509BundlesRequest {});
542            request
543                .metadata_mut()
544                .insert_bin("workload.spiffe.io-bin", MetadataValue::from_bytes(b"true"));
545            let status = spiffe
546                .fetch_x509_bundles(request)
547                .await
548                .expect_err("binary Workload API metadata is fail-closed");
549            assert_eq!(status.code(), Code::InvalidArgument);
550        }
551
552        {
553            let channel = uds_channel(&socket).await;
554            let mut sds = SecretDiscoveryServiceClient::new(channel);
555            let status = sds
556                .fetch_secrets(DiscoveryRequest {
557                    version_info: String::new(),
558                    node: None,
559                    resource_names: vec!["default".to_string()],
560                    type_url: crate::sds::SECRET_TYPE_URL.to_string(),
561                    response_nonce: String::new(),
562                    error_detail: None,
563                })
564                .await
565                .expect_err("registered SDS service has no configured resources");
566            assert_eq!(status.code(), Code::NotFound);
567        }
568        let _ = shutdown.send(());
569    }
570}