1use std::path::PathBuf;
2use std::sync::Arc;
3
4use crossbeam_channel::Sender;
5use koan_core::audio::viz::VizSnapshot;
6use koan_core::auth::{self, parse_duration_secs};
7use koan_core::config::Config;
8use koan_core::db::pool::Pool;
9use koan_core::player::commands::PlayerCommand;
10use koan_core::player::state::SharedPlayerState;
11
12use super::KoanSchema;
13use crate::auth::AuthUser;
14use crate::auth::middleware::{AuthState, auth_middleware};
15use crate::auth::routes::{AuthRouteState, RateLimiter, auth_router};
16
17fn require_server_url() {
24 let why = match Config::cached().server_url() {
25 None => "server.url is not set. Set it, or KOAN_SERVER__URL, to the address clients reach this server at, e.g. https://koan.example.com".to_owned(),
26 Some(url) if !url.starts_with("https://") && !url.starts_with("http://") => {
27 format!("server.url is {url:?}, which is not an http:// or https:// address")
28 }
29 Some(url) => match crate::base::problem(&url) {
30 Some(why) => format!("server.url is {url:?}: {why}"),
31 None => return,
32 },
33 };
34 log::error!("{why}");
35 eprintln!("koan: {why}");
36 std::process::exit(1);
37}
38
39pub fn cmd_serve(
40 port: Option<u16>,
41 bind: Option<std::net::IpAddr>,
42 subsonic_port: Option<u16>,
43 playground: bool,
44) {
45 use koan_core::player::Player;
46
47 require_server_url();
48 if let Err(e) = koan_core::db::connection::Database::open_default() {
52 log::error!("cannot open the database: {e}");
53 eprintln!("koan: cannot open the database: {e}");
54 std::process::exit(1);
55 }
56 let db_path = koan_core::config::db_path();
57 let pool = Arc::new(Pool::new(db_path.clone()));
58 koan_core::db::pool::on_outdated(|| OUTDATED.cancel());
59
60 let (state, _timeline, _viz, cmd_tx) = Player::spawn();
61
62 let watched = db_path.clone();
65 let jobs = super::jobs::JobRegistry::default();
66 let record = super::jobs::record_watch(&jobs);
67 koan_core::helpers::spawn_library_watch(db_path, move |scan| {
68 record(&scan);
69 if let koan_core::helpers::WatchScan::Finished(_) = scan {
72 crate::clients::fulfil_from(&watched);
73 if let Ok(db) = koan_core::db::connection::Database::open_existing(&watched) {
74 crate::clients::changed_if_library_moved(&db.conn);
75 }
76 }
77 });
78
79 let started = ApiServer::start_with(
80 ApiServerOpts {
81 state,
82 cmd_tx,
83 pool,
84 port,
85 bind,
86 subsonic_port,
87 playground,
88 viz: None, profile: Profile::Headless,
90 hosts: Vec::new(),
91 },
92 jobs,
93 );
94 match started {
95 Ok(server) => server.wait(),
96 Err(e) => {
97 eprintln!("koan: {}", e);
98 std::process::exit(1);
99 }
100 }
101}
102
103const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
110
111const MAX_INFLIGHT_QUERIES: usize = 64;
115
116const MAX_QUERY_BODY: usize = 2 << 20;
120
121fn load_perimeter<S>(router: axum::Router<S>) -> axum::Router<S>
126where
127 S: Clone + Send + Sync + 'static,
128{
129 router
130 .layer(tower_http::catch_panic::CatchPanicLayer::new())
133 .layer(tower_http::limit::RequestBodyLimitLayer::new(
134 MAX_QUERY_BODY,
135 ))
136 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
137 axum::http::StatusCode::REQUEST_TIMEOUT,
138 REQUEST_TIMEOUT,
139 ))
140 .layer(
144 tower::ServiceBuilder::new()
145 .layer(axum::error_handling::HandleErrorLayer::new(
146 |err: tower::BoxError| async move {
147 if err.is::<tower::load_shed::error::Overloaded>() {
148 (
149 axum::http::StatusCode::SERVICE_UNAVAILABLE,
150 "server at capacity",
151 )
152 } else {
153 (
154 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
155 "internal error",
156 )
157 }
158 },
159 ))
160 .load_shed()
161 .concurrency_limit(MAX_INFLIGHT_QUERIES),
162 )
163}
164
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
168pub enum Profile {
169 Headless,
172 Tui,
174 Embedded,
179}
180
181pub struct ApiServerOpts {
183 pub state: Arc<SharedPlayerState>,
184 pub cmd_tx: Sender<PlayerCommand>,
185 pub pool: Arc<Pool>,
188 pub port: Option<u16>,
189 pub bind: Option<std::net::IpAddr>,
190 pub subsonic_port: Option<u16>,
191 pub playground: bool,
192 pub viz: Option<Arc<VizSnapshot>>,
193 pub profile: Profile,
194 pub hosts: Vec<String>,
197}
198
199pub struct ApiServer {
202 addr: std::net::SocketAddr,
203 host_key: Option<String>,
206 stop: tokio_util::sync::CancellationToken,
207 thread: Option<std::thread::JoinHandle<()>>,
208}
209
210impl ApiServer {
211 pub fn start(opts: ApiServerOpts) -> Result<Self, String> {
221 Self::start_with(opts, Default::default())
222 }
223
224 pub(crate) fn start_with(
225 opts: ApiServerOpts,
226 jobs: super::jobs::JobRegistry,
227 ) -> Result<Self, String> {
228 let ApiServerOpts {
229 state,
230 cmd_tx,
231 pool,
232 port,
233 bind,
234 subsonic_port,
235 playground,
236 viz,
237 profile,
238 hosts,
239 } = opts;
240 use axum::routing::{get, post};
241
242 let cfg = Config::cached();
243 let embedded = profile == Profile::Embedded;
244 let port = port.unwrap_or(cfg.graphql.port);
245 let setup_wizard = cfg.graphql.setup_wizard && profile == Profile::Headless;
249 let bind = bind.unwrap_or(cfg.graphql.bind);
250 let subsonic_port = if embedded {
252 None
253 } else {
254 subsonic_port.or(cfg.subsonic.port)
255 };
256 let subsonic_enabled = embedded || cfg.subsonic.enabled;
257 let playground_enabled = !embedded && (playground || cfg.graphql.playground);
258 let auth_enabled = cfg.graphql.auth_enabled;
259 let demo = cfg.server.demo;
260
261 if embedded && !auth_enabled {
262 return Err(
263 "graphql.auth_enabled is off, which would make anyone who reaches \
264 this port an admin"
265 .into(),
266 );
267 }
268 if demo && embedded {
269 return Err("server.demo is for a headless server".into());
270 }
271 if demo && !auth_enabled {
273 return Err("server.demo needs graphql.auth_enabled".into());
274 }
275
276 let (private_pem, public_pem) = if auth_enabled {
283 let kp = auth::load_or_generate_keypair().map_err(|e| {
284 format!(
285 "auth_enabled = true but the keypair could not be loaded or created: {}",
286 e
287 )
288 })?;
289 if kp.0.is_empty() || kp.1.is_empty() {
292 return Err("auth_enabled = true but the keypair files are empty. \
293 Run `koan auth regenerate-keys`."
294 .into());
295 }
296 kp
297 } else {
298 auth::load_or_generate_keypair().unwrap_or_default()
301 };
302
303 let access_ttl = parse_duration_secs(&cfg.graphql.access_token_ttl).unwrap_or(900);
304 let refresh_ttl = parse_duration_secs(&cfg.graphql.refresh_token_ttl).unwrap_or(2_592_000);
305
306 let introspection_key = if playground_enabled && auth_enabled {
310 Some(Arc::new(auth::random_token().map_err(|e| {
311 format!("failed to generate introspection key: {}", e)
312 })?))
313 } else {
314 None
315 };
316
317 let proxy_auth = if embedded {
318 None
319 } else {
320 crate::ui::ProxyAuth::from_config(
321 &cfg.graphql.proxy_auth_header,
322 &cfg.graphql.proxy_auth_from,
323 )?
324 };
325
326 if bind.is_unspecified() && port != 0 {
331 let loopbacks: &[std::net::IpAddr] = match bind {
333 std::net::IpAddr::V4(_) => &[std::net::Ipv4Addr::LOCALHOST.into()],
334 std::net::IpAddr::V6(_) => &[
335 std::net::Ipv4Addr::LOCALHOST.into(),
336 std::net::Ipv6Addr::LOCALHOST.into(),
337 ],
338 };
339 for loopback in loopbacks {
340 std::net::TcpListener::bind((*loopback, port))
341 .map_err(|e| format!("port {port} is already in use on this machine — {e}"))?;
342 }
343 }
344 let gql_listener = std::net::TcpListener::bind((bind, port))
345 .and_then(|l| l.set_nonblocking(true).map(|()| l))
346 .map_err(|e| {
347 format!("failed to bind GraphQL port {port} — {e} (another instance running?)")
348 })?;
349 let addr = gql_listener
350 .local_addr()
351 .map_err(|e| format!("failed to read the GraphQL listener's address — {e}"))?;
352
353 let sub_listener = subsonic_port
356 .filter(|p| subsonic_enabled && *p != port)
357 .and_then(|sub_port| {
358 match std::net::TcpListener::bind((bind, sub_port))
359 .and_then(|l| l.set_nonblocking(true).map(|()| l))
360 {
361 Ok(l) => Some(l),
362 Err(e) => {
363 log::warn!(
364 "Dedicated Subsonic port {sub_port} unavailable — {e}. Mounted on GraphQL port only."
365 );
366 None
367 }
368 }
369 });
370
371 let rt = tokio::runtime::Runtime::new()
372 .map_err(|e| format!("could not start the API server's runtime — {e}"))?;
373 let (gql_listener, sub_listener) = {
374 let _in = rt.enter();
375 let gql = tokio::net::TcpListener::from_std(gql_listener)
376 .map_err(|e| format!("failed to listen on {addr} — {e}"))?;
377 let sub = sub_listener.and_then(|l| match tokio::net::TcpListener::from_std(l) {
378 Ok(l) => Some(l),
379 Err(e) => {
380 log::warn!("Dedicated Subsonic listener unusable — {e}");
381 None
382 }
383 });
384 (gql, sub)
385 };
386
387 let host_key = embedded
388 .then(auth::random_token)
389 .transpose()
390 .map_err(|e| format!("failed to generate the host key: {e}"))?;
391
392 if demo {
393 let db = pool.get().map_err(|e| format!("demo: {e}"))?;
394 crate::demo::start(&db.conn).map_err(|e| format!("demo: {e}"))?;
395 }
396
397 let _ = std::fs::remove_file(auth::keypair_dir().join("subsonic.key"));
400
401 crate::auth::set_signing_keys(Arc::new(private_pem.clone()), Arc::new(public_pem.clone()));
402
403 let auth_state = AuthState {
404 public_pem: Arc::new(public_pem.clone()),
405 auth_enabled,
406 introspection_key: introspection_key.clone(),
407 pool: pool.clone(),
408 };
409
410 let users = Arc::new(crate::auth::password::PasswordVerifier::new(pool.clone()));
412 let auth_route_state = AuthRouteState {
413 users: users.clone(),
414 pool: pool.clone(),
415 private_pem: Arc::new(private_pem),
416 public_pem: Arc::new(public_pem),
417 access_ttl_secs: access_ttl,
418 refresh_ttl_secs: refresh_ttl,
419 cookie_secure: cfg.graphql.cookie_secure,
420 login_limiter: Arc::new(RateLimiter::default()),
421 };
422
423 koan_core::scrobbling::start(pool.path().to_path_buf());
425 let _ = crate::push::pusher();
428
429 let stop = tokio_util::sync::CancellationToken::new();
431 let mcp_routes = (!embedded).then(|| {
434 crate::mcp::router(
435 state.clone(),
436 cmd_tx.clone(),
437 auth_state.clone(),
438 cfg.server_url(),
439 profile == Profile::Headless,
440 stop.clone(),
441 )
442 });
443 let schema = super::build_schema_sharing(state, cmd_tx, pool.clone(), viz, jobs.clone());
444
445 if auth_enabled {
446 log::info!(
447 "Auth enabled (Ed25519 JWT, access TTL {}s, refresh TTL {}s)",
448 access_ttl,
449 refresh_ttl
450 );
451 let no_accounts = pool
452 .get()
453 .ok()
454 .and_then(|db| koan_core::db::queries::auth::has_users(&db.conn).ok())
455 == Some(false);
456 if no_accounts && setup_wizard {
457 log::warn!(
458 "No accounts yet: whoever opens /setup first becomes the admin. \
459 Set graphql.setup_wizard = false to make it with `koan auth setup` instead."
460 );
461 }
462 } else {
463 log::info!("Auth disabled — all requests treated as admin");
464 }
465
466 let mut allowed_hosts = cfg.graphql.allowed_hosts.clone();
467 allowed_hosts.extend(hosts);
468 let forwarded_from = if embedded {
471 crate::auth::routes::ForwardedFrom::Loopback
472 } else {
473 crate::auth::routes::ForwardedFrom::Private
474 };
475 let browser_policy = Arc::new(BrowserPolicy {
476 origins: cfg.graphql.cors_origins.clone(),
477 hosts: allowed_hosts,
478 });
479
480 if cfg.graphql.cors_origins.is_empty() {
481 log::info!("CORS: no origins configured — browsers get no cross-origin access");
482 }
483
484 let serving = stop.clone();
485 let serving_key = host_key.clone();
486 let thread = std::thread::Builder::new()
487 .name("koan-api".into())
488 .spawn(move || {
489 let stop = serving;
490 rt.block_on(async {
491 if !embedded {
492 let stop = stop.clone();
493 tokio::spawn(async move {
494 shutdown_signal().await;
495 stop.cancel();
496 });
497 }
498
499 let query_route =
504 load_perimeter(axum::Router::new().route("/graphql", post(graphql_handler)));
505
506 let gql_app = axum::Router::new()
507 .merge(query_route)
508 .route("/graphql/ws", get(graphql_ws_handler))
509 .layer(axum::Extension(stop.clone()))
510 .layer(axum::middleware::from_fn_with_state(
511 auth_state.clone(),
512 auth_middleware,
513 ))
514 .layer(axum::middleware::from_fn_with_state(
517 browser_policy.clone(),
518 browser_guard,
519 ))
520 .with_state(schema);
521
522 let covers = Arc::new(crate::covers::Covers::in_config_dir());
526 let ui_routes = (!embedded).then(|| crate::ui::router(
527 pool.clone(),
528 auth_route_state.clone(),
529 auth_enabled,
530 setup_wizard,
531 covers.clone(),
532 cfg.server_url(),
533 cfg.mcp.redirect_hosts.clone(),
534 proxy_auth,
535 jobs,
536 )
537 .layer(tower_http::catch_panic::CatchPanicLayer::new()));
539 let ui_routes = ui_routes.map(|ui| {
540 if demo {
541 ui.layer(axum::middleware::from_fn(crate::demo::guard))
542 } else {
543 ui
544 }
545 });
546
547 let auth_app = auth_router(auth_route_state);
549
550 let origins: Vec<axum::http::HeaderValue> = cfg
553 .graphql
554 .cors_origins
555 .iter()
556 .filter_map(|o| o.parse().ok())
557 .collect();
558 let cors = tower_http::cors::CorsLayer::new()
559 .allow_origin(origins)
560 .allow_methods([
561 axum::http::Method::GET,
562 axum::http::Method::POST,
563 axum::http::Method::OPTIONS,
564 ])
565 .allow_headers([
566 axum::http::header::AUTHORIZATION,
567 axum::http::header::CONTENT_TYPE,
568 axum::http::HeaderName::from_static("x-introspection-key"),
569 ])
570 .allow_credentials(true);
571
572 let share_url = match embedded {
578 true => crate::ServerUrl::Live,
579 false => crate::ServerUrl::Fixed(cfg.server_url()),
580 };
581 let edge = axum::middleware::from_fn_with_state(
582 crate::base::Base::of(&share_url),
583 crate::base::edge,
584 );
585 let share_routes =
586 crate::share::router(pool.clone(), share_url, covers.clone())
587 .layer(tower_http::catch_panic::CatchPanicLayer::new());
588 let subsonic_merged = subsonic_enabled
595 .then(|| {
596 crate::subsonic::subsonic_router(
597 pool,
598 covers,
599 users,
600 profile,
601 stop.clone(),
602 serving_key,
603 )
604 })
605 .map(|sub| {
606 if demo {
607 sub.layer(axum::middleware::from_fn(crate::demo::guard))
608 } else {
609 sub
610 }
611 });
612 let subsonic_dedicated = subsonic_merged.clone();
613
614 let mut app = match embedded {
618 true => share_routes,
619 false => auth_app.merge(gql_app).merge(share_routes),
620 };
621 if let Some(ui) = ui_routes {
622 app = app.merge(ui);
623 }
624 if let Some(mcp) = mcp_routes {
625 app = app.merge(mcp);
626 }
627 if let Some(sub) = subsonic_merged {
628 app = app.merge(sub);
629 }
630 if playground_enabled {
631 app = app.route(
632 "/graphql",
633 get(graphql_playground).with_state((
634 introspection_key.clone(),
635 crate::base::path_of(cfg.server_url().as_deref()),
636 )),
637 );
638 }
639 let app = app
643 .layer(cors)
644 .layer(axum::middleware::from_fn_with_state(
645 browser_policy.clone(),
646 host_guard,
647 ))
648 .layer(axum::Extension(forwarded_from));
649
650 log::info!("GraphQL API on http://{addr}/graphql");
651 if subsonic_enabled {
652 log::info!("Subsonic REST on http://{addr}/rest/");
653 }
654 if playground_enabled {
655 let playground_url = match introspection_key {
656 Some(ref key) => {
657 format!("http://{addr}/graphql?introspection-key={key}")
658 }
659 None => format!("http://{addr}/graphql"),
660 };
661 log::info!("GraphiQL: {}", playground_url);
662 #[cfg(target_os = "macos")]
664 let _ = std::process::Command::new("open").arg(&playground_url).spawn();
665 #[cfg(target_os = "linux")]
666 let _ = std::process::Command::new("xdg-open")
667 .arg(&playground_url)
668 .spawn();
669 }
670 let gql_server = axum::serve(
671 gql_listener,
672 axum::ServiceExt::<axum::extract::Request>::into_make_service_with_connect_info::<std::net::SocketAddr>(
673 tower::Layer::layer(&edge, app),
674 ),
675 )
676 .with_graceful_shutdown(stop.clone().cancelled_owned());
677
678 if let Some((sub_listener, sub_app)) = sub_listener.zip(subsonic_dedicated) {
679 if let Ok(a) = sub_listener.local_addr() {
680 log::info!("Subsonic REST also on http://{a}/rest/ (dedicated port)");
681 }
682 let sub_server = axum::serve(
685 sub_listener,
686 axum::ServiceExt::<axum::extract::Request>::into_make_service_with_connect_info::<std::net::SocketAddr>(
687 tower::Layer::layer(&edge, sub_app),
688 ),
689 )
690 .with_graceful_shutdown(stop.clone().cancelled_owned());
691
692 tokio::select! {
693 r = gql_server => { if let Err(e) = r { log::error!("GraphQL server error: {e}"); } },
694 r = sub_server => { if let Err(e) = r { log::error!("Subsonic server error: {e}"); } },
695 _ = drain_deadline(&stop) => log::info!("shutting down with connections still open"),
696 }
697 return;
698 }
699
700 tokio::select! {
701 r = gql_server => { if let Err(e) = r { log::error!("GraphQL server error: {e}"); } },
702 _ = drain_deadline(&stop) => log::info!("shutting down with connections still open"),
703 }
704 });
705 rt.shutdown_timeout(DRAIN);
708 })
709 .map_err(|e| format!("could not start the API server thread — {e}"))?;
710
711 Ok(Self {
712 addr,
713 host_key,
714 stop,
715 thread: Some(thread),
716 })
717 }
718
719 pub fn host_key(&self) -> Option<&str> {
721 self.host_key.as_deref()
722 }
723
724 pub fn addr(&self) -> std::net::SocketAddr {
726 self.addr
727 }
728
729 pub fn stop(mut self) {
732 self.stop.cancel();
733 self.join();
734 }
735
736 pub fn wait(mut self) {
739 self.join();
740 }
741
742 fn join(&mut self) {
743 if let Some(thread) = self.thread.take()
744 && thread.join().is_err()
745 {
746 log::error!("API server thread panicked");
747 }
748 }
749}
750
751impl Drop for ApiServer {
752 fn drop(&mut self) {
753 self.stop.cancel();
754 }
755}
756
757pub fn start_api_background(
760 state: Arc<SharedPlayerState>,
761 cmd_tx: Sender<PlayerCommand>,
762 db_path: PathBuf,
763 port: Option<u16>,
764 bind: Option<std::net::IpAddr>,
765 subsonic_port: Option<u16>,
766 playground: bool,
767) {
768 let started = ApiServer::start(ApiServerOpts {
769 state,
770 cmd_tx,
771 pool: Arc::new(Pool::new(db_path)),
772 port,
773 bind,
774 subsonic_port,
775 playground,
776 viz: None,
777 profile: Profile::Tui,
778 hosts: Vec::new(),
779 });
780 match started {
781 Ok(server) => server.wait(),
782 Err(e) => log::error!("API server not started: {}", e),
783 }
784}
785
786pub(crate) struct BrowserPolicy {
795 origins: Vec<String>,
796 hosts: Vec<String>,
797}
798
799impl BrowserPolicy {
800 fn host_allowed(&self, host: &str) -> bool {
801 if self.hosts.iter().any(|h| h.eq_ignore_ascii_case(host)) {
802 return true;
803 }
804 let bare = strip_port(host);
805 if self.hosts.iter().any(|h| h.eq_ignore_ascii_case(bare)) {
806 return true;
807 }
808 bare.eq_ignore_ascii_case("localhost") || bare.parse::<std::net::IpAddr>().is_ok()
811 }
812
813 fn origin_allowed(&self, origin: &str, host: Option<&str>) -> bool {
816 if self.origins.iter().any(|o| o == origin) {
817 return true;
818 }
819 match (origin.split_once("://"), host) {
820 (Some((_, authority)), Some(host)) => authority.eq_ignore_ascii_case(host),
821 _ => false,
822 }
823 }
824}
825
826fn strip_port(host: &str) -> &str {
828 if let Some(rest) = host.strip_prefix('[') {
829 return rest.split(']').next().unwrap_or(rest);
830 }
831 match host.rsplit_once(':') {
832 Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,
833 _ => host,
834 }
835}
836
837fn header_str(request: &axum::extract::Request, name: axum::http::HeaderName) -> Option<&str> {
838 request.headers().get(name).and_then(|v| v.to_str().ok())
839}
840
841async fn host_guard(
843 axum::extract::State(policy): axum::extract::State<Arc<BrowserPolicy>>,
844 request: axum::extract::Request,
845 next: axum::middleware::Next,
846) -> axum::response::Response {
847 use axum::response::IntoResponse;
848
849 let host = header_str(&request, axum::http::header::HOST)
852 .map(str::to_owned)
853 .or_else(|| request.uri().host().map(str::to_owned));
854
855 if let Some(ref host) = host
856 && !policy.host_allowed(host)
857 {
858 log::warn!("rejected request for unrecognised Host: {}", host);
859 return (axum::http::StatusCode::FORBIDDEN, "host not allowed").into_response();
860 }
861
862 next.run(request).await
863}
864
865async fn browser_guard(
874 axum::extract::State(policy): axum::extract::State<Arc<BrowserPolicy>>,
875 request: axum::extract::Request,
876 next: axum::middleware::Next,
877) -> axum::response::Response {
878 use axum::response::IntoResponse;
879
880 let host = header_str(&request, axum::http::header::HOST).map(str::to_owned);
881 if let Some(origin) = header_str(&request, axum::http::header::ORIGIN)
883 && !policy.origin_allowed(origin, host.as_deref())
884 {
885 log::warn!(
886 "rejected GraphQL request from disallowed Origin: {}",
887 origin
888 );
889 return (axum::http::StatusCode::FORBIDDEN, "origin not allowed").into_response();
890 }
891
892 if request.method() == axum::http::Method::POST && !is_graphql_content_type(&request) {
893 return (
894 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
895 "content type must be application/json or application/graphql",
896 )
897 .into_response();
898 }
899
900 next.run(request).await
901}
902
903fn is_graphql_content_type(request: &axum::extract::Request) -> bool {
904 header_str(request, axum::http::header::CONTENT_TYPE).is_some_and(|ct| {
905 let ct = ct.trim().to_ascii_lowercase();
906 ct.starts_with("application/json") || ct.starts_with("application/graphql")
907 })
908}
909
910static OUTDATED: std::sync::LazyLock<tokio_util::sync::CancellationToken> =
914 std::sync::LazyLock::new(tokio_util::sync::CancellationToken::new);
915
916async fn shutdown_signal() {
921 #[cfg(unix)]
922 {
923 use tokio::signal::unix::{SignalKind, signal};
924 let mut terminate = signal(SignalKind::terminate()).expect("failed to listen for SIGTERM");
925 tokio::select! {
926 r = tokio::signal::ctrl_c() => r.expect("failed to listen for ctrl+c"),
927 _ = terminate.recv() => {}
928 _ = OUTDATED.cancelled() => {}
929 }
930 }
931 #[cfg(not(unix))]
932 tokio::select! {
933 r = tokio::signal::ctrl_c() => r.expect("failed to listen for ctrl+c"),
934 _ = OUTDATED.cancelled() => {}
935 }
936}
937
938const DRAIN: std::time::Duration = std::time::Duration::from_secs(10);
940
941async fn drain_deadline(stop: &tokio_util::sync::CancellationToken) {
945 stop.cancelled().await;
946 tokio::time::sleep(DRAIN).await;
947}
948
949async fn graphql_handler(
950 axum::Extension(user): axum::Extension<AuthUser>,
951 axum::extract::State(schema): axum::extract::State<KoanSchema>,
952 req: async_graphql_axum::GraphQLRequest,
953) -> async_graphql_axum::GraphQLResponse {
954 let mut request = req.into_inner();
955 request = request.data(user);
958 schema.execute(request).await.into()
959}
960
961async fn graphql_ws_handler(
966 axum::Extension(user): axum::Extension<AuthUser>,
967 lease: Option<axum::Extension<crate::auth::Lease>>,
968 axum::Extension(stop): axum::Extension<tokio_util::sync::CancellationToken>,
969 axum::extract::State(schema): axum::extract::State<KoanSchema>,
970 protocol: async_graphql_axum::GraphQLProtocol,
971 websocket: axum::extract::WebSocketUpgrade,
972) -> axum::response::Response {
973 use axum::extract::ws::{CloseFrame, Message, close_code};
974 use futures_util::{SinkExt, StreamExt};
975 websocket
976 .protocols(async_graphql::http::ALL_WEBSOCKET_PROTOCOLS)
977 .on_upgrade(move |socket| async move {
978 let (mut sink, stream) = socket.split();
979 let serve = async_graphql_axum::GraphQLWebSocket::new_with_pair(
980 &mut sink, stream, schema, protocol,
981 )
982 .on_connection_init(move |_| async move {
983 let mut data = async_graphql::Data::default();
984 data.insert(user);
985 Ok(data)
986 })
987 .serve();
988 let ended = async {
989 match lease {
990 Some(axum::Extension(lease)) => lease.ended().await,
991 None => std::future::pending().await,
992 }
993 };
994 let close = tokio::select! {
995 _ = serve => None,
996 _ = ended => Some((close_code::NORMAL, "sign in again")),
997 _ = stop.cancelled() => Some((close_code::AWAY, "server stopping")),
998 };
999 if let Some((code, reason)) = close {
1000 let _ = sink
1001 .send(Message::Close(Some(CloseFrame {
1002 code,
1003 reason: reason.into(),
1004 })))
1005 .await;
1006 }
1007 })
1008}
1009
1010async fn graphql_playground(
1011 axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
1012 axum::extract::State((key, base)): axum::extract::State<(Option<Arc<String>>, String)>,
1013) -> axum::response::Response {
1014 use axum::response::IntoResponse;
1015
1016 if let Some(ref expected) = key {
1018 let provided = params.get("introspection-key");
1019 if provided.map(|k| k.as_str()) != Some(expected.as_str()) {
1020 return (
1021 axum::http::StatusCode::FORBIDDEN,
1022 "invalid or missing introspection-key",
1023 )
1024 .into_response();
1025 }
1026 }
1027
1028 let endpoint = format!("{base}/graphql");
1032 let mut source = async_graphql::http::GraphiQLSource::build().endpoint(&endpoint);
1033 if let Some(ref k) = key {
1034 source = source.header("X-Introspection-Key", k.as_str());
1035 }
1036
1037 axum::response::Html(source.finish()).into_response()
1038}
1039
1040pub fn cmd_serve_daemon(
1042 port: Option<u16>,
1043 bind: Option<std::net::IpAddr>,
1044 subsonic_port: Option<u16>,
1045 playground: bool,
1046) {
1047 use std::fs;
1048 use std::process::Command;
1049
1050 require_server_url();
1051
1052 let cfg = Config::cached();
1053 let port_val = port.unwrap_or(cfg.graphql.port);
1054 let bind_val = bind.unwrap_or(cfg.graphql.bind);
1055
1056 let exe = std::env::current_exe().expect("failed to get current exe path");
1057 let mut cmd = Command::new(exe);
1058
1059 cmd.arg("--headless");
1060 cmd.arg("--port").arg(port_val.to_string());
1061 cmd.arg("--bind").arg(bind_val.to_string());
1062 if let Some(sp) = subsonic_port {
1063 cmd.arg("--subsonic").arg(sp.to_string());
1064 }
1065 if playground || cfg.graphql.playground {
1066 cmd.arg("--playground");
1067 }
1068
1069 cmd.stdin(std::process::Stdio::null());
1070 cmd.stdout(std::process::Stdio::null());
1071 cmd.stderr(std::process::Stdio::null());
1072
1073 let mut child = cmd.spawn().expect("failed to spawn daemon process");
1074 let pid = child.id();
1075
1076 let pid_path = koan_core::config::config_dir().join("koan-serve.pid");
1077 fs::write(&pid_path, pid.to_string()).ok();
1078
1079 std::thread::spawn(move || {
1080 let _ = child.wait();
1081 });
1082
1083 eprintln!("kōan daemon started (pid {}) on port {}", pid, port_val);
1084 if let Some(sp) = subsonic_port {
1085 eprintln!(" Subsonic REST on port {}", sp);
1086 }
1087 eprintln!(" PID file: {}", pid_path.display());
1088}
1089
1090pub async fn execute_in_process(
1099 schema: &KoanSchema,
1100 query: &str,
1101 variables: Option<serde_json::Value>,
1102 caller: AuthUser,
1103) -> serde_json::Value {
1104 let mut request = async_graphql::Request::new(query).data(caller);
1105 if let Some(serde_json::Value::Object(map)) = variables {
1106 let mut gql_vars = async_graphql::Variables::default();
1107 for (k, v) in map {
1108 gql_vars.insert(
1109 async_graphql::Name::new(&k),
1110 async_graphql::Value::from_json(v).unwrap_or(async_graphql::Value::Null),
1111 );
1112 }
1113 request = request.variables(gql_vars);
1114 }
1115 let response = schema.execute(request).await;
1116 serde_json::to_value(&response).unwrap_or(serde_json::Value::Null)
1117}
1118
1119#[cfg(test)]
1124mod tests {
1125 use super::*;
1126 use axum::body::Body;
1127 use axum::http::{Request as HttpRequest, StatusCode};
1128 use axum::routing::{get, post};
1129 use tower::ServiceExt as _;
1130
1131 fn policy() -> Arc<BrowserPolicy> {
1132 Arc::new(BrowserPolicy {
1133 origins: vec!["https://music.example.com".into()],
1134 hosts: vec!["koan.local".into()],
1135 })
1136 }
1137
1138 async fn ok() -> &'static str {
1139 "ok"
1140 }
1141
1142 fn routes() -> axum::Router<Arc<BrowserPolicy>> {
1143 axum::Router::new()
1144 .route("/graphql", post(ok).get(ok))
1145 .route("/graphql/ws", get(ok))
1146 }
1147
1148 async fn run_host(req: HttpRequest<Body>) -> StatusCode {
1149 let app = routes()
1150 .layer(axum::middleware::from_fn_with_state(policy(), host_guard))
1151 .with_state(policy());
1152 app.oneshot(req).await.unwrap().status()
1153 }
1154
1155 async fn run_browser(req: HttpRequest<Body>) -> StatusCode {
1156 let app = routes()
1157 .layer(axum::middleware::from_fn_with_state(
1158 policy(),
1159 browser_guard,
1160 ))
1161 .with_state(policy());
1162 app.oneshot(req).await.unwrap().status()
1163 }
1164
1165 fn json_post(uri: &str) -> axum::http::request::Builder {
1166 HttpRequest::post(uri).header(axum::http::header::CONTENT_TYPE, "application/json")
1167 }
1168
1169 #[test]
1172 fn host_policy_accepts_loopback_literals_and_configured_names() {
1173 let p = policy();
1174 assert!(p.host_allowed("localhost:4000"));
1175 assert!(p.host_allowed("127.0.0.1:4000"));
1176 assert!(p.host_allowed("192.168.1.20:4000"));
1177 assert!(p.host_allowed("[::1]:4000"));
1178 assert!(p.host_allowed("koan.local"));
1179 assert!(p.host_allowed("koan.local:4000"));
1180 }
1181
1182 #[test]
1183 fn host_policy_rejects_attacker_controlled_names() {
1184 let p = policy();
1185 assert!(!p.host_allowed("evil.com"));
1186 assert!(!p.host_allowed("rebind.evil.com:4000"));
1187 assert!(!p.host_allowed("koan.local.evil.com"));
1188 }
1189
1190 #[tokio::test]
1191 async fn host_guard_rejects_foreign_host() {
1192 let req = json_post("/graphql")
1193 .header(axum::http::header::HOST, "rebind.evil.com")
1194 .body(Body::empty())
1195 .unwrap();
1196 assert_eq!(run_host(req).await, StatusCode::FORBIDDEN);
1197 }
1198
1199 #[tokio::test]
1200 async fn host_guard_allows_known_host_and_missing_host() {
1201 let req = json_post("/graphql")
1202 .header(axum::http::header::HOST, "127.0.0.1:4000")
1203 .body(Body::empty())
1204 .unwrap();
1205 assert_eq!(run_host(req).await, StatusCode::OK);
1206
1207 let req = json_post("/graphql").body(Body::empty()).unwrap();
1208 assert_eq!(run_host(req).await, StatusCode::OK);
1209 }
1210
1211 #[tokio::test]
1214 async fn ws_upgrade_from_foreign_origin_is_rejected() {
1215 let req = HttpRequest::get("/graphql/ws")
1216 .header(axum::http::header::HOST, "127.0.0.1:4000")
1217 .header(axum::http::header::ORIGIN, "https://evil.com")
1218 .body(Body::empty())
1219 .unwrap();
1220 assert_eq!(run_browser(req).await, StatusCode::FORBIDDEN);
1221 }
1222
1223 #[tokio::test]
1224 async fn ws_upgrade_without_origin_is_allowed() {
1225 let req = HttpRequest::get("/graphql/ws")
1226 .header(axum::http::header::HOST, "127.0.0.1:4000")
1227 .body(Body::empty())
1228 .unwrap();
1229 assert_eq!(run_browser(req).await, StatusCode::OK);
1230 }
1231
1232 #[tokio::test]
1233 async fn configured_and_same_origin_are_allowed() {
1234 let req = HttpRequest::get("/graphql/ws")
1235 .header(axum::http::header::HOST, "127.0.0.1:4000")
1236 .header(axum::http::header::ORIGIN, "https://music.example.com")
1237 .body(Body::empty())
1238 .unwrap();
1239 assert_eq!(run_browser(req).await, StatusCode::OK);
1240
1241 let req = json_post("/graphql")
1243 .header(axum::http::header::HOST, "127.0.0.1:4000")
1244 .header(axum::http::header::ORIGIN, "http://127.0.0.1:4000")
1245 .body(Body::empty())
1246 .unwrap();
1247 assert_eq!(run_browser(req).await, StatusCode::OK);
1248 }
1249
1250 #[tokio::test]
1253 async fn text_plain_post_is_rejected() {
1254 let req = HttpRequest::post("/graphql")
1255 .header(axum::http::header::CONTENT_TYPE, "text/plain")
1256 .body(Body::from(r#"{"query":"mutation{clearQueue{ok}}"}"#))
1257 .unwrap();
1258 assert_eq!(run_browser(req).await, StatusCode::UNSUPPORTED_MEDIA_TYPE);
1259 }
1260
1261 #[tokio::test]
1262 async fn post_without_content_type_is_rejected() {
1263 let req = HttpRequest::post("/graphql").body(Body::empty()).unwrap();
1264 assert_eq!(run_browser(req).await, StatusCode::UNSUPPORTED_MEDIA_TYPE);
1265 }
1266
1267 #[tokio::test]
1270 async fn load_perimeter_refuses_an_oversized_query_body() {
1271 async fn parse(_: async_graphql_axum::GraphQLRequest) -> StatusCode {
1272 StatusCode::OK
1273 }
1274 let app = load_perimeter(axum::Router::new().route("/graphql", post(parse)));
1275 let body = |padding: usize| {
1278 let chunks = [
1279 axum::body::Bytes::from_static(br#"{"query":"{__typename}""#),
1280 axum::body::Bytes::from(vec![b' '; padding]),
1281 axum::body::Bytes::from_static(b"}"),
1282 ];
1283 Body::from_stream(tokio_stream::iter(chunks.map(Ok::<_, std::io::Error>)))
1284 };
1285 let req = json_post("/graphql").body(body(1 << 10)).unwrap();
1286 assert_eq!(
1287 app.clone().oneshot(req).await.unwrap().status(),
1288 StatusCode::OK
1289 );
1290 let req = json_post("/graphql").body(body(3 << 20)).unwrap();
1291 assert_ne!(app.oneshot(req).await.unwrap().status(), StatusCode::OK);
1292 }
1293
1294 #[tokio::test]
1295 async fn load_perimeter_passes_requests_and_turns_panics_into_500s() {
1296 async fn boom() -> &'static str {
1297 panic!("resolver exploded");
1298 }
1299
1300 let app = load_perimeter(
1301 axum::Router::new()
1302 .route("/graphql", post(ok))
1303 .route("/boom", post(boom)),
1304 );
1305
1306 let req = json_post("/graphql").body(Body::empty()).unwrap();
1307 assert_eq!(
1308 app.clone().oneshot(req).await.unwrap().status(),
1309 StatusCode::OK
1310 );
1311
1312 let req = json_post("/boom").body(Body::empty()).unwrap();
1314 assert_eq!(
1315 app.oneshot(req).await.unwrap().status(),
1316 StatusCode::INTERNAL_SERVER_ERROR
1317 );
1318 }
1319
1320 #[tokio::test]
1321 async fn json_post_is_accepted() {
1322 let req = json_post("/graphql").body(Body::empty()).unwrap();
1323 assert_eq!(run_browser(req).await, StatusCode::OK);
1324
1325 let req = HttpRequest::post("/graphql")
1326 .header(
1327 axum::http::header::CONTENT_TYPE,
1328 "application/json; charset=utf-8",
1329 )
1330 .body(Body::empty())
1331 .unwrap();
1332 assert_eq!(run_browser(req).await, StatusCode::OK);
1333 }
1334
1335 #[test]
1339 fn an_embedded_server_ends_its_links_on_stop_and_starts_again() {
1340 use koan_core::auth::Role;
1341 use koan_core::db::connection::Database;
1342 use koan_core::player::commands::CommandChannel;
1343
1344 let _own = koan_core::config::SWITCHING
1345 .lock()
1346 .unwrap_or_else(|e| e.into_inner());
1347 let dir = tempfile::tempdir().unwrap();
1348 koan_core::config::set_config_dir(dir.path());
1349 let db_path = dir.path().join("koan.db");
1350 let db = Database::open(&db_path).unwrap();
1351 koan_core::db::schema::create_tables(&db.conn).unwrap();
1352 koan_core::db::queries::auth::create_user(&db.conn, "sharer", "hunter22", Role::Admin)
1353 .unwrap();
1354
1355 let start = || {
1356 ApiServer::start(ApiServerOpts {
1357 state: SharedPlayerState::new(),
1358 cmd_tx: CommandChannel::new().tx,
1359 pool: Arc::new(Pool::new(db_path.clone())),
1360 port: Some(0),
1361 bind: Some(std::net::Ipv4Addr::LOCALHOST.into()),
1362 subsonic_port: None,
1363 playground: false,
1364 viz: None,
1365 profile: Profile::Embedded,
1366 hosts: Vec::new(),
1367 })
1368 .unwrap()
1369 };
1370 let ping = |addr: std::net::SocketAddr| {
1372 let body = reqwest::blocking::get(format!(
1373 "http://{addr}/rest/ping?u=sharer&p=hunter22&v=1.16.1&c=test&f=json"
1374 ))
1375 .unwrap()
1376 .text()
1377 .unwrap();
1378 assert!(body.contains(r#""status":"ok""#), "{body}");
1379 };
1380 let linked = || {
1381 crate::clients::registry()
1382 .list(Some("sharer"))
1383 .iter()
1384 .any(|c| c.device == "embedded-restart")
1385 };
1386
1387 let server = start();
1388 let addr = server.addr();
1389 ping(addr);
1390 let (mut socket, _) = tungstenite::connect(format!(
1391 "ws://{addr}/rest/koanLink?u=sharer&p=hunter22&v=1.16.1&c=test&device=embedded-restart"
1392 ))
1393 .unwrap();
1394 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1395 while !linked() {
1396 assert!(
1397 std::time::Instant::now() < deadline,
1398 "the device never linked"
1399 );
1400 std::thread::sleep(std::time::Duration::from_millis(20));
1401 }
1402
1403 let stopping = std::time::Instant::now();
1404 server.stop();
1405 assert!(stopping.elapsed() < DRAIN, "stop waited out the drain");
1406 assert!(!linked(), "the link outlived the server");
1407 let closed = loop {
1409 match socket.read() {
1410 Ok(tungstenite::Message::Close(frame)) => break frame,
1411 Ok(_) => continue,
1412 Err(e) => panic!("no close frame: {e}"),
1413 }
1414 };
1415 assert_eq!(
1416 closed.map(|f| f.code),
1417 Some(tungstenite::protocol::frame::coding::CloseCode::Away)
1418 );
1419
1420 let again = start();
1421 ping(again.addr());
1422 again.stop();
1423 }
1424
1425 #[test]
1428 fn an_embedded_server_refuses_a_port_held_on_loopback() {
1429 use koan_core::player::commands::CommandChannel;
1430
1431 let _own = koan_core::config::SWITCHING
1432 .lock()
1433 .unwrap_or_else(|e| e.into_inner());
1434 let dir = tempfile::tempdir().unwrap();
1435 koan_core::config::set_config_dir(dir.path());
1436 let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1437 let port = held.local_addr().unwrap().port();
1438 let started = ApiServer::start(ApiServerOpts {
1439 state: SharedPlayerState::new(),
1440 cmd_tx: CommandChannel::new().tx,
1441 pool: Arc::new(Pool::new(dir.path().join("koan.db"))),
1442 port: Some(port),
1443 bind: Some(std::net::Ipv4Addr::UNSPECIFIED.into()),
1444 subsonic_port: None,
1445 playground: false,
1446 viz: None,
1447 profile: Profile::Embedded,
1448 hosts: Vec::new(),
1449 });
1450 let Err(why) = started else {
1451 panic!("started beside another listener on port {port}");
1452 };
1453 assert!(why.contains("already in use"), "{why}");
1454 }
1455}