diff --git a/AGENTS.md b/AGENTS.md index c11b1d0..0f2d310 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ Hydrant consists of several components: - **[`hydrant::resolver`]**: Manages DID resolution and key lookups. Supports multiple PLC directory sources with failover and caching. - **[`hydrant::backfill`]**: A dedicated worker that fetches full repository CAR files. Uses LIFO prioritization and adaptive concurrency to manage backfill load efficiently. - **[`hydrant::backlinks`]** (feature-gated): Maintains a reverse index of record references. Exposes `blue.microcosm.links.getBacklinks` and `blue.microcosm.links.getBacklinksCount` XRPC endpoints. -- **[`hydrant::api`]**: An Axum-based XRPC server implementing repository read methods (`getRecord`, `listRecords`, `countRecords`) and system stats. It also provides a WebSocket event stream and management APIs: +- **[`hydrant::api`]**: An Axum-based XRPC server implementing repository read methods (`getRecord`, `listRecords`, `countRecords`) and system stats. It also provides a WebSocket event stream and management APIs, which `serve` only puts on `unix:` binds unless `HYDRANT_API_TCP_MANAGEMENT` is set (`ApiBinds::with_tcp_management`): - `/filter` (`GET`/`PATCH`): Configure indexing mode, signals, and collection patterns. - `/repos` (`GET`/`PUT`/`DELETE`): Repository management (supports pagination). - `/ingestion` (`GET`/`PATCH`): Pause/resume crawler, firehose, and backfill components at runtime. diff --git a/docs/api/README.md b/docs/api/README.md index 5bed8f3..7d8d987 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -14,6 +14,9 @@ hydrant's REST API is split into public endpoints (safe to expose) and managemen ## management +only served on `unix:` binds unless `HYDRANT_API_TCP_MANAGEMENT=true`, see [getting started](../getting-started.md#reverse-proxying). + + - [filter](filter.md): NSID filter configuration - [ingestion](ingestion.md): enable/disable crawler, firehose, backfill at runtime - [crawler](crawler.md): crawler source management diff --git a/docs/configuration.md b/docs/configuration.md index e0e0daa..9ccf782 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,6 +12,7 @@ hydrant is configured via environment variables, all prefixed with `HYDRANT_` (e | `RUST_LOG` | `info` | log filter directives (e.g., `debug`, `hydrant=trace`). [tracing env-filter syntax](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html) | | `API_BIND` | `0.0.0.0:3000,[::]:3000` | comma-separated list of `:` socket addresses and `unix:` sockets to bind the API server to. literal IPs only (hostnames not resolved). when both an ipv4 and ipv6 entry share the same port, the v6 listener is set to v6-only to avoid bind collision; a lone `[::]:` listens dual-stack. unix sockets get `API_SOCKET_MODE`; a stale socket left by a crashed hydrant is replaced, but one that is still being listened on is not. the socket is set up in a short-lived `.hydrant-/` directory next to it and renamed into place, so its directory has to be writable and its path about 20 characters shorter than the os limit (104 on macOS, 108 on linux). `none` runs without the API | | `API_SOCKET_MODE` | `0600` | octal permissions for `unix:` sockets in `API_BIND`. owner-only by default since the API has no auth; `0660` also lets the socket's group in | +| `API_TCP_MANAGEMENT` | `false` | also serve the [management endpoints](getting-started.md#management-endpoints-keep-private) on tcp binds. by default they're only served on `unix:` binds, since they have no auth and the socket's permissions are what guards them, so with only tcp binds there is no management API | | `ENABLE_DEBUG` | `false` | enable debug endpoints | | `DEBUG_PORT` | first tcp `API_BIND` port + 1, or `3001` | port for debug endpoints (if enabled) | diff --git a/docs/getting-started.md b/docs/getting-started.md index 59dc4a7..d1fecff 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,6 +37,8 @@ see [configuration](configuration.md) for all available variables. if a `.env` f it is **highly recommended** to run hydrant behind a reverse proxy (like nginx or caddy) if you intend to expose the XRPC or event stream APIs to the public. hydrant's API includes several management endpoints that do not require or support authentication. **you MUST NOT expose these management endpoints to the public internet.** +management endpoints are only served on `unix:` binds by default, so a tcp bind (the default `0.0.0.0:3000,[::]:3000`) only serves the public endpoints. to manage hydrant, add a socket to `HYDRANT_API_BIND`, eg. `0.0.0.0:3000,unix:/run/hydrant/api.sock`, and talk to it with `curl --unix-socket /run/hydrant/api.sock http://localhost/filter`. to serve them on tcp like before, set `HYDRANT_API_TCP_MANAGEMENT=true` and keep them private yourself. see [configuration](configuration.md). + ### public endpoints (safe to proxy) - `/xrpc/*`: XRPC endpoints. @@ -47,7 +49,10 @@ it is **highly recommended** to run hydrant behind a reverse proxy (like nginx o ### management endpoints (keep private) +only served on `unix:` binds unless `HYDRANT_API_TCP_MANAGEMENT=true`. + - `/repos`: explicit repository tracking/resyncing/untracking. +- `/redactions`: operator erasure of stored record bodies. - `/filter`: management of NSID filter patterns. - `/ingestion`: manual control over component lifecycle (crawler, firehose, etc.). - `/crawler/sources`: management of crawler relays. @@ -55,4 +60,4 @@ it is **highly recommended** to run hydrant behind a reverse proxy (like nginx o - `/pds/tiers`: rate-limit tier assignments. - `/db/train` / `/db/compact`: database maintenance tasks. - `*/cursors`: cursor management. -- `/debug/*`: introspection and testing endpoints. +- `/debug/*`: introspection and testing endpoints. these are on their own `DEBUG_PORT` listener on `127.0.0.1`, not on `API_BIND`. diff --git a/src/api/mod.rs b/src/api/mod.rs index 7dd2c0a..6657d26 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -81,8 +81,11 @@ pub async fn serve(hydrant: Hydrant, binds: ApiBinds) -> miette::Result<()> { let app = app.nest("/stream", stream::router()); #[cfg(feature = "jetstream")] let app = app.route("/subscribe", get(jetstream::handle_subscribe)); - let app = app - .merge(xrpc::router(record_bodies_available)) + let app = app.merge(xrpc::router(record_bodies_available)); + #[cfg(feature = "backlinks")] + let app = app.merge(crate::backlinks::api::router()); + + let management = Router::new() .merge(filter::router()) .merge(pds::router()) .merge(repos::router()) @@ -90,17 +93,26 @@ pub async fn serve(hydrant: Hydrant, binds: ApiBinds) -> miette::Result<()> { .merge(ingestion::router()) .merge(firehose::router()) .merge(db::router()); - #[cfg(feature = "indexer")] - let app = app.merge(crawler::router()); - - #[cfg(feature = "backlinks")] - let app = app.merge(crate::backlinks::api::router()); + let management = management.merge(crawler::router()); - let app = app - .with_state(hydrant) - .layer(TraceLayer::new_for_http()) - .layer(CorsLayer::permissive()); + if !binds.serves_management() { + tracing::warn!( + "management api is only served on unix binds and none are configured, add a unix: \ + bind to HYDRANT_API_BIND or set HYDRANT_API_TCP_MANAGEMENT=true" + ); + } + let finish = |app: Router| { + app.with_state(hydrant.clone()) + .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::permissive()) + }; + let full = finish(app.clone().merge(management)); + let tcp_app = if binds.tcp_management { + full.clone() + } else { + finish(app) + }; // bind everything before serving anything, so a bad bind fails startup instead of // leaving the api half up @@ -108,7 +120,6 @@ pub async fn serve(hydrant: Hydrant, binds: ApiBinds) -> miette::Result<()> { let services = binds .iter() .map(|bind| { - let app = app.clone(); let service: BoxFuture<'static, std::io::Result<()>> = match bind { ApiBind::Tcp(addr) => { let listener = bind_listener(*addr, v6only_for(*addr, &tcp_binds)) @@ -116,7 +127,9 @@ pub async fn serve(hydrant: Hydrant, binds: ApiBinds) -> miette::Result<()> { tracing::info!("API server listening on {}", listener.local_addr().unwrap()); axum::serve( listener, - app.into_make_service_with_connect_info::(), + tcp_app + .clone() + .into_make_service_with_connect_info::(), ) .into_future() .boxed() @@ -125,7 +138,7 @@ pub async fn serve(hydrant: Hydrant, binds: ApiBinds) -> miette::Result<()> { let listener = bind_unix_listener(path, binds.unix_mode) .map_err(|e| miette::miette!("failed to bind to {bind}: {e}"))?; tracing::info!("API server listening on {bind}"); - axum::serve(listener, app.into_make_service()) + axum::serve(listener, full.clone().into_make_service()) .into_future() .boxed() } diff --git a/src/control/hosts.rs b/src/control/hosts.rs index 2af503f..9fb8a56 100644 --- a/src/control/hosts.rs +++ b/src/control/hosts.rs @@ -65,6 +65,7 @@ pub struct ApiBinds { pub(crate) head: ApiBind, pub(crate) tail: Vec, pub(crate) unix_mode: u32, + pub(crate) tcp_management: bool, } impl ApiBinds { @@ -73,6 +74,7 @@ impl ApiBinds { head: bind.into(), tail: Vec::new(), unix_mode: DEFAULT_UNIX_SOCKET_MODE, + tcp_management: false, } } @@ -87,6 +89,7 @@ impl ApiBinds { head, tail: iter.collect(), unix_mode: DEFAULT_UNIX_SOCKET_MODE, + tcp_management: false, }) } @@ -96,12 +99,25 @@ impl ApiBinds { self } + /// serves the management endpoints on tcp binds too. they're unix-only by default, since + /// they have no auth and a unix socket's permissions are the only thing guarding them. + pub fn with_tcp_management(mut self, enabled: bool) -> Self { + self.tcp_management = enabled; + self + } + + /// whether any bind serves the management endpoints. + pub fn serves_management(&self) -> bool { + self.tcp_management || self.iter().any(|bind| matches!(bind, ApiBind::Unix(_))) + } + pub fn iter(&self) -> impl Iterator + '_ { std::iter::once(&self.head).chain(self.tail.iter()) } - /// reads `HYDRANT_API_BIND` and `HYDRANT_API_SOCKET_MODE` through `lookup`. an unset bind - /// gives `default`, and `none` gives `None`, meaning no api at all. + /// reads `HYDRANT_API_BIND`, `HYDRANT_API_SOCKET_MODE` and `HYDRANT_API_TCP_MANAGEMENT` + /// through `lookup`. an unset bind gives `default`, and `none` gives `None`, meaning no api + /// at all. pub fn from_lookup( lookup: impl Fn(&str) -> Option, default: Option, @@ -120,6 +136,12 @@ impl ApiBinds { if let Some(mode) = lookup("HYDRANT_API_SOCKET_MODE") { binds = binds.with_unix_mode(parse_socket_mode(&mode)?); } + if let Some(raw) = lookup("HYDRANT_API_TCP_MANAGEMENT") { + let enabled = raw.trim().parse().map_err(|_| { + miette::miette!("invalid HYDRANT_API_TCP_MANAGEMENT `{raw}`: want true or false") + })?; + binds = binds.with_tcp_management(enabled); + } Ok(Some(binds)) } } @@ -414,7 +436,29 @@ mod api_bind_tests { } #[test] - fn from_lookup_refuses_bad_binds_and_modes() { + fn management_is_unix_only_unless_enabled_for_tcp() { + let tcp = ApiBinds::new(ApiBind::Tcp("127.0.0.1:3000".parse().unwrap())); + assert!(!tcp.tcp_management); + assert!(!tcp.serves_management()); + assert!(tcp.clone().with_tcp_management(true).serves_management()); + let both: ApiBinds = "127.0.0.1:3000,unix:/tmp/api.sock".parse().unwrap(); + assert!(both.serves_management()); + + // the flag applies to the default binds too, not just ones set in HYDRANT_API_BIND + let enabled = ApiBinds::from_lookup( + lookup(&[("HYDRANT_API_TCP_MANAGEMENT", "true")]), + Some(tcp.clone()), + ); + assert!(enabled.unwrap().unwrap().tcp_management); + let disabled = ApiBinds::from_lookup( + lookup(&[("HYDRANT_API_TCP_MANAGEMENT", "false")]), + Some(tcp), + ); + assert!(!disabled.unwrap().unwrap().tcp_management); + } + + #[test] + fn from_lookup_refuses_bad_values() { let sock = ("HYDRANT_API_BIND", "unix:/tmp/api.sock"); for bad in [ &[("HYDRANT_API_BIND", "")][..], @@ -423,6 +467,7 @@ mod api_bind_tests { &[sock, ("HYDRANT_API_SOCKET_MODE", "1777")], &[sock, ("HYDRANT_API_SOCKET_MODE", "+660")], &[sock, ("HYDRANT_API_SOCKET_MODE", "rw")], + &[sock, ("HYDRANT_API_TCP_MANAGEMENT", "yes")], ] { assert!( ApiBinds::from_lookup(lookup(bad), None).is_err(), diff --git a/tests/api_unix_socket.nu b/tests/api_unix_socket.nu index b50afaa..cf5a03c 100644 --- a/tests/api_unix_socket.nu +++ b/tests/api_unix_socket.nu @@ -60,6 +60,36 @@ def main [] { print $"stopping hydrant - pid: ($instance.pid)..." try { kill $instance.pid } + # with both kinds of bind, management only answers on the socket + let split_db = (mktemp -d -t hydrant_api_split_test.XXXXXX) + let split_socket = $"($split_db)/api.sock" + let split = with-env { + HYDRANT_API_BIND: $"127.0.0.1:($port),unix:($split_socket)" + HYDRANT_API_TCP_MANAGEMENT: "false" + } { + start-hydrant $binary $split_db $port + } + let tcp_url = $"http://127.0.0.1:($port)" + if not (wait-for-api $tcp_url) { + $failures = ($failures | append "split api never answered on tcp") + } else { + let status = {|args| ^curl --silent --output /dev/null --write-out "%{http_code}" ...$args } + let checks = [ + [what args want]; + ["tcp /stats" [$"($tcp_url)/stats"] "200"] + ["tcp /filter" [$"($tcp_url)/filter"] "404"] + ["socket /filter" [--unix-socket $split_socket "http://localhost/filter"] "200"] + ] + for c in $checks { + let got = (do $status $c.args) + if $got != $c.want { + $failures = ($failures | append $"($c.what) answered ($got), want ($c.want)") + } + } + } + print $"stopping hydrant - pid: ($split.pid)..." + try { kill $split.pid } + # `none` turns the api off but hydrant itself keeps running let none_db = (mktemp -d -t hydrant_api_none_test.XXXXXX) let headless = with-env { HYDRANT_API_BIND: "none" } { diff --git a/tests/common.nu b/tests/common.nu index 1d4d1c2..3a1e0a5 100644 --- a/tests/common.nu +++ b/tests/common.nu @@ -183,6 +183,8 @@ export def start-hydrant [binary: string, db_path: string, port: int] { HYDRANT_DATABASE_PATH: ($db_path), HYDRANT_FULL_NETWORK: "false", HYDRANT_API_BIND: $"127.0.0.1:($port)", + # tests drive the management api over tcp, but it's unix-only by default + HYDRANT_API_TCP_MANAGEMENT: "true", HYDRANT_ENABLE_DEBUG: "true", HYDRANT_DEBUG_PORT: (resolve-test-debug-port ($port + 1) | into string), HYDRANT_PLC_URL: "https://plc.klbr.net", diff --git a/tests/crawler_pending_throttling.nu b/tests/crawler_pending_throttling.nu index 56b29f7..eea556b 100644 --- a/tests/crawler_pending_throttling.nu +++ b/tests/crawler_pending_throttling.nu @@ -45,6 +45,7 @@ def main [] { HYDRANT_ENABLE_FIREHOSE: "false", HYDRANT_ENABLE_CRAWLER: "false", HYDRANT_API_BIND: $"127.0.0.1:($port)", + HYDRANT_API_TCP_MANAGEMENT: "true", HYDRANT_LOG_LEVEL: "debug", RUST_LOG: "debug", HYDRANT_CRAWLER_MAX_PENDING_REPOS: "2",