feat(auth): verify Cloudflare Access tokens for Caddy forward auth
This commit is contained in:
13 files changed
+1216
-15
No files matched your search
@@ -107,6 +107,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
super::resolve::validate_device_retention(device_retention)?;
|
||||
|
||||
let body = FileConfig {
|
||||
access: base.access,
|
||||
data_dir: Some(data_dir),
|
||||
bind: Some(bind.to_string()),
|
||||
public_url: Some(public_url),
|
||||
@@ -245,6 +246,11 @@ pid_file = "old.pid"
|
||||
ui_dist_dir = "/opt/wakey/ui"
|
||||
bootstrap_enroll_tokens = ["old-token"]
|
||||
|
||||
[access]
|
||||
issuer = "https://access.example"
|
||||
audience = "protected-app"
|
||||
jwks_url = "https://keys.example/custom"
|
||||
|
||||
[telemetry]
|
||||
otlp_endpoint = "http://otel.example"
|
||||
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
|
||||
@@ -293,6 +299,14 @@ json_logs = true
|
||||
);
|
||||
assert!(parsed.get("command_timeout_ms").is_none());
|
||||
assert_eq!(converted.device_retention, Some(std::time::Duration::ZERO));
|
||||
assert_eq!(
|
||||
converted.access,
|
||||
Some(crate::config::AccessConfig {
|
||||
issuer: "https://access.example".into(),
|
||||
audience: "protected-app".into(),
|
||||
jwks_url: Some("https://keys.example/custom".into()),
|
||||
})
|
||||
);
|
||||
assert!(parsed.get("enroll_token_ttl_seconds").is_none());
|
||||
assert_eq!(
|
||||
parsed["state_file"].as_str(),
|
||||
|
||||
@@ -8,4 +8,4 @@ pub use resolve::{
|
||||
resolve_revoke_agent_settings, resolve_revoke_enroll_token_settings,
|
||||
resolve_state_stats_settings,
|
||||
};
|
||||
pub use types::{DaemonConfig, TelemetryConfig};
|
||||
pub use types::{AccessConfig, DaemonConfig, TelemetryConfig};
|
||||
@@ -105,6 +105,7 @@ impl DaemonConfig {
|
||||
ui_dist_dir,
|
||||
bootstrap_enroll_tokens,
|
||||
telemetry,
|
||||
access: file.access,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,17 @@ pub struct DaemonConfig {
|
||||
pub ui_dist_dir: PathBuf,
|
||||
pub bootstrap_enroll_tokens: Vec<String>,
|
||||
pub telemetry: TelemetryConfig,
|
||||
pub access: Option<AccessConfig>,
|
||||
}
|
||||
|
||||
/// Trusted origin-verifier settings; no URL is taken from a JWT header or claim.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AccessConfig {
|
||||
pub issuer: String,
|
||||
pub audience: String,
|
||||
/// Omission uses the Cloudflare Access certs path beneath the issuer.
|
||||
pub jwks_url: Option<String>,
|
||||
}
|
||||
|
||||
/// TOML DTO for both loading partial files and generating complete files.
|
||||
@@ -46,6 +57,7 @@ pub(crate) struct FileConfig {
|
||||
#[serde(alias = "enroll_tokens")]
|
||||
pub(crate) bootstrap_enroll_tokens: Option<Vec<String>>,
|
||||
pub(crate) telemetry: Option<FileTelemetryConfig>,
|
||||
pub(crate) access: Option<AccessConfig>,
|
||||
}
|
||||
|
||||
pub(crate) const DURATION_KEYS: &[LegacyDuration] = &[
|
||||
@@ -103,6 +115,7 @@ pub(crate) static DEFAULT_CONFIG: LazyLock<DaemonConfig> = LazyLock::new(|| Daem
|
||||
pid_file: "wakey-control-plane.pid".into(),
|
||||
ui_dist_dir: "ui/dist".into(),
|
||||
bootstrap_enroll_tokens: Vec::new(),
|
||||
access: None,
|
||||
telemetry: TelemetryConfig {
|
||||
console_filter: None,
|
||||
otlp_log_filter: None,
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Origin authentication oracle for Caddy, not middleware on the control API.
|
||||
//! Enforcement belongs to the proxy; the daemon must remain loopback-only.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use axum::{
|
||||
Extension, Router,
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::get,
|
||||
};
|
||||
use http_cache_semantics::{AfterResponse, BeforeRequest, CacheOptions, CachePolicy};
|
||||
use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
||||
use reqwest::{Client, Url};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use super::AppState;
|
||||
use crate::config::AccessConfig;
|
||||
|
||||
const REFRESH_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_JWKS_BYTES: usize = 256 * 1024;
|
||||
const MAX_TOKEN_BYTES: usize = 16 * 1024;
|
||||
const MAX_KEYS: usize = 64;
|
||||
|
||||
// This is a private, single-resource cache. Do not invent a freshness lifetime
|
||||
// when the issuer supplies none, or override its no-cache/no-store directives.
|
||||
const CACHE_OPTIONS: CacheOptions = CacheOptions {
|
||||
shared: false,
|
||||
cache_heuristic: 0.0,
|
||||
immutable_min_time_to_live: Duration::ZERO,
|
||||
ignore_cargo_cult: false,
|
||||
};
|
||||
|
||||
/// Absent configuration deliberately leaves the oracle denying every request.
|
||||
pub(super) fn routes(config: Option<&AccessConfig>, bind: SocketAddr) -> Result<Router<AppState>> {
|
||||
let verifier = config
|
||||
.map(|config| {
|
||||
ensure!(
|
||||
bind.ip().is_loopback(),
|
||||
"Access verification requires a loopback bind; Caddy owns enforcement"
|
||||
);
|
||||
Verifier::new(config).map(Arc::new)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(verifier_routes(verifier))
|
||||
}
|
||||
|
||||
fn verifier_routes<S: Clone + Send + Sync + 'static>(verifier: Option<Arc<Verifier>>) -> Router<S> {
|
||||
Router::new()
|
||||
.route("/internal/auth/verify", get(verify))
|
||||
.layer(Extension(verifier))
|
||||
}
|
||||
|
||||
async fn verify(
|
||||
Extension(verifier): Extension<Option<Arc<Verifier>>>,
|
||||
headers: HeaderMap,
|
||||
) -> StatusCode {
|
||||
let Some(verifier) = verifier else {
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
};
|
||||
let mut values = headers.get_all("cf-access-jwt-assertion").iter();
|
||||
let Some(token) = values.next().and_then(|value| value.to_str().ok()) else {
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
};
|
||||
if values.next().is_some() {
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
match verifier.validate(token).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT,
|
||||
Err(VerifyError::Invalid) => StatusCode::UNAUTHORIZED,
|
||||
Err(VerifyError::Unavailable) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
}
|
||||
}
|
||||
|
||||
struct Verifier {
|
||||
client: Client,
|
||||
jwks_url: Url,
|
||||
validation: Validation,
|
||||
cache: Mutex<KeyCache>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct KeyCache {
|
||||
keys: HashMap<String, DecodingKey>,
|
||||
policy: Option<CachePolicy>,
|
||||
attempted_at: Option<Instant>,
|
||||
last_fetch_failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
enum VerifyError {
|
||||
#[error("invalid Access assertion")]
|
||||
Invalid,
|
||||
#[error("Access signing keys unavailable")]
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
impl Verifier {
|
||||
fn new(config: &AccessConfig) -> Result<Self> {
|
||||
let issuer = trusted_url(&config.issuer)?;
|
||||
ensure!(
|
||||
issuer.path() == "/" && !config.issuer.ends_with('/'),
|
||||
"Access issuer must be an HTTPS origin without a trailing slash"
|
||||
);
|
||||
ensure!(
|
||||
!config.audience.trim().is_empty(),
|
||||
"Access audience must not be empty"
|
||||
);
|
||||
let jwks_url = match &config.jwks_url {
|
||||
Some(url) => trusted_url(url)?,
|
||||
None => issuer.join("/cdn-cgi/access/certs")?,
|
||||
};
|
||||
let mut validation = Validation::new(Algorithm::RS256);
|
||||
validation.set_issuer(&[&config.issuer]);
|
||||
validation.set_audience(&[&config.audience]);
|
||||
validation.set_required_spec_claims(&["exp", "iss", "aud"]);
|
||||
validation.validate_nbf = true;
|
||||
validation.leeway = 0;
|
||||
Ok(Self {
|
||||
client: Client::builder()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?,
|
||||
jwks_url,
|
||||
validation,
|
||||
cache: Mutex::new(KeyCache::default()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate(&self, token: &str) -> Result<(), VerifyError> {
|
||||
if token.len() > MAX_TOKEN_BYTES {
|
||||
return Err(VerifyError::Invalid);
|
||||
}
|
||||
// Only kid selects a cached key. Token-supplied URLs never drive I/O.
|
||||
let header = decode_header(token).map_err(|_| VerifyError::Invalid)?;
|
||||
if header.alg != Algorithm::RS256 {
|
||||
return Err(VerifyError::Invalid);
|
||||
}
|
||||
let kid = header
|
||||
.kid
|
||||
.filter(|kid| !kid.is_empty() && kid.len() <= 256)
|
||||
.ok_or(VerifyError::Invalid)?;
|
||||
let key = self.key(&kid).await?;
|
||||
decode::<serde_json::Value>(token, &key, &self.validation)
|
||||
.map(|_| ())
|
||||
.map_err(|_| VerifyError::Invalid)
|
||||
}
|
||||
|
||||
async fn key(&self, kid: &str) -> Result<DecodingKey, VerifyError> {
|
||||
// Single-flight refresh with a bounded HTTP fetch. The cooldown is
|
||||
// global, not an attacker-controlled per-kid cache, including outages.
|
||||
let mut cache = self.cache.lock().await;
|
||||
let mut request = self
|
||||
.client
|
||||
.get(self.jwks_url.clone())
|
||||
.build()
|
||||
.map_err(|_| VerifyError::Unavailable)?;
|
||||
let now = Instant::now();
|
||||
let fresh = cache.policy.as_ref().is_some_and(|policy| {
|
||||
matches!(
|
||||
policy.before_request(&request, SystemTime::now()),
|
||||
BeforeRequest::Fresh(_)
|
||||
)
|
||||
});
|
||||
if fresh && let Some(key) = cache.keys.get(kid) {
|
||||
return Ok(key.clone());
|
||||
}
|
||||
if cache
|
||||
.attempted_at
|
||||
.is_none_or(|at| now.duration_since(at) >= REFRESH_COOLDOWN)
|
||||
{
|
||||
cache.attempted_at = Some(now);
|
||||
// A missing kid must bypass a still-fresh response cache to notice
|
||||
// rotation. The HTTP library adds ETag/Last-Modified validators.
|
||||
request.headers_mut().insert(
|
||||
reqwest::header::CACHE_CONTROL,
|
||||
reqwest::header::HeaderValue::from_static("no-cache"),
|
||||
);
|
||||
if let Some(policy) = &cache.policy
|
||||
&& let BeforeRequest::Stale {
|
||||
request: conditional,
|
||||
..
|
||||
} = policy.before_request(&request, SystemTime::now())
|
||||
{
|
||||
*request.headers_mut() = conditional.headers;
|
||||
}
|
||||
match self.fetch_keys(&request, &cache).await {
|
||||
Ok((keys, policy)) => {
|
||||
let selected = keys.get(kid).cloned().ok_or(VerifyError::Invalid);
|
||||
if policy.is_storable() {
|
||||
cache.keys = keys;
|
||||
cache.policy = Some(policy);
|
||||
} else {
|
||||
// no-store permits this response's use for this request,
|
||||
// but neither keys nor HTTP metadata may be retained.
|
||||
cache.keys.clear();
|
||||
cache.policy = None;
|
||||
}
|
||||
cache.last_fetch_failed = false;
|
||||
return selected;
|
||||
}
|
||||
Err(_) => {
|
||||
// Never include upstream bodies, tokens or claims in logs.
|
||||
tracing::warn!(
|
||||
"Access signing-key refresh failed; verification fails closed when no fresh matching key is available"
|
||||
);
|
||||
cache.last_fetch_failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !fresh || cache.last_fetch_failed {
|
||||
Err(VerifyError::Unavailable)
|
||||
} else {
|
||||
Err(VerifyError::Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_keys(
|
||||
&self,
|
||||
request: &reqwest::Request,
|
||||
cached: &KeyCache,
|
||||
) -> Result<(HashMap<String, DecodingKey>, CachePolicy)> {
|
||||
let mut response = self
|
||||
.client
|
||||
.execute(
|
||||
request
|
||||
.try_clone()
|
||||
.context("JWKS request cannot be cloned")?,
|
||||
)
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let received_at = SystemTime::now();
|
||||
if response.status() == StatusCode::NOT_MODIFIED {
|
||||
ensure!(
|
||||
request
|
||||
.headers()
|
||||
.contains_key(reqwest::header::IF_NONE_MATCH)
|
||||
|| request
|
||||
.headers()
|
||||
.contains_key(reqwest::header::IF_MODIFIED_SINCE),
|
||||
"unsolicited JWKS 304"
|
||||
);
|
||||
let policy = cached
|
||||
.policy
|
||||
.as_ref()
|
||||
.context("JWKS 304 without stored keys")?;
|
||||
return match policy.after_response(request, &response, received_at) {
|
||||
AfterResponse::NotModified(policy, _) => Ok((cached.keys.clone(), policy)),
|
||||
AfterResponse::Modified(_, _) => {
|
||||
anyhow::bail!("JWKS 304 did not match stored validators")
|
||||
}
|
||||
};
|
||||
}
|
||||
ensure!(
|
||||
response.status() == StatusCode::OK,
|
||||
"JWKS must return 200 or a matching 304"
|
||||
);
|
||||
let policy = CachePolicy::new_options(request, &response, received_at, CACHE_OPTIONS);
|
||||
ensure!(
|
||||
response
|
||||
.content_length()
|
||||
.is_none_or(|len| len <= MAX_JWKS_BYTES as u64),
|
||||
"JWKS too large"
|
||||
);
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
ensure!(chunk.len() <= MAX_JWKS_BYTES - body.len(), "JWKS too large");
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok((parse_keys(&body)?, policy))
|
||||
}
|
||||
}
|
||||
|
||||
fn trusted_url(value: &str) -> Result<Url> {
|
||||
let url = Url::parse(value).context("invalid Access URL")?;
|
||||
ensure!(
|
||||
url.scheme() == "https"
|
||||
&& url.host_str().is_some()
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.query().is_none()
|
||||
&& url.fragment().is_none(),
|
||||
"Access URLs require HTTPS without credentials, query or fragment"
|
||||
);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn parse_keys(body: &[u8]) -> Result<HashMap<String, DecodingKey>> {
|
||||
let jwks: JwkSet = serde_json::from_slice(body)?;
|
||||
ensure!(jwks.keys.len() <= MAX_KEYS, "too many signing keys");
|
||||
let mut keys = HashMap::new();
|
||||
for jwk in jwks.keys {
|
||||
if !matches!(jwk.algorithm, AlgorithmParameters::RSA(_))
|
||||
|| jwk
|
||||
.common
|
||||
.key_algorithm
|
||||
.is_some_and(|alg| alg != KeyAlgorithm::RS256)
|
||||
|| jwk
|
||||
.common
|
||||
.public_key_use
|
||||
.as_ref()
|
||||
.is_some_and(|usage| *usage != PublicKeyUse::Signature)
|
||||
|| jwk
|
||||
.common
|
||||
.key_operations
|
||||
.as_ref()
|
||||
.is_some_and(|ops| !ops.contains(&KeyOperations::Verify))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(kid) = jwk
|
||||
.common
|
||||
.key_id
|
||||
.as_ref()
|
||||
.filter(|kid| !kid.is_empty() && kid.len() <= 256)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let key = DecodingKey::from_jwk(&jwk)?;
|
||||
ensure!(
|
||||
keys.insert(kid.clone(), key).is_none(),
|
||||
"duplicate signing key ID"
|
||||
);
|
||||
}
|
||||
// A valid empty set revokes every cached key. Do not treat it as a fetch
|
||||
// failure and preserve keys that the issuer has deliberately removed.
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpN0wYAVcYDhNr
|
||||
MNgF22jtk0uiZZQQl4K+uM1S3ivY3sQDjZj3y6icwR5MhYHOtCW2+EY/2IctpEcd
|
||||
/3li5OOna2RKDdTpshw/mE3Q4NYZyk8JCAoIY+J9yzQsBdTjKbSUu3tMFxQb89ap
|
||||
baAooZ5TpnZ3ZVZUN1SQjYq5my8sZAkO9JOOMHnPx/5Ba5Z4IT9DX8hzlgG+e1F3
|
||||
lW3XpjTt3xgGp3ZWNUU6hA2dnwHrkvmy91U1ojC0Bow2ySL0hbgHnFwFNpsx5wFs
|
||||
Mum4/LI+ZA9yaWKdbPKXx7W0g+x0ihqyq/K00z/KE4zIIENS+hNv/Ko7nMB0S/s2
|
||||
gkdmgiEjAgMBAAECggEAE0olR6lDczpg4VEYlxZWGvBgaLCj/9OIXn3KPijrHlZK
|
||||
nvhnGHxOtMvY9zJ9mSwfuStf4AsADJETtao3/VuH+L+nwj9bEZGHDILWqrnZU5L5
|
||||
49bW70eU+++2+1EQ3ce/zk1O9rNnd0ZFJrGYq85nXN7MK2Z8awOXBWwf2nJZHs9Q
|
||||
QYM+LFiLDqkT+sTGIwV8mNwVN+S4tKEyxPWuFmGU649nQL6LAy/NJKtH+6C7feEa
|
||||
pdIoUZ6TT9QvR2kVC5XTaDeLGf1uwgzFBkB9hZoFMBzh8Bx1k0p9WkA1ViJkSzXc
|
||||
T38deIES5RUid0/GVxOKZkuYk7jnoLWMxK8T5SzeWQKBgQDg3VOyPy0ykD+YtgbL
|
||||
4DWrBW/kregMfximpESEL7yP+XAKv6vYox4oGDncoX7g48C6K6c1kSdlGb0We1nA
|
||||
md+TraIQjDWv9Zyfr7FeiD8739xJGz8T7Y0TU7sdd1EYz5fUkURLqzkX9Tpy4+Ia
|
||||
mXSUH4jfyjMWVghhe9E2rJmUawKBgQDApWvHFIp2nWDvErE/xPQnMCTxc3ndGbfi
|
||||
1CszYlQcm+C57KhMprWUbDSXrGzqlqn75npdIsXXbY1SORb44antwHzofJyhE9aA
|
||||
RGPDKtym9d8/+zy7DtYfw7IlIDZtXFpgCQPtKPXVZ0v3gE0/kYfeRcoyyQnsT8jd
|
||||
MqiX0zMUKQKBgEf+tQhx78+rnaEQTrbB74z1LbEi+RPfMY5RM9q6yf2y2GuomKXA
|
||||
/CvMLeJ1FoF8ij1RMrUU528YQZFMJx1iZtIKmsP/kETpN59tCjYBG3XZI9eIULbZ
|
||||
rPyOJ2+0vxNZi/w0EpDDnoEMNA2K5t6rWUb/nbxBKgAxxXcoxIBj3LxHAoGAJYFP
|
||||
DVATcOpMz96MwYR/oA2Ac71ukTwLj86MppFTHMrAIpJPZbTjDwVN6+V2wukYKOGD
|
||||
GxTE/Z7nb4WL0mJkqdPavYxdFgL9tLUb0whKMvsVPqiT9zr5Nu/rg8zUq8/aDG5U
|
||||
5FwgGC7nHEVPRLv5fq2Jov9+vhH+YgoVxpEolZkCgYAgZ+PdXcm1XkwioHlBCfOH
|
||||
ilXAaubbb1SZtrodabplqut8ox9oq9RL/Fa549IPIB5e+SSAU3S/al21/0+bxs8C
|
||||
QyoWII8ayb7vgZ46TBuaaZVT4Oxd83GQgc4Az91m7UZIgxhbS/3os1tqXJqQKujt
|
||||
Wy7tHJh9RC/z2+kFSog3tA==
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,590 @@
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{HeaderValue, Request};
|
||||
use jsonwebtoken::jwk::Jwk;
|
||||
use jsonwebtoken::{EncodingKey, Header, encode};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::sync::RwLock;
|
||||
use tower::ServiceExt;
|
||||
|
||||
// Public test-only private key, never used as a deployment credential.
|
||||
const TEST_KEY: &[u8] = include_bytes!("test-key.pem");
|
||||
const ISSUER: &str = "https://access.example";
|
||||
const AUDIENCE: &str = "wakey-test";
|
||||
|
||||
fn config() -> AccessConfig {
|
||||
AccessConfig {
|
||||
issuer: ISSUER.into(),
|
||||
audience: AUDIENCE.into(),
|
||||
jwks_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn encoding_key() -> EncodingKey {
|
||||
EncodingKey::from_rsa_pem(TEST_KEY).unwrap()
|
||||
}
|
||||
|
||||
fn jwks(kid: &str) -> Value {
|
||||
let mut jwk = Jwk::from_encoding_key(&encoding_key(), Algorithm::RS256).unwrap();
|
||||
jwk.common.key_id = Some(kid.into());
|
||||
json!({"keys": [jwk]})
|
||||
}
|
||||
|
||||
fn claims() -> Value {
|
||||
// Fixed dates make boundary tests independent of scheduling delays.
|
||||
json!({"iss": ISSUER, "aud": [AUDIENCE], "exp": 4102444800u64})
|
||||
}
|
||||
|
||||
fn token(kid: &str, claims: &Value) -> String {
|
||||
let mut header = Header::new(Algorithm::RS256);
|
||||
header.kid = Some(kid.into());
|
||||
encode(&header, claims, &encoding_key()).unwrap()
|
||||
}
|
||||
|
||||
async fn cached() -> Arc<Verifier> {
|
||||
let verifier = Arc::new(Verifier::new(&config()).unwrap());
|
||||
let mut cache = verifier.cache.lock().await;
|
||||
cache.keys = parse_keys(&serde_json::to_vec(&jwks("one")).unwrap()).unwrap();
|
||||
cache.policy = Some(test_policy(
|
||||
&verifier,
|
||||
"public, max-age=14400, must-revalidate",
|
||||
None,
|
||||
));
|
||||
cache.attempted_at = Some(Instant::now());
|
||||
drop(cache);
|
||||
verifier
|
||||
}
|
||||
|
||||
fn test_policy(verifier: &Verifier, cache_control: &str, etag: Option<&str>) -> CachePolicy {
|
||||
let request = verifier
|
||||
.client
|
||||
.get(verifier.jwks_url.clone())
|
||||
.build()
|
||||
.unwrap();
|
||||
let mut response = axum::http::Response::builder().header("cache-control", cache_control);
|
||||
if let Some(etag) = etag {
|
||||
response = response.header("etag", etag);
|
||||
}
|
||||
CachePolicy::new_options(
|
||||
&request,
|
||||
&response.body(()).unwrap(),
|
||||
SystemTime::now(),
|
||||
CACHE_OPTIONS,
|
||||
)
|
||||
}
|
||||
|
||||
async fn status(app: Router, assertion: Option<&str>) -> StatusCode {
|
||||
let mut request = Request::builder().uri("/internal/auth/verify");
|
||||
if let Some(assertion) = assertion {
|
||||
request = request.header("cf-access-jwt-assertion", assertion);
|
||||
}
|
||||
app.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oracle_accepts_only_valid_application_tokens() {
|
||||
let verifier = cached().await;
|
||||
let app = verifier_routes(Some(verifier));
|
||||
assert_eq!(
|
||||
status(app.clone(), Some(&token("one", &claims()))).await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
assert_eq!(status(app.clone(), None).await, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
status(app.clone(), Some("wakey-invalid-test")).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
for (field, value) in [
|
||||
("iss", json!("https://other.example")),
|
||||
("aud", json!(["bypass-app"])),
|
||||
("exp", json!(1)),
|
||||
("nbf", json!(4102444800u64)),
|
||||
] {
|
||||
let mut invalid = claims();
|
||||
invalid[field] = value;
|
||||
assert_eq!(
|
||||
status(app.clone(), Some(&token("one", &invalid))).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
for field in ["iss", "aud", "exp"] {
|
||||
let mut invalid = claims();
|
||||
invalid.as_object_mut().unwrap().remove(field);
|
||||
assert_eq!(
|
||||
status(app.clone(), Some(&token("one", &invalid))).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing {field}"
|
||||
);
|
||||
}
|
||||
let mut forged = token("one", &claims()).into_bytes();
|
||||
let signature_start = forged.iter().rposition(|byte| *byte == b'.').unwrap() + 1;
|
||||
forged[signature_start] = if forged[signature_start] == b'A' {
|
||||
b'B'
|
||||
} else {
|
||||
b'A'
|
||||
};
|
||||
assert_eq!(
|
||||
status(app.clone(), Some(std::str::from_utf8(&forged).unwrap())).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let mut header = Header::new(Algorithm::HS256);
|
||||
header.kid = Some("one".into());
|
||||
let hmac = encode(
|
||||
&header,
|
||||
&claims(),
|
||||
&EncodingKey::from_secret(b"not-an-rsa-key"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status(app.clone(), Some(&hmac)).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
status(app.clone(), Some(&token("unknown", &claims()))).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
status(app.clone(), Some(&"a".repeat(MAX_TOKEN_BYTES + 1))).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let mut request = Request::builder()
|
||||
.uri("/internal/auth/verify")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let valid = token("one", &claims());
|
||||
request.headers_mut().append(
|
||||
"cf-access-jwt-assertion",
|
||||
HeaderValue::from_str(&valid).unwrap(),
|
||||
);
|
||||
request
|
||||
.headers_mut()
|
||||
.append("cf-access-jwt-assertion", HeaderValue::from_static("fake"));
|
||||
assert_eq!(
|
||||
app.oneshot(request).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
status(verifier_routes(None), Some(&valid)).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_configuration_rejects_unsafe_urls_and_non_loopback_bind() {
|
||||
assert_eq!(
|
||||
Verifier::new(&config()).unwrap().jwks_url.as_str(),
|
||||
"https://access.example/cdn-cgi/access/certs"
|
||||
);
|
||||
let mut explicit = config();
|
||||
explicit.jwks_url = Some("https://keys.example/custom".into());
|
||||
assert_eq!(
|
||||
Verifier::new(&explicit).unwrap().jwks_url.as_str(),
|
||||
"https://keys.example/custom"
|
||||
);
|
||||
for url in [
|
||||
"http://access.example",
|
||||
"https://user:[email protected]",
|
||||
"https://access.example?q=1",
|
||||
"https://access.example/#fragment",
|
||||
"not a url",
|
||||
] {
|
||||
assert!(trusted_url(url).is_err(), "{url}");
|
||||
}
|
||||
let mut invalid = config();
|
||||
invalid.audience.clear();
|
||||
assert!(Verifier::new(&invalid).is_err());
|
||||
invalid = config();
|
||||
invalid.issuer.push_str("/wrong-path");
|
||||
assert!(Verifier::new(&invalid).is_err());
|
||||
assert!(routes(Some(&config()), "0.0.0.0:8080".parse().unwrap()).is_err());
|
||||
assert!(routes(Some(&config()), "[::1]:8080".parse().unwrap()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_key_set_rejects_duplicates_and_unusable_keys() {
|
||||
let key = jwks("one")["keys"][0].clone();
|
||||
assert!(parse_keys(br#"{"keys": []}"#).unwrap().is_empty());
|
||||
for set in [
|
||||
json!({"keys": [key.clone(), key.clone()]}),
|
||||
json!({"keys": vec![key.clone(); MAX_KEYS + 1]}),
|
||||
] {
|
||||
assert!(parse_keys(&serde_json::to_vec(&set).unwrap()).is_err());
|
||||
}
|
||||
for (field, value) in [
|
||||
("use", json!("enc")),
|
||||
("alg", json!("RS512")),
|
||||
("key_ops", json!(["encrypt"])),
|
||||
] {
|
||||
let mut invalid = key.clone();
|
||||
invalid[field] = value;
|
||||
assert!(
|
||||
parse_keys(&serde_json::to_vec(&json!({"keys": [invalid]})).unwrap())
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_fetch_is_single_flight_rotates_and_fails_closed() {
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let reply = Arc::new(RwLock::new((
|
||||
StatusCode::OK,
|
||||
serde_json::to_string(&jwks("one")).unwrap(),
|
||||
)));
|
||||
let app = Router::new().route(
|
||||
"/keys",
|
||||
get({
|
||||
let hits = hits.clone();
|
||||
let reply = reply.clone();
|
||||
move || {
|
||||
let hits = hits.clone();
|
||||
let reply = reply.clone();
|
||||
async move {
|
||||
hits.fetch_add(1, Ordering::Relaxed);
|
||||
let (status, body) = reply.read().await.clone();
|
||||
(
|
||||
status,
|
||||
[("cache-control", "public, max-age=14400, must-revalidate")],
|
||||
body,
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
let mut verifier = Verifier::new(&config()).unwrap();
|
||||
// HTTP is allowed only in this in-process fixture, never in loaded config.
|
||||
verifier.jwks_url = Url::parse(&format!("http://{address}/keys")).unwrap();
|
||||
let verifier = Arc::new(verifier);
|
||||
let valid = token("one", &claims());
|
||||
let (first, second) = tokio::join!(verifier.validate(&valid), verifier.validate(&valid));
|
||||
assert!(first.is_ok() && second.is_ok());
|
||||
assert_eq!(hits.load(Ordering::Relaxed), 1);
|
||||
for kid in ["random-1", "random-2"] {
|
||||
assert_eq!(
|
||||
verifier.validate(&token(kid, &claims())).await,
|
||||
Err(VerifyError::Invalid)
|
||||
);
|
||||
}
|
||||
assert_eq!(hits.load(Ordering::Relaxed), 1);
|
||||
*reply.write().await = (StatusCode::OK, serde_json::to_string(&jwks("two")).unwrap());
|
||||
verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN);
|
||||
assert!(verifier.validate(&token("two", &claims())).await.is_ok());
|
||||
assert_eq!(verifier.validate(&valid).await, Err(VerifyError::Invalid));
|
||||
assert_eq!(hits.load(Ordering::Relaxed), 2);
|
||||
// A failed refresh cannot authorize unknown keys or expired cached keys.
|
||||
*reply.write().await = (StatusCode::SERVICE_UNAVAILABLE, "unavailable".into());
|
||||
verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN);
|
||||
assert_eq!(
|
||||
verifier.validate(&valid).await,
|
||||
Err(VerifyError::Unavailable)
|
||||
);
|
||||
assert!(verifier.validate(&token("two", &claims())).await.is_ok());
|
||||
verifier.cache.lock().await.policy =
|
||||
Some(test_policy(&verifier, "max-age=0, must-revalidate", None));
|
||||
assert_eq!(
|
||||
verifier.validate(&token("two", &claims())).await,
|
||||
Err(VerifyError::Unavailable)
|
||||
);
|
||||
assert_eq!(hits.load(Ordering::Relaxed), 3);
|
||||
// Oversized and malformed responses also fail closed, with bounded retries.
|
||||
for body in ["x".repeat(MAX_JWKS_BYTES + 1), "not json".into()] {
|
||||
*reply.write().await = (StatusCode::OK, body);
|
||||
verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN);
|
||||
assert_eq!(
|
||||
verifier.validate(&valid).await,
|
||||
Err(VerifyError::Unavailable)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
status(verifier_routes(Some(verifier.clone())), Some(&valid)).await,
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
);
|
||||
// A successful empty set must revoke previously cached keys immediately.
|
||||
*reply.write().await = (StatusCode::OK, r#"{"keys": []}"#.into());
|
||||
verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN);
|
||||
assert_eq!(
|
||||
verifier.validate(&token("two", &claims())).await,
|
||||
Err(VerifyError::Invalid)
|
||||
);
|
||||
assert!(verifier.cache.lock().await.keys.is_empty());
|
||||
server.abort();
|
||||
let _ = server.await;
|
||||
}
|
||||
|
||||
struct KeyOrigin {
|
||||
verifier: Arc<Verifier>,
|
||||
reply: Arc<RwLock<(StatusCode, HeaderMap, String)>>,
|
||||
requests: Arc<Mutex<Vec<HeaderMap>>>,
|
||||
server: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl KeyOrigin {
|
||||
async fn new(headers: &[(&str, &str)]) -> Self {
|
||||
let reply = Arc::new(RwLock::new((
|
||||
StatusCode::OK,
|
||||
HeaderMap::new(),
|
||||
String::new(),
|
||||
)));
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let app = Router::new().route(
|
||||
"/keys",
|
||||
get({
|
||||
let reply = reply.clone();
|
||||
let requests = requests.clone();
|
||||
move |headers: HeaderMap| {
|
||||
let reply = reply.clone();
|
||||
let requests = requests.clone();
|
||||
async move {
|
||||
requests.lock().await.push(headers);
|
||||
reply.read().await.clone()
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
let mut verifier = Verifier::new(&config()).unwrap();
|
||||
verifier.jwks_url = Url::parse(&format!("http://{address}/keys")).unwrap();
|
||||
let origin = Self {
|
||||
verifier: Arc::new(verifier),
|
||||
reply,
|
||||
requests,
|
||||
server,
|
||||
};
|
||||
origin
|
||||
.respond(
|
||||
StatusCode::OK,
|
||||
serde_json::to_string(&jwks("one")).unwrap(),
|
||||
headers,
|
||||
)
|
||||
.await;
|
||||
origin
|
||||
}
|
||||
|
||||
async fn respond(&self, status: StatusCode, body: String, headers: &[(&str, &str)]) {
|
||||
let mut response = axum::http::Response::builder().status(status);
|
||||
for (name, value) in headers {
|
||||
response = response.header(*name, *value);
|
||||
}
|
||||
let (parts, body) = response.body(body).unwrap().into_parts();
|
||||
*self.reply.write().await = (status, parts.headers, body);
|
||||
}
|
||||
|
||||
async fn allow_refresh(&self) {
|
||||
self.verifier.cache.lock().await.attempted_at = None;
|
||||
}
|
||||
|
||||
async fn stop(self) {
|
||||
self.server.abort();
|
||||
let _ = self.server.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_cache_reuses_fresh_keys_revalidates_etags_and_forces_rotation() {
|
||||
let origin = KeyOrigin::new(&[
|
||||
(
|
||||
"cache-control",
|
||||
"public, max-age=14400, must-revalidate, no-transform",
|
||||
),
|
||||
("etag", "\"v1\""),
|
||||
])
|
||||
.await;
|
||||
let one = token("one", &claims());
|
||||
for _ in 0..2 {
|
||||
assert!(origin.verifier.validate(&one).await.is_ok());
|
||||
}
|
||||
assert_eq!(origin.requests.lock().await.len(), 1);
|
||||
// Expire the policy directly; no wall-clock sleeps or delayed retries.
|
||||
origin.verifier.cache.lock().await.policy = Some(test_policy(
|
||||
&origin.verifier,
|
||||
"max-age=0, must-revalidate",
|
||||
Some("\"v1\""),
|
||||
));
|
||||
origin.allow_refresh().await;
|
||||
origin
|
||||
.respond(
|
||||
StatusCode::NOT_MODIFIED,
|
||||
String::new(),
|
||||
&[
|
||||
("etag", "\"v1\""),
|
||||
("cache-control", "public, max-age=14400, must-revalidate"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
for _ in 0..2 {
|
||||
assert!(origin.verifier.validate(&one).await.is_ok());
|
||||
}
|
||||
assert_eq!(origin.requests.lock().await.len(), 2);
|
||||
assert_eq!(origin.requests.lock().await[1]["if-none-match"], "\"v1\"");
|
||||
// An unknown kid forces revalidation even though the HTTP entry is fresh.
|
||||
origin.allow_refresh().await;
|
||||
origin
|
||||
.respond(
|
||||
StatusCode::OK,
|
||||
serde_json::to_string(&jwks("two")).unwrap(),
|
||||
&[
|
||||
("etag", "\"v2\""),
|
||||
("cache-control", "public, max-age=14400, must-revalidate"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
origin
|
||||
.verifier
|
||||
.validate(&token("two", &claims()))
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(origin.requests.lock().await.len(), 3);
|
||||
assert_eq!(origin.requests.lock().await[2]["cache-control"], "no-cache");
|
||||
assert_eq!(origin.requests.lock().await[2]["if-none-match"], "\"v1\"");
|
||||
assert_eq!(
|
||||
origin.verifier.validate(&one).await,
|
||||
Err(VerifyError::Invalid)
|
||||
);
|
||||
// A mismatched 304 must not renew trust in the stored keys.
|
||||
origin.verifier.cache.lock().await.policy = Some(test_policy(
|
||||
&origin.verifier,
|
||||
"max-age=0, must-revalidate",
|
||||
Some("\"v2\""),
|
||||
));
|
||||
origin.allow_refresh().await;
|
||||
origin
|
||||
.respond(
|
||||
StatusCode::NOT_MODIFIED,
|
||||
String::new(),
|
||||
&[("etag", "\"wrong\""), ("cache-control", "max-age=14400")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
origin.verifier.validate(&token("two", &claims())).await,
|
||||
Err(VerifyError::Unavailable)
|
||||
);
|
||||
origin.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_cache_honors_age_no_cache_and_no_store() {
|
||||
for (cache_control, age, stores) in [
|
||||
("no-store", "0", false),
|
||||
("no-cache", "0", true),
|
||||
("public, max-age=14400, must-revalidate", "14401", true),
|
||||
("", "0", true),
|
||||
] {
|
||||
let origin = KeyOrigin::new(&[("cache-control", cache_control), ("age", age)]).await;
|
||||
let one = token("one", &claims());
|
||||
assert!(origin.verifier.validate(&one).await.is_ok());
|
||||
assert_eq!(origin.verifier.cache.lock().await.policy.is_some(), stores);
|
||||
if !stores {
|
||||
assert!(origin.verifier.cache.lock().await.keys.is_empty());
|
||||
}
|
||||
// A successful origin response can serve the requesting caller, but
|
||||
// an HTTP-stale/non-storable response cannot authorize later requests.
|
||||
assert_eq!(
|
||||
origin.verifier.validate(&one).await,
|
||||
Err(VerifyError::Unavailable)
|
||||
);
|
||||
assert_eq!(origin.requests.lock().await.len(), 1);
|
||||
origin.allow_refresh().await;
|
||||
assert!(origin.verifier.validate(&one).await.is_ok());
|
||||
assert_eq!(origin.requests.lock().await.len(), 2);
|
||||
origin.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_cache_revalidates_last_modified_and_rejects_unsolicited_304() {
|
||||
let modified = "Wed, 01 Jan 2025 00:00:00 GMT";
|
||||
let origin = KeyOrigin::new(&[
|
||||
("cache-control", "max-age=0, must-revalidate"),
|
||||
("last-modified", modified),
|
||||
])
|
||||
.await;
|
||||
let one = token("one", &claims());
|
||||
assert!(origin.verifier.validate(&one).await.is_ok());
|
||||
origin.allow_refresh().await;
|
||||
origin
|
||||
.respond(
|
||||
StatusCode::NOT_MODIFIED,
|
||||
String::new(),
|
||||
&[
|
||||
("last-modified", modified),
|
||||
("cache-control", "max-age=14400"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert!(origin.verifier.validate(&one).await.is_ok());
|
||||
assert_eq!(
|
||||
origin.requests.lock().await[1]["if-modified-since"],
|
||||
modified
|
||||
);
|
||||
origin.stop().await;
|
||||
|
||||
let origin = KeyOrigin::new(&[]).await;
|
||||
origin
|
||||
.respond(StatusCode::NOT_MODIFIED, String::new(), &[])
|
||||
.await;
|
||||
assert_eq!(
|
||||
origin.verifier.validate(&one).await,
|
||||
Err(VerifyError::Unavailable)
|
||||
);
|
||||
assert!(origin.verifier.cache.lock().await.policy.is_none());
|
||||
origin.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verifier_routes_do_not_gate_agent_or_control_handlers() {
|
||||
let dir = std::env::temp_dir().join(format!("wakey-access-routes-{}", uuid::Uuid::new_v4()));
|
||||
let store = crate::state::Store::load_or_init(
|
||||
&dir.join("state.sqlite3"),
|
||||
vec![],
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let state = AppState {
|
||||
store: Arc::new(store),
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
public_url: "http://localhost".into(),
|
||||
command_timeout: Duration::from_secs(30),
|
||||
enroll_token_ttl: Duration::from_secs(60),
|
||||
terminals: super::super::terminals::TerminalRegistry::new(),
|
||||
alerts: crate::api::AlertsHub::new(),
|
||||
fleet: Arc::new(crate::api::FleetCache::default()),
|
||||
};
|
||||
let app = Router::new()
|
||||
.merge(routes(Some(&config()), "127.0.0.1:8080".parse().unwrap()).unwrap())
|
||||
.merge(super::super::public_api_routes(dir.clone()))
|
||||
.merge(super::super::control_api_routes())
|
||||
.with_state(state);
|
||||
for (path, expected) in [
|
||||
("/healthz", StatusCode::OK),
|
||||
("/api/v1/control/agents", StatusCode::OK),
|
||||
("/internal/auth/verify", StatusCode::UNAUTHORIZED),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), expected, "{path}");
|
||||
}
|
||||
drop(app);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -23,6 +23,7 @@ use crate::config;
|
||||
use crate::state;
|
||||
use crate::ws;
|
||||
|
||||
mod access;
|
||||
mod admin;
|
||||
mod fleet_metrics;
|
||||
mod process;
|
||||
@@ -177,6 +178,9 @@ fn control_api_routes() -> Router<AppState> {
|
||||
|
||||
/// Starts the control-plane HTTP and websocket surfaces and manages daemon lifecycle hooks.
|
||||
pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
||||
// The verifier delegates enforcement to Caddy. Do not expose the other
|
||||
// handlers directly when that boundary is configured.
|
||||
let access_routes = access::routes(daemon.access.as_ref(), daemon.bind)?;
|
||||
write_pid_file(&daemon.pid_file)?;
|
||||
info!(pid_file = %daemon.pid_file.display(), "wrote control-plane pid file");
|
||||
|
||||
@@ -207,6 +211,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
||||
// - public_api_routes: intended internet-facing agent endpoints
|
||||
// - control_api_routes: intended admin-only endpoints behind Access
|
||||
let app = Router::new()
|
||||
.merge(access_routes)
|
||||
.merge(public_api_routes(daemon.ui_dist_dir.clone()))
|
||||
.merge(control_api_routes())
|
||||
.with_state(app_state.clone());
|
||||
|
||||
Reference in new issue
Block a user