Skip to main content

koan_server/graphql/
server.rs

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
17// ---------------------------------------------------------------------------
18// `koan --headless` entry point (standalone headless server)
19// ---------------------------------------------------------------------------
20
21/// A server hands out links to itself — invites, shares, sign-ins — and is
22/// known to its clients by one address, which it cannot work out for itself.
23fn 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    // Open, and migrate, before binding: a database this build cannot read,
46    // such as one a newer koan has migrated, stops it here with the reason
47    // rather than serving errors.
48    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    // A server has no one to press "scan": index at start and whenever the
60    // library folders change, as the macOS app does.
61    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        // A scan that just finished may have brought in an album someone asked
67        // to have queued when it arrived.
68        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, // headless — no viz analyzer
86            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
100// ---------------------------------------------------------------------------
101// Shared API server logic — used by both headless and TUI+API modes
102// ---------------------------------------------------------------------------
103
104/// Ceiling on a single GraphQL query. Anything longer than this —
105/// a library scan, a remote sync — runs as a job instead.
106const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
107
108/// Queries executing at once. Resolvers do their SQLite and HTTP work on the
109/// blocking pool, so this bounds concurrent work rather than protecting the
110/// runtime's workers from it.
111const MAX_INFLIGHT_QUERIES: usize = 64;
112
113/// Largest query body accepted. async-graphql reads the whole body before
114/// parsing it, with no limit of its own. A query is text; even a playlist of
115/// thousands of ids is a fraction of this.
116const MAX_QUERY_BODY: usize = 2 << 20;
117
118/// Timeout, panic catch, body limit and load shed for the query route.
119///
120/// Not applied to `/graphql/ws`: a subscription is meant to outlive any request
121/// timeout.
122fn load_perimeter<S>(router: axum::Router<S>) -> axum::Router<S>
123where
124    S: Clone + Send + Sync + 'static,
125{
126    router
127        // Innermost so it is inside the timeout: a panicking resolver becomes a
128        // 500 rather than a silently dropped connection.
129        .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        // Shed rather than queue. A concurrency limit on its own parks callers
138        // on a semaphore, so an overloaded server answers every client slowly
139        // instead of telling the surplus to come back.
140        .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/// Who the server runs for, which decides what it takes from the `[graphql]`,
163/// `[subsonic]` and `[server]` settings.
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165pub enum Profile {
166    /// `koan --headless`: a server and nothing else. Its player is heard by
167    /// nobody.
168    Headless,
169    /// Beside the TUI, on the TUI's player.
170    Tui,
171    /// Inside an app sharing its library, on the app's player. Subsonic is
172    /// always on and only accounts sign in to it. The app owns the process, so
173    /// signals are left to it, and the settings a headless deployment uses
174    /// (demo, proxy sign-in, the playground) do not apply.
175    Embedded,
176}
177
178/// Options for the API server — avoids too-many-arguments.
179pub struct ApiServerOpts {
180    pub state: Arc<SharedPlayerState>,
181    pub cmd_tx: Sender<PlayerCommand>,
182    /// Shared by every route that reads the library outside GraphQL, and by
183    /// the MCP listener when there is one.
184    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    /// Names this server is reached by beyond `graphql.allowed_hosts`, such as
192    /// the Mac's Bonjour name.
193    pub hosts: Vec<String>,
194}
195
196/// A running API server. Dropping it asks the server to stop and does not
197/// wait for it.
198pub struct ApiServer {
199    addr: std::net::SocketAddr,
200    /// What the app hosting an embedded server administers it with: an API
201    /// key good for the first admin, made for this run and never written down.
202    host_key: Option<String>,
203    stop: tokio_util::sync::CancellationToken,
204    thread: Option<std::thread::JoinHandle<()>>,
205}
206
207impl ApiServer {
208    /// Bind the listeners, then serve on a thread of its own.
209    ///
210    /// `Err` means the server refused to start, on a misconfiguration or a port
211    /// it could not bind, and the caller has to surface it — never a silent
212    /// downgrade to an unauthenticated server. Nothing process-wide is touched
213    /// until the main port is bound.
214    ///
215    /// `jobs` is made by the caller, so a library watch started before the
216    /// server records its scans from the first.
217    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        // The wizard is for a server with no shell to hand, in a container. One
243        // beside a terminal session or inside an app would open its /setup to
244        // any local process with nothing on screen to say so.
245        let setup_wizard = cfg.graphql.setup_wizard && profile == Profile::Headless;
246        let bind = bind.unwrap_or(cfg.graphql.bind);
247        // An app's server has one port, the one it was given.
248        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        // A demo is one shared account; without sign-in everyone would be admin.
269        if demo && !auth_enabled {
270            return Err("server.demo needs graphql.auth_enabled".into());
271        }
272
273        // Load or generate Ed25519 keypair for JWT signing. A server's first
274        // start makes its own: it is this server's signing key and nothing
275        // else, a fresh one invalidates no token (there can be none yet), and a
276        // server in a container has no terminal to run `koan auth setup` in
277        // before it starts. Accounts are still created deliberately; until one
278        // exists, nothing signs in.
279        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            // An empty or truncated key file would otherwise leave every request an
287            // unauthenticated admin, which is the opposite of what was asked for.
288            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            // When auth is disabled, we still need dummy keys for the route state
296            // (routes exist but won't be hit by middleware). Generate if available.
297            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        // Process-scoped introspection key for playground access. It is a bearer
304        // credential compared verbatim, so it has to be full-entropy random — a
305        // UUID would leak the server start time and cut the guessable space.
306        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        // `--subsonic <port>` set to something other than the GraphQL port adds
333        // a dedicated Subsonic listener.
334        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        // What once encrypted account passwords for Subsonic token auth. Nothing
377        // is encrypted with it any more; a copy left on disk is a liability.
378        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        // One for every door a password comes through, so its limits are shared.
390        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        // Plays accounts forward to ListenBrainz; it sleeps while none do.
403        koan_core::scrobbling::start(pool.path().to_path_buf());
404        // Made here, off the runtime: the relay's client is a blocking one,
405        // which cannot be built inside it, and the routes ask for it there.
406        let _ = crate::push::pusher();
407
408        // Ends graceful shutdown, every open session and the MCP streams.
409        let stop = tokio_util::sync::CancellationToken::new();
410        // An app sharing its library serves its own apps; the web UI and MCP
411        // are for a server people reach with a browser or an assistant.
412        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        // On a home network every device has a private address; only a tunnel
448        // client on this machine forwards for anyone.
449        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                    // GraphQL routes — protected by auth middleware.
479                    //
480                    // The query route carries the load perimeter; the WebSocket route does
481                    // not, because a subscription is meant to outlive any request timeout.
482                    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                        // Runs before auth: a rejected request should never reach a
494                        // credential check, let alone execute.
495                        .layer(axum::middleware::from_fn_with_state(
496                            browser_policy.clone(),
497                            browser_guard,
498                        ))
499                        .with_state(schema);
500
501                    // The web UI checks its own session (a page load without one is sent
502                    // to sign in rather than refused), so it sits outside the GraphQL auth
503                    // layer. Built before the auth routes take their state.
504                    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                    // A template that fails to render panics; this makes it a 500.
517                    .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                    // Auth routes — always accessible (no auth middleware).
527                    let auth_app = auth_router(auth_route_state);
528
529                    // CORS. An empty origin list emits no `Access-Control-Allow-Origin` at
530                    // all; a wildcard would let any web page read this library.
531                    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                    // Public by design, so outside the auth layers: each route answers for
552                    // one share's own tracks, or the one cover a notification's signed
553                    // link names, and nothing else. The Host guard still applies.
554                    // An app's own server reads the address as each page is
555                    // asked for, since the app changes it as it runs.
556                    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                    // Subsonic on the GraphQL port whenever it is enabled: the remote
564                    // TUI bridge builds its stream URL off the GraphQL base. Built once
565                    // and cloned for the dedicated listener, since each build reads the
566                    // config from disk.
567                    // The demo guard goes on Subsonic itself, so a dedicated Subsonic
568                    // port has it too, and on the web UI above.
569                    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                    // Outermost: a request whose `Host` we do not recognise is refused
606                    // before anything else looks at it. Without this a DNS-rebinding page
607                    // reaches the API as same-origin and CORS stops mattering.
608                    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                        // Open browser on macOS/Linux.
629                        #[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                        // With connection info, as the main listener: the sign-in
647                        // throttle keys on the client's address.
648                        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                // Blocking work still running (a scan job, a stream's file read)
668                // is left to finish on its own rather than holding up the stop.
669                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    /// See `host_key` on the struct. `None` but for an embedded server.
682    pub fn host_key(&self) -> Option<&str> {
683        self.host_key.as_deref()
684    }
685
686    /// The address the main listener is bound to.
687    pub fn addr(&self) -> std::net::SocketAddr {
688        self.addr
689    }
690
691    /// Stop accepting, close open sessions, and wait for the server to finish:
692    /// at most `DRAIN` for connections, then `DRAIN` for blocking work.
693    pub fn stop(mut self) {
694        self.stop.cancel();
695        self.join();
696    }
697
698    /// Wait until the server stops on its own: on a signal, for the profiles
699    /// that listen for them.
700    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
719/// Start the API server beside the TUI, blocking until it stops.
720/// Called from a background thread when TUI mode has API enabled.
721pub 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
748// ---------------------------------------------------------------------------
749// Browser perimeter
750// ---------------------------------------------------------------------------
751
752/// What this server will answer to when the caller is a browser.
753///
754/// Two separate questions: which `Host` values name this server (DNS rebinding),
755/// and which `Origin` values may talk to it (CSRF, cross-site WebSockets).
756pub(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        // A rebinding attack needs a name it controls; literals and localhost
771        // resolve to this machine by definition.
772        bare.eq_ignore_ascii_case("localhost") || bare.parse::<std::net::IpAddr>().is_ok()
773    }
774
775    /// An origin is allowed if it is configured, or if it is simply this server
776    /// talking to itself — which is what the bundled playground does.
777    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
788/// `example.com:4000` -> `example.com`, `[::1]:4000` -> `::1`.
789fn 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
803/// Reject requests carrying an unrecognised `Host`.
804async 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    // No `Host` at all means no browser: only HTTP/1.0 and raw tooling omit it,
812    // and neither can be steered by an attacker page.
813    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
827/// Reject cross-site GraphQL traffic.
828///
829/// Two holes, one guard. A WebSocket handshake is exempt from CORS entirely, so
830/// a foreign page can open `/graphql/ws`, have the browser attach the session
831/// cookie, and read every response. And a POST whose content type is
832/// CORS-safelisted (`text/plain`) is sent without a preflight, yet
833/// async-graphql parses it as JSON regardless — so the mutation lands even
834/// though the reply is unreadable.
835async 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    // No `Origin` means a non-browser client, which CSRF cannot reach.
844    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
872/// Cancelled when the database turns out to have been upgraded by a newer
873/// koan, as the one replacing this server does when it starts: this build
874/// can no longer serve it, so it drains and exits as on SIGTERM.
875static OUTDATED: std::sync::LazyLock<tokio_util::sync::CancellationToken> =
876    std::sync::LazyLock::new(tokio_util::sync::CancellationToken::new);
877
878/// Resolves on SIGINT, SIGTERM where there is one, or the database being
879/// upgraded past this build. SIGTERM is how a service manager stops a
880/// process, and as PID 1 in a container an unhandled one is dropped by the
881/// kernel, leaving the server running until it is killed.
882async 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
900/// How long shutdown waits for open connections before giving up on them.
901const DRAIN: std::time::Duration = std::time::Duration::from_secs(10);
902
903/// Resolves `DRAIN` after the server is told to stop. Graceful shutdown waits for every
904/// connection to close, and subscription websockets and audio streams never
905/// close on their own, so without a deadline the wait ends only in a kill.
906async 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    // The auth middleware always injects AuthUser (anonymous_admin when auth is
918    // disabled, or a real user when auth is enabled). No fallback needed here.
919    request = request.data(user);
920    schema.execute(request).await.into()
921}
922
923/// Subscriptions over a WebSocket, as the account the upgrade request
924/// authenticated. Closed when that account changes or its token lapses (see
925/// `Lease`), and when the server stops; otherwise, as when auth is off, it
926/// stays open.
927async 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 an introspection key exists, require it in the URL.
979    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    // Use async-graphql's built-in GraphiQL (self-contained, no CDN).
991    // Inject the introspection key as a default header so all queries are authed.
992    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
1000/// Run the server as a background daemon (fork + detach).
1001pub 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
1050// ---------------------------------------------------------------------------
1051// In-process execution (for MCP `graphql` tool)
1052// ---------------------------------------------------------------------------
1053
1054/// Execute a GraphQL query in-process (no HTTP round-trip).
1055///
1056/// There is no credential to check, so the caller states who the query runs as
1057/// and at what role — see `mcp::mcp_role`.
1058pub 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// ---------------------------------------------------------------------------
1080// Tests
1081// ---------------------------------------------------------------------------
1082
1083#[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    // -- Host allowlist (DNS rebinding) --
1130
1131    #[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    // -- Cross-site WebSocket --
1172
1173    #[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        // The bundled playground posts to the host it was served from.
1202        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    // -- CSRF via a CORS-safelisted content type --
1211
1212    #[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    // -- Load perimeter --
1228
1229    #[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        // A valid query padded with whitespace, streamed so no Content-Length
1236        // warns the limit ahead of the bytes.
1237        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        // Without CatchPanicLayer this drops the connection with nothing logged.
1273        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    /// A server in an app is stopped and started again within one process:
1296    /// a stop ends its links rather than waiting them out, and leaves nothing
1297    /// behind that the next start trips over.
1298    #[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        // Subsonic answers, though `[subsonic]` is off by default.
1331        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        // The device is told, so it links again once the server is back.
1368        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}