feat(bin): flag serving gated weights without client auth
An upstream token is what makes gated repositories fetchable, and a gated repository's licence is between the operator whose token accepted the terms and its publisher. Serving those weights to anyone who can reach the port is redistribution, which most such licences forbid -- and the design rests on the stored copy being for the operator's own use (spec §7). Nothing said so when upstream.token_file was set and auth.mode was "none", which is the shipped default. Warns at startup and reports an advisory from `doctor`. Checks that the token file has content rather than merely existing, because the deploy renders it whether or not the secret behind it was set. Advisories are a new, non-failing tier in `doctor`. It runs in the deploy, so promoting this to an error would block a deployment on a question that is the operator's to answer: whether mesh-only reach already counts as own use is a licensing judgement, not something this binary should decide. auth.mode stays explicit rather than being inferred from the presence of auth.token_file. Inference fails open -- a rendered-but-empty credential would silently disable authentication and the service would come up looking healthy -- whereas an explicit mode turns the same slip into a refusal to start. The redundancy is what makes losing either half detectable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
This commit is contained in:
@@ -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<String> = 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());
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user