diff options
| author | tv <tv@krebsco.de> | 2026-11-02 22:46:48 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-02 22:46:48 +0100 |
| commit | e4331d13f313f439aff9cef1ea221c8adeeebb3a (patch) | |
| tree | 38648722d73ddf9b1deea6c3b05430994b82f4e6 | |
| parent | 4ae3326ebca32252020b5899e4a3566d0a6b5ec7 (diff) | |
api: allow listening to Unix sockets
| -rw-r--r-- | docs/example.toml | 14 | ||||
| -rw-r--r-- | docs/operations.md | 54 | ||||
| -rw-r--r-- | docs/trust.md | 6 | ||||
| -rw-r--r-- | src/api/auth.rs | 47 | ||||
| -rw-r--r-- | src/api/listen.rs | 220 | ||||
| -rw-r--r-- | src/api/mod.rs | 1 | ||||
| -rw-r--r-- | src/config/mod.rs | 26 | ||||
| -rw-r--r-- | src/main.rs | 43 |
8 files changed, 345 insertions, 66 deletions
diff --git a/docs/example.toml b/docs/example.toml index b6a6ad0..43fdaf1 100644 --- a/docs/example.toml +++ b/docs/example.toml @@ -52,8 +52,10 @@ node-key-file = "/var/lib/continuum/node.key" # --- The HTTP API ------------------------------------------------------------------------ -# Address of the HTTP API. Plain HTTP: to expose it, put a TLS proxy in front. -# bind-addr = "127.0.0.1:3000" +# Where the HTTP API listens: TCP addresses, and Unix sockets (unix:PATH, made readable +# and writable by the node's user and group only), as many as given. Plain HTTP: to +# expose it, put a TLS proxy in front. +# bind-addr = ["127.0.0.1:3000", "unix:/run/continuum/api.sock"] # Users of the HTTP API (an htpasswd file with Argon2 hashes). Required (but with # user-header, or relay-only: without either, no HTTP API). @@ -65,9 +67,11 @@ htpasswd = "/etc/continuum/htpasswd" # authentication. The proxy must set or clear it on every request. # user-header = "X-Remote-User" -# Addresses (an IP address, or a range as ADDRESS/BITS) of the reverse proxies whose -# user-header is believed. -# trusted-proxies = ["127.0.0.1", "::1"] +# Where the reverse proxies whose user-header is believed connect from: IP addresses (or +# ranges, as ADDRESS/BITS), and unix for the API's Unix sockets (bind-addr). On a host +# with other users, leave out the addresses and have the proxy use a Unix socket: others +# can connect over loopback too. +# trusted-proxies = ["127.0.0.1", "::1", "unix"] # HTTP API users (from htpasswd or user-header) who may write protocol data under /store/_continuum # (remake records, members, forget records), e.g. to repair a stuck remake. Such writes diff --git a/docs/operations.md b/docs/operations.md index 4f59208..da73fce 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -107,23 +107,59 @@ One node at a time, restarting each with its files. What to know: The HTTP API speaks plain HTTP on `127.0.0.1:3000`; a reverse proxy in front adds TLS, and can authenticate users itself (single sign-on, client certificates, its own -password file). It then names the user in a header, which the node believes from the -proxy's address alone (`--user-header`, `--trusted-proxies`, by default this host's -loopback addresses); requests from elsewhere still need `--htpasswd`, which becomes -optional. The proxy must set or clear that header on every request, or a client could -set it itself. With nginx: +password file). It then names the user in a header, which the node believes from where +the proxy connects alone (`--user-header`, `--trusted-proxies`); requests from +elsewhere still need `--htpasswd`, which becomes optional. The proxy must set or clear +that header on every request, or a client could set it itself. + +Where the proxy connects decides who can pose as it. Every user of a host connects +from its loopback addresses, so trusting those (as `--trusted-proxies` does by +default) trusts everyone on the host. A Unix socket is better: `--bind-addr` takes +`unix:PATH` too, as many places as given, and makes the socket readable and writable +by the node's user and group only, so only members of that group (the proxy's user, +added to it) can connect at all. Trust it alone with `--trusted-proxies unix`, and keep +a TCP address for the rest (a command-line tool, with `--htpasswd`): + +```sh +continuum --bind-addr 127.0.0.1:3000,unix:/run/continuum/api.sock \ + --user-header X-Remote-User --trusted-proxies unix --htpasswd htpasswd ... +``` ```nginx location / { auth_basic "continuum"; auth_basic_user_file /etc/nginx/continuum.htpasswd; proxy_set_header X-Remote-User $remote_user; - proxy_pass http://127.0.0.1:3000; + proxy_pass http://unix:/run/continuum/api.sock; +} +``` + +The directory of the socket must exist and let the proxy's user through (with systemd, +`RuntimeDirectory=continuum` and `RuntimeDirectoryMode=0750`, the proxy's user in the +node's group). A socket left behind by an earlier run is replaced, and the node removes +its own when it stops. With a proxy on another host, trust its address instead, and +have the node listen where only that host reaches it. + +Where a socket won't do, the host's firewall can keep everyone but the proxy's user +off the API's port, as Linux filters local connections by the user that opens them. +With nftables, and nginx running as `www-data`: + +``` +table inet continuum { + chain output { + type filter hook output priority 0; + oifname "lo" tcp dport 3000 meta skuid != "www-data" reject + } } ``` -and the node started with `--user-header X-Remote-User`. The names the proxy gives are -the users of `--protocol-writers` and of who wrote what ([`api.md`](api.md)). +(with iptables: `-A OUTPUT -o lo -p tcp --dport 3000 -m owner ! --uid-owner www-data -j +REJECT`). The loopback addresses can then stay trusted; root can still get past it, as it +can past a socket's permissions. Every address of the API on loopback is trusted alike, +so a command-line tool of another user goes through the proxy then. + +The names the proxy gives are the users of `--protocol-writers` and of who wrote what +([`api.md`](api.md)). What streams (`/stream`, the dashboard's events) says `X-Accel-Buffering: no`, so nginx passes it on as it comes. The dashboard's events have a keepalive every 15 s; `/stream` @@ -131,7 +167,7 @@ sends nothing while nothing changes, so nginx ends a quiet one after its `proxy_read_timeout` (60 s): raise that for it, or have clients reconnect. The API may also be served under a path of its own (`location /continuum/ { -proxy_pass http://127.0.0.1:3000/; }`): its redirects (`Location`), the dashboard's +proxy_pass http://unix:/run/continuum/api.sock:/; }`): its redirects (`Location`), the dashboard's requests and the Swagger UI's are relative, so they stay under it. ## What to alert on diff --git a/docs/trust.md b/docs/trust.md index dc80045..165fc9b 100644 --- a/docs/trust.md +++ b/docs/trust.md @@ -36,8 +36,10 @@ it creates is). **The HTTP API.** Every endpoint but the API description needs a user from `--htpasswd`, or one a reverse proxy names (`--user-header`): the node believes that header from `--trusted-proxies` alone, but from those blindly, so whoever can send -requests from such an address (another user of the same host, with the default -loopback addresses) can be any user. It listens on `127.0.0.1:3000` by default (`--bind-addr`) and speaks +requests from there can be any user. With the default loopback addresses, that is every +user of the host; with only the API's Unix socket (`--trusted-proxies unix`), those its +file's permissions let in: the node's user and group ([`operations.md`](operations.md), Behind a +reverse proxy). It listens on `127.0.0.1:3000` by default (`--bind-addr`) and speaks plain HTTP: to expose it, put a TLS proxy in front. All users may read and write user data and use the admin endpoints (remakes, forgetting peers, exports, log filters). There are no read-only users: whoever may open the dashboard may also edit and delete diff --git a/src/api/auth.rs b/src/api/auth.rs index 48ec02d..400feed 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,6 +1,8 @@ use anyhow::Context; use argon2::{Argon2, password_hash::PasswordVerifier}; use axum::extract::{ConnectInfo, State}; + +use super::listen::Peer; use axum::{ body::Body, http::{ @@ -16,7 +18,7 @@ use std::{ collections::HashMap, fs::File, io::{BufRead, BufReader}, - net::{IpAddr, SocketAddr}, + net::IpAddr, sync::Arc, time::{Duration, Instant}, }; @@ -68,7 +70,32 @@ pub(crate) struct AuthState { /// A reverse proxy authenticates, and names the user in `header`. struct ProxyAuth { header: HeaderName, - trusted: Vec<Range>, + trusted: Vec<Trusted>, +} + +/// Where a proxy is believed from (`--trusted-proxies`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Trusted { + Addresses(Range), + /// The API's Unix sockets (`unix`). + Unix, +} + +impl Trusted { + fn parse(text: &str) -> anyhow::Result<Self> { + if text.trim() == "unix" { + return Ok(Self::Unix); + } + Range::parse(text).map(Self::Addresses) + } + + fn holds(&self, peer: Peer) -> bool { + match (self, peer) { + (Self::Addresses(range), Peer::Tcp(address)) => range.contains(address.ip()), + (Self::Unix, Peer::Unix) => true, + _ => false, + } + } } /// An address, or the addresses sharing its first `bits` bits. @@ -84,9 +111,9 @@ impl Range { Some((address, bits)) => (address, Some(bits)), None => (text.trim(), None), }; - let address: IpAddr = address - .parse() - .with_context(|| format!("trusted-proxies: {text:?} is no IP address"))?; + let address: IpAddr = address.parse().with_context(|| { + format!("trusted-proxies: {text:?} is neither an IP address nor unix") + })?; let all = if address.is_ipv4() { 32 } else { 128 }; let bits = match bits { None => all, @@ -140,7 +167,7 @@ impl AuthState { let trusted = settings .trusted_proxies .iter() - .map(|range| Range::parse(range)) + .map(|trusted| Trusted::parse(trusted)) .collect::<anyhow::Result<Vec<_>>>()?; Ok::<_, anyhow::Error>(Arc::new(ProxyAuth { header, trusted })) }) @@ -174,13 +201,13 @@ fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { pub(crate) struct AuthenticatedUser(pub(crate) String); /// The user the trusted proxy the request came from names, if it does. -fn proxied_user(state: &AuthState, headers: &HeaderMap, from: Option<IpAddr>) -> Option<String> { +fn proxied_user(state: &AuthState, headers: &HeaderMap, from: Option<Peer>) -> Option<String> { let proxy = state.proxy.as_ref()?; let user = headers.get(&proxy.header)?.to_str().ok()?.trim(); if user.is_empty() { return None; } - let trusted = from.is_some_and(|from| proxy.trusted.iter().any(|range| range.contains(from))); + let trusted = from.is_some_and(|from| proxy.trusted.iter().any(|trusted| trusted.holds(from))); if !trusted { tracing::debug!(from = ?from, header = %proxy.header, "User header from an address not in trusted-proxies; ignored"); return None; @@ -247,8 +274,8 @@ pub(crate) async fn basic_auth( ) -> Response { let from = request .extensions() - .get::<ConnectInfo<SocketAddr>>() - .map(|ConnectInfo(address)| address.ip()); + .get::<ConnectInfo<Peer>>() + .map(|ConnectInfo(peer)| *peer); let user = match proxied_user(&state, &headers, from) { Some(user) => Some(user), None => authenticate(&state, &headers).await, diff --git a/src/api/listen.rs b/src/api/listen.rs new file mode 100644 index 0000000..df2df22 --- /dev/null +++ b/src/api/listen.rs @@ -0,0 +1,220 @@ +//! Where the HTTP API listens (`--bind-addr`): TCP addresses, and Unix sockets +//! (`unix:PATH`), any number of them at once, all serving the same API. A request +//! carries where it came from (`Peer`), which decides whether a reverse proxy's user +//! header is believed (`api::auth`, `--trusted-proxies`): on a Unix socket, its file's +//! permissions decide who may connect at all, which on one host is what an address +//! can't. + +use std::future::Future; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::str::FromStr; + +use anyhow::{Context, Result}; +use axum::Router; +use axum::extract::connect_info::Connected; +use axum::serve::IncomingStream; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use tracing::{info, warn}; + +/// One place the API listens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Bind { + Tcp(SocketAddr), + /// A Unix socket at this path, made by the node (readable and writable by its user + /// and group only), and removed when it stops. + Unix(PathBuf), +} + +const UNIX: &str = "unix:"; + +impl FromStr for Bind { + type Err = anyhow::Error; + + fn from_str(text: &str) -> Result<Self> { + let text = text.trim(); + match text.strip_prefix(UNIX) { + Some(path) if !path.is_empty() => Ok(Self::Unix(PathBuf::from(path))), + Some(_) => { + anyhow::bail!("bind-addr: unix: needs a path (unix:/run/continuum/api.sock)") + } + None => text.parse().map(Self::Tcp).with_context(|| { + format!("bind-addr: {text:?} is neither ADDRESS:PORT nor unix:PATH") + }), + } + } +} + +impl std::fmt::Display for Bind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Tcp(address) => write!(f, "{address}"), + Self::Unix(path) => write!(f, "{UNIX}{}", path.display()), + } + } +} + +impl Serialize for Bind { + fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Bind { + fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + let text = String::deserialize(deserializer)?; + text.parse() + .map_err(|error: anyhow::Error| serde::de::Error::custom(format!("{error:#}"))) + } +} + +/// `--bind-addr`: one place (as before it took more), or several. +pub(crate) fn one_or_more<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result<Vec<Bind>, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMore { + One(Bind), + More(Vec<Bind>), + } + Ok(match OneOrMore::deserialize(deserializer)? { + OneOrMore::One(bind) => vec![bind], + OneOrMore::More(binds) => binds, + }) +} + +/// Where a request came from (`axum::extract::ConnectInfo`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Peer { + Tcp(SocketAddr), + /// Over one of the API's Unix sockets. + Unix, +} + +impl Connected<IncomingStream<'_, tokio::net::TcpListener>> for Peer { + fn connect_info(stream: IncomingStream<'_, tokio::net::TcpListener>) -> Self { + Self::Tcp(*stream.remote_addr()) + } +} + +#[cfg(unix)] +impl Connected<IncomingStream<'_, tokio::net::UnixListener>> for Peer { + fn connect_info(_stream: IncomingStream<'_, tokio::net::UnixListener>) -> Self { + Self::Unix + } +} + +/// Serves `app` at every one of `binds` until `stop` resolves (each server then drains +/// its connections); resolves once all have stopped, with the first error if any did. +/// Binds them all before it returns: one that can't be bound is an error at once. +pub(crate) async fn serve<F>( + binds: &[Bind], + app: Router, + stop: impl Fn() -> F, +) -> Result<std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send>>> +where + F: Future<Output = ()> + Send + 'static, +{ + let mut servers = tokio::task::JoinSet::new(); + for bind in binds { + let service = app.clone().into_make_service_with_connect_info::<Peer>(); + let stopped = stop(); + match bind { + Bind::Tcp(address) => { + let listener = tokio::net::TcpListener::bind(address) + .await + .with_context(|| format!("Binding the HTTP API to {address}"))?; + info!(address = %listener.local_addr()?, "HTTP API listening"); + servers.spawn(async move { + axum::serve(listener, service) + .with_graceful_shutdown(stopped) + .await + .map_err(anyhow::Error::from) + }); + } + #[cfg(unix)] + Bind::Unix(path) => { + let listener = bind_unix(path)?; + info!(socket = %path.display(), "HTTP API listening"); + let path = path.clone(); + servers.spawn(async move { + let served = axum::serve(listener, service) + .with_graceful_shutdown(stopped) + .await; + if let Err(error) = std::fs::remove_file(&path) { + warn!(socket = %path.display(), error = %error, "Couldn't remove the HTTP API's socket"); + } + served.map_err(anyhow::Error::from) + }); + } + #[cfg(not(unix))] + Bind::Unix(_) => anyhow::bail!("bind-addr: Unix sockets need a Unix"), + } + } + Ok(Box::pin(async move { + let mut first = Ok(()); + while let Some(served) = servers.join_next().await { + let served = served + .map_err(anyhow::Error::from) + .and_then(|served| served); + if let Err(error) = served + && first.is_ok() + { + first = Err(error); + } + } + first + })) +} + +/// A Unix socket at `path`: one left there by an earlier run is replaced, anything else +/// there is an error. Its user and group may connect, nobody else. +#[cfg(unix)] +fn bind_unix(path: &std::path::Path) -> Result<tokio::net::UnixListener> { + use std::os::unix::fs::{FileTypeExt, PermissionsExt}; + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_socket() => std::fs::remove_file(path) + .with_context(|| format!("Removing the old socket {}", path.display()))?, + Ok(_) => anyhow::bail!( + "bind-addr: {} exists and is no socket; not replacing it", + path.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("Looking at {}", path.display())); + } + } + let listener = tokio::net::UnixListener::bind(path) + .with_context(|| format!("Binding the HTTP API to {}", path.display()))?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660)) + .with_context(|| format!("Setting who may use {}", path.display()))?; + Ok(listener) +} + +#[cfg(test)] +mod tests { + use super::Bind; + + #[test] + fn binds_parse() { + assert_eq!( + "127.0.0.1:3000".parse::<Bind>().unwrap(), + Bind::Tcp("127.0.0.1:3000".parse().unwrap()) + ); + assert_eq!( + "unix:/run/continuum/api.sock".parse::<Bind>().unwrap(), + Bind::Unix("/run/continuum/api.sock".into()) + ); + assert_eq!( + "unix:/run/continuum/api.sock" + .parse::<Bind>() + .unwrap() + .to_string(), + "unix:/run/continuum/api.sock" + ); + for bad in ["unix:", "localhost", "3000"] { + assert!(bad.parse::<Bind>().is_err(), "{bad}"); + } + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 35b1a06..e4ccee6 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -9,6 +9,7 @@ mod dashboard; mod debug; mod document; mod health; +pub(crate) mod listen; mod log; mod peers; mod stats; diff --git a/src/config/mod.rs b/src/config/mod.rs index 527dced..a1011ee 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -90,10 +90,10 @@ pub(crate) const FORCE: &[&str] = &["swarmId", "badDocumentFile", "replaceDocume pub(crate) const OPTIONS: &[OptionSpec] = &[ option( "bind-addr", - Kind::Text, - "HOSTNAME:PORT", + Kind::List, + "ADDRESS:PORT|unix:PATH,[...]", Some("127.0.0.1:3000"), - "Address of the HTTP API.", + "Where the HTTP API listens: TCP addresses, and Unix sockets (unix:PATH, made readable and writable by the node's user and group only), as many as given.", ), option( "htpasswd", @@ -113,8 +113,8 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "trusted-proxies", Kind::List, "ADDRESS,[ADDRESS...]", - Some("127.0.0.1,::1"), - "Addresses (an IP address, or a range as ADDRESS/BITS) of the reverse proxies whose user-header is believed.", + Some("127.0.0.1,::1,unix"), + "Where the reverse proxies whose user-header is believed connect from: IP addresses (or ranges, as ADDRESS/BITS), and unix for the API's Unix sockets (bind-addr). On a host with other users, leave out the addresses and have the proxy use a Unix socket: others can connect over loopback too.", ), option( "protocol-writers", @@ -501,8 +501,11 @@ pub(crate) fn spec(name: &str) -> Option<&'static OptionSpec> { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub(crate) struct Settings { - #[serde(default = "default_bind_addr")] - pub(crate) bind_addr: SocketAddr, + #[serde( + default = "default_bind_addr", + deserialize_with = "crate::api::listen::one_or_more" + )] + pub(crate) bind_addr: Vec<crate::api::listen::Bind>, /// Required (`check`), but with `user_header` or `relay_only`. pub(crate) htpasswd: Option<String>, pub(crate) user_header: Option<String>, @@ -643,11 +646,14 @@ pub(crate) struct Settings { pub(crate) debug_epoch: Option<u64>, } -fn default_bind_addr() -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], 3000)) +fn default_bind_addr() -> Vec<crate::api::listen::Bind> { + vec![crate::api::listen::Bind::Tcp(SocketAddr::from(( + [127, 0, 0, 1], + 3000, + )))] } fn default_trusted_proxies() -> Vec<String> { - vec!["127.0.0.1".to_owned(), "::1".to_owned()] + vec!["127.0.0.1".to_owned(), "::1".to_owned(), "unix".to_owned()] } fn default_document_append() -> Duration { Duration::from_secs(5) diff --git a/src/main.rs b/src/main.rs index c474d45..49971fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,6 @@ //! main.rs only wires things together: arguments, the starting document, the node //! (`node`), bootstrap and the HTTP server. -use std::future::IntoFuture; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -98,7 +97,7 @@ async fn main() -> Result<()> { AuthState::from_settings(&args)?.context("htpasswd or user-header is required")?; info!( - bind_addr = args.bind_addr.to_string(), + bind_addr = ?args.bind_addr.iter().map(ToString::to_string).collect::<Vec<_>>(), transports = ?args.transports, "Starting replication service" ); @@ -262,23 +261,16 @@ async fn main() -> Result<()> { let joined = AtomicBool::new(false); let app = api::router(state.clone(), auth_state, args.debug_api); - let listener = tokio::net::TcpListener::bind(args.bind_addr) - .await - .with_context(|| format!("Binding local API to {}", args.bind_addr))?; - - info!(address = %listener.local_addr()?, "HTTP API listening"); - - // The node shutting down (`AppState::closing`): the server drains. + // The node shutting down (`AppState::closing`): the servers drain. let closing = state.closing.subscribe(); - // With the address each request came from: a proxy's user header is believed from - // `--trusted-proxies` only (`api::auth`). - let app = app.into_make_service_with_connect_info::<std::net::SocketAddr>(); - let server_builder = axum::serve(listener, app).with_graceful_shutdown(async move { - stopped(closing).await; - info!("Shutdown signal received. Draining API connections...") - }); - - let server_fut = server_builder.into_future(); + let server_fut = api::listen::serve(&args.bind_addr, app, move || { + let closing = closing.clone(); + async move { + stopped(closing).await; + info!("Shutdown signal received. Draining API connections...") + } + }) + .await?; tokio::pin!(server_fut); // Joins, then runs until stopped; returns only if joining fails. @@ -350,19 +342,10 @@ async fn relay_only(args: &config::Settings) -> Result<()> { let server = match auth_state { Some(auth_state) => { let app = api::relay_only_router(Arc::clone(&transport), auth_state); - let listener = tokio::net::TcpListener::bind(args.bind_addr) - .await - .with_context(|| format!("Binding local API to {}", args.bind_addr))?; - info!(address = %listener.local_addr()?, "HTTP API listening"); let stop = stop.clone(); - Some(tokio::spawn(async move { - axum::serve( - listener, - app.into_make_service_with_connect_info::<std::net::SocketAddr>(), - ) - .with_graceful_shutdown(stopped(stop)) - .await - })) + let served = + api::listen::serve(&args.bind_addr, app, move || stopped(stop.clone())).await?; + Some(tokio::spawn(served)) } None => { info!("No htpasswd or user-header: no HTTP API"); |
