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.

Add mDNS discovery so --host is optional

Server advertises `_zerod._tcp.local.` via a new pure-Rust `zerod-discovery`
crate wrapping mdns-sd. Clients omit `--host` and the binary browses the
LAN for ~1.5s; multiple responders error out with a listing, `--name`
(or `ZEROD_NAME`) filters. `zerod discover` lists every responder.

IPv4 picker skips loopback and Docker's 172.17.0.0/16 default bridge so
the wrong interface doesn't leak into the URL.

`[mdns] enabled / name` in zerod.toml; instance name defaults to
gethostname().

+520 -52
+105 -7
Cargo.lock
··· 141 141 "futures-lite", 142 142 "parking", 143 143 "polling", 144 - "rustix", 144 + "rustix 1.1.4", 145 145 "slab", 146 146 "windows-sys 0.61.2", 147 147 ] ··· 172 172 "cfg-if", 173 173 "event-listener", 174 174 "futures-lite", 175 - "rustix", 175 + "rustix 1.1.4", 176 176 ] 177 177 178 178 [[package]] ··· 198 198 "cfg-if", 199 199 "futures-core", 200 200 "futures-io", 201 - "rustix", 201 + "rustix 1.1.4", 202 202 "signal-hook-registry", 203 203 "slab", 204 204 "windows-sys 0.61.2", ··· 963 963 checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" 964 964 965 965 [[package]] 966 + name = "flume" 967 + version = "0.11.1" 968 + source = "registry+https://github.com/rust-lang/crates.io-index" 969 + checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" 970 + dependencies = [ 971 + "futures-core", 972 + "futures-sink", 973 + "spin", 974 + ] 975 + 976 + [[package]] 966 977 name = "fnv" 967 978 version = "1.0.7" 968 979 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1101 1112 dependencies = [ 1102 1113 "typenum", 1103 1114 "version_check", 1115 + ] 1116 + 1117 + [[package]] 1118 + name = "gethostname" 1119 + version = "0.5.0" 1120 + source = "registry+https://github.com/rust-lang/crates.io-index" 1121 + checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" 1122 + dependencies = [ 1123 + "rustix 0.38.44", 1124 + "windows-targets 0.52.6", 1104 1125 ] 1105 1126 1106 1127 [[package]] ··· 1472 1493 ] 1473 1494 1474 1495 [[package]] 1496 + name = "if-addrs" 1497 + version = "0.15.0" 1498 + source = "registry+https://github.com/rust-lang/crates.io-index" 1499 + checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" 1500 + dependencies = [ 1501 + "libc", 1502 + "windows-sys 0.61.2", 1503 + ] 1504 + 1505 + [[package]] 1475 1506 name = "indexmap" 1476 1507 version = "1.9.3" 1477 1508 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1643 1674 1644 1675 [[package]] 1645 1676 name = "linux-raw-sys" 1677 + version = "0.4.15" 1678 + source = "registry+https://github.com/rust-lang/crates.io-index" 1679 + checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 1680 + 1681 + [[package]] 1682 + name = "linux-raw-sys" 1646 1683 version = "0.12.1" 1647 1684 source = "registry+https://github.com/rust-lang/crates.io-index" 1648 1685 checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" ··· 1715 1752 checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" 1716 1753 1717 1754 [[package]] 1755 + name = "mdns-sd" 1756 + version = "0.20.0" 1757 + source = "registry+https://github.com/rust-lang/crates.io-index" 1758 + checksum = "892f96f6d2ebe1ea641279f986ac52a2a6bac71e8f743bb258315cfe2bd7e88e" 1759 + dependencies = [ 1760 + "fastrand", 1761 + "flume", 1762 + "if-addrs", 1763 + "log", 1764 + "mio", 1765 + "socket-pktinfo", 1766 + "socket2 0.6.4", 1767 + ] 1768 + 1769 + [[package]] 1718 1770 name = "memchr" 1719 1771 version = "2.8.1" 1720 1772 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1748 1800 checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" 1749 1801 dependencies = [ 1750 1802 "libc", 1803 + "log", 1751 1804 "wasi", 1752 1805 "windows-sys 0.61.2", 1753 1806 ] ··· 2025 2078 "concurrent-queue", 2026 2079 "hermit-abi", 2027 2080 "pin-project-lite", 2028 - "rustix", 2081 + "rustix 1.1.4", 2029 2082 "windows-sys 0.61.2", 2030 2083 ] 2031 2084 ··· 2396 2449 2397 2450 [[package]] 2398 2451 name = "rustix" 2452 + version = "0.38.44" 2453 + source = "registry+https://github.com/rust-lang/crates.io-index" 2454 + checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 2455 + dependencies = [ 2456 + "bitflags 2.13.0", 2457 + "errno", 2458 + "libc", 2459 + "linux-raw-sys 0.4.15", 2460 + "windows-sys 0.59.0", 2461 + ] 2462 + 2463 + [[package]] 2464 + name = "rustix" 2399 2465 version = "1.1.4" 2400 2466 source = "registry+https://github.com/rust-lang/crates.io-index" 2401 2467 checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" ··· 2403 2469 "bitflags 2.13.0", 2404 2470 "errno", 2405 2471 "libc", 2406 - "linux-raw-sys", 2472 + "linux-raw-sys 0.12.1", 2407 2473 "windows-sys 0.61.2", 2408 2474 ] 2409 2475 ··· 2716 2782 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 2717 2783 2718 2784 [[package]] 2785 + name = "socket-pktinfo" 2786 + version = "0.3.2" 2787 + source = "registry+https://github.com/rust-lang/crates.io-index" 2788 + checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" 2789 + dependencies = [ 2790 + "libc", 2791 + "socket2 0.6.4", 2792 + "windows-sys 0.60.2", 2793 + ] 2794 + 2795 + [[package]] 2719 2796 name = "socket2" 2720 2797 version = "0.5.10" 2721 2798 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2733 2810 dependencies = [ 2734 2811 "libc", 2735 2812 "windows-sys 0.61.2", 2813 + ] 2814 + 2815 + [[package]] 2816 + name = "spin" 2817 + version = "0.9.8" 2818 + source = "registry+https://github.com/rust-lang/crates.io-index" 2819 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 2820 + dependencies = [ 2821 + "lock_api", 2736 2822 ] 2737 2823 2738 2824 [[package]] ··· 2917 3003 "fastrand", 2918 3004 "getrandom 0.4.2", 2919 3005 "once_cell", 2920 - "rustix", 3006 + "rustix 1.1.4", 2921 3007 "windows-sys 0.61.2", 2922 3008 ] 2923 3009 ··· 4045 4131 checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" 4046 4132 dependencies = [ 4047 4133 "libc", 4048 - "rustix", 4134 + "rustix 1.1.4", 4049 4135 ] 4050 4136 4051 4137 [[package]] ··· 4170 4256 "tonic", 4171 4257 "tracing", 4172 4258 "tracing-subscriber", 4259 + "zerod-discovery", 4173 4260 "zerod-proto", 4174 4261 "zerod-server", 4175 4262 ] ··· 4198 4285 ] 4199 4286 4200 4287 [[package]] 4288 + name = "zerod-discovery" 4289 + version = "0.1.0" 4290 + dependencies = [ 4291 + "anyhow", 4292 + "mdns-sd", 4293 + "tracing", 4294 + ] 4295 + 4296 + [[package]] 4201 4297 name = "zerod-proto" 4202 4298 version = "0.1.0" 4203 4299 dependencies = [ ··· 4212 4308 dependencies = [ 4213 4309 "anyhow", 4214 4310 "async-trait", 4311 + "gethostname", 4215 4312 "getrandom 0.2.17", 4216 4313 "serde", 4217 4314 "serde_json", ··· 4222 4319 "tracing", 4223 4320 "zerod-bluetooth", 4224 4321 "zerod-config", 4322 + "zerod-discovery", 4225 4323 "zerod-proto", 4226 4324 "zerod-stream", 4227 4325 "zerod-systemd",
+2
Cargo.toml
··· 14 14 "crates/stream", 15 15 "crates/systemd", 16 16 "crates/config", 17 + "crates/discovery", 17 18 "crates/server", 18 19 "crates/volume", 19 20 ] ··· 38 39 url = "2" 39 40 40 41 [dependencies] 42 + zerod-discovery = { path = "crates/discovery" } 41 43 zerod-proto = { path = "crates/proto" } 42 44 zerod-server = { path = "crates/server" } 43 45 anyhow = { workspace = true }
+33 -11
README.md
··· 44 44 - **Auth** — bearer token. Three sources, in order: `zerod.toml`, 45 45 `ZEROD_BEARER_TOKEN` env var, or a 32-byte random one generated and logged 46 46 at startup. 47 + - **mDNS / zeroconf discovery** — the server advertises itself as 48 + `_zerod._tcp.local.`, so clients on the same LAN can find it without 49 + `--host`. `zerod discover` lists every responder. Pure-Rust, no Avahi 50 + or Bonjour dependency. 47 51 - **Reflection** — `tonic-reflection` is wired up, so `grpcurl 48 52 -plaintext localhost:50151 list` works out of the box. 49 53 ··· 158 162 159 163 ### Client 160 164 161 - Client subcommands default to `--host localhost --port 50151`. Each global 162 - flag also reads from an env var so you can pin the target once per shell 163 - session and skip the flags entirely: 165 + With no `--host`, the client browses mDNS (`_zerod._tcp.local.`) for ~1.5s 166 + and connects to the only responder. If multiple servers reply, you get a 167 + listing and the command exits non-zero — pin one with `--name` (the server 168 + hostname, or whatever you set as `[mdns].name` in `zerod.toml`). Pass 169 + `--host` (or `ZEROD_HOST`) to bypass discovery entirely. 164 170 165 - | Flag | Env var | 166 - | ---------------- | -------------------- | 167 - | `--host` | `ZEROD_HOST` | 168 - | `--port` | `ZEROD_PORT` | 169 - | `--bearer-token` | `ZEROD_BEARER_TOKEN` | 171 + Each global flag also reads from an env var so you can pin the target once 172 + per shell session and skip the flags entirely: 173 + 174 + | Flag | Env var | 175 + | ------------------------ | --------------------------- | 176 + | `--host` | `ZEROD_HOST` | 177 + | `--port` | `ZEROD_PORT` | 178 + | `--bearer-token` | `ZEROD_BEARER_TOKEN` | 179 + | `--name` | `ZEROD_NAME` | 180 + | `--discover-timeout-ms` | `ZEROD_DISCOVER_TIMEOUT_MS` | 170 181 171 182 ``` 172 - export ZEROD_HOST=pi.lan 183 + # No host needed — the daemon is found via mDNS. 173 184 export ZEROD_BEARER_TOKEN="$(cat ~/.zerod-token)" 174 - zerod systemd status snapserver.service # uses pi.lan + token from env 185 + zerod systemd status snapserver.service 186 + 187 + # List every zerod on the LAN. 188 + zerod discover 189 + 190 + # Two daemons on the LAN? Pick one by name (matches [mdns].name). 191 + zerod --name living-room-pi system health 192 + 193 + # Skip mDNS entirely. 194 + export ZEROD_HOST=pi.lan 195 + zerod systemd status snapserver.service 175 196 ``` 176 197 177 198 ``` ··· 200 221 zerod config get snapserver 201 222 zerod config put snapserver ./snapserver.conf --action restart 202 223 203 - zerod --host pi.lan --bearer-token "$(cat ~/.zerod-token)" systemd status snapserver.service 224 + zerod --bearer-token "$(cat ~/.zerod-token)" systemd status snapserver.service 204 225 ``` 205 226 206 227 ### gRPC directly ··· 241 262 ├── stream/ # HLS/DASH player + AudioSink trait + 3 sinks 242 263 ├── systemd/ # zbus systemd1 client (target_os="linux" gated) 243 264 ├── config/ # ManagedConfig registry, atomic write 265 + ├── discovery/ # mDNS register + browse over mdns-sd 244 266 └── server/ # tonic services + settings loader + bearer interceptor 245 267 ``` 246 268
+9
crates/discovery/Cargo.toml
··· 1 + [package] 2 + name = "zerod-discovery" 3 + version = "0.1.0" 4 + edition = "2021" 5 + 6 + [dependencies] 7 + anyhow = { workspace = true } 8 + mdns-sd = "0.20" 9 + tracing = { workspace = true }
+168
crates/discovery/src/lib.rs
··· 1 + //! mDNS service registration + browsing for zerod. 2 + //! 3 + //! Service type: `_zerod._tcp.local.` 4 + //! 5 + //! Servers call [`advertise`] once at startup and hold the returned 6 + //! [`Advertisement`] for the daemon's lifetime — dropping it unregisters 7 + //! the service. 8 + //! 9 + //! Clients call [`discover`] to browse the LAN with a short timeout. 10 + 11 + use anyhow::{Context, Result}; 12 + use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo}; 13 + use std::collections::HashMap; 14 + use std::net::IpAddr; 15 + use std::time::Duration; 16 + 17 + pub const SERVICE_TYPE: &str = "_zerod._tcp.local."; 18 + 19 + /// Live mDNS registration. The service is unregistered when this is dropped. 20 + pub struct Advertisement { 21 + daemon: ServiceDaemon, 22 + fullname: String, 23 + } 24 + 25 + impl Advertisement { 26 + /// The instance name as advertised (without the service-type suffix). 27 + pub fn instance_name(&self) -> &str { 28 + self.fullname 29 + .strip_suffix(&format!(".{}", SERVICE_TYPE)) 30 + .unwrap_or(&self.fullname) 31 + } 32 + } 33 + 34 + impl Drop for Advertisement { 35 + fn drop(&mut self) { 36 + if let Err(e) = self.daemon.unregister(&self.fullname) { 37 + tracing::warn!("mDNS unregister failed: {e}"); 38 + } 39 + if let Err(e) = self.daemon.shutdown() { 40 + tracing::warn!("mDNS shutdown failed: {e}"); 41 + } 42 + } 43 + } 44 + 45 + /// Register zerod on the LAN under `instance_name` on the given port. 46 + /// 47 + /// `instance_name` is the human-visible name (e.g. the machine hostname). 48 + /// `txt` is included verbatim as TXT records — useful for version, etc. 49 + pub fn advertise(instance_name: &str, port: u16, txt: &[(String, String)]) -> Result<Advertisement> { 50 + let daemon = ServiceDaemon::new().context("create mDNS daemon")?; 51 + let hostname = format!("{}.local.", sanitize(instance_name)); 52 + let props: HashMap<String, String> = txt.iter().cloned().collect(); 53 + 54 + // Empty IP list → mdns-sd auto-detects all non-loopback interfaces. 55 + let ips: &[IpAddr] = &[]; 56 + 57 + let info = ServiceInfo::new( 58 + SERVICE_TYPE, 59 + instance_name, 60 + &hostname, 61 + ips, 62 + port, 63 + props, 64 + ) 65 + .context("build mDNS ServiceInfo")? 66 + .enable_addr_auto(); 67 + 68 + let fullname = info.get_fullname().to_string(); 69 + daemon.register(info).context("register mDNS service")?; 70 + tracing::info!("mDNS: advertising {fullname} on port {port}"); 71 + Ok(Advertisement { daemon, fullname }) 72 + } 73 + 74 + /// One server discovered on the LAN. 75 + #[derive(Debug, Clone)] 76 + pub struct Discovered { 77 + /// Instance name as set by the server (e.g. the machine hostname). 78 + pub name: String, 79 + /// All addresses the server resolved on. Prefer the first IPv4 entry. 80 + pub addresses: Vec<IpAddr>, 81 + pub port: u16, 82 + /// TXT records the server published. 83 + pub properties: HashMap<String, String>, 84 + } 85 + 86 + impl Discovered { 87 + /// Pick the best IPv4 to connect to. Skips loopback and the Docker default 88 + /// bridge range (172.17.0.0/16) — those leak in when zerod runs inside 89 + /// Docker or when docker0 is up on the host. IPv6 is ignored entirely. 90 + pub fn best_host(&self) -> Option<String> { 91 + let v4s: Vec<std::net::Ipv4Addr> = self 92 + .addresses 93 + .iter() 94 + .filter_map(|ip| match ip { 95 + IpAddr::V4(v4) => Some(*v4), 96 + _ => None, 97 + }) 98 + .filter(|v4| !v4.is_loopback()) 99 + .collect(); 100 + 101 + let pick = v4s.iter().find(|v4| !is_docker_default(v4)).or_else(|| v4s.first()); 102 + pick.map(|v4| v4.to_string()) 103 + } 104 + } 105 + 106 + /// Docker's default bridge is 172.17.0.0/16. Filtering the rest of 172.16/12 107 + /// would catch legitimate corporate / home LANs, so we only blacklist the 108 + /// well-known Docker default. 109 + fn is_docker_default(v4: &std::net::Ipv4Addr) -> bool { 110 + let o = v4.octets(); 111 + o[0] == 172 && o[1] == 17 112 + } 113 + 114 + /// Browse for `_zerod._tcp.local.` on the LAN for `timeout`. 115 + /// 116 + /// Returns every distinct instance that resolved within the window. 117 + pub fn discover(timeout: Duration) -> Result<Vec<Discovered>> { 118 + let daemon = ServiceDaemon::new().context("create mDNS daemon")?; 119 + let receiver = daemon.browse(SERVICE_TYPE).context("start mDNS browse")?; 120 + 121 + let deadline = std::time::Instant::now() + timeout; 122 + let mut found: HashMap<String, Discovered> = HashMap::new(); 123 + loop { 124 + let now = std::time::Instant::now(); 125 + if now >= deadline { 126 + break; 127 + } 128 + match receiver.recv_timeout(deadline - now) { 129 + Ok(ServiceEvent::ServiceResolved(info)) => { 130 + let name = info 131 + .get_fullname() 132 + .strip_suffix(&format!(".{}", SERVICE_TYPE)) 133 + .unwrap_or(info.get_fullname()) 134 + .to_string(); 135 + let addresses: Vec<IpAddr> = 136 + info.get_addresses().iter().map(|s| s.to_ip_addr()).collect(); 137 + let properties = info 138 + .get_properties() 139 + .iter() 140 + .map(|p| (p.key().to_string(), p.val_str().to_string())) 141 + .collect(); 142 + found.insert( 143 + name.clone(), 144 + Discovered { 145 + name, 146 + addresses, 147 + port: info.get_port(), 148 + properties, 149 + }, 150 + ); 151 + } 152 + Ok(_) => {} 153 + Err(_) => break, // timeout 154 + } 155 + } 156 + 157 + let _ = daemon.shutdown(); 158 + let mut out: Vec<Discovered> = found.into_values().collect(); 159 + out.sort_by(|a, b| a.name.cmp(&b.name)); 160 + Ok(out) 161 + } 162 + 163 + /// mdns-sd disallows whitespace and a few control chars in hostnames; replace them with `-`. 164 + fn sanitize(s: &str) -> String { 165 + s.chars() 166 + .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '.' { c } else { '-' }) 167 + .collect() 168 + }
+2
crates/server/Cargo.toml
··· 7 7 anyhow = { workspace = true } 8 8 async-trait = { workspace = true } 9 9 getrandom = "0.2" 10 + gethostname = "0.5" 10 11 serde = { workspace = true } 11 12 serde_json = { workspace = true } 12 13 toml = "0.8" ··· 16 17 tracing = { workspace = true } 17 18 zerod-bluetooth = { path = "../bluetooth" } 18 19 zerod-config = { path = "../config" } 20 + zerod-discovery = { path = "../discovery" } 19 21 zerod-proto = { path = "../proto" } 20 22 zerod-stream = { path = "../stream" } 21 23 zerod-systemd = { path = "../systemd" }
+38 -1
crates/server/src/lib.rs
··· 8 8 mod systemd; 9 9 mod volume; 10 10 11 - pub use settings::{load_settings, Settings}; 11 + pub use settings::{load_settings, MdnsSettings, Settings}; 12 12 13 13 use anyhow::{Context, Result}; 14 14 use std::net::SocketAddr; ··· 31 31 let allow = Arc::new(settings.systemd_allowlist()); 32 32 let registry = Arc::new(settings.config_registry()); 33 33 let bearer = resolve_bearer_token(&settings.server.bearer_token)?; 34 + 35 + let _mdns = maybe_advertise(&settings.mdns, addr.port()); 34 36 35 37 let reflection = tonic_reflection::server::Builder::configure() 36 38 .register_encoded_file_descriptor_set(zerod_proto::FILE_DESCRIPTOR_SET) ··· 125 127 ); 126 128 tracing::warn!("auth: BEARER TOKEN = {}", token); 127 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. 134 + fn 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 + 155 + fn 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 + } 128 165 } 129 166 130 167 fn random_hex_token() -> Result<String> {
+23
crates/server/src/settings.rs
··· 26 26 #[serde(default)] 27 27 pub systemd: SystemdSettings, 28 28 #[serde(default)] 29 + pub mdns: MdnsSettings, 30 + #[serde(default)] 29 31 pub configs: Vec<ManagedConfig>, 30 32 } 31 33 ··· 43 45 pub units: Vec<String>, 44 46 } 45 47 48 + #[derive(Debug, Clone, Deserialize, Serialize)] 49 + pub struct MdnsSettings { 50 + /// Advertise the daemon on the LAN via mDNS. Default: true. 51 + #[serde(default = "default_true")] 52 + pub enabled: bool, 53 + /// Instance name to advertise. Empty → derive from the machine hostname. 54 + #[serde(default)] 55 + pub name: String, 56 + } 57 + 58 + impl Default for MdnsSettings { 59 + fn default() -> Self { 60 + Self { enabled: true, name: String::new() } 61 + } 62 + } 63 + 64 + fn default_true() -> bool { 65 + true 66 + } 67 + 46 68 fn default_bind() -> String { 47 69 // Bind on all interfaces by default so the daemon is reachable from the 48 70 // LAN out of the box — bearer-token auth (random fallback) covers the ··· 64 86 Self { 65 87 server: ServerSettings::default(), 66 88 systemd: SystemdSettings::default(), 89 + mdns: MdnsSettings::default(), 67 90 configs: Vec::new(), 68 91 } 69 92 }
+134 -33
src/main.rs
··· 2 2 //! 3 3 //! `zerod` (no args) → run as a gRPC server (reads zerod.toml). 4 4 //! `zerod serve …` → explicit server invocation. 5 - //! `zerod bluetooth scan` → client subcommands. Defaults host=localhost 6 - //! `zerod stream play …` port=50151; override with --host / --port and 7 - //! `zerod systemd start …` optional --bearer-token. 8 - //! `zerod config get …` 5 + //! `zerod bluetooth scan` → client subcommands. With no --host, the 6 + //! `zerod stream play …` client browses mDNS (`_zerod._tcp.local.`) 7 + //! `zerod systemd start …` and connects to the only responder. Use 8 + //! `zerod config get …` --name to disambiguate when several reply, 9 + //! `zerod discover` or --host to bypass discovery entirely. 9 10 10 - use anyhow::{Context, Result}; 11 + use anyhow::{anyhow, Context, Result}; 11 12 use clap::builder::styling::{Color, RgbColor, Style, Styles}; 12 13 use clap::{Parser, Subcommand}; 13 14 use std::path::PathBuf; 15 + use std::time::Duration; 14 16 use tonic::metadata::MetadataValue; 15 17 use tonic::transport::Channel; 16 18 use tonic::Request; ··· 49 51 #[arg(long, global = true)] 50 52 config: Option<PathBuf>, 51 53 52 - /// Client mode: target server host. Ignored for `serve`. 53 - #[arg(long, global = true, env = "ZEROD_HOST", default_value = "localhost")] 54 - host: String, 54 + /// Client mode: target server host. When omitted, discovers a server via 55 + /// mDNS on the LAN. Ignored for `serve`. 56 + #[arg(long, global = true, env = "ZEROD_HOST")] 57 + host: Option<String>, 55 58 56 - /// Client mode: target server port. Ignored for `serve`. 59 + /// Client mode: target server port. Ignored for `serve` and when --host 60 + /// is omitted (the discovered port is used). 57 61 #[arg(long, global = true, env = "ZEROD_PORT", default_value_t = 50151)] 58 62 port: u16, 59 63 60 64 /// Client mode: bearer token to send. 61 65 #[arg(long, global = true, env = "ZEROD_BEARER_TOKEN")] 62 66 bearer_token: Option<String>, 67 + 68 + /// Client mode: when discovering via mDNS, pick the responder whose 69 + /// instance name matches this exactly. Has no effect when --host is set. 70 + #[arg(long, global = true, env = "ZEROD_NAME")] 71 + name: Option<String>, 72 + 73 + /// Client mode: how long to browse mDNS before giving up (milliseconds). 74 + #[arg(long, global = true, env = "ZEROD_DISCOVER_TIMEOUT_MS", default_value_t = 1500)] 75 + discover_timeout_ms: u64, 63 76 64 77 #[command(subcommand)] 65 78 command: Option<Command>, ··· 90 103 /// Manage zerod's own systemd --user unit (Linux only). 91 104 #[command(subcommand)] 92 105 Service(ServiceCmd), 106 + /// Browse mDNS for zerod servers on the LAN. 107 + Discover, 93 108 } 94 109 95 110 #[derive(Subcommand)] ··· 249 264 ) 250 265 .init(); 251 266 let cli = Cli::parse(); 252 - match cli.command { 253 - None | Some(Command::Serve) => run_server(cli.config.as_deref()).await, 254 - Some(Command::Bluetooth(cmd)) => run_bluetooth(&cli.host, cli.port, cli.bearer_token, cmd).await, 255 - Some(Command::Stream(cmd)) => run_stream(&cli.host, cli.port, cli.bearer_token, cmd).await, 256 - Some(Command::Systemd(cmd)) => run_systemd(&cli.host, cli.port, cli.bearer_token, cmd).await, 257 - Some(Command::Config(cmd)) => run_config(&cli.host, cli.port, cli.bearer_token, cmd).await, 258 - Some(Command::System(cmd)) => run_system(&cli.host, cli.port, cli.bearer_token, cmd).await, 259 - Some(Command::Volume(cmd)) => run_volume(&cli.host, cli.port, cli.bearer_token, cmd).await, 267 + let Cli { 268 + config, 269 + host, 270 + port, 271 + bearer_token, 272 + name, 273 + discover_timeout_ms, 274 + command, 275 + } = cli; 276 + let discover_timeout = Duration::from_millis(discover_timeout_ms); 277 + let needs_endpoint = !matches!( 278 + command, 279 + None | Some(Command::Serve) | Some(Command::Service(_)) | Some(Command::Discover) 280 + ); 281 + let endpoint = if needs_endpoint { 282 + Some(resolve_endpoint(host.as_deref(), port, name.as_deref(), discover_timeout)?) 283 + } else { 284 + None 285 + }; 286 + match command { 287 + None | Some(Command::Serve) => run_server(config.as_deref()).await, 260 288 Some(Command::Service(cmd)) => run_service(cmd), 289 + Some(Command::Discover) => run_discover(discover_timeout), 290 + Some(Command::Bluetooth(cmd)) => run_bluetooth(&endpoint.unwrap(), bearer_token, cmd).await, 291 + Some(Command::Stream(cmd)) => run_stream(&endpoint.unwrap(), bearer_token, cmd).await, 292 + Some(Command::Systemd(cmd)) => run_systemd(&endpoint.unwrap(), bearer_token, cmd).await, 293 + Some(Command::Config(cmd)) => run_config(&endpoint.unwrap(), bearer_token, cmd).await, 294 + Some(Command::System(cmd)) => run_system(&endpoint.unwrap(), bearer_token, cmd).await, 295 + Some(Command::Volume(cmd)) => run_volume(&endpoint.unwrap(), bearer_token, cmd).await, 261 296 } 262 297 } 263 298 299 + struct Endpoint { 300 + host: String, 301 + port: u16, 302 + } 303 + 304 + fn resolve_endpoint( 305 + host: Option<&str>, 306 + port: u16, 307 + name: Option<&str>, 308 + timeout: Duration, 309 + ) -> Result<Endpoint> { 310 + if let Some(h) = host.filter(|s| !s.is_empty()) { 311 + return Ok(Endpoint { host: h.to_string(), port }); 312 + } 313 + let mut found = zerod_discovery::discover(timeout).context("mDNS browse")?; 314 + if let Some(n) = name.filter(|s| !s.is_empty()) { 315 + found.retain(|d| d.name == n); 316 + } 317 + match found.len() { 318 + 0 => Err(anyhow!( 319 + "no zerod server found on the LAN within {}ms. \ 320 + Pass --host explicitly, or check that the daemon is running with [mdns] enabled.", 321 + timeout.as_millis(), 322 + )), 323 + 1 => { 324 + let d = found.remove(0); 325 + let h = d.best_host().ok_or_else(|| { 326 + anyhow!("discovered server {:?} has no usable IPv4 address", d.name) 327 + })?; 328 + tracing::info!("mDNS: using {} ({}:{})", d.name, h, d.port); 329 + Ok(Endpoint { host: h, port: d.port }) 330 + } 331 + _ => { 332 + let mut msg = String::from( 333 + "multiple zerod servers responded. Re-run with --name <name> to pick one:\n", 334 + ); 335 + for d in &found { 336 + let h = d.best_host().unwrap_or_else(|| "?".to_string()); 337 + msg.push_str(&format!(" {:30} {}:{}\n", d.name, h, d.port)); 338 + } 339 + Err(anyhow!(msg)) 340 + } 341 + } 342 + } 343 + 344 + fn run_discover(timeout: Duration) -> Result<()> { 345 + let found = zerod_discovery::discover(timeout).context("mDNS browse")?; 346 + if found.is_empty() { 347 + println!("no zerod servers found on the LAN within {}ms", timeout.as_millis()); 348 + return Ok(()); 349 + } 350 + for d in &found { 351 + let host = d.best_host().unwrap_or_else(|| "?".to_string()); 352 + let version = d 353 + .properties 354 + .get("version") 355 + .map(String::as_str) 356 + .unwrap_or("?"); 357 + println!("{:30} {}:{} version={}", d.name, host, d.port, version); 358 + } 359 + Ok(()) 360 + } 361 + 264 362 fn run_service(cmd: ServiceCmd) -> Result<()> { 265 363 match cmd { 266 364 ServiceCmd::Install { force, token } => { ··· 281 379 println!("So the service survives logout / starts on boot:"); 282 380 println!(" sudo loginctl enable-linger \"$USER\""); 283 381 println!(); 284 - println!("Use the token from any client:"); 382 + println!("Use the token from any client (auto-discovers via mDNS):"); 285 383 println!(" export ZEROD_BEARER_TOKEN={}", installed.token); 286 - println!(" zerod --host <pi> system health"); 384 + println!(" zerod system health"); 385 + println!(); 386 + println!("Or list every responder on the LAN:"); 387 + println!(" zerod discover"); 287 388 Ok(()) 288 389 } 289 390 ServiceCmd::Uninstall => match service::uninstall()? { ··· 314 415 315 416 // --- client helpers --------------------------------------------------------- 316 417 317 - async fn channel(host: &str, port: u16) -> Result<Channel> { 318 - let url = format!("http://{host}:{port}"); 418 + async fn channel(ep: &Endpoint) -> Result<Channel> { 419 + let url = format!("http://{}:{}", ep.host, ep.port); 319 420 Channel::from_shared(url.clone()) 320 421 .with_context(|| format!("invalid endpoint {url}"))? 321 422 .connect() ··· 338 439 339 440 // --- bluetooth ------------------------------------------------------------- 340 441 341 - async fn run_bluetooth(host: &str, port: u16, token: Option<String>, cmd: BluetoothCmd) -> Result<()> { 342 - let ch = channel(host, port).await?; 442 + async fn run_bluetooth(ep: &Endpoint, token: Option<String>, cmd: BluetoothCmd) -> Result<()> { 443 + let ch = channel(ep).await?; 343 444 let mut client = pb::bluetooth_service_client::BluetoothServiceClient::new(ch); 344 445 match cmd { 345 446 BluetoothCmd::Scan { timeout_secs } => { ··· 388 489 389 490 // --- stream ---------------------------------------------------------------- 390 491 391 - async fn run_stream(host: &str, port: u16, token: Option<String>, cmd: StreamCmd) -> Result<()> { 392 - let ch = channel(host, port).await?; 492 + async fn run_stream(ep: &Endpoint, token: Option<String>, cmd: StreamCmd) -> Result<()> { 493 + let ch = channel(ep).await?; 393 494 let mut client = pb::stream_service_client::StreamServiceClient::new(ch); 394 495 match cmd { 395 496 StreamCmd::Play { url, output, pipe_path } => { ··· 457 558 Ok(()) 458 559 } 459 560 460 - async fn run_volume(host: &str, port: u16, token: Option<String>, cmd: VolumeCmd) -> Result<()> { 461 - let ch = channel(host, port).await?; 561 + async fn run_volume(ep: &Endpoint, token: Option<String>, cmd: VolumeCmd) -> Result<()> { 562 + let ch = channel(ep).await?; 462 563 let mut client = pb::volume_service_client::VolumeServiceClient::new(ch); 463 564 let mk = |card: Option<String>, control: Option<String>, index: u32| pb::MixerSelector { 464 565 card: card.unwrap_or_default(), ··· 532 633 533 634 // --- systemd --------------------------------------------------------------- 534 635 535 - async fn run_systemd(host: &str, port: u16, token: Option<String>, cmd: SystemdCmd) -> Result<()> { 536 - let ch = channel(host, port).await?; 636 + async fn run_systemd(ep: &Endpoint, token: Option<String>, cmd: SystemdCmd) -> Result<()> { 637 + let ch = channel(ep).await?; 537 638 let mut client = pb::systemd_service_client::SystemdServiceClient::new(ch); 538 639 match cmd { 539 640 SystemdCmd::List => { ··· 574 675 575 676 // --- config ---------------------------------------------------------------- 576 677 577 - async fn run_config(host: &str, port: u16, token: Option<String>, cmd: ConfigCmd) -> Result<()> { 578 - let ch = channel(host, port).await?; 678 + async fn run_config(ep: &Endpoint, token: Option<String>, cmd: ConfigCmd) -> Result<()> { 679 + let ch = channel(ep).await?; 579 680 let mut client = pb::config_service_client::ConfigServiceClient::new(ch); 580 681 match cmd { 581 682 ConfigCmd::List => { ··· 621 722 622 723 // --- system ---------------------------------------------------------------- 623 724 624 - async fn run_system(host: &str, port: u16, token: Option<String>, cmd: SystemCmd) -> Result<()> { 625 - let ch = channel(host, port).await?; 725 + async fn run_system(ep: &Endpoint, token: Option<String>, cmd: SystemCmd) -> Result<()> { 726 + let ch = channel(ep).await?; 626 727 let mut client = pb::system_service_client::SystemServiceClient::new(ch); 627 728 match cmd { 628 729 SystemCmd::Version => {
+6
zerod.toml.example
··· 10 10 # set this explicitly when bind is non-loopback. 11 11 bearer_token = "" 12 12 13 + [mdns] 14 + # Advertise this daemon on the LAN as `_zerod._tcp.local.` so clients can 15 + # discover it without --host. Empty `name` uses the machine hostname. 16 + enabled = true 17 + name = "" 18 + 13 19 [systemd] 14 20 # Only units listed here can be controlled via the SystemdService API. 15 21 # Acts as an allowlist so the gRPC server can't be turned into a generic