Headless Rust gRPC daemon to drive Bluetooth, HLS/DASH playback, and snapcast/shairport-sync/squeezelite on Raspberry Pi audio rigs.
0

Configure Feed

Select the types of activity you want to include in your feed.

zerod / crates / server / src / lib.rs
6.0 kB 176 lines
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}