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