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 koan_core::artist_info::start();
64
65 let watched = db_path.clone();
68 let jobs = super::jobs::JobRegistry::default();
69 let record = super::jobs::record_watch(&jobs);
70 koan_core::helpers::spawn_library_watch(db_path, move |scan| {
71 record(&scan);
72 if let koan_core::helpers::WatchScan::Finished(_) = scan {
75 crate::clients::fulfil_from(&watched);
76 if let Ok(db) = koan_core::db::connection::Database::open_existing(&watched) {
77 crate::clients::changed_if_library_moved(&db.conn);
78 }
79 }
80 });
81
82 let started = ApiServer::start_with(
83 ApiServerOpts {
84 state,
85 cmd_tx,
86 pool,
87 port,
88 bind,
89 subsonic_port,
90 playground,
91 viz: None, profile: Profile::Headless,
93 hosts: Vec::new(),
94 session: None,
95 },
96 jobs,
97 );
98 match started {
99 Ok(server) => server.wait(),
100 Err(e) => {
101 eprintln!("koan: {}", e);
102 std::process::exit(1);
103 }
104 }
105}
106
107const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
114
115const MAX_INFLIGHT_QUERIES: usize = 64;
119
120const MAX_QUERY_BODY: usize = 2 << 20;
124
125fn load_perimeter<S>(router: axum::Router<S>) -> axum::Router<S>
130where
131 S: Clone + Send + Sync + 'static,
132{
133 router
134 .layer(tower_http::catch_panic::CatchPanicLayer::new())
137 .layer(tower_http::limit::RequestBodyLimitLayer::new(
138 MAX_QUERY_BODY,
139 ))
140 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
141 axum::http::StatusCode::REQUEST_TIMEOUT,
142 REQUEST_TIMEOUT,
143 ))
144 .layer(
148 tower::ServiceBuilder::new()
149 .layer(axum::error_handling::HandleErrorLayer::new(
150 |err: tower::BoxError| async move {
151 if err.is::<tower::load_shed::error::Overloaded>() {
152 (
153 axum::http::StatusCode::SERVICE_UNAVAILABLE,
154 "server at capacity",
155 )
156 } else {
157 (
158 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
159 "internal error",
160 )
161 }
162 },
163 ))
164 .load_shed()
165 .concurrency_limit(MAX_INFLIGHT_QUERIES),
166 )
167}
168
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
172pub enum Profile {
173 Headless,
176 Tui,
178 Embedded,
183}
184
185pub struct ApiServerOpts {
187 pub state: Arc<SharedPlayerState>,
188 pub cmd_tx: Sender<PlayerCommand>,
189 pub pool: Arc<Pool>,
192 pub port: Option<u16>,
193 pub bind: Option<std::net::IpAddr>,
194 pub subsonic_port: Option<u16>,
195 pub playground: bool,
196 pub viz: Option<Arc<VizSnapshot>>,
197 pub profile: Profile,
198 pub hosts: Vec<String>,
201 pub session: Option<&'static str>,
205}
206
207pub struct ApiServer {
210 addr: std::net::SocketAddr,
211 host_key: Option<String>,
214 stop: tokio_util::sync::CancellationToken,
215 thread: Option<std::thread::JoinHandle<()>>,
216 browser_policy: Arc<BrowserPolicy>,
217}
218
219impl ApiServer {
220 pub fn start(opts: ApiServerOpts) -> Result<Self, String> {
230 Self::start_with(opts, Default::default())
231 }
232
233 pub(crate) fn start_with(
234 opts: ApiServerOpts,
235 jobs: super::jobs::JobRegistry,
236 ) -> Result<Self, String> {
237 let ApiServerOpts {
238 state,
239 cmd_tx,
240 pool,
241 port,
242 bind,
243 subsonic_port,
244 playground,
245 viz,
246 profile,
247 hosts,
248 session,
249 } = opts;
250 use axum::routing::{get, post};
251
252 let cfg = Config::cached();
253 let embedded = profile == Profile::Embedded;
254 let port = port.unwrap_or(cfg.graphql.port);
255 let setup_wizard = cfg.graphql.setup_wizard && profile == Profile::Headless;
259 let bind = bind.unwrap_or(cfg.graphql.bind);
260 let subsonic_port = if embedded {
262 None
263 } else {
264 subsonic_port.or(cfg.subsonic.port)
265 };
266 let subsonic_enabled = embedded || cfg.subsonic.enabled;
267 let playground_enabled = !embedded && (playground || cfg.graphql.playground);
268 let auth_enabled = cfg.graphql.auth_enabled;
269 let demo = cfg.server.demo;
270
271 if embedded && !auth_enabled {
272 return Err(
273 "graphql.auth_enabled is off, which would make anyone who reaches \
274 this port an admin"
275 .into(),
276 );
277 }
278 if demo && embedded {
279 return Err("server.demo is for a headless server".into());
280 }
281 if demo && !auth_enabled {
283 return Err("server.demo needs graphql.auth_enabled".into());
284 }
285
286 let (private_pem, public_pem) = if auth_enabled {
293 let kp = auth::load_or_generate_keypair().map_err(|e| {
294 format!(
295 "auth_enabled = true but the keypair could not be loaded or created: {}",
296 e
297 )
298 })?;
299 if kp.0.is_empty() || kp.1.is_empty() {
302 return Err("auth_enabled = true but the keypair files are empty. \
303 Run `koan auth regenerate-keys`."
304 .into());
305 }
306 kp
307 } else {
308 auth::load_or_generate_keypair().unwrap_or_default()
311 };
312
313 let access_ttl = parse_duration_secs(&cfg.graphql.access_token_ttl).unwrap_or(900);
314 let refresh_ttl = parse_duration_secs(&cfg.graphql.refresh_token_ttl).unwrap_or(2_592_000);
315
316 let introspection_key = if playground_enabled && auth_enabled {
320 Some(Arc::new(auth::random_token().map_err(|e| {
321 format!("failed to generate introspection key: {}", e)
322 })?))
323 } else {
324 None
325 };
326
327 let proxy_auth = if embedded {
328 None
329 } else {
330 crate::ui::ProxyAuth::from_config(
331 &cfg.graphql.proxy_auth_header,
332 &cfg.graphql.proxy_auth_from,
333 )?
334 };
335
336 if bind.is_unspecified() && port != 0 {
341 let loopbacks: &[std::net::IpAddr] = match bind {
343 std::net::IpAddr::V4(_) => &[std::net::Ipv4Addr::LOCALHOST.into()],
344 std::net::IpAddr::V6(_) => &[
345 std::net::Ipv4Addr::LOCALHOST.into(),
346 std::net::Ipv6Addr::LOCALHOST.into(),
347 ],
348 };
349 for loopback in loopbacks {
350 std::net::TcpListener::bind((*loopback, port))
351 .map_err(|e| format!("port {port} is already in use on this machine — {e}"))?;
352 }
353 }
354 let gql_listener = std::net::TcpListener::bind((bind, port))
355 .and_then(|l| l.set_nonblocking(true).map(|()| l))
356 .map_err(|e| {
357 format!("failed to bind GraphQL port {port} — {e} (another instance running?)")
358 })?;
359 let addr = gql_listener
360 .local_addr()
361 .map_err(|e| format!("failed to read the GraphQL listener's address — {e}"))?;
362
363 let sub_listener = subsonic_port
366 .filter(|p| subsonic_enabled && *p != port)
367 .and_then(|sub_port| {
368 match std::net::TcpListener::bind((bind, sub_port))
369 .and_then(|l| l.set_nonblocking(true).map(|()| l))
370 {
371 Ok(l) => Some(l),
372 Err(e) => {
373 log::warn!(
374 "Dedicated Subsonic port {sub_port} unavailable — {e}. Mounted on GraphQL port only."
375 );
376 None
377 }
378 }
379 });
380
381 let rt = tokio::runtime::Runtime::new()
382 .map_err(|e| format!("could not start the API server's runtime — {e}"))?;
383 let (gql_listener, sub_listener) = {
384 let _in = rt.enter();
385 let gql = tokio::net::TcpListener::from_std(gql_listener)
386 .map_err(|e| format!("failed to listen on {addr} — {e}"))?;
387 let sub = sub_listener.and_then(|l| match tokio::net::TcpListener::from_std(l) {
388 Ok(l) => Some(l),
389 Err(e) => {
390 log::warn!("Dedicated Subsonic listener unusable — {e}");
391 None
392 }
393 });
394 (gql, sub)
395 };
396
397 let host_key = embedded
398 .then(auth::random_token)
399 .transpose()
400 .map_err(|e| format!("failed to generate the host key: {e}"))?;
401
402 if demo {
403 let db = pool.get().map_err(|e| format!("demo: {e}"))?;
404 crate::demo::start(&db.conn).map_err(|e| format!("demo: {e}"))?;
405 }
406
407 let _ = std::fs::remove_file(auth::keypair_dir().join("subsonic.key"));
410
411 crate::auth::set_signing_keys(Arc::new(private_pem.clone()), Arc::new(public_pem.clone()));
412
413 let auth_state = AuthState {
414 public_pem: Arc::new(public_pem.clone()),
415 auth_enabled,
416 introspection_key: introspection_key.clone(),
417 pool: pool.clone(),
418 };
419
420 let users = Arc::new(crate::auth::password::PasswordVerifier::new(pool.clone()));
422 let auth_route_state = AuthRouteState {
423 users: users.clone(),
424 pool: pool.clone(),
425 private_pem: Arc::new(private_pem),
426 public_pem: Arc::new(public_pem),
427 access_ttl_secs: access_ttl,
428 refresh_ttl_secs: refresh_ttl,
429 cookie_secure: cfg.graphql.cookie_secure,
430 login_limiter: Arc::new(RateLimiter::default()),
431 };
432
433 koan_core::scrobbling::start(pool.path().to_path_buf());
435 let _ = crate::push::pusher();
438
439 let stop = tokio_util::sync::CancellationToken::new();
441 let mcp_routes = (!embedded).then(|| {
444 crate::mcp::router(
445 state.clone(),
446 cmd_tx.clone(),
447 auth_state.clone(),
448 cfg.server_url(),
449 profile == Profile::Headless,
450 session,
451 stop.clone(),
452 )
453 });
454 let covers = Arc::new(crate::covers::Covers::in_config_dir());
455 let schema = super::build_schema_sharing(
456 state,
457 cmd_tx,
458 pool.clone(),
459 viz,
460 jobs.clone(),
461 session,
462 Some(covers.clone()),
463 );
464
465 if auth_enabled {
466 log::info!(
467 "Auth enabled (Ed25519 JWT, access TTL {}s, refresh TTL {}s)",
468 access_ttl,
469 refresh_ttl
470 );
471 let no_accounts = pool
472 .get()
473 .ok()
474 .and_then(|db| koan_core::db::queries::auth::has_users(&db.conn).ok())
475 == Some(false);
476 if no_accounts && setup_wizard {
477 log::warn!(
478 "No accounts yet: whoever opens /setup first becomes the admin. \
479 Set graphql.setup_wizard = false to make it with `koan auth setup` instead."
480 );
481 }
482 } else {
483 log::info!("Auth disabled — all requests treated as admin");
484 }
485
486 let mut allowed_hosts = cfg.graphql.allowed_hosts.clone();
487 allowed_hosts.extend(hosts);
488 let forwarded_from = if embedded {
491 crate::auth::routes::ForwardedFrom::Loopback
492 } else {
493 crate::auth::routes::ForwardedFrom::Private
494 };
495 let browser_policy = Arc::new(BrowserPolicy {
496 origins: cfg.graphql.cors_origins.clone(),
497 hosts: allowed_hosts,
498 ..Default::default()
499 });
500
501 if cfg.graphql.cors_origins.is_empty() {
502 log::info!("CORS: no origins configured — browsers get no cross-origin access");
503 }
504
505 let serving = stop.clone();
506 let serving_key = host_key.clone();
507 let kept_policy = browser_policy.clone();
508 let thread = std::thread::Builder::new()
509 .name("koan-api".into())
510 .spawn(move || {
511 let stop = serving;
512 rt.block_on(async {
513 let links = tokio_util::task::TaskTracker::new();
514 if !embedded {
515 let stop = stop.clone();
516 tokio::spawn(async move {
517 shutdown_signal().await;
518 stop.cancel();
519 });
520 }
521
522 let query_route =
527 load_perimeter(axum::Router::new().route("/graphql", post(graphql_handler)));
528
529 let gql_app = axum::Router::new()
530 .merge(query_route)
531 .route("/graphql/ws", get(graphql_ws_handler))
532 .layer(axum::Extension(stop.clone()))
533 .layer(axum::middleware::from_fn_with_state(
534 auth_state.clone(),
535 auth_middleware,
536 ))
537 .layer(axum::middleware::from_fn_with_state(
540 browser_policy.clone(),
541 browser_guard,
542 ))
543 .with_state(schema);
544
545 if profile == Profile::Headless {
552 covers.warm(pool.clone());
553 }
554 let ui_routes = (!embedded).then(|| crate::ui::router(
555 pool.clone(),
556 auth_route_state.clone(),
557 auth_enabled,
558 setup_wizard,
559 covers.clone(),
560 cfg.server_url(),
561 cfg.mcp.redirect_hosts.clone(),
562 proxy_auth,
563 jobs,
564 )
565 .layer(tower_http::catch_panic::CatchPanicLayer::new()));
567 let ui_routes = ui_routes.map(|ui| {
568 if demo {
569 ui.layer(axum::middleware::from_fn(crate::demo::guard))
570 } else {
571 ui
572 }
573 });
574
575 let auth_app = auth_router(auth_route_state);
577
578 let origins: Vec<axum::http::HeaderValue> = cfg
581 .graphql
582 .cors_origins
583 .iter()
584 .filter_map(|o| o.parse().ok())
585 .collect();
586 let cors = tower_http::cors::CorsLayer::new()
587 .allow_origin(origins)
588 .allow_methods([
589 axum::http::Method::GET,
590 axum::http::Method::POST,
591 axum::http::Method::OPTIONS,
592 ])
593 .allow_headers([
594 axum::http::header::AUTHORIZATION,
595 axum::http::header::CONTENT_TYPE,
596 axum::http::HeaderName::from_static("x-introspection-key"),
597 ])
598 .allow_credentials(true);
599
600 let share_url = match embedded {
606 true => crate::ServerUrl::Live,
607 false => crate::ServerUrl::Fixed(cfg.server_url()),
608 };
609 let edge = axum::middleware::from_fn_with_state(
610 crate::base::Base::of(&share_url),
611 crate::base::edge,
612 );
613 let share_routes =
614 crate::share::router(pool.clone(), share_url, covers.clone())
615 .layer(tower_http::catch_panic::CatchPanicLayer::new());
616 let subsonic_merged = subsonic_enabled
623 .then(|| {
624 crate::subsonic::subsonic_router(
625 pool,
626 covers,
627 users,
628 profile,
629 stop.clone(),
630 links.clone(),
631 serving_key,
632 )
633 })
634 .map(|sub| {
635 if demo {
636 sub.layer(axum::middleware::from_fn(crate::demo::guard))
637 } else {
638 sub
639 }
640 });
641 let subsonic_dedicated = subsonic_merged.clone();
642
643 let mut app = match embedded {
647 true => share_routes,
648 false => auth_app.merge(gql_app).merge(share_routes),
649 };
650 if let Some(ui) = ui_routes {
651 app = app.merge(ui);
652 }
653 if let Some(mcp) = mcp_routes {
654 app = app.merge(mcp);
655 }
656 if let Some(sub) = subsonic_merged {
657 app = app.merge(sub);
658 }
659 if playground_enabled {
660 app = app.route(
661 "/graphql",
662 get(graphql_playground).with_state((
663 introspection_key.clone(),
664 crate::base::path_of(cfg.server_url().as_deref()),
665 )),
666 );
667 }
668 let app = app
672 .layer(cors)
673 .layer(axum::middleware::from_fn_with_state(
674 browser_policy.clone(),
675 host_guard,
676 ))
677 .layer(axum::Extension(forwarded_from));
678
679 log::info!("GraphQL API on http://{addr}/graphql");
680 if subsonic_enabled {
681 log::info!("Subsonic REST on http://{addr}/rest/");
682 }
683 if playground_enabled {
684 let playground_url = match introspection_key {
685 Some(ref key) => {
686 format!("http://{addr}/graphql?introspection-key={key}")
687 }
688 None => format!("http://{addr}/graphql"),
689 };
690 log::info!("GraphiQL: {}", playground_url);
691 #[cfg(target_os = "macos")]
693 let _ = std::process::Command::new("open").arg(&playground_url).spawn();
694 #[cfg(target_os = "linux")]
695 let _ = std::process::Command::new("xdg-open")
696 .arg(&playground_url)
697 .spawn();
698 }
699 let gql_server = axum::serve(
700 gql_listener,
701 axum::ServiceExt::<axum::extract::Request>::into_make_service_with_connect_info::<std::net::SocketAddr>(
702 tower::Layer::layer(&edge, app),
703 ),
704 )
705 .with_graceful_shutdown(stop.clone().cancelled_owned());
706
707 if let Some((sub_listener, sub_app)) = sub_listener.zip(subsonic_dedicated) {
708 if let Ok(a) = sub_listener.local_addr() {
709 log::info!("Subsonic REST also on http://{a}/rest/ (dedicated port)");
710 }
711 let sub_server = axum::serve(
714 sub_listener,
715 axum::ServiceExt::<axum::extract::Request>::into_make_service_with_connect_info::<std::net::SocketAddr>(
716 tower::Layer::layer(&edge, sub_app),
717 ),
718 )
719 .with_graceful_shutdown(stop.clone().cancelled_owned());
720
721 tokio::select! {
722 r = gql_server => { if let Err(e) = r { log::error!("GraphQL server error: {e}"); } },
723 r = sub_server => { if let Err(e) = r { log::error!("Subsonic server error: {e}"); } },
724 _ = drain_deadline(&stop) => log::info!("shutting down with connections still open"),
725 }
726 } else {
727 tokio::select! {
728 r = gql_server => { if let Err(e) = r { log::error!("GraphQL server error: {e}"); } },
729 _ = drain_deadline(&stop) => log::info!("shutting down with connections still open"),
730 }
731 }
732 links.close();
737 if tokio::time::timeout(DRAIN, links.wait()).await.is_err() {
738 log::info!("shutting down with links still open");
739 }
740 });
741 rt.shutdown_timeout(DRAIN);
744 })
745 .map_err(|e| format!("could not start the API server thread — {e}"))?;
746
747 Ok(Self {
748 addr,
749 host_key,
750 stop,
751 thread: Some(thread),
752 browser_policy: kept_policy,
753 })
754 }
755
756 pub fn follow_hosts(&self, names: impl Fn() -> Vec<String> + Send + Sync + 'static) {
763 let _ = self.browser_policy.relearn.set(Box::new(names));
764 self.browser_policy.learn();
765 }
766
767 pub fn host_key(&self) -> Option<&str> {
769 self.host_key.as_deref()
770 }
771
772 pub fn addr(&self) -> std::net::SocketAddr {
774 self.addr
775 }
776
777 pub fn stop(mut self) {
780 self.stop.cancel();
781 self.join();
782 }
783
784 pub fn wait(mut self) {
787 self.join();
788 }
789
790 fn join(&mut self) {
791 if let Some(thread) = self.thread.take()
792 && thread.join().is_err()
793 {
794 log::error!("API server thread panicked");
795 }
796 }
797}
798
799impl Drop for ApiServer {
800 fn drop(&mut self) {
801 self.stop.cancel();
802 }
803}
804
805#[allow(clippy::too_many_arguments)]
809pub fn start_api_background(
810 state: Arc<SharedPlayerState>,
811 cmd_tx: Sender<PlayerCommand>,
812 db_path: PathBuf,
813 port: Option<u16>,
814 bind: Option<std::net::IpAddr>,
815 subsonic_port: Option<u16>,
816 playground: bool,
817 session: Option<&'static str>,
818) {
819 let started = ApiServer::start(ApiServerOpts {
820 state,
821 cmd_tx,
822 pool: Arc::new(Pool::new(db_path)),
823 port,
824 bind,
825 subsonic_port,
826 playground,
827 viz: None,
828 profile: Profile::Tui,
829 hosts: Vec::new(),
830 session,
831 });
832 match started {
833 Ok(server) => server.wait(),
834 Err(e) => log::error!("API server not started: {}", e),
835 }
836}
837
838#[derive(Default)]
847pub(crate) struct BrowserPolicy {
848 origins: Vec<String>,
849 hosts: Vec<String>,
850 learned: parking_lot::RwLock<Vec<String>>,
852 relearn: std::sync::OnceLock<Relearn>,
854 learned_at: parking_lot::Mutex<Option<std::time::Instant>>,
857}
858
859type Relearn = Box<dyn Fn() -> Vec<String> + Send + Sync>;
860
861const RELEARN: std::time::Duration = std::time::Duration::from_secs(5);
863
864impl BrowserPolicy {
865 fn host_allowed(&self, host: &str) -> bool {
866 let bare = strip_port(host);
867 let learned = self.learned.read();
868 if self
869 .hosts
870 .iter()
871 .chain(learned.iter())
872 .any(|h| h.eq_ignore_ascii_case(host) || h.eq_ignore_ascii_case(bare))
873 {
874 return true;
875 }
876 bare.eq_ignore_ascii_case("localhost") || bare.parse::<std::net::IpAddr>().is_ok()
879 }
880
881 fn origin_allowed(&self, origin: &str, host: Option<&str>) -> bool {
884 if self.origins.iter().any(|o| o == origin) {
885 return true;
886 }
887 match (origin.split_once("://"), host) {
888 (Some((_, authority)), Some(host)) => authority.eq_ignore_ascii_case(host),
889 _ => false,
890 }
891 }
892}
893
894impl BrowserPolicy {
895 fn may_relearn(&self) -> bool {
901 self.relearn.get().is_some()
902 && self
903 .learned_at
904 .try_lock()
905 .is_some_and(|at| at.is_none_or(|at| at.elapsed() >= RELEARN))
906 }
907
908 fn learn(&self) {
911 let Some(relearn) = self.relearn.get() else {
912 return;
913 };
914 let mut at = self.learned_at.lock();
915 if at.is_some_and(|at| at.elapsed() < RELEARN) {
916 return;
917 }
918 *self.learned.write() = relearn();
919 *at = Some(std::time::Instant::now());
920 }
921}
922
923fn strip_port(host: &str) -> &str {
925 if let Some(rest) = host.strip_prefix('[') {
926 return rest.split(']').next().unwrap_or(rest);
927 }
928 match host.rsplit_once(':') {
929 Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,
930 _ => host,
931 }
932}
933
934fn header_str(request: &axum::extract::Request, name: axum::http::HeaderName) -> Option<&str> {
935 request.headers().get(name).and_then(|v| v.to_str().ok())
936}
937
938async fn host_guard(
940 axum::extract::State(policy): axum::extract::State<Arc<BrowserPolicy>>,
941 request: axum::extract::Request,
942 next: axum::middleware::Next,
943) -> axum::response::Response {
944 use axum::response::IntoResponse;
945
946 let host = header_str(&request, axum::http::header::HOST)
949 .map(str::to_owned)
950 .or_else(|| request.uri().host().map(str::to_owned));
951
952 if let Some(ref host) = host
953 && !policy.host_allowed(host)
954 && !(policy.may_relearn() && {
955 let asked = policy.clone();
956 let _ = tokio::task::spawn_blocking(move || asked.learn()).await;
957 policy.host_allowed(host)
958 })
959 {
960 log::warn!("rejected request for unrecognised Host: {}", host);
961 return (axum::http::StatusCode::FORBIDDEN, "host not allowed").into_response();
962 }
963
964 next.run(request).await
965}
966
967async fn browser_guard(
976 axum::extract::State(policy): axum::extract::State<Arc<BrowserPolicy>>,
977 request: axum::extract::Request,
978 next: axum::middleware::Next,
979) -> axum::response::Response {
980 use axum::response::IntoResponse;
981
982 let host = header_str(&request, axum::http::header::HOST).map(str::to_owned);
983 if let Some(origin) = header_str(&request, axum::http::header::ORIGIN)
985 && !policy.origin_allowed(origin, host.as_deref())
986 {
987 log::warn!(
988 "rejected GraphQL request from disallowed Origin: {}",
989 origin
990 );
991 return (axum::http::StatusCode::FORBIDDEN, "origin not allowed").into_response();
992 }
993
994 if request.method() == axum::http::Method::POST && !is_graphql_content_type(&request) {
995 return (
996 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
997 "content type must be application/json or application/graphql",
998 )
999 .into_response();
1000 }
1001
1002 next.run(request).await
1003}
1004
1005fn is_graphql_content_type(request: &axum::extract::Request) -> bool {
1006 header_str(request, axum::http::header::CONTENT_TYPE).is_some_and(|ct| {
1007 let ct = ct.trim().to_ascii_lowercase();
1008 ct.starts_with("application/json") || ct.starts_with("application/graphql")
1009 })
1010}
1011
1012static OUTDATED: std::sync::LazyLock<tokio_util::sync::CancellationToken> =
1016 std::sync::LazyLock::new(tokio_util::sync::CancellationToken::new);
1017
1018async fn shutdown_signal() {
1023 #[cfg(unix)]
1024 {
1025 use tokio::signal::unix::{SignalKind, signal};
1026 let mut terminate = signal(SignalKind::terminate()).expect("failed to listen for SIGTERM");
1027 tokio::select! {
1028 r = tokio::signal::ctrl_c() => r.expect("failed to listen for ctrl+c"),
1029 _ = terminate.recv() => {}
1030 _ = OUTDATED.cancelled() => {}
1031 }
1032 }
1033 #[cfg(not(unix))]
1034 tokio::select! {
1035 r = tokio::signal::ctrl_c() => r.expect("failed to listen for ctrl+c"),
1036 _ = OUTDATED.cancelled() => {}
1037 }
1038}
1039
1040const DRAIN: std::time::Duration = std::time::Duration::from_secs(10);
1042
1043async fn drain_deadline(stop: &tokio_util::sync::CancellationToken) {
1047 stop.cancelled().await;
1048 tokio::time::sleep(DRAIN).await;
1049}
1050
1051async fn graphql_handler(
1052 axum::Extension(user): axum::Extension<AuthUser>,
1053 axum::extract::State(schema): axum::extract::State<KoanSchema>,
1054 req: async_graphql_axum::GraphQLRequest,
1055) -> async_graphql_axum::GraphQLResponse {
1056 let mut request = req.into_inner();
1057 request = request.data(user);
1060 schema.execute(request).await.into()
1061}
1062
1063async fn graphql_ws_handler(
1068 axum::Extension(user): axum::Extension<AuthUser>,
1069 lease: Option<axum::Extension<crate::auth::Lease>>,
1070 axum::Extension(stop): axum::Extension<tokio_util::sync::CancellationToken>,
1071 axum::extract::State(schema): axum::extract::State<KoanSchema>,
1072 protocol: async_graphql_axum::GraphQLProtocol,
1073 websocket: axum::extract::WebSocketUpgrade,
1074) -> axum::response::Response {
1075 use axum::extract::ws::{CloseFrame, Message, close_code};
1076 use futures_util::{SinkExt, StreamExt};
1077 websocket
1078 .protocols(async_graphql::http::ALL_WEBSOCKET_PROTOCOLS)
1079 .on_upgrade(move |socket| async move {
1080 let (mut sink, stream) = socket.split();
1081 let serve = async_graphql_axum::GraphQLWebSocket::new_with_pair(
1082 &mut sink, stream, schema, protocol,
1083 )
1084 .on_connection_init(move |_| async move {
1085 let mut data = async_graphql::Data::default();
1086 data.insert(user);
1087 Ok(data)
1088 })
1089 .serve();
1090 let ended = async {
1091 match lease {
1092 Some(axum::Extension(lease)) => lease.ended().await,
1093 None => std::future::pending().await,
1094 }
1095 };
1096 let close = tokio::select! {
1097 _ = serve => None,
1098 _ = ended => Some((close_code::NORMAL, "sign in again")),
1099 _ = stop.cancelled() => Some((close_code::AWAY, "server stopping")),
1100 };
1101 if let Some((code, reason)) = close {
1102 let _ = sink
1103 .send(Message::Close(Some(CloseFrame {
1104 code,
1105 reason: reason.into(),
1106 })))
1107 .await;
1108 }
1109 })
1110}
1111
1112async fn graphql_playground(
1113 axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
1114 axum::extract::State((key, base)): axum::extract::State<(Option<Arc<String>>, String)>,
1115) -> axum::response::Response {
1116 use axum::response::IntoResponse;
1117
1118 if let Some(ref expected) = key {
1120 let provided = params.get("introspection-key");
1121 if provided.map(|k| k.as_str()) != Some(expected.as_str()) {
1122 return (
1123 axum::http::StatusCode::FORBIDDEN,
1124 "invalid or missing introspection-key",
1125 )
1126 .into_response();
1127 }
1128 }
1129
1130 let endpoint = format!("{base}/graphql");
1134 let mut source = async_graphql::http::GraphiQLSource::build().endpoint(&endpoint);
1135 if let Some(ref k) = key {
1136 source = source.header("X-Introspection-Key", k.as_str());
1137 }
1138
1139 axum::response::Html(source.finish()).into_response()
1140}
1141
1142pub fn cmd_serve_daemon(
1144 port: Option<u16>,
1145 bind: Option<std::net::IpAddr>,
1146 subsonic_port: Option<u16>,
1147 playground: bool,
1148) {
1149 use std::fs;
1150 use std::process::Command;
1151
1152 require_server_url();
1153
1154 let cfg = Config::cached();
1155 let port_val = port.unwrap_or(cfg.graphql.port);
1156 let bind_val = bind.unwrap_or(cfg.graphql.bind);
1157
1158 let exe = std::env::current_exe().expect("failed to get current exe path");
1159 let mut cmd = Command::new(exe);
1160
1161 cmd.arg("--headless");
1162 cmd.arg("--port").arg(port_val.to_string());
1163 cmd.arg("--bind").arg(bind_val.to_string());
1164 if let Some(sp) = subsonic_port {
1165 cmd.arg("--subsonic").arg(sp.to_string());
1166 }
1167 if playground || cfg.graphql.playground {
1168 cmd.arg("--playground");
1169 }
1170
1171 cmd.stdin(std::process::Stdio::null());
1172 cmd.stdout(std::process::Stdio::null());
1173 cmd.stderr(std::process::Stdio::null());
1174
1175 let mut child = cmd.spawn().expect("failed to spawn daemon process");
1176 let pid = child.id();
1177
1178 let pid_path = koan_core::config::config_dir().join("koan-serve.pid");
1179 fs::write(&pid_path, pid.to_string()).ok();
1180
1181 std::thread::spawn(move || {
1182 let _ = child.wait();
1183 });
1184
1185 eprintln!("kōan daemon started (pid {}) on port {}", pid, port_val);
1186 if let Some(sp) = subsonic_port {
1187 eprintln!(" Subsonic REST on port {}", sp);
1188 }
1189 eprintln!(" PID file: {}", pid_path.display());
1190}
1191
1192pub async fn execute_in_process(
1201 schema: &KoanSchema,
1202 query: &str,
1203 variables: Option<serde_json::Value>,
1204 caller: AuthUser,
1205) -> serde_json::Value {
1206 let mut request = async_graphql::Request::new(query).data(caller);
1207 if let Some(serde_json::Value::Object(map)) = variables {
1208 let mut gql_vars = async_graphql::Variables::default();
1209 for (k, v) in map {
1210 gql_vars.insert(
1211 async_graphql::Name::new(&k),
1212 async_graphql::Value::from_json(v).unwrap_or(async_graphql::Value::Null),
1213 );
1214 }
1215 request = request.variables(gql_vars);
1216 }
1217 let response = schema.execute(request).await;
1218 serde_json::to_value(&response).unwrap_or(serde_json::Value::Null)
1219}
1220
1221#[cfg(test)]
1226mod tests {
1227 use super::*;
1228 use axum::body::Body;
1229 use axum::http::{Request as HttpRequest, StatusCode};
1230 use axum::routing::{get, post};
1231 use tower::ServiceExt as _;
1232
1233 fn policy() -> Arc<BrowserPolicy> {
1234 Arc::new(BrowserPolicy {
1235 origins: vec!["https://music.example.com".into()],
1236 hosts: vec!["koan.local".into()],
1237 ..Default::default()
1238 })
1239 }
1240
1241 async fn ok() -> &'static str {
1242 "ok"
1243 }
1244
1245 fn routes() -> axum::Router<Arc<BrowserPolicy>> {
1246 axum::Router::new()
1247 .route("/graphql", post(ok).get(ok))
1248 .route("/graphql/ws", get(ok))
1249 }
1250
1251 async fn run_host(req: HttpRequest<Body>) -> StatusCode {
1252 let app = routes()
1253 .layer(axum::middleware::from_fn_with_state(policy(), host_guard))
1254 .with_state(policy());
1255 app.oneshot(req).await.unwrap().status()
1256 }
1257
1258 async fn run_browser(req: HttpRequest<Body>) -> StatusCode {
1259 let app = routes()
1260 .layer(axum::middleware::from_fn_with_state(
1261 policy(),
1262 browser_guard,
1263 ))
1264 .with_state(policy());
1265 app.oneshot(req).await.unwrap().status()
1266 }
1267
1268 fn json_post(uri: &str) -> axum::http::request::Builder {
1269 HttpRequest::post(uri).header(axum::http::header::CONTENT_TYPE, "application/json")
1270 }
1271
1272 #[tokio::test]
1278 async fn moving_names_are_learned_from_the_request_that_names_them() {
1279 let up = Arc::new(std::sync::atomic::AtomicBool::new(false));
1280 let policy = Arc::new(BrowserPolicy {
1281 hosts: vec!["koan.local".into()],
1282 ..Default::default()
1283 });
1284 let tailscale = up.clone();
1285 let _ = policy.relearn.set(Box::new(move || {
1286 match tailscale.load(std::sync::atomic::Ordering::Relaxed) {
1287 true => vec!["mac.tailnet.ts.net".into()],
1288 false => Vec::new(),
1289 }
1290 }));
1291 let app = routes()
1292 .layer(axum::middleware::from_fn_with_state(
1293 policy.clone(),
1294 host_guard,
1295 ))
1296 .with_state(policy.clone());
1297 let ask = |host: &str| {
1298 HttpRequest::get("/graphql")
1299 .header(axum::http::header::HOST, host)
1300 .body(Body::empty())
1301 .unwrap()
1302 };
1303
1304 let status = app
1305 .clone()
1306 .oneshot(ask("mac.tailnet.ts.net"))
1307 .await
1308 .unwrap()
1309 .status();
1310 assert_eq!(status, StatusCode::FORBIDDEN, "Tailscale is stopped");
1311 up.store(true, std::sync::atomic::Ordering::Relaxed);
1312 *policy.learned_at.lock() = None;
1313 let status = app
1314 .clone()
1315 .oneshot(ask("mac.tailnet.ts.net"))
1316 .await
1317 .unwrap()
1318 .status();
1319 assert_eq!(status, StatusCode::OK, "learned once Tailscale is up");
1320 let status = app
1321 .clone()
1322 .oneshot(ask("evil.example"))
1323 .await
1324 .unwrap()
1325 .status();
1326 assert_eq!(status, StatusCode::FORBIDDEN);
1327 }
1328
1329 #[test]
1330 fn host_policy_accepts_loopback_literals_and_configured_names() {
1331 let p = policy();
1332 assert!(p.host_allowed("localhost:4000"));
1333 assert!(p.host_allowed("127.0.0.1:4000"));
1334 assert!(p.host_allowed("192.168.1.20:4000"));
1335 assert!(p.host_allowed("[::1]:4000"));
1336 assert!(p.host_allowed("koan.local"));
1337 assert!(p.host_allowed("koan.local:4000"));
1338 }
1339
1340 #[test]
1341 fn host_policy_rejects_attacker_controlled_names() {
1342 let p = policy();
1343 assert!(!p.host_allowed("evil.com"));
1344 assert!(!p.host_allowed("rebind.evil.com:4000"));
1345 assert!(!p.host_allowed("koan.local.evil.com"));
1346 }
1347
1348 #[tokio::test]
1349 async fn host_guard_rejects_foreign_host() {
1350 let req = json_post("/graphql")
1351 .header(axum::http::header::HOST, "rebind.evil.com")
1352 .body(Body::empty())
1353 .unwrap();
1354 assert_eq!(run_host(req).await, StatusCode::FORBIDDEN);
1355 }
1356
1357 #[tokio::test]
1358 async fn host_guard_allows_known_host_and_missing_host() {
1359 let req = json_post("/graphql")
1360 .header(axum::http::header::HOST, "127.0.0.1:4000")
1361 .body(Body::empty())
1362 .unwrap();
1363 assert_eq!(run_host(req).await, StatusCode::OK);
1364
1365 let req = json_post("/graphql").body(Body::empty()).unwrap();
1366 assert_eq!(run_host(req).await, StatusCode::OK);
1367 }
1368
1369 #[tokio::test]
1372 async fn ws_upgrade_from_foreign_origin_is_rejected() {
1373 let req = HttpRequest::get("/graphql/ws")
1374 .header(axum::http::header::HOST, "127.0.0.1:4000")
1375 .header(axum::http::header::ORIGIN, "https://evil.com")
1376 .body(Body::empty())
1377 .unwrap();
1378 assert_eq!(run_browser(req).await, StatusCode::FORBIDDEN);
1379 }
1380
1381 #[tokio::test]
1382 async fn ws_upgrade_without_origin_is_allowed() {
1383 let req = HttpRequest::get("/graphql/ws")
1384 .header(axum::http::header::HOST, "127.0.0.1:4000")
1385 .body(Body::empty())
1386 .unwrap();
1387 assert_eq!(run_browser(req).await, StatusCode::OK);
1388 }
1389
1390 #[tokio::test]
1391 async fn configured_and_same_origin_are_allowed() {
1392 let req = HttpRequest::get("/graphql/ws")
1393 .header(axum::http::header::HOST, "127.0.0.1:4000")
1394 .header(axum::http::header::ORIGIN, "https://music.example.com")
1395 .body(Body::empty())
1396 .unwrap();
1397 assert_eq!(run_browser(req).await, StatusCode::OK);
1398
1399 let req = json_post("/graphql")
1401 .header(axum::http::header::HOST, "127.0.0.1:4000")
1402 .header(axum::http::header::ORIGIN, "http://127.0.0.1:4000")
1403 .body(Body::empty())
1404 .unwrap();
1405 assert_eq!(run_browser(req).await, StatusCode::OK);
1406 }
1407
1408 #[tokio::test]
1411 async fn text_plain_post_is_rejected() {
1412 let req = HttpRequest::post("/graphql")
1413 .header(axum::http::header::CONTENT_TYPE, "text/plain")
1414 .body(Body::from(r#"{"query":"mutation{clearQueue{ok}}"}"#))
1415 .unwrap();
1416 assert_eq!(run_browser(req).await, StatusCode::UNSUPPORTED_MEDIA_TYPE);
1417 }
1418
1419 #[tokio::test]
1420 async fn post_without_content_type_is_rejected() {
1421 let req = HttpRequest::post("/graphql").body(Body::empty()).unwrap();
1422 assert_eq!(run_browser(req).await, StatusCode::UNSUPPORTED_MEDIA_TYPE);
1423 }
1424
1425 #[tokio::test]
1428 async fn load_perimeter_refuses_an_oversized_query_body() {
1429 async fn parse(_: async_graphql_axum::GraphQLRequest) -> StatusCode {
1430 StatusCode::OK
1431 }
1432 let app = load_perimeter(axum::Router::new().route("/graphql", post(parse)));
1433 let body = |padding: usize| {
1436 let chunks = [
1437 axum::body::Bytes::from_static(br#"{"query":"{__typename}""#),
1438 axum::body::Bytes::from(vec![b' '; padding]),
1439 axum::body::Bytes::from_static(b"}"),
1440 ];
1441 Body::from_stream(tokio_stream::iter(chunks.map(Ok::<_, std::io::Error>)))
1442 };
1443 let req = json_post("/graphql").body(body(1 << 10)).unwrap();
1444 assert_eq!(
1445 app.clone().oneshot(req).await.unwrap().status(),
1446 StatusCode::OK
1447 );
1448 let req = json_post("/graphql").body(body(3 << 20)).unwrap();
1449 assert_ne!(app.oneshot(req).await.unwrap().status(), StatusCode::OK);
1450 }
1451
1452 #[tokio::test]
1453 async fn load_perimeter_passes_requests_and_turns_panics_into_500s() {
1454 async fn boom() -> &'static str {
1455 panic!("resolver exploded");
1456 }
1457
1458 let app = load_perimeter(
1459 axum::Router::new()
1460 .route("/graphql", post(ok))
1461 .route("/boom", post(boom)),
1462 );
1463
1464 let req = json_post("/graphql").body(Body::empty()).unwrap();
1465 assert_eq!(
1466 app.clone().oneshot(req).await.unwrap().status(),
1467 StatusCode::OK
1468 );
1469
1470 let req = json_post("/boom").body(Body::empty()).unwrap();
1472 assert_eq!(
1473 app.oneshot(req).await.unwrap().status(),
1474 StatusCode::INTERNAL_SERVER_ERROR
1475 );
1476 }
1477
1478 #[tokio::test]
1479 async fn json_post_is_accepted() {
1480 let req = json_post("/graphql").body(Body::empty()).unwrap();
1481 assert_eq!(run_browser(req).await, StatusCode::OK);
1482
1483 let req = HttpRequest::post("/graphql")
1484 .header(
1485 axum::http::header::CONTENT_TYPE,
1486 "application/json; charset=utf-8",
1487 )
1488 .body(Body::empty())
1489 .unwrap();
1490 assert_eq!(run_browser(req).await, StatusCode::OK);
1491 }
1492
1493 #[test]
1497 fn an_embedded_server_ends_its_links_on_stop_and_starts_again() {
1498 use koan_core::auth::Role;
1499 use koan_core::db::connection::Database;
1500 use koan_core::player::commands::CommandChannel;
1501
1502 let _own = koan_core::config::SWITCHING
1503 .lock()
1504 .unwrap_or_else(|e| e.into_inner());
1505 let dir = tempfile::tempdir().unwrap();
1506 koan_core::config::set_config_dir(dir.path());
1507 let db_path = dir.path().join("koan.db");
1508 let db = Database::open(&db_path).unwrap();
1509 koan_core::db::schema::create_tables(&db.conn).unwrap();
1510 koan_core::db::queries::auth::create_user(&db.conn, "sharer", "hunter22", Role::Admin)
1511 .unwrap();
1512
1513 let start = || {
1514 ApiServer::start(ApiServerOpts {
1515 state: SharedPlayerState::new(),
1516 cmd_tx: CommandChannel::new().tx,
1517 pool: Arc::new(Pool::new(db_path.clone())),
1518 port: Some(0),
1519 bind: Some(std::net::Ipv4Addr::LOCALHOST.into()),
1520 subsonic_port: None,
1521 playground: false,
1522 viz: None,
1523 profile: Profile::Embedded,
1524 hosts: Vec::new(),
1525 session: None,
1526 })
1527 .unwrap()
1528 };
1529 let ping = |addr: std::net::SocketAddr| {
1531 let body = reqwest::blocking::get(format!(
1532 "http://{addr}/rest/ping?u=sharer&p=hunter22&v=1.16.1&c=test&f=json"
1533 ))
1534 .unwrap()
1535 .text()
1536 .unwrap();
1537 assert!(body.contains(r#""status":"ok""#), "{body}");
1538 };
1539 let linked = || {
1540 crate::clients::registry()
1541 .list(Some("sharer"))
1542 .iter()
1543 .any(|c| c.device == "embedded-restart")
1544 };
1545
1546 let server = start();
1547 let addr = server.addr();
1548 ping(addr);
1549 let (mut socket, _) = tungstenite::connect(format!(
1550 "ws://{addr}/rest/koanLink?u=sharer&p=hunter22&v=1.16.1&c=test&device=embedded-restart"
1551 ))
1552 .unwrap();
1553 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1554 while !linked() {
1555 assert!(
1556 std::time::Instant::now() < deadline,
1557 "the device never linked"
1558 );
1559 std::thread::sleep(std::time::Duration::from_millis(20));
1560 }
1561
1562 let stopping = std::time::Instant::now();
1563 server.stop();
1564 assert!(stopping.elapsed() < DRAIN, "stop waited out the drain");
1565 assert!(!linked(), "the link outlived the server");
1566 let closed = loop {
1568 match socket.read() {
1569 Ok(tungstenite::Message::Close(frame)) => break frame,
1570 Ok(_) => continue,
1571 Err(e) => panic!("no close frame: {e}"),
1572 }
1573 };
1574 assert_eq!(
1575 closed.map(|f| f.code),
1576 Some(tungstenite::protocol::frame::coding::CloseCode::Away)
1577 );
1578
1579 let again = start();
1580 ping(again.addr());
1581 again.stop();
1582 }
1583
1584 #[test]
1587 fn an_embedded_server_refuses_a_port_held_on_loopback() {
1588 use koan_core::player::commands::CommandChannel;
1589
1590 let _own = koan_core::config::SWITCHING
1591 .lock()
1592 .unwrap_or_else(|e| e.into_inner());
1593 let dir = tempfile::tempdir().unwrap();
1594 koan_core::config::set_config_dir(dir.path());
1595 let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1596 let port = held.local_addr().unwrap().port();
1597 let started = ApiServer::start(ApiServerOpts {
1598 state: SharedPlayerState::new(),
1599 cmd_tx: CommandChannel::new().tx,
1600 pool: Arc::new(Pool::new(dir.path().join("koan.db"))),
1601 port: Some(port),
1602 bind: Some(std::net::Ipv4Addr::UNSPECIFIED.into()),
1603 subsonic_port: None,
1604 playground: false,
1605 viz: None,
1606 profile: Profile::Embedded,
1607 hosts: Vec::new(),
1608 session: None,
1609 });
1610 let Err(why) = started else {
1611 panic!("started beside another listener on port {port}");
1612 };
1613 assert!(why.contains("already in use"), "{why}");
1614 }
1615}