Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions doc/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,15 @@ paths:
because it is designed for dashboards and operators who need to see the
full picture.
operationId: getHealth
parameters:
- name: Authorization
in: header
required: false
description: >
Bearer token required when `TRUSS_HEALTH_TOKEN` is set.
schema:
type: string
example: 'Bearer my-secret-token'
responses:
'200':
description: Comprehensive health diagnostic
Expand Down Expand Up @@ -364,6 +373,12 @@ paths:
rssBytes: 134217728
thresholdBytes: 536870912
maxInputPixels: 40000000
'401':
description: Authentication required (when TRUSS_HEALTH_TOKEN is set)
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetail'

/health/live:
get:
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ truss is configured through environment variables and CLI flags. This page docum
| `TRUSS_KEEP_ALIVE_MAX_REQUESTS` | Max requests per keep-alive connection before the server closes it (default: `100`, range: 1-100000) |
| `TRUSS_HEALTH_CACHE_MIN_FREE_BYTES` | Minimum free bytes on cache disk; `/health/ready` returns 503 when breached (disabled by default) |
| `TRUSS_HEALTH_MAX_MEMORY_BYTES` | Maximum process RSS in bytes; `/health/ready` returns 503 when breached (disabled by default, Linux only) |
| `TRUSS_HEALTH_HYSTERESIS_MARGIN` | Recovery margin for readiness probe hysteresis (default: `0.05`, range: 0.01-0.50). After a threshold is breached, the value must recover past threshold ± margin before the check returns to ok |
| `TRUSS_SHUTDOWN_DRAIN_SECS` | Drain period in seconds during graceful shutdown; `/health/ready` returns 503 immediately (default: `10`, range: 0-300). Total shutdown time is drain + 15 s worker drain. On Kubernetes, set `terminationGracePeriodSeconds` >= drain + 20 (e.g. `35` for the default 10 s drain) |
| `TRUSS_RESPONSE_HEADERS` | JSON object of custom headers added to all image responses including private transforms (e.g. `{"CDN-Cache-Control":"max-age=86400"}`). Framing / hop-by-hop headers (`Content-Length`, `Transfer-Encoding`, `Content-Encoding`, `Content-Type`, `Connection`, etc.) are rejected at startup. Header names must be valid RFC 7230 tokens; values must contain only visible ASCII, SP, or HTAB (CRLF is rejected) |
| `TRUSS_DISABLE_COMPRESSION` | Disable gzip compression for non-image responses (`true`/`1`/`yes`/`on`, case-insensitive). When compression is enabled (default), `Vary: Accept-Encoding` is added to compressible responses |
Expand Down Expand Up @@ -83,6 +84,7 @@ The server exposes a `/metrics` endpoint in Prometheus text exposition format. B
|------|------|
| `TRUSS_METRICS_TOKEN` | Bearer token for `/metrics`; when set, requests must include `Authorization: Bearer <token>` |
| `TRUSS_DISABLE_METRICS` | Disable the `/metrics` endpoint entirely (`true`/`1`; returns 404) |
| `TRUSS_HEALTH_TOKEN` | Bearer token for `/health`; when set, requests must include `Authorization: Bearer <token>`. `/health/live` and `/health/ready` remain unauthenticated |

For the full metrics reference, bucket boundaries, and example PromQL queries, see [../doc/prometheus.md](../doc/prometheus.md).

Expand Down
17 changes: 13 additions & 4 deletions src/adapters/server/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ use std::collections::BTreeMap;
use subtle::ConstantTimeEq;
use url::Url;

/// Extracts the Bearer token from an `Authorization` header value.
///
/// Returns `Some(token)` when the value starts with `Bearer ` (case-insensitive),
/// or `None` if the scheme does not match or the value is malformed.
pub(super) fn extract_bearer_token(value: &str) -> Option<&str> {
let (scheme, token) = value.split_once(|c: char| c.is_whitespace())?;
scheme
.eq_ignore_ascii_case("Bearer")
.then(|| token.trim())
.filter(|t| !t.is_empty())
}

