Headless Rust gRPC daemon to drive Bluetooth, HLS/DASH playback, and snapcast/shairport-sync/squeezelite on Raspberry Pi audio rigs.
1//! Tonic server wiring. `serve(settings)` boots all services on a single port.
2
3mod bluetooth;
4mod config;
5mod settings;
6mod stream;
7mod system;
8mod systemd;
9mod volume;
10
11pub use settings::{load_settings, MdnsSettings, Settings};
12
13use anyhow::{Context, Result};
14use std::net::SocketAddr;
15use std::sync::Arc;
16use tonic::transport::Server;
17use zerod_proto::v1alpha1::{
18 bluetooth_service_server::BluetoothServiceServer,
19 config_service_server::ConfigServiceServer, stream_service_server::StreamServiceServer,
20 system_service_server::SystemServiceServer, systemd_service_server::SystemdServiceServer,
21 volume_service_server::VolumeServiceServer,
22};
23
24pub async fn serve(settings: Settings) -> Result<()> {
25 let addr: SocketAddr = settings
26 .server
27 .bind
28 .parse()
29 .with_context(|| format!("parse bind {}", settings.server.bind))?;
30
31 let allow = Arc::new(settings.systemd_allowlist());
32 let registry = Arc::new(settings.config_registry());
33 let bearer = resolve_bearer_token(&settings.server.bearer_token)?;
34
35 let _mdns = maybe_advertise(&settings.mdns, addr.port());
36
37 let reflection = tonic_reflection::server::Builder::configure()
38 .register_encoded_file_descriptor_set(zerod_proto::FILE_DESCRIPTOR_SET)
39 .build_v1()
40 .context("build reflection service")?;
41
42 let interceptor = bearer_interceptor(bearer);
43
44 tracing::info!(
45 "zerod gRPC listening on {} ({} systemd unit(s), {} managed config(s))",
46 addr,
47 allow.units.len(),
48 registry.list().len(),
49 );
50 for u in &allow.units {
51 tracing::info!(" systemd allowlist: {}", u);
52 }
53 for c in registry.list() {
54 tracing::info!(" config: {} → {} (unit={})", c.key, c.path.display(), c.unit);
55 }
56 Server::builder()
57 .add_service(reflection)
58 .add_service(SystemServiceServer::with_interceptor(
59 system::SystemSvc::default(),
60 interceptor.clone(),
61 ))
62 .add_service(BluetoothServiceServer::with_interceptor(
63 bluetooth::BluetoothSvc::default(),
64 interceptor.clone(),
65 ))
66 .add_service(StreamServiceServer::with_interceptor(
67 stream::StreamSvc::default(),
68 interceptor.clone(),
69 ))
70 .add_service(SystemdServiceServer::with_interceptor(
71 systemd::SystemdSvc::new(allow.clone()),
72 interceptor.clone(),
73 ))
74 .add_service(ConfigServiceServer::with_interceptor(
75 config::ConfigSvc::new(registry, allow),
76 interceptor.clone(),
77 ))
78 .add_service(VolumeServiceServer::with_interceptor(
79 volume::VolumeSvc::default(),
80 interceptor,
81 ))
82 .serve(addr)
83 .await
84 .context("tonic serve")?;
85 Ok(())
86}
87
88/// Bearer-token check applied to every RPC. When `token` is empty, the
89/// interceptor is a no-op.
90fn bearer_interceptor(
91 token: String,
92) -> impl tonic::service::Interceptor + Clone {
93 move |req: tonic::Request<()>| -> Result<tonic::Request<()>, tonic::Status> {
94 if token.is_empty() {
95 return Ok(req);
96 }
97 let expected = format!("Bearer {token}");
98 match req.metadata().get("authorization") {
99 Some(v) if v.to_str().map(|s| s == expected).unwrap_or(false) => Ok(req),
100 _ => {
101 tracing::warn!("rejected request: invalid or missing bearer token");
102 Err(tonic::Status::unauthenticated("invalid bearer token"))
103 }
104 }
105 }
106}
107
108/// Resolve the bearer token used by the server. Precedence:
109/// 1. `settings.server.bearer_token` (from zerod.toml)
110/// 2. `ZEROD_BEARER_TOKEN` env var
111/// 3. Generate a random 32-byte token and log it once at startup.
112fn resolve_bearer_token(from_settings: &str) -> Result<String> {
113 if !from_settings.is_empty() {
114 tracing::info!("auth: using bearer token from zerod.toml");
115 return Ok(from_settings.to_string());
116 }
117 if let Ok(t) = std::env::var("ZEROD_BEARER_TOKEN") {
118 if !t.is_empty() {
119 tracing::info!("auth: using bearer token from ZEROD_BEARER_TOKEN");
120 return Ok(t);
121 }
122 }
123 let token = random_hex_token()?;
124 tracing::warn!(
125 "auth: no bearer token configured — generated one for this run. \
126 Set ZEROD_BEARER_TOKEN or zerod.toml [server].bearer_token to suppress this."
127 );
128 tracing::warn!("auth: BEARER TOKEN = {}", token);
129 Ok(token)
130}
131
132/// Returns the live [`Advertisement`] so the caller keeps it alive for the
133/// daemon's lifetime — dropping it tears down the mDNS registration.
134fn maybe_advertise(mdns: &MdnsSettings, port: u16) -> Option<zerod_discovery::Advertisement> {
135 if !mdns.enabled {
136 tracing::info!("mDNS: disabled in zerod.toml");
137 return None;
138 }
139 let name = if mdns.name.is_empty() {
140 hostname_fallback()
141 } else {
142 mdns.name.clone()
143 };
144 let version = env!("CARGO_PKG_VERSION").to_string();
145 let txt = vec![("version".to_string(), version)];
146 match zerod_discovery::advertise(&name, port, &txt) {
147 Ok(adv) => Some(adv),
148 Err(e) => {
149 tracing::warn!("mDNS: advertise failed: {e:#}");
150 None
151 }
152 }
153}
154
155fn hostname_fallback() -> String {
156 let raw = gethostname::gethostname().to_string_lossy().into_owned();
157 // Some systems return "host.local" — strip a single trailing .local to
158 // keep the mDNS instance name clean.
159 let trimmed = raw.strip_suffix(".local").unwrap_or(&raw);
160 if trimmed.is_empty() {
161 "zerod".to_string()
162 } else {
163 trimmed.to_string()
164 }
165}
166
167fn random_hex_token() -> Result<String> {
168 let mut buf = [0u8; 32];
169 getrandom::getrandom(&mut buf).context("getrandom for bearer token")?;
170 let mut s = String::with_capacity(buf.len() * 2);
171 for b in buf {
172 use std::fmt::Write;
173 write!(&mut s, "{b:02x}").unwrap();
174 }
175 Ok(s)
176}