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 / src / main.rs
27 kB 745 lines
1//! zerod — one binary, two modes. 2//! 3//! `zerod` (no args) → run as a gRPC server (reads zerod.toml). 4//! `zerod serve …` → explicit server invocation. 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. 10 11use anyhow::{anyhow, Context, Result}; 12use clap::builder::styling::{Color, RgbColor, Style, Styles}; 13use clap::{Parser, Subcommand}; 14use std::path::PathBuf; 15use std::time::Duration; 16use tonic::metadata::MetadataValue; 17use tonic::transport::Channel; 18use tonic::Request; 19use zerod_proto::v1alpha1 as pb; 20 21mod service; 22 23// Charm-inspired neon violet palette for the clap help menu. 24// #7D56F4 — Charm signature purple-violet-blue (headers, usage line) 25// #A78BFA — bright violet (commands, flags, valid values) 26// #C4B5FD — soft lavender (placeholders / value names) 27// #FF5C8A — pink-red (errors, invalid values) 28const CHARM_VIOLET: Color = Color::Rgb(RgbColor(0x7D, 0x56, 0xF4)); 29const NEON_VIOLET: Color = Color::Rgb(RgbColor(0xA7, 0x8B, 0xFA)); 30const SOFT_LAVENDER: Color = Color::Rgb(RgbColor(0xC4, 0xB5, 0xFD)); 31const NEON_PINK_ERR: Color = Color::Rgb(RgbColor(0xFF, 0x5C, 0x8A)); 32 33const STYLES: Styles = Styles::styled() 34 .header(Style::new().bold().underline().fg_color(Some(CHARM_VIOLET))) 35 .usage(Style::new().bold().fg_color(Some(CHARM_VIOLET))) 36 .literal(Style::new().bold().fg_color(Some(NEON_VIOLET))) 37 .placeholder(Style::new().fg_color(Some(SOFT_LAVENDER))) 38 .valid(Style::new().bold().fg_color(Some(NEON_VIOLET))) 39 .invalid(Style::new().bold().fg_color(Some(NEON_PINK_ERR))) 40 .error(Style::new().bold().fg_color(Some(NEON_PINK_ERR))); 41 42#[derive(Parser)] 43#[command( 44 name = "zerod", 45 version, 46 about = "headless audio/bluetooth/systemd control daemon", 47 styles = STYLES, 48)] 49struct Cli { 50 /// Path to zerod.toml. Server mode only. 51 #[arg(long, global = true)] 52 config: Option<PathBuf>, 53 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>, 58 59 /// Client mode: target server port. Ignored for `serve` and when --host 60 /// is omitted (the discovered port is used). 61 #[arg(long, global = true, env = "ZEROD_PORT", default_value_t = 50151)] 62 port: u16, 63 64 /// Client mode: bearer token to send. 65 #[arg(long, global = true, env = "ZEROD_BEARER_TOKEN")] 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, 76 77 #[command(subcommand)] 78 command: Option<Command>, 79} 80 81#[derive(Subcommand)] 82enum Command { 83 /// Run as a gRPC server (default when no subcommand is given). 84 Serve, 85 /// Bluetooth control (client). 86 #[command(subcommand)] 87 Bluetooth(BluetoothCmd), 88 /// HLS/DASH stream control (client). 89 #[command(subcommand)] 90 Stream(StreamCmd), 91 /// Systemd unit control (client). 92 #[command(subcommand)] 93 Systemd(SystemdCmd), 94 /// Managed config files (client). 95 #[command(subcommand)] 96 Config(ConfigCmd), 97 /// Server version / health (client). 98 #[command(subcommand)] 99 System(SystemCmd), 100 /// System volume control via ALSA mixer (client). 101 #[command(subcommand)] 102 Volume(VolumeCmd), 103 /// Manage zerod's own systemd --user unit (Linux only). 104 #[command(subcommand)] 105 Service(ServiceCmd), 106 /// Browse mDNS for zerod servers on the LAN. 107 Discover, 108} 109 110#[derive(Subcommand)] 111enum ServiceCmd { 112 /// Write ~/.config/systemd/user/zerod.service pointing at this binary. 113 /// A bearer token is pinned into the unit so it doesn't change on every 114 /// restart — pass --token to choose one, otherwise a random 32-byte hex 115 /// token is generated. Re-running with --force rotates the token unless 116 /// --token is supplied. 117 Install { 118 /// Overwrite an existing unit file (rotates the bearer token unless --token is given). 119 #[arg(long)] 120 force: bool, 121 /// Pin this exact bearer token into the unit instead of generating a random one. 122 #[arg(long)] 123 token: Option<String>, 124 }, 125 /// Remove ~/.config/systemd/user/zerod.service. 126 Uninstall, 127 /// Print where the unit file would be installed. 128 Path, 129} 130 131#[derive(Subcommand)] 132enum BluetoothCmd { 133 Scan { 134 #[arg(long, default_value_t = 10)] 135 timeout_secs: u32, 136 }, 137 List, 138 Pair { address: String }, 139 Connect { address: String }, 140 Disconnect { address: String }, 141 Remove { address: String }, 142} 143 144#[derive(Subcommand)] 145enum StreamCmd { 146 Play { 147 url: String, 148 #[arg(long, value_enum, default_value_t = OutputArg::Cpal)] 149 output: OutputArg, 150 #[arg(long)] 151 pipe_path: Option<String>, 152 }, 153 Pause, 154 Resume, 155 Stop, 156 Status, 157 /// Per-stream software gain (0..=100), independent of the system mixer. 158 #[command(subcommand)] 159 Volume(StreamVolumeCmd), 160} 161 162#[derive(Subcommand)] 163enum StreamVolumeCmd { 164 Get, 165 Set { percent: u32 }, 166} 167 168#[derive(Subcommand)] 169enum VolumeCmd { 170 /// List ALSA selems on a card (default card if --card omitted). 171 List { 172 #[arg(long)] 173 card: Option<String>, 174 }, 175 Get { 176 #[arg(long)] 177 card: Option<String>, 178 #[arg(long)] 179 control: Option<String>, 180 #[arg(long, default_value_t = 0)] 181 index: u32, 182 }, 183 Set { 184 percent: u32, 185 #[arg(long)] 186 card: Option<String>, 187 #[arg(long)] 188 control: Option<String>, 189 #[arg(long, default_value_t = 0)] 190 index: u32, 191 }, 192 Mute { 193 #[arg(long)] 194 card: Option<String>, 195 #[arg(long)] 196 control: Option<String>, 197 #[arg(long, default_value_t = 0)] 198 index: u32, 199 }, 200 Unmute { 201 #[arg(long)] 202 card: Option<String>, 203 #[arg(long)] 204 control: Option<String>, 205 #[arg(long, default_value_t = 0)] 206 index: u32, 207 }, 208} 209 210#[derive(Clone, Copy, clap::ValueEnum)] 211enum OutputArg { 212 Cpal, 213 Stdout, 214 Pipe, 215} 216 217#[derive(Subcommand)] 218enum SystemdCmd { 219 List, 220 Status { name: String }, 221 Start { name: String }, 222 Stop { name: String }, 223 Restart { name: String }, 224 Reload { name: String }, 225 Enable { name: String }, 226 Disable { name: String }, 227} 228 229#[derive(Subcommand)] 230enum ConfigCmd { 231 List, 232 Get { 233 key: String, 234 }, 235 /// Write the contents of a local file as the new config and optionally 236 /// reload/restart the bound unit. 237 Put { 238 key: String, 239 file: PathBuf, 240 #[arg(long, value_enum, default_value_t = ActionArg::None)] 241 action: ActionArg, 242 }, 243} 244 245#[derive(Clone, Copy, clap::ValueEnum)] 246enum ActionArg { 247 None, 248 Reload, 249 Restart, 250} 251 252#[derive(Subcommand)] 253enum SystemCmd { 254 Version, 255 Health, 256} 257 258#[tokio::main] 259async fn main() -> Result<()> { 260 tracing_subscriber::fmt() 261 .with_env_filter( 262 tracing_subscriber::EnvFilter::try_from_default_env() 263 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), 264 ) 265 .init(); 266 let cli = Cli::parse(); 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, 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, 296 } 297} 298 299struct Endpoint { 300 host: String, 301 port: u16, 302} 303 304fn 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 344fn 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 362fn run_service(cmd: ServiceCmd) -> Result<()> { 363 match cmd { 364 ServiceCmd::Install { force, token } => { 365 let installed = service::install(force, token)?; 366 println!("Wrote {}", installed.path.display()); 367 println!(); 368 println!("Bearer token (pinned in the unit, mode 0600):"); 369 println!(" {}", installed.token); 370 println!(); 371 println!("Next steps:"); 372 println!(" systemctl --user daemon-reload"); 373 println!(" systemctl --user enable --now zerod.service"); 374 println!(); 375 println!("Then check it's healthy:"); 376 println!(" systemctl --user status zerod.service"); 377 println!(" journalctl --user -u zerod.service -f"); 378 println!(); 379 println!("So the service survives logout / starts on boot:"); 380 println!(" sudo loginctl enable-linger \"$USER\""); 381 println!(); 382 println!("Use the token from any client (auto-discovers via mDNS):"); 383 println!(" export ZEROD_BEARER_TOKEN={}", installed.token); 384 println!(" zerod system health"); 385 println!(); 386 println!("Or list every responder on the LAN:"); 387 println!(" zerod discover"); 388 Ok(()) 389 } 390 ServiceCmd::Uninstall => match service::uninstall()? { 391 Some(path) => { 392 println!("Removed {}", path.display()); 393 println!(); 394 println!("Tell systemd to forget it:"); 395 println!(" systemctl --user disable --now zerod.service"); 396 println!(" systemctl --user daemon-reload"); 397 Ok(()) 398 } 399 None => { 400 println!("No unit file installed at {}", service::unit_path()?.display()); 401 Ok(()) 402 } 403 }, 404 ServiceCmd::Path => { 405 println!("{}", service::unit_path()?.display()); 406 Ok(()) 407 } 408 } 409} 410 411async fn run_server(config: Option<&std::path::Path>) -> Result<()> { 412 let settings = zerod_server::load_settings(config)?; 413 zerod_server::serve(settings).await 414} 415 416// --- client helpers --------------------------------------------------------- 417 418async fn channel(ep: &Endpoint) -> Result<Channel> { 419 let url = format!("http://{}:{}", ep.host, ep.port); 420 Channel::from_shared(url.clone()) 421 .with_context(|| format!("invalid endpoint {url}"))? 422 .connect() 423 .await 424 .with_context(|| format!("connect {url}")) 425} 426 427fn attach_token<T>(mut req: Request<T>, token: &Option<String>) -> Request<T> { 428 if let Some(t) = token.as_deref().filter(|s| !s.is_empty()) { 429 let v: MetadataValue<_> = format!("Bearer {t}").parse().expect("bearer token must be ascii"); 430 req.metadata_mut().insert("authorization", v); 431 } 432 req 433} 434 435fn print_json(v: &serde_json::Value) -> Result<()> { 436 println!("{}", serde_json::to_string_pretty(v)?); 437 Ok(()) 438} 439 440// --- bluetooth ------------------------------------------------------------- 441 442async fn run_bluetooth(ep: &Endpoint, token: Option<String>, cmd: BluetoothCmd) -> Result<()> { 443 let ch = channel(ep).await?; 444 let mut client = pb::bluetooth_service_client::BluetoothServiceClient::new(ch); 445 match cmd { 446 BluetoothCmd::Scan { timeout_secs } => { 447 let r = client 448 .scan(attach_token(Request::new(pb::ScanRequest { timeout_secs }), &token)) 449 .await? 450 .into_inner(); 451 for d in r.devices { 452 println!( 453 "{} {:30} paired={} trusted={} connected={} rssi={:?}", 454 d.address, d.name, d.paired, d.trusted, d.connected, d.rssi 455 ); 456 } 457 } 458 BluetoothCmd::List => { 459 let r = client 460 .list_devices(attach_token(Request::new(pb::ListDevicesRequest {}), &token)) 461 .await? 462 .into_inner(); 463 for d in r.devices { 464 println!( 465 "{} {:30} paired={} trusted={} connected={} rssi={:?}", 466 d.address, d.name, d.paired, d.trusted, d.connected, d.rssi 467 ); 468 } 469 } 470 BluetoothCmd::Pair { address } => { 471 client.pair(attach_token(Request::new(pb::PairRequest { address }), &token)).await?; 472 println!("ok"); 473 } 474 BluetoothCmd::Connect { address } => { 475 client.connect_device(attach_token(Request::new(pb::ConnectDeviceRequest { address }), &token)).await?; 476 println!("ok"); 477 } 478 BluetoothCmd::Disconnect { address } => { 479 client.disconnect(attach_token(Request::new(pb::DisconnectRequest { address }), &token)).await?; 480 println!("ok"); 481 } 482 BluetoothCmd::Remove { address } => { 483 client.remove(attach_token(Request::new(pb::RemoveRequest { address }), &token)).await?; 484 println!("ok"); 485 } 486 } 487 Ok(()) 488} 489 490// --- stream ---------------------------------------------------------------- 491 492async fn run_stream(ep: &Endpoint, token: Option<String>, cmd: StreamCmd) -> Result<()> { 493 let ch = channel(ep).await?; 494 let mut client = pb::stream_service_client::StreamServiceClient::new(ch); 495 match cmd { 496 StreamCmd::Play { url, output, pipe_path } => { 497 let output = match output { 498 OutputArg::Cpal => pb::AudioOutput::Cpal, 499 OutputArg::Stdout => pb::AudioOutput::Stdout, 500 OutputArg::Pipe => pb::AudioOutput::Pipe, 501 } as i32; 502 client 503 .play(attach_token( 504 Request::new(pb::PlayRequest { 505 url, 506 output, 507 pipe_path, 508 cpal_device: None, 509 }), 510 &token, 511 )) 512 .await?; 513 println!("ok"); 514 } 515 StreamCmd::Pause => { 516 client.pause(attach_token(Request::new(pb::PauseRequest {}), &token)).await?; 517 println!("ok"); 518 } 519 StreamCmd::Resume => { 520 client.resume(attach_token(Request::new(pb::ResumeRequest {}), &token)).await?; 521 println!("ok"); 522 } 523 StreamCmd::Stop => { 524 client.stop(attach_token(Request::new(pb::StopRequest {}), &token)).await?; 525 println!("ok"); 526 } 527 StreamCmd::Status => { 528 let r = client 529 .status(attach_token(Request::new(pb::StatusRequest {}), &token)) 530 .await? 531 .into_inner(); 532 let state = pb::PlayerState::try_from(r.state).unwrap_or(pb::PlayerState::Unspecified); 533 let out = pb::AudioOutput::try_from(r.output).unwrap_or(pb::AudioOutput::Unspecified); 534 println!( 535 "state={state:?} url={} position_ms={} duration_ms={} is_live={} output={out:?} volume={}% error={:?}", 536 r.url, r.position_ms, r.duration_ms, r.is_live, r.volume_percent, r.error, 537 ); 538 } 539 StreamCmd::Volume(cmd) => match cmd { 540 StreamVolumeCmd::Get => { 541 let r = client 542 .get_stream_volume(attach_token(Request::new(pb::GetStreamVolumeRequest {}), &token)) 543 .await? 544 .into_inner(); 545 println!("{}%", r.volume_percent); 546 } 547 StreamVolumeCmd::Set { percent } => { 548 client 549 .set_stream_volume(attach_token( 550 Request::new(pb::SetStreamVolumeRequest { volume_percent: percent }), 551 &token, 552 )) 553 .await?; 554 println!("ok"); 555 } 556 }, 557 } 558 Ok(()) 559} 560 561async fn run_volume(ep: &Endpoint, token: Option<String>, cmd: VolumeCmd) -> Result<()> { 562 let ch = channel(ep).await?; 563 let mut client = pb::volume_service_client::VolumeServiceClient::new(ch); 564 let mk = |card: Option<String>, control: Option<String>, index: u32| pb::MixerSelector { 565 card: card.unwrap_or_default(), 566 control: control.unwrap_or_default(), 567 index, 568 }; 569 match cmd { 570 VolumeCmd::List { card } => { 571 let r = client 572 .list_mixers(attach_token(Request::new(pb::ListMixersRequest { card }), &token)) 573 .await? 574 .into_inner(); 575 for m in r.mixers { 576 println!( 577 "{:20} index={} volume={} switch={}", 578 m.control, m.index, m.has_volume, m.has_switch 579 ); 580 } 581 } 582 VolumeCmd::Get { card, control, index } => { 583 let r = client 584 .get_volume(attach_token( 585 Request::new(pb::GetVolumeRequest { mixer: Some(mk(card, control, index)) }), 586 &token, 587 )) 588 .await? 589 .into_inner(); 590 if let Some(s) = r.status { 591 println!("{}% muted={}", s.volume_percent, s.muted); 592 } 593 } 594 VolumeCmd::Set { percent, card, control, index } => { 595 client 596 .set_volume(attach_token( 597 Request::new(pb::SetVolumeRequest { 598 mixer: Some(mk(card, control, index)), 599 volume_percent: percent, 600 }), 601 &token, 602 )) 603 .await?; 604 println!("ok"); 605 } 606 VolumeCmd::Mute { card, control, index } => { 607 client 608 .set_mute(attach_token( 609 Request::new(pb::SetMuteRequest { 610 mixer: Some(mk(card, control, index)), 611 muted: true, 612 }), 613 &token, 614 )) 615 .await?; 616 println!("ok"); 617 } 618 VolumeCmd::Unmute { card, control, index } => { 619 client 620 .set_mute(attach_token( 621 Request::new(pb::SetMuteRequest { 622 mixer: Some(mk(card, control, index)), 623 muted: false, 624 }), 625 &token, 626 )) 627 .await?; 628 println!("ok"); 629 } 630 } 631 Ok(()) 632} 633 634// --- systemd --------------------------------------------------------------- 635 636async fn run_systemd(ep: &Endpoint, token: Option<String>, cmd: SystemdCmd) -> Result<()> { 637 let ch = channel(ep).await?; 638 let mut client = pb::systemd_service_client::SystemdServiceClient::new(ch); 639 match cmd { 640 SystemdCmd::List => { 641 let r = client 642 .list_managed_units(attach_token(Request::new(pb::ListManagedUnitsRequest {}), &token)) 643 .await? 644 .into_inner(); 645 for u in r.units { 646 let state = pb::UnitActiveState::try_from(u.active_state).unwrap_or(pb::UnitActiveState::Unspecified); 647 println!( 648 "{:35} {:?} sub={} enabled={} {}", 649 u.name, state, u.sub_state, u.enabled, u.description 650 ); 651 } 652 } 653 SystemdCmd::Status { name } => { 654 let r = client 655 .status(attach_token(Request::new(pb::StatusRequestUnit { name }), &token)) 656 .await? 657 .into_inner(); 658 if let Some(u) = r.unit { 659 let state = pb::UnitActiveState::try_from(u.active_state).unwrap_or(pb::UnitActiveState::Unspecified); 660 println!( 661 "{:35} {:?} sub={} enabled={} {}", 662 u.name, state, u.sub_state, u.enabled, u.description 663 ); 664 } 665 } 666 SystemdCmd::Start { name } => { client.start(attach_token(Request::new(pb::UnitRequest { name }), &token)).await?; println!("ok"); } 667 SystemdCmd::Stop { name } => { client.stop(attach_token(Request::new(pb::UnitRequest { name }), &token)).await?; println!("ok"); } 668 SystemdCmd::Restart { name } => { client.restart(attach_token(Request::new(pb::UnitRequest { name }), &token)).await?; println!("ok"); } 669 SystemdCmd::Reload { name } => { client.reload(attach_token(Request::new(pb::UnitRequest { name }), &token)).await?; println!("ok"); } 670 SystemdCmd::Enable { name } => { client.enable(attach_token(Request::new(pb::UnitRequest { name }), &token)).await?; println!("ok"); } 671 SystemdCmd::Disable { name } => { client.disable(attach_token(Request::new(pb::UnitRequest { name }), &token)).await?; println!("ok"); } 672 } 673 Ok(()) 674} 675 676// --- config ---------------------------------------------------------------- 677 678async fn run_config(ep: &Endpoint, token: Option<String>, cmd: ConfigCmd) -> Result<()> { 679 let ch = channel(ep).await?; 680 let mut client = pb::config_service_client::ConfigServiceClient::new(ch); 681 match cmd { 682 ConfigCmd::List => { 683 let r = client 684 .list_configs(attach_token(Request::new(pb::ListConfigsRequest {}), &token)) 685 .await? 686 .into_inner(); 687 for c in r.configs { 688 println!("{:20} {} unit={}", c.key, c.path, c.unit); 689 } 690 } 691 ConfigCmd::Get { key } => { 692 let r = client 693 .get_config(attach_token(Request::new(pb::GetConfigRequest { key }), &token)) 694 .await? 695 .into_inner(); 696 if let Some(c) = r.config { 697 eprintln!("# {}{} (unit={})", c.key, c.path, c.unit); 698 } 699 print!("{}", r.content); 700 } 701 ConfigCmd::Put { key, file, action } => { 702 let content = tokio::fs::read_to_string(&file) 703 .await 704 .with_context(|| format!("read {}", file.display()))?; 705 let action = match action { 706 ActionArg::None => pb::PostWriteAction::None, 707 ActionArg::Reload => pb::PostWriteAction::Reload, 708 ActionArg::Restart => pb::PostWriteAction::Restart, 709 } as i32; 710 let r = client 711 .put_config(attach_token( 712 Request::new(pb::PutConfigRequest { key, content, action }), 713 &token, 714 )) 715 .await? 716 .into_inner(); 717 print_json(&serde_json::json!({"action_applied": r.action_applied}))?; 718 } 719 } 720 Ok(()) 721} 722 723// --- system ---------------------------------------------------------------- 724 725async fn run_system(ep: &Endpoint, token: Option<String>, cmd: SystemCmd) -> Result<()> { 726 let ch = channel(ep).await?; 727 let mut client = pb::system_service_client::SystemServiceClient::new(ch); 728 match cmd { 729 SystemCmd::Version => { 730 let r = client 731 .version(attach_token(Request::new(pb::VersionRequest {}), &token)) 732 .await? 733 .into_inner(); 734 print_json(&serde_json::json!({"version": r.version, "os": r.os, "arch": r.arch}))?; 735 } 736 SystemCmd::Health => { 737 let r = client 738 .health(attach_token(Request::new(pb::HealthRequest {}), &token)) 739 .await? 740 .into_inner(); 741 print_json(&serde_json::json!({"ok": r.ok}))?; 742 } 743 } 744 Ok(()) 745}