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(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    // Open, and migrate, before binding: a database this build cannot read,
49    // such as one a newer koan has migrated, stops it here with the reason
50    // rather than serving errors.
51    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    // A server has no one to press "scan": index at start and whenever the
63    // library folders change, as the macOS app does.
64    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        // A scan that just finished may have brought in an album someone asked
70        // to have queued when it arrived.
71        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, // headless — no viz analyzer
89            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
104// ---------------------------------------------------------------------------
105// Shared API server logic — used by both headless and TUI+API modes
106// ---------------------------------------------------------------------------
107
108/// Ceiling on a single GraphQL query. Anything longer than this —
109/// a library scan, a remote sync — runs as a job instead.
110const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
111
112/// Queries executing at once. Resolvers do their SQLite and HTTP work on the
113/// blocking pool, so this bounds concurrent work rather than protecting the
114/// runtime's workers from it.
115const MAX_INFLIGHT_QUERIES: usize = 64;
116
117/// Largest query body accepted. async-graphql reads the whole body before
118/// parsing it, with no limit of its own. A query is text; even a playlist of
119/// thousands of ids is a fraction of this.
120const MAX_QUERY_BODY: usize = 2 << 20;
121
122/// Timeout, panic catch, body limit and load shed for the query route.
123///
124/// Not applied to `/graphql/ws`: a subscription is meant to outlive any request
125/// timeout.
126fn load_perimeter<S>(router: axum::Router<S>) -> axum::Router<S>
127where
128    S: Clone + Send + Sync + 'static,
129{
130    router
131        // Innermost so it is inside the timeout: a panicking resolver becomes a
132        // 500 rather than a silently dropped connection.
133        .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        // Shed rather than queue. A concurrency limit on its own parks callers
142        // on a semaphore, so an overloaded server answers every client slowly
143        // instead of telling the surplus to come back.
144        .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/// Who the server runs for, which decides what it takes from the `[graphql]`,
167/// `[subsonic]` and `[server]` settings.
168#[derive(Clone, Copy, Debug, PartialEq, Eq)]
169pub enum Profile {
170    /// `koan --headless`: a server and nothing else. Its player is heard by
171    /// nobody.
172    Headless,
173    /// Beside the TUI, on the TUI's player.
174    Tui,
175    /// Inside an app sharing its library, on the app's player. Subsonic is
176    /// always on and only accounts sign in to it. The app owns the process, so
177    /// signals are left to it, and the settings a headless deployment uses
178    /// (demo, proxy sign-in, the playground) do not apply.
179    Embedded,
180}
181
182/// Options for the API server — avoids too-many-arguments.
183pub struct ApiServerOpts {
184    pub state: Arc<SharedPlayerState>,
185    pub cmd_tx: Sender<PlayerCommand>,
186    /// Shared by every route that reads the library outside GraphQL, and by
187    /// the MCP listener when there is one.
188    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    /// Names this server is reached by beyond `graphql.allowed_hosts`, such as
196    /// the Mac's Bonjour name.
197    pub hosts: Vec<String>,
198    /// Whose saved session `savePlaybackState` writes: the files prefix of
199    /// the device whose player this is (`LinkIdentity::files`). None where
200    /// nothing restores one, as on a headless server.
201    pub session: Option<&'static str>,
202}
203
204/// A running API server. Dropping it asks the server to stop and does not
205/// wait for it.
206pub struct ApiServer {
207    addr: std::net::SocketAddr,
208    /// What the app hosting an embedded server administers it with: an API
209    /// key good for the first admin, made for this run and never written down.
210    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    /// Bind the listeners, then serve on a thread of its own.
218    ///
219    /// `Err` means the server refused to start, on a misconfiguration or a port
220    /// it could not bind, and the caller has to surface it — never a silent
221    /// downgrade to an unauthenticated server. Nothing process-wide is touched
222    /// until the main port is bound.
223    ///
224    /// `jobs` is made by the caller, so a library watch started before the
225    /// server records its scans from the first.
226    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        // The wizard is for a server with no shell to hand, in a container. One
253        // beside a terminal session or inside an app would open its /setup to
254        // any local process with nothing on screen to say so.
255        let setup_wizard = cfg.graphql.setup_wizard && profile == Profile::Headless;
256        let bind = bind.unwrap_or(cfg.graphql.bind);
257        // An app's server has one port, the one it was given.
258        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        // A demo is one shared account; without sign-in everyone would be admin.
279        if demo && !auth_enabled {
280            return Err("server.demo needs graphql.auth_enabled".into());
281        }
282
283        // Load or generate Ed25519 keypair for JWT signing. A server's first
284        // start makes its own: it is this server's signing key and nothing
285        // else, a fresh one invalidates no token (there can be none yet), and a
286        // server in a container has no terminal to run `koan auth setup` in
287        // before it starts. Accounts are still created deliberately; until one
288        // exists, nothing signs in.
289        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            // An empty or truncated key file would otherwise leave every request an
297            // unauthenticated admin, which is the opposite of what was asked for.
298            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            // When auth is disabled, we still need dummy keys for the route state
306            // (routes exist but won't be hit by middleware). Generate if available.
307            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        // Process-scoped introspection key for playground access. It is a bearer
314        // credential compared verbatim, so it has to be full-entropy random — a
315        // UUID would leak the server start time and cut the guessable space.
316        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        // A listener on every interface may share its port with another
334        // process's on loopback, which then answers everything sent to
335        // 127.0.0.1: an app's own administration and Funnel among it. Such a
336        // port counts as taken.
337        if bind.is_unspecified() && port != 0 {
338            // `::` takes IPv4 as well, so both loopbacks count.
339            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        // `--subsonic <port>` set to something other than the GraphQL port adds
361        // a dedicated Subsonic listener.
362        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        // What once encrypted account passwords for Subsonic token auth. Nothing
405        // is encrypted with it any more; a copy left on disk is a liability.
406        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        // One for every door a password comes through, so its limits are shared.
418        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        // Plays accounts forward to ListenBrainz; it sleeps while none do.
431        koan_core::scrobbling::start(pool.path().to_path_buf());
432        // Made here, off the runtime: the relay's client is a blocking one,
433        // which cannot be built inside it, and the routes ask for it there.
434        let _ = crate::push::pusher();
435
436        // Ends graceful shutdown, every open session and the MCP streams.
437        let stop = tokio_util::sync::CancellationToken::new();
438        // An app sharing its library serves its own apps; the web UI and MCP
439        // are for a server people reach with a browser or an assistant.
440        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        // On a home network every device has a private address; only a tunnel
486        // client on this machine forwards for anyone.
487        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                    // GraphQL routes — protected by auth middleware.
519                    //
520                    // The query route carries the load perimeter; the WebSocket route does
521                    // not, because a subscription is meant to outlive any request timeout.
522                    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                        // Runs before auth: a rejected request should never reach a
534                        // credential check, let alone execute.
535                        .layer(axum::middleware::from_fn_with_state(
536                            browser_policy.clone(),
537                            browser_guard,
538                        ))
539                        .with_state(schema);
540
541                    // The web UI checks its own session (a page load without one is sent
542                    // to sign in rather than refused), so it sits outside the GraphQL auth
543                    // layer. Built before the auth routes take their state.
544                    // A standing server keeps every album's grid cover ready; an
545                    // app sharing its own library, and the API beside the
546                    // terminal UI, make them as asked.
547                    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                    // A template that fails to render panics; this makes it a 500.
562                    .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                    // Auth routes — always accessible (no auth middleware).
572                    let auth_app = auth_router(auth_route_state);
573
574                    // CORS. An empty origin list emits no `Access-Control-Allow-Origin` at
575                    // all; a wildcard would let any web page read this library.
576                    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                    // Public by design, so outside the auth layers: each route answers for
597                    // one share's own tracks, or the one cover a notification's signed
598                    // link names, and nothing else. The Host guard still applies.
599                    // An app's own server reads the address as each page is
600                    // asked for, since the app changes it as it runs.
601                    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                    // Subsonic on the GraphQL port whenever it is enabled: the remote
613                    // TUI bridge builds its stream URL off the GraphQL base. Built once
614                    // and cloned for the dedicated listener, since each build reads the
615                    // config from disk.
616                    // The demo guard goes on Subsonic itself, so a dedicated Subsonic
617                    // port has it too, and on the web UI above.
618                    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                    // An app's own server serves its apps: Subsonic and the
639                    // share pages. GraphQL and its sign-in would let any
640                    // account drive the app's own player.
641                    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                    // Outermost: a request whose `Host` we do not recognise is refused
664                    // before anything else looks at it. Without this a DNS-rebinding page
665                    // reaches the API as same-origin and CORS stops mattering.
666                    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                        // Open browser on macOS/Linux.
687                        #[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                        // With connection info, as the main listener: the sign-in
707                        // throttle keys on the client's address.
708                        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                // Blocking work still running (a scan job, a stream's file read)
730                // is left to finish on its own rather than holding up the stop.
731                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    /// Names this server answers to that move while it runs, such as the
745    /// device's Tailscale name, which exists only while Tailscale is up, and
746    /// a Funnel's address. `names` is asked again when a request names a host
747    /// not otherwise allowed, off the async threads and at most once every
748    /// `RELEARN` however many such requests arrive, and its answer replaces
749    /// the last.
750    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    /// See `host_key` on the struct. `None` but for an embedded server.
756    pub fn host_key(&self) -> Option<&str> {
757        self.host_key.as_deref()
758    }
759
760    /// The address the main listener is bound to.
761    pub fn addr(&self) -> std::net::SocketAddr {
762        self.addr
763    }
764
765    /// Stop accepting, close open sessions, and wait for the server to finish:
766    /// at most `DRAIN` for connections, then `DRAIN` for blocking work.
767    pub fn stop(mut self) {
768        self.stop.cancel();
769        self.join();
770    }
771
772    /// Wait until the server stops on its own: on a signal, for the profiles
773    /// that listen for them.
774    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/// Start the API server beside the TUI, blocking until it stops.
794/// Called from a background thread when TUI mode has API enabled. `session`
795/// is whose saved session `savePlaybackState` writes: the caller's, or none.
796#[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// ---------------------------------------------------------------------------
827// Browser perimeter
828// ---------------------------------------------------------------------------
829
830/// What this server will answer to when the caller is a browser.
831///
832/// Two separate questions: which `Host` values name this server (DNS rebinding),
833/// and which `Origin` values may talk to it (CSRF, cross-site WebSockets).
834#[derive(Default)]
835pub(crate) struct BrowserPolicy {
836    origins: Vec<String>,
837    hosts: Vec<String>,
838    /// The names `relearn` last gave, beside `hosts`.
839    learned: parking_lot::RwLock<Vec<String>>,
840    /// See `ApiServer::follow_hosts`.
841    relearn: std::sync::OnceLock<Relearn>,
842    /// When `relearn` was last asked; held while it is, so requests arriving
843    /// together ask once.
844    learned_at: parking_lot::Mutex<Option<std::time::Instant>>,
845}
846
847type Relearn = Box<dyn Fn() -> Vec<String> + Send + Sync>;
848
849/// The least time between two askings of a server's moving names.
850const 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        // A rebinding attack needs a name it controls; literals and localhost
865        // resolve to this machine by definition.
866        bare.eq_ignore_ascii_case("localhost") || bare.parse::<std::net::IpAddr>().is_ok()
867    }
868
869    /// An origin is allowed if it is configured, or if it is simply this server
870    /// talking to itself — which is what the bundled playground does.
871    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    /// Whether a host refused now may be allowed once the moving names are
884    /// asked again.
885    /// Never waits: while another request is asking (the Tailscale CLI can
886    /// take seconds), this one is refused rather than holding an async
887    /// worker. `learn` itself runs on the blocking pool.
888    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    /// Ask the moving names again, unless they were asked within `RELEARN`.
897    /// Blocking.
898    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
911/// `example.com:4000` -> `example.com`, `[::1]:4000` -> `::1`.
912fn 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
926/// Reject requests carrying an unrecognised `Host`.
927async 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    // No `Host` at all means no browser: only HTTP/1.0 and raw tooling omit it,
935    // and neither can be steered by an attacker page.
936    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
955/// Reject cross-site GraphQL traffic.
956///
957/// Two holes, one guard. A WebSocket handshake is exempt from CORS entirely, so
958/// a foreign page can open `/graphql/ws`, have the browser attach the session
959/// cookie, and read every response. And a POST whose content type is
960/// CORS-safelisted (`text/plain`) is sent without a preflight, yet
961/// async-graphql parses it as JSON regardless — so the mutation lands even
962/// though the reply is unreadable.
963async 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    // No `Origin` means a non-browser client, which CSRF cannot reach.
972    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
1000/// Cancelled when the database turns out to have been upgraded by a newer
1001/// koan, as the one replacing this server does when it starts: this build
1002/// can no longer serve it, so it drains and exits as on SIGTERM.
1003static OUTDATED: std::sync::LazyLock<tokio_util::sync::CancellationToken> =
1004    std::sync::LazyLock::new(tokio_util::sync::CancellationToken::new);
1005
1006/// Resolves on SIGINT, SIGTERM where there is one, or the database being
1007/// upgraded past this build. SIGTERM is how a service manager stops a
1008/// process, and as PID 1 in a container an unhandled one is dropped by the
1009/// kernel, leaving the server running until it is killed.
1010async 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
1028/// How long shutdown waits for open connections before giving up on them.
1029const DRAIN: std::time::Duration = std::time::Duration::from_secs(10);
1030
1031/// Resolves `DRAIN` after the server is told to stop. Graceful shutdown waits for every
1032/// connection to close, and subscription websockets and audio streams never
1033/// close on their own, so without a deadline the wait ends only in a kill.
1034async 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    // The auth middleware always injects AuthUser (anonymous_admin when auth is
1046    // disabled, or a real user when auth is enabled). No fallback needed here.
1047    request = request.data(user);
1048    schema.execute(request).await.into()
1049}
1050
1051/// Subscriptions over a WebSocket, as the account the upgrade request
1052/// authenticated. Closed when that account changes or its token lapses (see
1053/// `Lease`), and when the server stops; otherwise, as when auth is off, it
1054/// stays open.
1055async 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 an introspection key exists, require it in the URL.
1107    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    // Use async-graphql's built-in GraphiQL (self-contained, no CDN). It
1119    // resolves the endpoint against the origin, so the base path is named.
1120    // Inject the introspection key as a default header so all queries are authed.
1121    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
1130/// Run the server as a background daemon (fork + detach).
1131pub 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
1180// ---------------------------------------------------------------------------
1181// In-process execution (for MCP `graphql` tool)
1182// ---------------------------------------------------------------------------
1183
1184/// Execute a GraphQL query in-process (no HTTP round-trip).
1185///
1186/// There is no credential to check, so the caller states who the query runs as
1187/// and at what role — see `mcp::mcp_role`.
1188pub 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// ---------------------------------------------------------------------------
1210// Tests
1211// ---------------------------------------------------------------------------
1212
1213#[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    // -- Host allowlist (DNS rebinding) --
1261
1262    /// A name that comes up while the server runs, as the Tailscale name
1263    /// does when Tailscale starts, is allowed once the names are asked
1264    /// again, which a request naming it does; another name stays refused.
1265    #[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    // -- Cross-site WebSocket --
1358
1359    #[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        // The bundled playground posts to the host it was served from.
1388        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    // -- CSRF via a CORS-safelisted content type --
1397
1398    #[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    // -- Load perimeter --
1414
1415    #[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        // A valid query padded with whitespace, streamed so no Content-Length
1422        // warns the limit ahead of the bytes.
1423        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        // Without CatchPanicLayer this drops the connection with nothing logged.
1459        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    /// A server in an app is stopped and started again within one process:
1482    /// a stop ends its links rather than waiting them out, and leaves nothing
1483    /// behind that the next start trips over.
1484    #[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        // Subsonic answers, though `[subsonic]` is off by default.
1518        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        // The device is told, so it links again once the server is back.
1555        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    /// A port another process holds on loopback is taken, though the system
1573    /// would let a listener on every interface share it.
1574    #[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}