pub(super) fn authorize_request(
request: &HttpRequest,
config: &ServerConfig,
Expand All @@ -32,10 +44,7 @@ pub(super) fn authorize_request_headers(
let provided = headers
.iter()
.find_map(|(name, value)| (name == "authorization").then_some(value.as_str()))
.and_then(|value| {
let (scheme, token) = value.split_once(|c: char| c.is_whitespace())?;
scheme.eq_ignore_ascii_case("Bearer").then(|| token.trim())
});
.and_then(extract_bearer_token);

match provided {
Some(token) if token.as_bytes().ct_eq(expected.as_bytes()).into() => Ok(()),
Expand Down
67 changes: 64 additions & 3 deletions src/adapters/server/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,12 @@ pub struct ServerConfig {
///
/// Configurable via `TRUSS_DISABLE_METRICS`. When enabled, `/metrics` returns 404.
pub disable_metrics: bool,
/// Bearer token for the `/health` diagnostic endpoint.
///
/// When set, `GET /health` requires `Authorization: Bearer <token>`.
/// The `/health/live` and `/health/ready` probe endpoints remain
/// unauthenticated. Configurable via `TRUSS_HEALTH_TOKEN`.
pub health_token: Option<String>,
/// Minimum free bytes on the cache disk before `/health/ready` reports failure.
///
/// Configurable via `TRUSS_HEALTH_CACHE_MIN_FREE_BYTES`. When unset, the cache
Expand Down Expand Up @@ -551,6 +557,7 @@ impl Clone for ServerConfig {
keep_alive_max_requests: self.keep_alive_max_requests,
metrics_token: self.metrics_token.clone(),
disable_metrics: self.disable_metrics,
health_token: self.health_token.clone(),
health_cache_min_free_bytes: self.health_cache_min_free_bytes,
health_max_memory_bytes: self.health_max_memory_bytes,
health_cache: Arc::clone(&self.health_cache),
Expand Down Expand Up @@ -627,6 +634,10 @@ impl fmt::Debug for ServerConfig {
&self.metrics_token.as_ref().map(|_| "[REDACTED]"),
)
.field("disable_metrics", &self.disable_metrics)
.field(
"health_token",
&self.health_token.as_ref().map(|_| "[REDACTED]"),
)
.field(
"health_cache_min_free_bytes",
&self.health_cache_min_free_bytes,
Expand Down Expand Up @@ -694,9 +705,11 @@ impl PartialEq for ServerConfig {
&& self.keep_alive_max_requests == other.keep_alive_max_requests
&& self.metrics_token == other.metrics_token
&& self.disable_metrics == other.disable_metrics
&& self.health_token == other.health_token
&& self.health_cache_min_free_bytes == other.health_cache_min_free_bytes
&& self.health_max_memory_bytes == other.health_max_memory_bytes
&& self.health_cache.ttl_nanos == other.health_cache.ttl_nanos
&& self.health_cache.hysteresis_margin == other.health_cache.hysteresis_margin
&& self.shutdown_drain_secs == other.shutdown_drain_secs
&& self.custom_response_headers == other.custom_response_headers
&& self.max_source_bytes == other.max_source_bytes
Expand Down Expand Up @@ -805,10 +818,12 @@ impl ServerConfig {
keep_alive_max_requests: DEFAULT_KEEP_ALIVE_MAX_REQUESTS,
metrics_token: None,
disable_metrics: false,
health_token: None,
health_cache_min_free_bytes: None,
health_max_memory_bytes: None,
health_cache: Arc::new(super::handler::HealthCache::new(
super::handler::DEFAULT_HEALTH_CACHE_TTL_SECS,
super::handler::DEFAULT_HYSTERESIS_MARGIN,
)),
shutdown_drain_secs: DEFAULT_SHUTDOWN_DRAIN_SECS,
draining: Arc::new(AtomicBool::new(false)),
Expand Down Expand Up @@ -841,7 +856,8 @@ impl ServerConfig {
/// This builder-style method allows embedders to configure the TTL
/// programmatically without relying on environment variables.
pub fn with_health_cache_ttl_secs(mut self, ttl_secs: u64) -> Self {
self.health_cache = Arc::new(super::handler::HealthCache::new(ttl_secs));
let margin = self.health_cache.hysteresis_margin;
self.health_cache = Arc::new(super::handler::HealthCache::new(ttl_secs, margin));
self
}

Expand Down Expand Up @@ -1100,12 +1116,18 @@ impl ServerConfig {
/// requires `Authorization: Bearer <token>`. When absent, no authentication is required.
/// - `TRUSS_DISABLE_METRICS`: when set to `1`, `true`, `yes`, or `on`, disables the `/metrics`
/// endpoint entirely (returns 404).
/// - `TRUSS_HEALTH_TOKEN`: Bearer token for the `/health` diagnostic endpoint. When set,
/// `GET /health` requires `Authorization: Bearer <token>`. The `/health/live` and
/// `/health/ready` probe endpoints remain unauthenticated.
/// - `TRUSS_STORAGE_TIMEOUT_SECS`: download timeout for storage backends in seconds
/// (default: 30, range: 1–300).
/// - `TRUSS_HEALTH_CACHE_MIN_FREE_BYTES`: minimum free bytes on the cache disk before
/// `/health/ready` reports failure. When unset, the disk free-space check is skipped.
/// - `TRUSS_HEALTH_MAX_MEMORY_BYTES`: maximum resident memory (RSS) in bytes before
/// `/health/ready` reports failure. When unset, the memory check is skipped (Linux only).
/// - `TRUSS_HEALTH_HYSTERESIS_MARGIN`: recovery margin for readiness probe hysteresis
/// (default: 0.05, range: 0.01–0.50). A 5 % margin means that after a threshold is
/// breached, the value must recover past `threshold ± 5 %` before the check returns to ok.
/// - `TRUSS_HEALTH_CACHE_TTL_SECS`: TTL in seconds for cached syscall results
/// (`disk_free_bytes`, `process_rss_bytes`) used by health endpoints (default: 5,
/// range: 0–300). Set to `0` to disable caching and call syscalls on every request.
Expand Down Expand Up @@ -1331,16 +1353,29 @@ impl ServerConfig {

let metrics_token = env::var("TRUSS_METRICS_TOKEN")
.ok()
.filter(|value| !value.is_empty());
.filter(|value| !value.trim().is_empty());
let disable_metrics = env_flag("TRUSS_DISABLE_METRICS");
let health_token = env::var("TRUSS_HEALTH_TOKEN")
.ok()
.filter(|value| !value.trim().is_empty());
if health_token.is_some() {
eprintln!(
"truss: /health endpoint requires Bearer authentication (TRUSS_HEALTH_TOKEN is set)"
);
}

let health_cache_min_free_bytes =
parse_env_u64_ranged("TRUSS_HEALTH_CACHE_MIN_FREE_BYTES", 1, u64::MAX)?;
let health_max_memory_bytes =
parse_env_u64_ranged("TRUSS_HEALTH_MAX_MEMORY_BYTES", 1, u64::MAX)?;
let health_cache_ttl_secs = parse_env_u64_ranged("TRUSS_HEALTH_CACHE_TTL_SECS", 0, 300)?
.unwrap_or(super::handler::DEFAULT_HEALTH_CACHE_TTL_SECS);
let health_cache = Arc::new(super::handler::HealthCache::new(health_cache_ttl_secs));
let hysteresis_margin = parse_env_f64_ranged("TRUSS_HEALTH_HYSTERESIS_MARGIN", 0.01, 0.50)?
.unwrap_or(super::handler::DEFAULT_HYSTERESIS_MARGIN);
let health_cache = Arc::new(super::handler::HealthCache::new(
health_cache_ttl_secs,
hysteresis_margin,
));

let (presets, presets_file_path) = parse_presets_from_env()?;

Expand Down Expand Up @@ -1412,6 +1447,7 @@ impl ServerConfig {
keep_alive_max_requests,
metrics_token,
disable_metrics,
health_token,
health_cache_min_free_bytes,
health_max_memory_bytes,
health_cache,
Expand Down Expand Up @@ -1467,6 +1503,31 @@ pub(super) fn parse_env_u64_ranged(name: &str, min: u64, max: u64) -> io::Result
}
}

/// Parse an optional environment variable as `f64`, validating that its value
/// falls within `[min, max]`. Returns `Ok(None)` when the variable is unset or
/// empty, `Ok(Some(value))` on success, or an `io::Error` on parse / range
/// failure.
fn parse_env_f64_ranged(name: &str, min: f64, max: f64) -> io::Result<Option<f64>> {
match env::var(name).ok().filter(|v| !v.is_empty()) {
Some(value) => {
let n: f64 = value.parse().map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} must be a number"),
)
})?;
if n < min || n > max {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} must be between {min} and {max}"),
));
}
Ok(Some(n))
}
None => Ok(None),
}
}

/// Parses `TRUSS_FORMAT_PREFERENCE` into an ordered list of [`MediaType`] values.
///
/// The environment variable is a comma-separated list of format short names
Expand Down
Loading
Loading