449 lines
18 KiB
Rust
449 lines
18 KiB
Rust
// crates/qbzd/src/api/mod.rs — the HTTP control plane (02-cli-and-api.md §3).
|
|
//
|
|
// tiny_http 0.12, single listener, thread-per-connection collapsed to ONE
|
|
// serving thread (requests are handled inline, serialized — a control plane
|
|
// sees a handful of clients, never a load). Two mechanisms qualify the
|
|
// otherwise-open surface (§3.1.2):
|
|
// - Origin shield (ALWAYS on): any request carrying an `Origin` header is
|
|
// refused `403 origin_forbidden` before routing — CLI/curl/scripts send no
|
|
// `Origin`, browsers do (CSRF / DNS-rebinding guard at zero UX cost).
|
|
// - Opt-in `[server] token`: when set, `Authorization: Bearer <token>` is
|
|
// required on every route EXCEPT `GET /api/ping`; a mismatch is
|
|
// `401 invalid_token`. Unset = no auth machinery exists at runtime.
|
|
//
|
|
// The two-call split — `bind` at boot step 5 (stateless, so the foreign-occupant
|
|
// diagnosis runs BEFORE the stores/runtime exist), `serve` at boot step 11 — is
|
|
// what keeps the 01-architecture.md §8.1 boot order honest.
|
|
pub mod playback;
|
|
pub mod status;
|
|
|
|
use std::io::Cursor;
|
|
use std::net::SocketAddr;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Instant;
|
|
|
|
use tiny_http::{Header, Request, Response};
|
|
|
|
use crate::adapter::DaemonAdapter;
|
|
use crate::paths::ProfileRoots;
|
|
use crate::state::DaemonShared;
|
|
use qbz_app::shell::AppRuntime;
|
|
use qbz_audio::settings::AudioSettingsStore;
|
|
|
|
/// The counted P0 route table (02 §3.2). A route exists only iff a shipped
|
|
/// client calls it (§3.1.4). T1-T6 landed the first 3; T7 (this task) adds the
|
|
/// 9 playback + now-playing routes (rows 4-12); T8 adds the 4 queue routes,
|
|
/// T11 `/api/settings/reload` — the inline `route_table_matches_spec_count`
|
|
/// test pins the number so the 68-routes failure shape (a route with no
|
|
/// caller) cannot creep back in.
|
|
pub const P0_ROUTES: &[(&str, &str)] = &[
|
|
("GET", "/api/ping"),
|
|
("GET", "/api/info"),
|
|
("GET", "/api/status"),
|
|
("GET", "/api/now-playing"),
|
|
("POST", "/api/playback/play"),
|
|
("POST", "/api/playback/pause"),
|
|
("POST", "/api/playback/toggle"),
|
|
("POST", "/api/playback/stop"),
|
|
("POST", "/api/playback/next"),
|
|
("POST", "/api/playback/previous"),
|
|
("POST", "/api/playback/seek"),
|
|
("POST", "/api/playback/volume"),
|
|
];
|
|
|
|
/// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server
|
|
/// in an `Arc` so the serving thread and the shutdown handle can both hold it
|
|
/// (`unblock` from the handle terminates the thread's `incoming_requests`).
|
|
pub struct BoundServer {
|
|
server: Arc<tiny_http::Server>,
|
|
}
|
|
|
|
/// Everything the route handlers read. Owned by the single serving thread
|
|
/// (moved into it by [`serve`]), so it only needs `Send`, never `Sync` — which
|
|
/// is why a plain `AudioSettingsStore` (rusqlite `Connection`: Send, not Sync)
|
|
/// can live here directly. `token` is the opt-in `[server] token`, read once at
|
|
/// boot (`None` = open).
|
|
pub struct ApiState {
|
|
pub runtime: Arc<AppRuntime<DaemonAdapter>>,
|
|
pub shared: Arc<Mutex<DaemonShared>>,
|
|
pub roots: ProfileRoots,
|
|
pub token: Option<String>,
|
|
/// The bound address, echoed verbatim by `/api/info`.
|
|
pub bind: String,
|
|
/// Handle to the daemon's tokio runtime — the serving thread is a plain
|
|
/// `std::thread`, so async core calls (`get_queue_state`) run via
|
|
/// `Handle::block_on` (never called from a runtime worker → no panic).
|
|
pub rt: tokio::runtime::Handle,
|
|
/// Second read-only connection to the daemon-root audio settings DB (WAL
|
|
/// allows it alongside the Player's). Supplies `configured_device`/`backend`.
|
|
pub audio: AudioSettingsStore,
|
|
/// Cached device enumeration for `device_present` (refreshed on a TTL so a
|
|
/// `status` poll never re-enumerates CPAL on every call).
|
|
pub devices: Mutex<DeviceCache>,
|
|
}
|
|
|
|
/// TTL-cached output-device names for the `device_present` check.
|
|
#[derive(Default)]
|
|
pub struct DeviceCache {
|
|
pub at: Option<Instant>,
|
|
pub names: Vec<String>,
|
|
}
|
|
|
|
/// Live serving handle. [`ApiHandle::shutdown`] unblocks the serving thread and
|
|
/// joins it — dropping the thread's `ApiState` (and with it the `Arc<AppRuntime>`
|
|
/// clone) BEFORE the daemon drops the runtime, preserving the §8.2 audio
|
|
/// clock-release ordering (the API thread is one more `Arc<AppRuntime>` holder,
|
|
/// exactly like the driver and auth-retry tasks).
|
|
pub struct ApiHandle {
|
|
server: Arc<tiny_http::Server>,
|
|
thread: Option<std::thread::JoinHandle<()>>,
|
|
}
|
|
|
|
impl ApiHandle {
|
|
pub fn shutdown(mut self) {
|
|
self.server.unblock();
|
|
if let Some(t) = self.thread.take() {
|
|
let _ = t.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why a bind failed — `AddrInUse` is the case the boot step-5 diagnosis probes
|
|
/// (foreign qbzd vs another process); everything else is a generic fatal.
|
|
#[derive(Debug)]
|
|
pub enum BindError {
|
|
AddrInUse(SocketAddr),
|
|
Other(String),
|
|
}
|
|
|
|
/// Boot step 5 (01 §8.1): bind only — stateless, so the foreign-occupant
|
|
/// diagnosis (in `daemon.rs`) runs BEFORE stores (6) and runtime composition (7).
|
|
pub fn bind(addr: SocketAddr) -> Result<BoundServer, BindError> {
|
|
match tiny_http::Server::http(addr) {
|
|
Ok(server) => Ok(BoundServer {
|
|
server: Arc::new(server),
|
|
}),
|
|
Err(e) => Err(classify_bind_error(e, addr)),
|
|
}
|
|
}
|
|
|
|
fn classify_bind_error(
|
|
e: Box<dyn std::error::Error + Send + Sync + 'static>,
|
|
addr: SocketAddr,
|
|
) -> BindError {
|
|
if let Some(io) = e.downcast_ref::<std::io::Error>() {
|
|
if io.kind() == std::io::ErrorKind::AddrInUse {
|
|
return BindError::AddrInUse(addr);
|
|
}
|
|
}
|
|
BindError::Other(e.to_string())
|
|
}
|
|
|
|
/// Best-effort occupant probe for the step-5 diagnosis: `GET /api/ping` and
|
|
/// check the response identifies as qbzd (`"app":"qbzd"`). Loopback, short
|
|
/// timeout, no dependency on reqwest (the CLI's async client is not built here).
|
|
pub fn probe_is_qbzd(addr: SocketAddr) -> bool {
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpStream;
|
|
use std::time::Duration;
|
|
let mut stream = match TcpStream::connect_timeout(&addr, Duration::from_millis(500)) {
|
|
Ok(s) => s,
|
|
Err(_) => return false,
|
|
};
|
|
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
|
|
let _ = stream.set_write_timeout(Some(Duration::from_secs(1)));
|
|
let req = format!(
|
|
"GET /api/ping HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"
|
|
);
|
|
if stream.write_all(req.as_bytes()).is_err() {
|
|
return false;
|
|
}
|
|
let _ = stream.flush();
|
|
let mut buf = Vec::new();
|
|
let _ = stream.take(4096).read_to_end(&mut buf);
|
|
let text = String::from_utf8_lossy(&buf);
|
|
text.contains("\"app\":\"qbzd\"")
|
|
}
|
|
|
|
/// Boot step 11 (01 §8.1): start serving on the already-bound socket. Requests
|
|
/// are handled inline on one thread (serialized). `unblock` (from the handle)
|
|
/// ends `incoming_requests` for graceful shutdown.
|
|
pub fn serve(server: BoundServer, state: ApiState) -> ApiHandle {
|
|
// The counted P0 surface — logged at boot so an operator sees exactly how
|
|
// many routes this build exposes (and it anchors the const to production).
|
|
log::info!("control plane serving {} P0 route(s)", P0_ROUTES.len());
|
|
let srv = server.server;
|
|
let srv_handle = srv.clone();
|
|
let thread = std::thread::Builder::new()
|
|
.name("qbzd-api".into())
|
|
.spawn(move || {
|
|
for mut req in srv.incoming_requests() {
|
|
let resp = route(&state, &mut req);
|
|
let _ = req.respond(resp);
|
|
}
|
|
})
|
|
.expect("failed to spawn qbzd api thread");
|
|
ApiHandle {
|
|
server: srv_handle,
|
|
thread: Some(thread),
|
|
}
|
|
}
|
|
|
|
fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
|
|
let method = req.method().as_str().to_owned();
|
|
let path = req.url().split('?').next().unwrap_or("").to_owned();
|
|
let has_origin = req.headers().iter().any(|h| h.field.equiv("Origin"));
|
|
let auth_header = req
|
|
.headers()
|
|
.iter()
|
|
.find(|h| h.field.equiv("Authorization"))
|
|
.map(|h| h.value.as_str());
|
|
|
|
// Origin shield (ALWAYS on) + opt-in [server] token — one pre-routing gate,
|
|
// /api/ping exempt from the token only (§3.1.2).
|
|
if let Some(reject) = access_gate(has_origin, &method, &path, auth_header, state.token.as_deref())
|
|
{
|
|
return reject.response();
|
|
}
|
|
|
|
match (method.as_str(), path.as_str()) {
|
|
("GET", "/api/ping") => json(
|
|
200,
|
|
serde_json::json!({"ok": true, "app": "qbzd", "api_version": crate::API_VERSION}),
|
|
),
|
|
("GET", "/api/info") => status::info(state),
|
|
("GET", "/api/status") => status::status(state),
|
|
("GET", "/api/now-playing") => playback::now_playing(state),
|
|
("POST", "/api/playback/play") => playback::play(state),
|
|
("POST", "/api/playback/pause") => playback::pause(state),
|
|
("POST", "/api/playback/toggle") => playback::toggle(state),
|
|
("POST", "/api/playback/stop") => playback::stop(state),
|
|
("POST", "/api/playback/next") => playback::next(state),
|
|
("POST", "/api/playback/previous") => playback::previous(state),
|
|
("POST", "/api/playback/seek") => {
|
|
let body = read_json_body(req);
|
|
playback::seek(state, &body)
|
|
}
|
|
("POST", "/api/playback/volume") => {
|
|
let body = read_json_body(req);
|
|
playback::volume(state, &body)
|
|
}
|
|
// T8: 4 queue routes · T11: /api/settings/reload.
|
|
_ => err_json(404, "not_found", "unknown route", "see qbzd --help"),
|
|
}
|
|
}
|
|
|
|
/// Read and parse a request body as JSON (T7's seek/volume POST bodies).
|
|
/// An unreadable or absent body parses to `Value::Null` — the route handlers
|
|
/// treat a missing expected field as `400 bad_request`, never a panic.
|
|
fn read_json_body(req: &mut Request) -> serde_json::Value {
|
|
let mut buf = String::new();
|
|
let _ = req.as_reader().read_to_string(&mut buf);
|
|
serde_json::from_str(&buf).unwrap_or(serde_json::Value::Null)
|
|
}
|
|
|
|
/// A pre-routing rejection. Carried as a small enum so [`access_gate`] stays a
|
|
/// pure decision (unit-testable without a tiny_http `Request`, which has no
|
|
/// public constructor) while `route` renders the normative envelope.
|
|
enum GateReject {
|
|
OriginForbidden,
|
|
InvalidToken,
|
|
}
|
|
|
|
impl GateReject {
|
|
fn response(&self) -> Response<Cursor<Vec<u8>>> {
|
|
match self {
|
|
GateReject::OriginForbidden => err_json(
|
|
403,
|
|
"origin_forbidden",
|
|
"requests with an Origin header are refused",
|
|
"the control plane is not a browser API",
|
|
),
|
|
GateReject::InvalidToken => err_json(
|
|
401,
|
|
"invalid_token",
|
|
"missing or wrong bearer token",
|
|
"set QBZD_TOKEN or check [server] token in qbzd.toml",
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The pre-routing access decision (02 §3.1.2): Origin shield always on; the
|
|
/// opt-in Bearer required on every route except `GET /api/ping` when `token`
|
|
/// is `Some`. `None` = open (no auth machinery). Returns `Some(_)` to reject.
|
|
fn access_gate(
|
|
has_origin: bool,
|
|
method: &str,
|
|
path: &str,
|
|
auth_header: Option<&str>,
|
|
token: Option<&str>,
|
|
) -> Option<GateReject> {
|
|
if has_origin {
|
|
return Some(GateReject::OriginForbidden);
|
|
}
|
|
if let Some(secret) = token {
|
|
let is_ping = method == "GET" && path == "/api/ping";
|
|
let expected = format!("Bearer {secret}");
|
|
let ok = auth_header
|
|
.map(|v| constant_time_eq(v.as_bytes(), expected.as_bytes()))
|
|
.unwrap_or(false);
|
|
if !is_ping && !ok {
|
|
return Some(GateReject::InvalidToken);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
|
if a.len() != b.len() {
|
|
return false;
|
|
}
|
|
let mut diff = 0u8;
|
|
for (x, y) in a.iter().zip(b.iter()) {
|
|
diff |= x ^ y;
|
|
}
|
|
diff == 0
|
|
}
|
|
|
|
/// Canonical JSON number for a volume level (0.0-1.0). `serde_json::Value`
|
|
/// backs f32 via `Number::from_f32`, which stores the value as `f as f64` —
|
|
/// the f32→f64 widening turns `0.8f32` into `0.800000011920929` on the wire.
|
|
/// 02-cli-and-api.md §2.2/§3.3.4 document plain `0.8`, and `--json` is the
|
|
/// frozen machine contract ("scripts parse this"), so every volume-bearing
|
|
/// response routes through this instead of a bare `json!(v)`. 3 decimals is
|
|
/// plenty of precision for a 0.0-1.0 level.
|
|
pub(crate) fn canon_volume(v: f32) -> serde_json::Value {
|
|
let rounded = (v as f64 * 1000.0).round() / 1000.0;
|
|
serde_json::json!(rounded)
|
|
}
|
|
|
|
/// A 2xx JSON response. `pub(crate)` so the per-route handlers in `status.rs`
|
|
/// share the exact same envelope framing.
|
|
pub(crate) fn json(status: u16, body: serde_json::Value) -> Response<Cursor<Vec<u8>>> {
|
|
let bytes = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec());
|
|
let header = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
|
|
.expect("static content-type header");
|
|
Response::from_data(bytes)
|
|
.with_status_code(status)
|
|
.with_header(header)
|
|
}
|
|
|
|
/// The normative error envelope (02 §3.1.3): `{"error":{"code","message","hint"}}`.
|
|
/// The CLI keys its exit code off `code` (never raw HTTP status), and every hint
|
|
/// names the fix (§1.4 error voice). The G0 addendum's shorthand
|
|
/// `{"error":"origin_forbidden"}` is this same nested envelope — the uniform
|
|
/// §3.1.3 shape the CLI's `error_from_envelope` reads via `error.code`.
|
|
pub(crate) fn err_json(
|
|
status: u16,
|
|
code: &str,
|
|
message: &str,
|
|
hint: &str,
|
|
) -> Response<Cursor<Vec<u8>>> {
|
|
json(status, error_body(code, message, hint))
|
|
}
|
|
|
|
pub(crate) fn error_body(code: &str, message: &str, hint: &str) -> serde_json::Value {
|
|
serde_json::json!({"error": {"code": code, "message": message, "hint": hint}})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn route_table_matches_spec_count() {
|
|
// 02-cli-and-api.md §3.2 — P0 = exactly 17 routes; grows ONLY with a
|
|
// shipped client. T1-T6 landed 3; T7 (this task) +9 = 12; T8 +4; T11 +1.
|
|
assert_eq!(P0_ROUTES.len(), 12);
|
|
assert!(P0_ROUTES.contains(&("GET", "/api/ping")));
|
|
assert!(P0_ROUTES.contains(&("GET", "/api/info")));
|
|
assert!(P0_ROUTES.contains(&("GET", "/api/status")));
|
|
assert!(P0_ROUTES.contains(&("GET", "/api/now-playing")));
|
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/play")));
|
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/pause")));
|
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/toggle")));
|
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/stop")));
|
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/next")));
|
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/previous")));
|
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/seek")));
|
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/volume")));
|
|
}
|
|
|
|
#[test]
|
|
fn constant_time_eq_matches_only_identical_slices() {
|
|
assert!(constant_time_eq(b"Bearer s3cret", b"Bearer s3cret"));
|
|
assert!(!constant_time_eq(b"Bearer s3cret", b"Bearer wrong"));
|
|
assert!(!constant_time_eq(b"Bearer s3cret", b"Bearer s3cret-extra"));
|
|
assert!(!constant_time_eq(b"", b"x"));
|
|
}
|
|
|
|
fn code(r: Option<GateReject>) -> Option<&'static str> {
|
|
r.map(|r| match r {
|
|
GateReject::OriginForbidden => "origin_forbidden",
|
|
GateReject::InvalidToken => "invalid_token",
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn origin_header_is_refused_on_every_route_including_ping() {
|
|
// Step 4(a): an Origin header → 403 origin_forbidden everywhere, even
|
|
// /api/ping, and even in the open (token=None) default.
|
|
for (m, p) in P0_ROUTES {
|
|
assert_eq!(
|
|
code(access_gate(true, m, p, None, None)),
|
|
Some("origin_forbidden"),
|
|
"{m} {p} with Origin must be refused"
|
|
);
|
|
}
|
|
// ...and the Origin shield wins even when a valid Bearer is present.
|
|
assert_eq!(
|
|
code(access_gate(true, "GET", "/api/ping", Some("Bearer s3cret"), Some("s3cret"))),
|
|
Some("origin_forbidden")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn open_mode_answers_every_route_without_auth() {
|
|
// Step 4(b): token=None → no auth machinery; nothing is rejected.
|
|
for (m, p) in P0_ROUTES {
|
|
assert!(access_gate(false, m, p, None, None).is_none(), "{m} {p} must be open");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn opt_in_token_rejects_missing_or_wrong_bearer_but_never_ping() {
|
|
// Step 4(c): token=Some → missing/wrong bearer is 401 on non-ping routes;
|
|
// /api/ping stays 200 (exempt); the correct bearer passes.
|
|
let tok = Some("s3cret");
|
|
assert_eq!(code(access_gate(false, "GET", "/api/status", None, tok)), Some("invalid_token"));
|
|
assert_eq!(
|
|
code(access_gate(false, "GET", "/api/status", Some("Bearer nope"), tok)),
|
|
Some("invalid_token")
|
|
);
|
|
assert!(access_gate(false, "GET", "/api/status", Some("Bearer s3cret"), tok).is_none());
|
|
// /api/ping answers even with no/ wrong bearer.
|
|
assert!(access_gate(false, "GET", "/api/ping", None, tok).is_none());
|
|
assert!(access_gate(false, "GET", "/api/ping", Some("Bearer nope"), tok).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn canon_volume_pins_0_8_exactly_no_f32_widening() {
|
|
// `serde_json::Number::from_f32` widens f32→f64 (`0.8f32` would
|
|
// serialize raw as `0.800000011920929`); `canon_volume` must not.
|
|
assert_eq!(serde_json::to_string(&canon_volume(0.8f32)).unwrap(), "0.8");
|
|
assert_eq!(serde_json::to_string(&canon_volume(1.0f32)).unwrap(), "1.0");
|
|
assert_eq!(serde_json::to_string(&canon_volume(0.0f32)).unwrap(), "0.0");
|
|
assert_eq!(serde_json::to_string(&canon_volume(0.75f32)).unwrap(), "0.75");
|
|
}
|
|
|
|
#[test]
|
|
fn error_envelope_is_the_nested_shape_with_code() {
|
|
// 02 §3.1.3 — the on-the-wire shape the CLI reads via `error.code`.
|
|
let body = error_body("origin_forbidden", "refused", "not a browser API");
|
|
assert_eq!(body["error"]["code"], "origin_forbidden");
|
|
assert_eq!(body["error"]["message"], "refused");
|
|
assert_eq!(body["error"]["hint"], "not a browser API");
|
|
}
|
|
}
|