diff --git a/crates/rustingface/src/cmd/doctor.rs b/crates/rustingface/src/cmd/doctor.rs index b6c7d72..38655f7 100644 --- a/crates/rustingface/src/cmd/doctor.rs +++ b/crates/rustingface/src/cmd/doctor.rs @@ -11,9 +11,24 @@ use rustingface_entities::repo::RepoType; use crate::cmd::human_bytes; use crate::wire; +/// Whether an upstream token file exists and is non-empty. +/// +/// The deploy renders this file whether or not the secret behind it was set, +/// so its mere presence proves nothing; an empty one means no token. +fn upstream_token_present(path: &std::path::Path) -> bool { + std::fs::read_to_string(path) + .map(|contents| !contents.trim().is_empty()) + .unwrap_or(false) +} + /// Run the checks, reporting each and failing on the first that matters. pub async fn run(config: rustingface_entities::config::Config) -> Result<()> { let mut problems = Vec::new(); + // Advisories are things an operator should know and decide about, not + // failures. `doctor` runs in the deploy, so promoting a judgement call to + // an error would block a deployment on a question that is the operator's + // to answer, not this binary's. + let mut advisories: Vec = Vec::new(); println!("configuration"); println!(" listen {}", config.server.listen); @@ -73,6 +88,30 @@ pub async fn run(config: rustingface_entities::config::Config) -> Result<()> { .to_owned(), ); } + // An upstream token is what makes gated repositories fetchable, and a + // gated repository's licence is between the operator and its publisher. + // Serving those weights to anyone who can reach this port is + // redistribution, which most such licences forbid -- and the whole design + // rests on the stored copy being for the operator's own use. + if config.auth.mode == AuthMode::None + && config.upstream.enabled + && config + .upstream + .token_file + .as_ref() + .is_some_and(|path| upstream_token_present(path)) + { + advisories.push( + "an upstream token is configured, so this instance can fetch gated repositories, \ + and auth.mode = \"none\" serves whatever it fetches to anyone who can reach it. \ + Gated weights are licensed to the operator whose token accepted the terms; \ + serving them onward is redistribution. Set auth.mode = \"bearer\", or restrict \ + reach at the network layer, or use policy.allowlist to keep gated repositories \ + out of this instance." + .to_owned(), + ); + } + if config.auth.mode == AuthMode::None && config.server.listen.starts_with("0.0.0.0") { problems.push( "auth.mode = \"none\" with a wildcard bind publishes the registry on every address \ @@ -133,8 +172,22 @@ pub async fn run(config: rustingface_entities::config::Config) -> Result<()> { println!(" reachable n/a, sealed"); } + if !advisories.is_empty() { + println!("\n{} advisory(s):", advisories.len()); + for advisory in &advisories { + println!(" - {advisory}"); + } + } + if problems.is_empty() { - println!("\nno problems found"); + println!( + "\nno problems found{}", + if advisories.is_empty() { + "" + } else { + " (see the advisories above)" + } + ); return Ok(()); } println!("\n{} problem(s):", problems.len()); diff --git a/crates/rustingface/src/cmd/serve.rs b/crates/rustingface/src/cmd/serve.rs index 27e642e..f42a124 100644 --- a/crates/rustingface/src/cmd/serve.rs +++ b/crates/rustingface/src/cmd/serve.rs @@ -26,6 +26,22 @@ pub async fn run(config: Config) -> Result<()> { }; let tokens = Tokens::new(config.auth.mode, token_file.as_deref())?; + // Say this at startup, not only when somebody runs `doctor`: the operator + // who needs to hear it is the one who set an upstream token and left the + // registry open, and they will not necessarily run doctor again. + if config.auth.mode == AuthMode::None + && config.upstream.enabled + && token_file_has_content(&config) + { + tracing::warn!( + "an upstream token is configured, so this instance can fetch gated repositories, and \ + auth.mode = \"none\" serves whatever it fetches to anyone who can reach it. Gated \ + weights are licensed to the operator whose token accepted the terms; serving them \ + onward is redistribution. Set auth.mode = \"bearer\", restrict reach at the network \ + layer, or keep gated repositories out with policy.allowlist." + ); + } + let metrics = if config.observability.metrics { Some(telemetry::install()?) } else { @@ -54,6 +70,18 @@ pub async fn run(config: Config) -> Result<()> { .context("serving") } +/// Whether `upstream.token_file` names a file with a token actually in it. +/// +/// The deploy renders that file whether or not the secret behind it was set, +/// so presence alone proves nothing. +fn token_file_has_content(config: &Config) -> bool { + config.upstream.token_file.as_ref().is_some_and(|path| { + std::fs::read_to_string(path) + .map(|contents| !contents.trim().is_empty()) + .unwrap_or(false) + }) +} + /// Stop accepting on `SIGTERM`/`SIGINT` and let in-flight requests drain. /// /// Detached transfers are not requests and are not waited on here: the process