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(_) => return,
30 };
31 log::error!("{why}");
32 eprintln!("koan: {why}");
33 std::process::exit(1);
34}
35
36pub fn cmd_serve(
37 port: Option<u16>,
38 bind: Option<std::net::IpAddr>,
39 subsonic_port: Option<u16>,
40 playground: bool,
41) {
42 use koan_core::player::Player;
43
44 require_server_url();
45 if let Err(e) = koan_core::db::connection::Database::open_default() {
49 log::error!("cannot open the database: {e}");
50 eprintln!("koan: cannot open the database: {e}");
51 std::process::exit(1);
52 }
53 let db_path = koan_core::config::db_path();
54 let pool = Arc::new(Pool::new(db_path.clone()));
55 koan_core::db::pool::on_outdated(|| OUTDATED.cancel());
56
57 let (state, _timeline, _viz, cmd_tx) = Player::spawn();
58
59 let watched = db_path.clone();
62 let jobs = super::jobs::JobRegistry::default();
63 let record = super::jobs::record_watch(&jobs);
64 koan_core::helpers::spawn_library_watch(db_path, move |scan| {
65 record(&scan);
66 if let koan_core::helpers::WatchScan::Finished(_) = scan {
69 crate::clients::fulfil_from(&watched);
70 if let Ok(db) = koan_core::db::connection::Database::open_existing(&watched) {
71 crate::clients::changed_if_library_moved(&db.conn);
72 }
73 }
74 });
75
76 let started = ApiServer::start_with(
77 ApiServerOpts {
78 state,
79 cmd_tx,
80 pool,
81 port,
82 bind,
83 subsonic_port,
84 playground,
85 viz: None, profile: Profile::Headless,
87 hosts: Vec::new(),
88 },
89 jobs,
90 );
91 match started {
92 Ok(server) => server.wait(),
93 Err(e) => {
94 eprintln!("koan: {}", e);
95 std::process::exit(1);
96 }
97 }
98}
99
100const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
107
108const MAX_INFLIGHT_QUERIES: usize = 64;
112
113const MAX_QUERY_BODY: usize = 2 << 20;
117
118fn load_perimeter<S>(router: axum::Router<S>) -> axum::Router<S>
123where
124 S: Clone + Send + Sync + 'static,
125{
126 router
127 .layer(tower_http::catch_panic::CatchPanicLayer::new())
130 .layer(tower_http::limit::RequestBodyLimitLayer::new(
131 MAX_QUERY_BODY,
132 ))
133 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
134 axum::http::StatusCode::REQUEST_TIMEOUT,
135 REQUEST_TIMEOUT,
136 ))
137 .layer(
141 tower::ServiceBuilder::new()
142 .layer(axum::error_handling::HandleErrorLayer::new(
143 |err: tower::BoxError| async move {
144 if err.is::<tower::load_shed::error::Overloaded>() {
145 (
146 axum::http::StatusCode::SERVICE_UNAVAILABLE,
147 "server at capacity",
148 )
149 } else {
150 (
151 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
152 "internal error",
153 )
154 }
155 },
156 ))
157 .load_shed()
158 .concurrency_limit(MAX_INFLIGHT_QUERIES),
159 )
160}
161
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165pub enum Profile {
166 Headless,
169 Tui,
171 Embedded,
176}
177
178pub struct ApiServerOpts {
180 pub state: Arc<SharedPlayerState>,
181 pub cmd_tx: Sender<PlayerCommand>,
182 pub pool: Arc<Pool>,
185 pub port: Option<u16>,
186 pub bind: Option<std::net::IpAddr>,
187 pub subsonic_port: Option<u16>,
188 pub playground: bool,
189 pub viz: Option<Arc<VizSnapshot>>,
190 pub profile: Profile,
191 pub hosts: Vec<String>,
194}
195
196pub struct ApiServer {
199 addr: std::net::SocketAddr,
200 host_key: Option<String>,
203 stop: tokio_util::sync::CancellationToken,
204 thread: Option<std::thread::JoinHandle<()>>,
205}
206
207impl ApiServer {
208 pub fn start(opts: ApiServerOpts) -> Result<Self, String> {
218 Self::start_with(opts, Default::default())
219 }
220
221 pub(crate) fn start_with(
222 opts: ApiServerOpts,
223 jobs: super::jobs::JobRegistry,
224 ) -> Result<Self, String> {
225 let ApiServerOpts {
226 state,
227 cmd_tx,
228 pool,
229 port,
230 bind,
231 subsonic_port,
232 playground,
233 viz,
234 profile,
235 hosts,
236 } = opts;
237 use axum::routing::{get, post};
238
239 let cfg = Config::cached();
240 let embedded = profile == Profile::Embedded;
241 let port = port.unwrap_or(cfg.graphql.port);
242 let setup_wizard = cfg.graphql.setup_wizard && profile == Profile::Headless;
246 let bind = bind.unwrap_or(cfg.graphql.bind);
247 let subsonic_port = if embedded {
249 None
250 } else {
251 subsonic_port.or(cfg.subsonic.port)
252 };
253 let subsonic_enabled = embedded || cfg.subsonic.enabled;
254 let playground_enabled = !embedded && (playground || cfg.graphql.playground);
255 let auth_enabled = cfg.graphql.auth_enabled;
256 let demo = cfg.server.demo;
257
258 if embedded && !auth_enabled {
259 return Err(
260 "graphql.auth_enabled is off, which would make anyone who reaches \
261 this port an admin"
262 .into(),
263 );
264 }
265 if demo && embedded {
266 return Err("server.demo is for a headless server".into());
267 }
268 if demo && !auth_enabled {
270 return Err("server.demo needs graphql.auth_enabled".into());
271 }
272
273 let (private_pem, public_pem) = if auth_enabled {
280 let kp = auth::load_or_generate_keypair().map_err(|e| {
281 format!(
282 "auth_enabled = true but the keypair could not be loaded or created: {}",
283 e
284 )
285 })?;
286 if kp.0.is_empty() || kp.1.is_empty() {
289 return Err("auth_enabled = true but the keypair files are empty. \
290 Run `koan auth regenerate-keys`."
291 .into());
292 }
293 kp
294 } else {
295 auth::load_or_generate_keypair().unwrap_or_default()
298 };
299
300 let access_ttl = parse_duration_secs(&cfg.graphql.access_token_ttl).unwrap_or(900);
301 let refresh_ttl = parse_duration_secs(&cfg.graphql.refresh_token_ttl).unwrap_or(2_592_000);
302
303 let introspection_key = if playground_enabled && auth_enabled {
307 Some(Arc::new(auth::random_token().map_err(|e| {
308 format!("failed to generate introspection key: {}", e)
309 })?))
310 } else {
311 None
312 };
313
314 let proxy_auth = if embedded {
315 None
316 } else {
317 crate::ui::ProxyAuth::from_config(
318 &cfg.graphql.proxy_auth_header,
319 &cfg.graphql.proxy_auth_from,
320 )?
321 };
322
323 let gql_listener = std::net::TcpListener::bind((bind, port))
324 .and_then(|l| l.set_nonblocking(true).map(|()| l))
325 .map_err(|e| {
326 format!("failed to bind GraphQL port {port} — {e} (another instance running?)")
327 })?;
328 let addr = gql_listener
329 .local_addr()
330 .map_err(|e| format!("failed to read the GraphQL listener's address — {e}"))?;
331
332 let sub_listener = subsonic_port
335 .filter(|p| subsonic_enabled && *p != port)
336 .and_then(|sub_port| {
337 match std::net::TcpListener::bind((bind, sub_port))
338 .and_then(|l| l.set_nonblocking(true).map(|()| l))
339 {
340 Ok(l) => Some(l),
341 Err(e) => {
342 log::warn!(
343 "Dedicated Subsonic port {sub_port} unavailable — {e}. Mounted on GraphQL port only."
344 );
345 None
346 }
347 }
348 });
349
350 let rt = tokio::runtime::Runtime::new()
351 .map_err(|e| format!("could not start the API server's runtime — {e}"))?;
352 let (gql_listener, sub_listener) = {
353 let _in = rt.enter();
354 let gql = tokio::net::TcpListener::from_std(gql_listener)
355 .map_err(|e| format!("failed to listen on {addr} — {e}"))?;
356 let sub = sub_listener.and_then(|l| match tokio::net::TcpListener::from_std(l) {
357 Ok(l) => Some(l),
358 Err(e) => {
359 log::warn!("Dedicated Subsonic listener unusable — {e}");
360 None
361 }
362 });
363 (gql, sub)
364 };
365
366 let host_key = embedded
367 .then(auth::random_token)
368 .transpose()
369 .map_err(|e| format!("failed to generate the host key: {e}"))?;
370
371 if demo {
372 let db = pool.get().map_err(|e| format!("demo: {e}"))?;
373 crate::demo::start(&db.conn).map_err(|e| format!("demo: {e}"))?;
374 }
375
376 let _ = std::fs::remove_file(auth::keypair_dir().join("subsonic.key"));
379
380 crate::auth::set_signing_keys(Arc::new(private_pem.clone()), Arc::new(public_pem.clone()));
381
382 let auth_state = AuthState {
383 public_pem: Arc::new(public_pem.clone()),
384 auth_enabled,
385 introspection_key: introspection_key.clone(),
386 pool: pool.clone(),
387 };
388
389 let users = Arc::new(crate::auth::password::PasswordVerifier::new(pool.clone()));
391 let auth_route_state = AuthRouteState {
392 users: users.clone(),
393 pool: pool.clone(),
394 private_pem: Arc::new(private_pem),
395 public_pem: Arc::new(public_pem),
396 access_ttl_secs: access_ttl,
397 refresh_ttl_secs: refresh_ttl,
398 cookie_secure: cfg.graphql.cookie_secure,
399 login_limiter: Arc::new(RateLimiter::default()),
400 };
401
402 koan_core::scrobbling::start(pool.path().to_path_buf());
404 let _ = crate::push::pusher();
407
408 let stop = tokio_util::sync::CancellationToken::new();
410 let mcp_routes = (!embedded).then(|| {
413 crate::mcp::router(
414 state.clone(),
415 cmd_tx.clone(),
416 auth_state.clone(),
417 cfg.server_url(),
418 profile == Profile::Headless,
419 stop.clone(),
420 )
421 });
422 let schema = super::build_schema_sharing(state, cmd_tx, pool.clone(), viz, jobs.clone());
423
424 if auth_enabled {
425 log::info!(
426 "Auth enabled (Ed25519 JWT, access TTL {}s, refresh TTL {}s)",
427 access_ttl,
428 refresh_ttl
429 );
430 let no_accounts = pool
431 .get()
432 .ok()
433 .and_then(|db| koan_core::db::queries::auth::has_users(&db.conn).ok())
434 == Some(false);
435 if no_accounts && setup_wizard {
436 log::warn!(
437 "No accounts yet: whoever opens /setup first becomes the admin. \
438 Set graphql.setup_wizard = false to make it with `koan auth setup` instead."
439 );
440 }
441 } else {
442 log::info!("Auth disabled — all requests treated as admin");
443 }
444
445 let mut allowed_hosts = cfg.graphql.allowed_hosts.clone();
446 allowed_hosts.extend(hosts);
447 let forwarded_from = if embedded {
450 crate::auth::routes::ForwardedFrom::Loopback
451 } else {
452 crate::auth::routes::ForwardedFrom::Private
453 };
454 let browser_policy = Arc::new(BrowserPolicy {
455 origins: cfg.graphql.cors_origins.clone(),
456 hosts: allowed_hosts,
457 });
458
459 if cfg.graphql.cors_origins.is_empty() {
460 log::info!("CORS: no origins configured — browsers get no cross-origin access");
461 }
462
463 let serving = stop.clone();
464 let serving_key = host_key.clone();
465 let thread = std::thread::Builder::new()
466 .name("koan-api".into())
467 .spawn(move || {
468 let stop = serving;
469 rt.block_on(async {
470 if !embedded {
471 let stop = stop.clone();
472 tokio::spawn(async move {
473 shutdown_signal().await;
474 stop.cancel();
475 });
476 }
477
478 let query_route =
483 load_perimeter(axum::Router::new().route("/graphql", post(graphql_handler)));
484
485 let gql_app = axum::Router::new()
486 .merge(query_route)
487 .route("/graphql/ws", get(graphql_ws_handler))
488 .layer(axum::Extension(stop.clone()))
489 .layer(axum::middleware::from_fn_with_state(
490 auth_state.clone(),
491 auth_middleware,
492 ))
493 .layer(axum::middleware::from_fn_with_state(
496 browser_policy.clone(),
497 browser_guard,
498 ))
499 .with_state(schema);
500
501 let covers = Arc::new(crate::covers::Covers::in_config_dir());
505 let ui_routes = (!embedded).then(|| crate::ui::router(
506 pool.clone(),
507 auth_route_state.clone(),
508 auth_enabled,
509 setup_wizard,
510 covers.clone(),
511 cfg.server_url(),
512 cfg.mcp.redirect_hosts.clone(),
513 proxy_auth,
514 jobs,
515 )
516 .layer(tower_http::catch_panic::CatchPanicLayer::new()));
518 let ui_routes = ui_routes.map(|ui| {
519 if demo {
520 ui.layer(axum::middleware::from_fn(crate::demo::guard))
521 } else {
522 ui
523 }
524 });
525
526 let auth_app = auth_router(auth_route_state);
528
529 let origins: Vec<axum::http::HeaderValue> = cfg
532 .graphql
533 .cors_origins
534 .iter()
535 .filter_map(|o| o.parse().ok())
536 .collect();
537 let cors = tower_http::cors::CorsLayer::new()
538 .allow_origin(origins)
539 .allow_methods([
540 axum::http::Method::GET,
541 axum::http::Method::POST,
542 axum::http::Method::OPTIONS,
543 ])
544 .allow_headers([
545 axum::http::header::AUTHORIZATION,
546 axum::http::header::CONTENT_TYPE,
547 axum::http::HeaderName::from_static("x-introspection-key"),
548 ])
549 .allow_credentials(true);
550
551 let share_url = match embedded {
557 true => crate::ServerUrl::Live,
558 false => crate::ServerUrl::Fixed(cfg.server_url()),
559 };
560 let share_routes =
561 crate::share::router(pool.clone(), share_url, covers.clone())
562 .layer(tower_http::catch_panic::CatchPanicLayer::new());
563 let subsonic_merged = subsonic_enabled
570 .then(|| {
571 crate::subsonic::subsonic_router(
572 pool,
573 covers,
574 users,
575 profile,
576 stop.clone(),
577 serving_key,
578 )
579 })
580 .map(|sub| {
581 if demo {
582 sub.layer(axum::middleware::from_fn(crate::demo::guard))
583 } else {
584 sub
585 }
586 });
587 let subsonic_dedicated = subsonic_merged.clone();
588
589 let mut app = auth_app.merge(gql_app).merge(share_routes);
590 if let Some(ui) = ui_routes {
591 app = app.merge(ui);
592 }
593 if let Some(mcp) = mcp_routes {
594 app = app.merge(mcp);
595 }
596 if let Some(sub) = subsonic_merged {
597 app = app.merge(sub);
598 }
599 if playground_enabled {
600 app = app.route(
601 "/graphql",
602 get(graphql_playground).with_state(introspection_key.clone()),
603 );
604 }
605 let app = app
609 .layer(cors)
610 .layer(axum::middleware::from_fn_with_state(
611 browser_policy.clone(),
612 host_guard,
613 ))
614 .layer(axum::Extension(forwarded_from));
615
616 log::info!("GraphQL API on http://{addr}/graphql");
617 if subsonic_enabled {
618 log::info!("Subsonic REST on http://{addr}/rest/");
619 }
620 if playground_enabled {
621 let playground_url = match introspection_key {
622 Some(ref key) => {
623 format!("http://{addr}/graphql?introspection-key={key}")
624 }
625 None => format!("http://{addr}/graphql"),
626 };
627 log::info!("GraphiQL: {}", playground_url);
628 #[cfg(target_os = "macos")]
630 let _ = std::process::Command::new("open").arg(&playground_url).spawn();
631 #[cfg(target_os = "linux")]
632 let _ = std::process::Command::new("xdg-open")
633 .arg(&playground_url)
634 .spawn();
635 }
636 let gql_server = axum::serve(
637 gql_listener,
638 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
639 )
640 .with_graceful_shutdown(stop.clone().cancelled_owned());
641
642 if let Some((sub_listener, sub_app)) = sub_listener.zip(subsonic_dedicated) {
643 if let Ok(a) = sub_listener.local_addr() {
644 log::info!("Subsonic REST also on http://{a}/rest/ (dedicated port)");
645 }
646 let sub_server = axum::serve(
649 sub_listener,
650 sub_app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
651 )
652 .with_graceful_shutdown(stop.clone().cancelled_owned());
653
654 tokio::select! {
655 r = gql_server => { if let Err(e) = r { log::error!("GraphQL server error: {e}"); } },
656 r = sub_server => { if let Err(e) = r { log::error!("Subsonic server error: {e}"); } },
657 _ = drain_deadline(&stop) => log::info!("shutting down with connections still open"),
658 }
659 return;
660 }
661
662 tokio::select! {
663 r = gql_server => { if let Err(e) = r { log::error!("GraphQL server error: {e}"); } },
664 _ = drain_deadline(&stop) => log::info!("shutting down with connections still open"),
665 }
666 });
667 rt.shutdown_timeout(DRAIN);
670 })
671 .map_err(|e| format!("could not start the API server thread — {e}"))?;
672
673 Ok(Self {
674 addr,
675 host_key,
676 stop,
677 thread: Some(thread),
678 })
679 }
680
681 pub fn host_key(&self) -> Option<&str> {
683 self.host_key.as_deref()
684 }
685
686 pub fn addr(&self) -> std::net::SocketAddr {
688 self.addr
689 }
690
691 pub fn stop(mut self) {
694 self.stop.cancel();
695 self.join();
696 }
697
698 pub fn wait(mut self) {
701 self.join();
702 }
703
704 fn join(&mut self) {
705 if let Some(thread) = self.thread.take()
706 && thread.join().is_err()
707 {
708 log::error!("API server thread panicked");
709 }
710 }
711}
712
713impl Drop for ApiServer {
714 fn drop(&mut self) {
715 self.stop.cancel();
716 }
717}
718
719pub fn start_api_background(
722 state: Arc<SharedPlayerState>,
723 cmd_tx: Sender<PlayerCommand>,
724 db_path: PathBuf,
725 port: Option<u16>,
726 bind: Option<std::net::IpAddr>,
727 subsonic_port: Option<u16>,
728 playground: bool,
729) {
730 let started = ApiServer::start(ApiServerOpts {
731 state,
732 cmd_tx,
733 pool: Arc::new(Pool::new(db_path)),
734 port,
735 bind,
736 subsonic_port,
737 playground,
738 viz: None,
739 profile: Profile::Tui,
740 hosts: Vec::new(),
741 });
742 match started {
743 Ok(server) => server.wait(),
744 Err(e) => log::error!("API server not started: {}", e),
745 }
746}
747
748pub(crate) struct BrowserPolicy {
757 origins: Vec<String>,
758 hosts: Vec<String>,
759}
760
761impl BrowserPolicy {
762 fn host_allowed(&self, host: &str) -> bool {
763 if self.hosts.iter().any(|h| h.eq_ignore_ascii_case(host)) {
764 return true;
765 }
766 let bare = strip_port(host);
767 if self.hosts.iter().any(|h| h.eq_ignore_ascii_case(bare)) {
768 return true;
769 }
770 bare.eq_ignore_ascii_case("localhost") || bare.parse::<std::net::IpAddr>().is_ok()
773 }
774
775 fn origin_allowed(&self, origin: &str, host: Option<&str>) -> bool {
778 if self.origins.iter().any(|o| o == origin) {
779 return true;
780 }
781 match (origin.split_once("://"), host) {
782 (Some((_, authority)), Some(host)) => authority.eq_ignore_ascii_case(host),
783 _ => false,
784 }
785 }
786}
787
788fn strip_port(host: &str) -> &str {
790 if let Some(rest) = host.strip_prefix('[') {
791 return rest.split(']').next().unwrap_or(rest);
792 }
793 match host.rsplit_once(':') {
794 Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,
795 _ => host,
796 }
797}
798
799fn header_str(request: &axum::extract::Request, name: axum::http::HeaderName) -> Option<&str> {
800 request.headers().get(name).and_then(|v| v.to_str().ok())
801}
802
803async fn host_guard(
805 axum::extract::State(policy): axum::extract::State<Arc<BrowserPolicy>>,
806 request: axum::extract::Request,
807 next: axum::middleware::Next,
808) -> axum::response::Response {
809 use axum::response::IntoResponse;
810
811 let host = header_str(&request, axum::http::header::HOST)
814 .map(str::to_owned)
815 .or_else(|| request.uri().host().map(str::to_owned));
816
817 if let Some(ref host) = host
818 && !policy.host_allowed(host)
819 {
820 log::warn!("rejected request for unrecognised Host: {}", host);
821 return (axum::http::StatusCode::FORBIDDEN, "host not allowed").into_response();
822 }
823
824 next.run(request).await
825}
826
827async fn browser_guard(
836 axum::extract::State(policy): axum::extract::State<Arc<BrowserPolicy>>,
837 request: axum::extract::Request,
838 next: axum::middleware::Next,
839) -> axum::response::Response {
840 use axum::response::IntoResponse;
841
842 let host = header_str(&request, axum::http::header::HOST).map(str::to_owned);
843 if let Some(origin) = header_str(&request, axum::http::header::ORIGIN)
845 && !policy.origin_allowed(origin, host.as_deref())
846 {
847 log::warn!(
848 "rejected GraphQL request from disallowed Origin: {}",
849 origin
850 );
851 return (axum::http::StatusCode::FORBIDDEN, "origin not allowed").into_response();
852 }
853
854 if request.method() == axum::http::Method::POST && !is_graphql_content_type(&request) {
855 return (
856 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
857 "content type must be application/json or application/graphql",
858 )
859 .into_response();
860 }
861
862 next.run(request).await
863}
864
865fn is_graphql_content_type(request: &axum::extract::Request) -> bool {
866 header_str(request, axum::http::header::CONTENT_TYPE).is_some_and(|ct| {
867 let ct = ct.trim().to_ascii_lowercase();
868 ct.starts_with("application/json") || ct.starts_with("application/graphql")
869 })
870}
871
872static OUTDATED: std::sync::LazyLock<tokio_util::sync::CancellationToken> =
876 std::sync::LazyLock::new(tokio_util::sync::CancellationToken::new);
877
878async fn shutdown_signal() {
883 #[cfg(unix)]
884 {
885 use tokio::signal::unix::{SignalKind, signal};
886 let mut terminate = signal(SignalKind::terminate()).expect("failed to listen for SIGTERM");
887 tokio::select! {
888 r = tokio::signal::ctrl_c() => r.expect("failed to listen for ctrl+c"),
889 _ = terminate.recv() => {}
890 _ = OUTDATED.cancelled() => {}
891 }
892 }
893 #[cfg(not(unix))]
894 tokio::select! {
895 r = tokio::signal::ctrl_c() => r.expect("failed to listen for ctrl+c"),
896 _ = OUTDATED.cancelled() => {}
897 }
898}
899
900const DRAIN: std::time::Duration = std::time::Duration::from_secs(10);
902
903async fn drain_deadline(stop: &tokio_util::sync::CancellationToken) {
907 stop.cancelled().await;
908 tokio::time::sleep(DRAIN).await;
909}
910
911async fn graphql_handler(
912 axum::Extension(user): axum::Extension<AuthUser>,
913 axum::extract::State(schema): axum::extract::State<KoanSchema>,
914 req: async_graphql_axum::GraphQLRequest,
915) -> async_graphql_axum::GraphQLResponse {
916 let mut request = req.into_inner();
917 request = request.data(user);
920 schema.execute(request).await.into()
921}
922
923async fn graphql_ws_handler(
928 axum::Extension(user): axum::Extension<AuthUser>,
929 lease: Option<axum::Extension<crate::auth::Lease>>,
930 axum::Extension(stop): axum::Extension<tokio_util::sync::CancellationToken>,
931 axum::extract::State(schema): axum::extract::State<KoanSchema>,
932 protocol: async_graphql_axum::GraphQLProtocol,
933 websocket: axum::extract::WebSocketUpgrade,
934) -> axum::response::Response {
935 use axum::extract::ws::{CloseFrame, Message, close_code};
936 use futures_util::{SinkExt, StreamExt};
937 websocket
938 .protocols(async_graphql::http::ALL_WEBSOCKET_PROTOCOLS)
939 .on_upgrade(move |socket| async move {
940 let (mut sink, stream) = socket.split();
941 let serve = async_graphql_axum::GraphQLWebSocket::new_with_pair(
942 &mut sink, stream, schema, protocol,
943 )
944 .on_connection_init(move |_| async move {
945 let mut data = async_graphql::Data::default();
946 data.insert(user);
947 Ok(data)
948 })
949 .serve();
950 let ended = async {
951 match lease {
952 Some(axum::Extension(lease)) => lease.ended().await,
953 None => std::future::pending().await,
954 }
955 };
956 let close = tokio::select! {
957 _ = serve => None,
958 _ = ended => Some((close_code::NORMAL, "sign in again")),
959 _ = stop.cancelled() => Some((close_code::AWAY, "server stopping")),
960 };
961 if let Some((code, reason)) = close {
962 let _ = sink
963 .send(Message::Close(Some(CloseFrame {
964 code,
965 reason: reason.into(),
966 })))
967 .await;
968 }
969 })
970}
971
972async fn graphql_playground(
973 axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
974 axum::extract::State(key): axum::extract::State<Option<Arc<String>>>,
975) -> axum::response::Response {
976 use axum::response::IntoResponse;
977
978 if let Some(ref expected) = key {
980 let provided = params.get("introspection-key");
981 if provided.map(|k| k.as_str()) != Some(expected.as_str()) {
982 return (
983 axum::http::StatusCode::FORBIDDEN,
984 "invalid or missing introspection-key",
985 )
986 .into_response();
987 }
988 }
989
990 let mut source = async_graphql::http::GraphiQLSource::build().endpoint("/graphql");
993 if let Some(ref k) = key {
994 source = source.header("X-Introspection-Key", k.as_str());
995 }
996
997 axum::response::Html(source.finish()).into_response()
998}
999
1000pub fn cmd_serve_daemon(
1002 port: Option<u16>,
1003 bind: Option<std::net::IpAddr>,
1004 subsonic_port: Option<u16>,
1005 playground: bool,
1006) {
1007 use std::fs;
1008 use std::process::Command;
1009
1010 require_server_url();
1011
1012 let cfg = Config::cached();
1013 let port_val = port.unwrap_or(cfg.graphql.port);
1014 let bind_val = bind.unwrap_or(cfg.graphql.bind);
1015
1016 let exe = std::env::current_exe().expect("failed to get current exe path");
1017 let mut cmd = Command::new(exe);
1018
1019 cmd.arg("--headless");
1020 cmd.arg("--port").arg(port_val.to_string());
1021 cmd.arg("--bind").arg(bind_val.to_string());
1022 if let Some(sp) = subsonic_port {
1023 cmd.arg("--subsonic").arg(sp.to_string());
1024 }
1025 if playground || cfg.graphql.playground {
1026 cmd.arg("--playground");
1027 }
1028
1029 cmd.stdin(std::process::Stdio::null());
1030 cmd.stdout(std::process::Stdio::null());
1031 cmd.stderr(std::process::Stdio::null());
1032
1033 let mut child = cmd.spawn().expect("failed to spawn daemon process");
1034 let pid = child.id();
1035
1036 let pid_path = koan_core::config::config_dir().join("koan-serve.pid");
1037 fs::write(&pid_path, pid.to_string()).ok();
1038
1039 std::thread::spawn(move || {
1040 let _ = child.wait();
1041 });
1042
1043 eprintln!("kōan daemon started (pid {}) on port {}", pid, port_val);
1044 if let Some(sp) = subsonic_port {
1045 eprintln!(" Subsonic REST on port {}", sp);
1046 }
1047 eprintln!(" PID file: {}", pid_path.display());
1048}
1049
1050pub async fn execute_in_process(
1059 schema: &KoanSchema,
1060 query: &str,
1061 variables: Option<serde_json::Value>,
1062 caller: AuthUser,
1063) -> serde_json::Value {
1064 let mut request = async_graphql::Request::new(query).data(caller);
1065 if let Some(serde_json::Value::Object(map)) = variables {
1066 let mut gql_vars = async_graphql::Variables::default();
1067 for (k, v) in map {
1068 gql_vars.insert(
1069 async_graphql::Name::new(&k),
1070 async_graphql::Value::from_json(v).unwrap_or(async_graphql::Value::Null),
1071 );
1072 }
1073 request = request.variables(gql_vars);
1074 }
1075 let response = schema.execute(request).await;
1076 serde_json::to_value(&response).unwrap_or(serde_json::Value::Null)
1077}
1078
1079#[cfg(test)]
1084mod tests {
1085 use super::*;
1086 use axum::body::Body;
1087 use axum::http::{Request as HttpRequest, StatusCode};
1088 use axum::routing::{get, post};
1089 use tower::ServiceExt as _;
1090
1091 fn policy() -> Arc<BrowserPolicy> {
1092 Arc::new(BrowserPolicy {
1093 origins: vec!["https://music.example.com".into()],
1094 hosts: vec!["koan.local".into()],
1095 })
1096 }
1097
1098 async fn ok() -> &'static str {
1099 "ok"
1100 }
1101
1102 fn routes() -> axum::Router<Arc<BrowserPolicy>> {
1103 axum::Router::new()
1104 .route("/graphql", post(ok).get(ok))
1105 .route("/graphql/ws", get(ok))
1106 }
1107
1108 async fn run_host(req: HttpRequest<Body>) -> StatusCode {
1109 let app = routes()
1110 .layer(axum::middleware::from_fn_with_state(policy(), host_guard))
1111 .with_state(policy());
1112 app.oneshot(req).await.unwrap().status()
1113 }
1114
1115 async fn run_browser(req: HttpRequest<Body>) -> StatusCode {
1116 let app = routes()
1117 .layer(axum::middleware::from_fn_with_state(
1118 policy(),
1119 browser_guard,
1120 ))
1121 .with_state(policy());
1122 app.oneshot(req).await.unwrap().status()
1123 }
1124
1125 fn json_post(uri: &str) -> axum::http::request::Builder {
1126 HttpRequest::post(uri).header(axum::http::header::CONTENT_TYPE, "application/json")
1127 }
1128
1129 #[test]
1132 fn host_policy_accepts_loopback_literals_and_configured_names() {
1133 let p = policy();
1134 assert!(p.host_allowed("localhost:4000"));
1135 assert!(p.host_allowed("127.0.0.1:4000"));
1136 assert!(p.host_allowed("192.168.1.20:4000"));
1137 assert!(p.host_allowed("[::1]:4000"));
1138 assert!(p.host_allowed("koan.local"));
1139 assert!(p.host_allowed("koan.local:4000"));
1140 }
1141
1142 #[test]
1143 fn host_policy_rejects_attacker_controlled_names() {
1144 let p = policy();
1145 assert!(!p.host_allowed("evil.com"));
1146 assert!(!p.host_allowed("rebind.evil.com:4000"));
1147 assert!(!p.host_allowed("koan.local.evil.com"));
1148 }
1149
1150 #[tokio::test]
1151 async fn host_guard_rejects_foreign_host() {
1152 let req = json_post("/graphql")
1153 .header(axum::http::header::HOST, "rebind.evil.com")
1154 .body(Body::empty())
1155 .unwrap();
1156 assert_eq!(run_host(req).await, StatusCode::FORBIDDEN);
1157 }
1158
1159 #[tokio::test]
1160 async fn host_guard_allows_known_host_and_missing_host() {
1161 let req = json_post("/graphql")
1162 .header(axum::http::header::HOST, "127.0.0.1:4000")
1163 .body(Body::empty())
1164 .unwrap();
1165 assert_eq!(run_host(req).await, StatusCode::OK);
1166
1167 let req = json_post("/graphql").body(Body::empty()).unwrap();
1168 assert_eq!(run_host(req).await, StatusCode::OK);
1169 }
1170
1171 #[tokio::test]
1174 async fn ws_upgrade_from_foreign_origin_is_rejected() {
1175 let req = HttpRequest::get("/graphql/ws")
1176 .header(axum::http::header::HOST, "127.0.0.1:4000")
1177 .header(axum::http::header::ORIGIN, "https://evil.com")
1178 .body(Body::empty())
1179 .unwrap();
1180 assert_eq!(run_browser(req).await, StatusCode::FORBIDDEN);
1181 }
1182
1183 #[tokio::test]
1184 async fn ws_upgrade_without_origin_is_allowed() {
1185 let req = HttpRequest::get("/graphql/ws")
1186 .header(axum::http::header::HOST, "127.0.0.1:4000")
1187 .body(Body::empty())
1188 .unwrap();
1189 assert_eq!(run_browser(req).await, StatusCode::OK);
1190 }
1191
1192 #[tokio::test]
1193 async fn configured_and_same_origin_are_allowed() {
1194 let req = HttpRequest::get("/graphql/ws")
1195 .header(axum::http::header::HOST, "127.0.0.1:4000")
1196 .header(axum::http::header::ORIGIN, "https://music.example.com")
1197 .body(Body::empty())
1198 .unwrap();
1199 assert_eq!(run_browser(req).await, StatusCode::OK);
1200
1201 let req = json_post("/graphql")
1203 .header(axum::http::header::HOST, "127.0.0.1:4000")
1204 .header(axum::http::header::ORIGIN, "http://127.0.0.1:4000")
1205 .body(Body::empty())
1206 .unwrap();
1207 assert_eq!(run_browser(req).await, StatusCode::OK);
1208 }
1209
1210 #[tokio::test]
1213 async fn text_plain_post_is_rejected() {
1214 let req = HttpRequest::post("/graphql")
1215 .header(axum::http::header::CONTENT_TYPE, "text/plain")
1216 .body(Body::from(r#"{"query":"mutation{clearQueue{ok}}"}"#))
1217 .unwrap();
1218 assert_eq!(run_browser(req).await, StatusCode::UNSUPPORTED_MEDIA_TYPE);
1219 }
1220
1221 #[tokio::test]
1222 async fn post_without_content_type_is_rejected() {
1223 let req = HttpRequest::post("/graphql").body(Body::empty()).unwrap();
1224 assert_eq!(run_browser(req).await, StatusCode::UNSUPPORTED_MEDIA_TYPE);
1225 }
1226
1227 #[tokio::test]
1230 async fn load_perimeter_refuses_an_oversized_query_body() {
1231 async fn parse(_: async_graphql_axum::GraphQLRequest) -> StatusCode {
1232 StatusCode::OK
1233 }
1234 let app = load_perimeter(axum::Router::new().route("/graphql", post(parse)));
1235 let body = |padding: usize| {
1238 let chunks = [
1239 axum::body::Bytes::from_static(br#"{"query":"{__typename}""#),
1240 axum::body::Bytes::from(vec![b' '; padding]),
1241 axum::body::Bytes::from_static(b"}"),
1242 ];
1243 Body::from_stream(tokio_stream::iter(chunks.map(Ok::<_, std::io::Error>)))
1244 };
1245 let req = json_post("/graphql").body(body(1 << 10)).unwrap();
1246 assert_eq!(
1247 app.clone().oneshot(req).await.unwrap().status(),
1248 StatusCode::OK
1249 );
1250 let req = json_post("/graphql").body(body(3 << 20)).unwrap();
1251 assert_ne!(app.oneshot(req).await.unwrap().status(), StatusCode::OK);
1252 }
1253
1254 #[tokio::test]
1255 async fn load_perimeter_passes_requests_and_turns_panics_into_500s() {
1256 async fn boom() -> &'static str {
1257 panic!("resolver exploded");
1258 }
1259
1260 let app = load_perimeter(
1261 axum::Router::new()
1262 .route("/graphql", post(ok))
1263 .route("/boom", post(boom)),
1264 );
1265
1266 let req = json_post("/graphql").body(Body::empty()).unwrap();
1267 assert_eq!(
1268 app.clone().oneshot(req).await.unwrap().status(),
1269 StatusCode::OK
1270 );
1271
1272 let req = json_post("/boom").body(Body::empty()).unwrap();
1274 assert_eq!(
1275 app.oneshot(req).await.unwrap().status(),
1276 StatusCode::INTERNAL_SERVER_ERROR
1277 );
1278 }
1279
1280 #[tokio::test]
1281 async fn json_post_is_accepted() {
1282 let req = json_post("/graphql").body(Body::empty()).unwrap();
1283 assert_eq!(run_browser(req).await, StatusCode::OK);
1284
1285 let req = HttpRequest::post("/graphql")
1286 .header(
1287 axum::http::header::CONTENT_TYPE,
1288 "application/json; charset=utf-8",
1289 )
1290 .body(Body::empty())
1291 .unwrap();
1292 assert_eq!(run_browser(req).await, StatusCode::OK);
1293 }
1294
1295 #[test]
1299 fn an_embedded_server_ends_its_links_on_stop_and_starts_again() {
1300 use koan_core::auth::Role;
1301 use koan_core::db::connection::Database;
1302 use koan_core::player::commands::CommandChannel;
1303
1304 let _own = koan_core::config::SWITCHING
1305 .lock()
1306 .unwrap_or_else(|e| e.into_inner());
1307 let dir = tempfile::tempdir().unwrap();
1308 koan_core::config::set_config_dir(dir.path());
1309 let db_path = dir.path().join("koan.db");
1310 let db = Database::open(&db_path).unwrap();
1311 koan_core::db::schema::create_tables(&db.conn).unwrap();
1312 koan_core::db::queries::auth::create_user(&db.conn, "sharer", "hunter22", Role::Admin)
1313 .unwrap();
1314
1315 let start = || {
1316 ApiServer::start(ApiServerOpts {
1317 state: SharedPlayerState::new(),
1318 cmd_tx: CommandChannel::new().tx,
1319 pool: Arc::new(Pool::new(db_path.clone())),
1320 port: Some(0),
1321 bind: Some(std::net::Ipv4Addr::LOCALHOST.into()),
1322 subsonic_port: None,
1323 playground: false,
1324 viz: None,
1325 profile: Profile::Embedded,
1326 hosts: Vec::new(),
1327 })
1328 .unwrap()
1329 };
1330 let ping = |addr: std::net::SocketAddr| {
1332 let body = reqwest::blocking::get(format!(
1333 "http://{addr}/rest/ping?u=sharer&p=hunter22&v=1.16.1&c=test&f=json"
1334 ))
1335 .unwrap()
1336 .text()
1337 .unwrap();
1338 assert!(body.contains(r#""status":"ok""#), "{body}");
1339 };
1340 let linked = || {
1341 crate::clients::registry()
1342 .list(Some("sharer"))
1343 .iter()
1344 .any(|c| c.device == "embedded-restart")
1345 };
1346
1347 let server = start();
1348 let addr = server.addr();
1349 ping(addr);
1350 let (mut socket, _) = tungstenite::connect(format!(
1351 "ws://{addr}/rest/koanLink?u=sharer&p=hunter22&v=1.16.1&c=test&device=embedded-restart"
1352 ))
1353 .unwrap();
1354 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1355 while !linked() {
1356 assert!(
1357 std::time::Instant::now() < deadline,
1358 "the device never linked"
1359 );
1360 std::thread::sleep(std::time::Duration::from_millis(20));
1361 }
1362
1363 let stopping = std::time::Instant::now();
1364 server.stop();
1365 assert!(stopping.elapsed() < DRAIN, "stop waited out the drain");
1366 assert!(!linked(), "the link outlived the server");
1367 let closed = loop {
1369 match socket.read() {
1370 Ok(tungstenite::Message::Close(frame)) => break frame,
1371 Ok(_) => continue,
1372 Err(e) => panic!("no close frame: {e}"),
1373 }
1374 };
1375 assert_eq!(
1376 closed.map(|f| f.code),
1377 Some(tungstenite::protocol::frame::coding::CloseCode::Away)
1378 );
1379
1380 let again = start();
1381 ping(again.addr());
1382 again.stop();
1383 }
1384}