Add Keycloak client credentials authorization for machine routes
build-and-publish.yml (push) Invalid workflow file

This commit is contained in:
Ultradesu committed 2026-10-18 00:23:38 +01:00
1 parent 80b14bee5a
commit 449a09767c
7 files changed
+358 -75

No files matched your search

Generated
+1 -1
View File
@@ -1322,7 +1322,7 @@ dependencies = [
[[package]] [[package]]
name = "rsauth2-proxy" name = "rsauth2-proxy"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rsauth2-proxy" name = "rsauth2-proxy"
version = "0.1.1" version = "0.1.2"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
+18 -1
View File
@@ -13,7 +13,15 @@ Browser → Traefik → ForwardAuth (/auth) → rsauth2-proxy
Traefik calls `/auth` for every request to a protected service. The proxy checks the encrypted session cookie, verifies group membership against the route config, and returns 200 (allow), 403 (deny), or 302 (login required). Traefik calls `/auth` for every request to a protected service. The proxy checks the encrypted session cookie, verifies group membership against the route config, and returns 200 (allow), 403 (deny), or 302 (login required).
Sessions are stored entirely in an AES-256-GCM encrypted cookie. No server-side state. Any number of replicas work without coordination. Machine routes can instead accept HTTP Basic credentials over HTTPS. The proxy
checks the client ID and secret at Keycloak's `client_credentials` token endpoint
and caches successful checks for at most five minutes, never beyond the returned
token lifetime. Invalid credentials return 401, a forbidden route returns 403,
and a Keycloak outage returns 503 so the client can retry. Machine secrets are
never stored in the routes file.
Browser sessions are stored entirely in an AES-256-GCM encrypted cookie. Machine
credential caches are local to each replica; replicas require no coordination.
## Configuration ## Configuration
@@ -52,12 +60,21 @@ routes:
secret.example.com: secret.example.com:
allowed_groups: ["admins"] allowed_groups: ["admins"]
loki.example.com:
browser_access: false
machine_access:
client_ids: ["tsunagi-vector"]
method: POST
path: /loki/api/v1/push
``` ```
Rules: Rules:
- Host in routes, `allowed_groups` empty — any authenticated user is allowed - Host in routes, `allowed_groups` empty — any authenticated user is allowed
- Host in routes, `allowed_groups` set — user must be in at least one listed group - Host in routes, `allowed_groups` set — user must be in at least one listed group
- Host not in routes — denied (403) - Host not in routes — denied (403)
- `browser_access: false` — no cookie or OIDC redirect on this host
- `machine_access` — only listed Keycloak clients on the exact method and path
The file is polled every 5 seconds and reloaded on change. This works reliably with Kubernetes ConfigMap volume mounts. The file is polled every 5 seconds and reloaded on change. This works reliably with Kubernetes ConfigMap volume mounts.
+258 -51
View File
@@ -1,15 +1,23 @@
use crate::crypto::CookieCrypto; use crate::crypto::CookieCrypto;
use crate::oidc::OidcClient; use crate::oidc::{ClientCredentialsError, OidcClient};
use crate::routes::SharedRoutes; use crate::routes::SharedRoutes;
use crate::session::{now_timestamp, Session}; use crate::session::{now_timestamp, Session};
use axum::extract::{Query, State}; use axum::extract::{Query, State};
use axum::http::{header, HeaderMap, StatusCode}; use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use base64::{
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
Engine,
};
use rand::RngCore; use rand::RngCore;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::sync::Arc; use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::RwLock;
pub struct AppState { pub struct AppState {
pub config: crate::config::Config, pub config: crate::config::Config,
@@ -17,6 +25,7 @@ pub struct AppState {
pub routes: SharedRoutes, pub routes: SharedRoutes,
pub crypto: CookieCrypto, pub crypto: CookieCrypto,
pub metrics_handle: metrics_exporter_prometheus::PrometheusHandle, pub metrics_handle: metrics_exporter_prometheus::PrometheusHandle,
pub machine_cache: RwLock<HashMap<[u8; 32], Instant>>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -69,7 +78,10 @@ pub async fn metrics_handler(State(state): State<Arc<AppState>>) -> Response {
let body = state.metrics_handle.render(); let body = state.metrics_handle.render();
( (
StatusCode::OK, StatusCode::OK,
[(header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8")], [(
header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
body, body,
) )
.into_response() .into_response()
@@ -103,6 +115,22 @@ async fn auth_inner(state: &AppState, headers: &HeaderMap) -> Response {
let uri = uri.unwrap_or("/"); let uri = uri.unwrap_or("/");
let original_url = format!("{}://{}{}", proto, host, uri); let original_url = format!("{}://{}{}", proto, host, uri);
let (browser_access, machine_access) = state
.routes
.read()
.await
.get(host)
.map(|route| (Some(route.browser_access), route.machine_access.is_some()))
.unwrap_or((None, false));
if machine_access {
if let Some(authorization) = headers.get(header::AUTHORIZATION) {
return authorize_machine(state, headers, host, uri, authorization).await;
}
}
if browser_access == Some(false) {
return StatusCode::UNAUTHORIZED.into_response();
}
let session = extract_session_from_headers(headers, &state.crypto); let session = extract_session_from_headers(headers, &state.crypto);
match session { match session {
@@ -116,6 +144,88 @@ async fn auth_inner(state: &AppState, headers: &HeaderMap) -> Response {
} }
} }
async fn authorize_machine(
state: &AppState,
headers: &HeaderMap,
host: &str,
uri: &str,
authorization: &axum::http::HeaderValue,
) -> Response {
let Some(encoded) = authorization
.to_str()
.ok()
.and_then(|s| s.strip_prefix("Basic "))
else {
return StatusCode::UNAUTHORIZED.into_response();
};
let Some((client_id, client_secret)) = STANDARD
.decode(encoded)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|credentials| {
credentials
.split_once(':')
.map(|(id, secret)| (id.to_owned(), secret.to_owned()))
})
else {
return StatusCode::UNAUTHORIZED.into_response();
};
if client_id.is_empty() || client_secret.is_empty() {
return StatusCode::UNAUTHORIZED.into_response();
}
let method = get_header(headers, "x-forwarded-method").unwrap_or("");
let path = uri.split('?').next().unwrap_or("");
let allowed = state
.routes
.read()
.await
.get(host)
.and_then(|route| route.machine_access.as_ref())
.is_some_and(|machine| {
machine.client_ids.iter().any(|id| id == &client_id)
&& machine.method == method
&& machine.path == path
});
if !allowed {
return StatusCode::FORBIDDEN.into_response();
}
let cache_key: [u8; 32] = Sha256::digest(authorization.as_bytes()).into();
if state
.machine_cache
.read()
.await
.get(&cache_key)
.is_some_and(|expiry| *expiry > Instant::now())
{
return StatusCode::OK.into_response();
}
match state
.oidc
.check_client_credentials(&client_id, &client_secret)
.await
{
Ok(expires_in) => {
// Never cache longer than Keycloak's token lifetime; keep revocation
// delay bounded even when the client has a long token lifetime.
let ttl = expires_in.saturating_sub(30).min(300);
let mut cache = state.machine_cache.write().await;
cache.retain(|_, expiry| *expiry > Instant::now());
if ttl > 0 {
cache.insert(cache_key, Instant::now() + Duration::from_secs(ttl));
}
StatusCode::OK.into_response()
}
Err(ClientCredentialsError::Invalid) => StatusCode::UNAUTHORIZED.into_response(),
Err(ClientCredentialsError::Unavailable(error)) => {
tracing::warn!(error = %error, "Keycloak client credentials check unavailable");
StatusCode::SERVICE_UNAVAILABLE.into_response()
}
}
}
// --- /callback --- // --- /callback ---
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -402,11 +512,7 @@ async fn authorize_request(state: &AppState, session: &Session, host: &str) -> R
.iter() .iter()
.any(|g| route.allowed_groups.contains(g)); .any(|g| route.allowed_groups.contains(g));
if !has_access { if !has_access {
tracing::debug!( tracing::debug!(host, user = session.username, "user not in allowed groups");
host,
user = session.username,
"user not in allowed groups"
);
return forbidden_page( return forbidden_page(
host, host,
&session.username, &session.username,
@@ -536,7 +642,9 @@ fn redirect_to_login(state: &AppState, original_url: &str) -> Response {
} }
}; };
let auth_url = state.oidc.auth_url(&encrypted_state, &pkce_challenge, &nonce); let auth_url = state
.oidc
.auth_url(&encrypted_state, &pkce_challenge, &nonce);
(StatusCode::FOUND, [(header::LOCATION, auth_url)]).into_response() (StatusCode::FOUND, [(header::LOCATION, auth_url)]).into_response()
} }
@@ -554,8 +662,8 @@ fn redirect_to_refresh(state: &AppState, original_url: &str) -> Response {
} }
}; };
let mut url = url::Url::parse(&format!("{}/refresh", state.config.base_url())) let mut url =
.expect("valid base_url"); url::Url::parse(&format!("{}/refresh", state.config.base_url())).expect("valid base_url");
url.query_pairs_mut().append_pair("state", &encrypted); url.query_pairs_mut().append_pair("state", &encrypted);
(StatusCode::FOUND, [(header::LOCATION, url.to_string())]).into_response() (StatusCode::FOUND, [(header::LOCATION, url.to_string())]).into_response()
@@ -600,15 +708,15 @@ fn format_set_cookie(domain: &str, value: &str, max_age_secs: i64) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::Config;
use crate::crypto::CookieCrypto;
use crate::oidc::OidcClient;
use crate::routes::{MachineAccess, RouteConfig};
use crate::session::{now_timestamp, Session};
use axum::body::Body; use axum::body::Body;
use axum::http::Request; use axum::http::Request;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::Router; use axum::Router;
use crate::config::Config;
use crate::crypto::CookieCrypto;
use crate::oidc::OidcClient;
use crate::routes::RouteConfig;
use crate::session::{now_timestamp, Session};
use std::collections::HashMap; use std::collections::HashMap;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tower::ServiceExt; use tower::ServiceExt;
@@ -640,8 +748,7 @@ mod tests {
let pem = private_key let pem = private_key
.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF) .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
.unwrap(); .unwrap();
let encoding_key = let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap();
jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap();
TestKeys { TestKeys {
encoding_key, encoding_key,
@@ -674,9 +781,7 @@ mod tests {
})) }))
} }
async fn mock_jwks( async fn mock_jwks(State(st): State<Arc<MockKeycloakState>>) -> axum::Json<serde_json::Value> {
State(st): State<Arc<MockKeycloakState>>,
) -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({ axum::Json(serde_json::json!({
"keys": [{ "keys": [{
"kid": st.kid, "kid": st.kid,
@@ -688,9 +793,13 @@ mod tests {
})) }))
} }
async fn mock_token( async fn mock_token(State(st): State<Arc<MockKeycloakState>>, headers: HeaderMap) -> Response {
State(st): State<Arc<MockKeycloakState>>, if let Some(value) = headers.get(header::AUTHORIZATION) {
) -> axum::Json<serde_json::Value> { let expected = format!("Basic {}", STANDARD.encode("vector:test-machine-secret"));
if value.to_str().ok() != Some(expected.as_str()) {
return StatusCode::UNAUTHORIZED.into_response();
}
}
let now = now_timestamp(); let now = now_timestamp();
let claims = serde_json::json!({ let claims = serde_json::json!({
"iss": st.issuer, "iss": st.issuer,
@@ -709,8 +818,7 @@ mod tests {
kid: Some(st.kid.clone()), kid: Some(st.kid.clone()),
..Default::default() ..Default::default()
}; };
let id_token = let id_token = jsonwebtoken::encode(&header, &claims, &st.encoding_key).unwrap();
jsonwebtoken::encode(&header, &claims, &st.encoding_key).unwrap();
axum::Json(serde_json::json!({ axum::Json(serde_json::json!({
"id_token": id_token, "id_token": id_token,
@@ -720,12 +828,11 @@ mod tests {
"expires_in": 3600, "expires_in": 3600,
"refresh_expires_in": 86400, "refresh_expires_in": 86400,
})) }))
.into_response()
} }
async fn start_mock_keycloak(keys: &TestKeys) -> String { async fn start_mock_keycloak(keys: &TestKeys) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0") let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
.await
.unwrap();
let issuer = format!("http://{}", listener.local_addr().unwrap()); let issuer = format!("http://{}", listener.local_addr().unwrap());
let mock_state = Arc::new(MockKeycloakState { let mock_state = Arc::new(MockKeycloakState {
@@ -781,12 +888,28 @@ mod tests {
"grafana.example.com".into(), "grafana.example.com".into(),
RouteConfig { RouteConfig {
allowed_groups: vec!["admins".into(), "developers".into()], allowed_groups: vec!["admins".into(), "developers".into()],
browser_access: true,
machine_access: None,
}, },
); );
routes.insert( routes.insert(
"wiki.example.com".into(), "wiki.example.com".into(),
RouteConfig { RouteConfig {
allowed_groups: vec![], allowed_groups: vec![],
browser_access: true,
machine_access: None,
},
);
routes.insert(
"loki.example.com".into(),
RouteConfig {
allowed_groups: vec![],
browser_access: false,
machine_access: Some(MachineAccess {
client_ids: vec!["vector".into()],
method: "POST".into(),
path: "/loki/api/v1/push".into(),
}),
}, },
); );
@@ -805,6 +928,7 @@ mod tests {
routes: Arc::new(RwLock::new(routes)), routes: Arc::new(RwLock::new(routes)),
crypto: CookieCrypto::new(&TEST_KEY), crypto: CookieCrypto::new(&TEST_KEY),
metrics_handle, metrics_handle,
machine_cache: RwLock::new(HashMap::new()),
}); });
let app = Router::new() let app = Router::new()
@@ -903,7 +1027,12 @@ mod tests {
async fn health_returns_200() { async fn health_returns_200() {
let (app, _) = setup().await; let (app, _) = setup().await;
let resp = app let resp = app
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) .oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
@@ -927,7 +1056,10 @@ mod tests {
assert_eq!(resp.status(), StatusCode::FOUND); assert_eq!(resp.status(), StatusCode::FOUND);
let location = resp.headers().get("location").unwrap().to_str().unwrap(); let location = resp.headers().get("location").unwrap().to_str().unwrap();
assert!(location.contains("/authorize"), "should redirect to OIDC authorize"); assert!(
location.contains("/authorize"),
"should redirect to OIDC authorize"
);
assert!(location.contains("client_id=test-client")); assert!(location.contains("client_id=test-client"));
assert!(location.contains("code_challenge=")); assert!(location.contains("code_challenge="));
assert!(location.contains("code_challenge_method=S256")); assert!(location.contains("code_challenge_method=S256"));
@@ -1104,15 +1236,105 @@ mod tests {
async fn auth_missing_forwarded_host_returns_400() { async fn auth_missing_forwarded_host_returns_400() {
let (app, _) = setup().await; let (app, _) = setup().await;
let resp = app let resp = app
.oneshot(Request::builder().uri("/auth").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
async fn machine_request(
app: Router,
host: &str,
method: &str,
path: &str,
secret: &str,
) -> StatusCode {
let credentials = STANDARD.encode(format!("vector:{secret}"));
app.oneshot(
Request::builder()
.uri("/auth")
.header("x-forwarded-host", host)
.header("x-forwarded-method", method)
.header("x-forwarded-uri", path)
.header(header::AUTHORIZATION, format!("Basic {credentials}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
.status()
}
#[tokio::test]
async fn machine_credentials_are_limited_to_route_and_method() {
let (app, _) = setup().await;
assert_eq!(
machine_request(
app.clone(),
"loki.example.com",
"POST",
"/loki/api/v1/push",
"test-machine-secret"
)
.await,
StatusCode::OK
);
assert_eq!(
machine_request(
app.clone(),
"loki.example.com",
"POST",
"/loki/api/v1/push",
"wrong"
)
.await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
machine_request(
app.clone(),
"loki.example.com",
"GET",
"/loki/api/v1/push",
"test-machine-secret"
)
.await,
StatusCode::FORBIDDEN
);
assert_eq!(
machine_request(
app.clone(),
"loki.example.com",
"POST",
"/loki/api/v1/query",
"test-machine-secret"
)
.await,
StatusCode::FORBIDDEN
);
assert_eq!(
machine_request(
app.clone(),
"grafana.example.com",
"POST",
"/loki/api/v1/push",
"test-machine-secret"
)
.await,
StatusCode::FOUND
);
let no_credentials = app
.oneshot( .oneshot(
Request::builder() Request::builder()
.uri("/auth") .uri("/auth")
.header("x-forwarded-host", "loki.example.com")
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
) )
.await .await
.unwrap(); .unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST); assert_eq!(no_credentials.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test] #[tokio::test]
@@ -1145,12 +1367,7 @@ mod tests {
"https://grafana.example.com/dashboard" "https://grafana.example.com/dashboard"
); );
let set_cookie = resp let set_cookie = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
.headers()
.get("set-cookie")
.unwrap()
.to_str()
.unwrap();
assert!(set_cookie.starts_with("_auth_proxy_session=")); assert!(set_cookie.starts_with("_auth_proxy_session="));
assert!(set_cookie.contains("HttpOnly")); assert!(set_cookie.contains("HttpOnly"));
assert!(set_cookie.contains("Secure")); assert!(set_cookie.contains("Secure"));
@@ -1218,12 +1435,7 @@ mod tests {
); );
// New cookie should be set // New cookie should be set
let set_cookie = resp let set_cookie = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
.headers()
.get("set-cookie")
.unwrap()
.to_str()
.unwrap();
let cookie_value = set_cookie let cookie_value = set_cookie
.split(';') .split(';')
.next() .next()
@@ -1274,12 +1486,7 @@ mod tests {
assert_eq!(resp.status(), StatusCode::FOUND); assert_eq!(resp.status(), StatusCode::FOUND);
let set_cookie = resp let set_cookie = resp.headers().get("set-cookie").unwrap().to_str().unwrap();
.headers()
.get("set-cookie")
.unwrap()
.to_str()
.unwrap();
assert!(set_cookie.contains("Max-Age=0"), "cookie should be expired"); assert!(set_cookie.contains("Max-Age=0"), "cookie should be expired");
let location = resp.headers().get("location").unwrap().to_str().unwrap(); let location = resp.headers().get("location").unwrap().to_str().unwrap();
+1
View File
@@ -146,6 +146,7 @@ async fn main() -> anyhow::Result<()> {
routes: shared_routes.clone(), routes: shared_routes.clone(),
crypto, crypto,
metrics_handle, metrics_handle,
machine_cache: RwLock::new(std::collections::HashMap::new()),
}); });
// Background: watch routes file for changes // Background: watch routes file for changes
+49 -5
View File
@@ -28,6 +28,18 @@ struct JwksResponse {
keys: Vec<JwkKey>, keys: Vec<JwkKey>,
} }
#[derive(Deserialize)]
struct ClientCredentialsResponse {
access_token: String,
token_type: String,
expires_in: u64,
}
pub enum ClientCredentialsError {
Invalid,
Unavailable(anyhow::Error),
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct TokenResponse { pub struct TokenResponse {
@@ -59,13 +71,48 @@ pub struct OidcClient {
} }
impl OidcClient { impl OidcClient {
pub async fn check_client_credentials(
&self,
client_id: &str,
client_secret: &str,
) -> std::result::Result<u64, ClientCredentialsError> {
let response = self
.http
.post(&self.discovery.token_endpoint)
.basic_auth(client_id, Some(client_secret))
.form(&[("grant_type", "client_credentials")])
.send()
.await
.map_err(|e| ClientCredentialsError::Unavailable(e.into()))?;
if response.status().is_client_error()
&& response.status() != reqwest::StatusCode::TOO_MANY_REQUESTS
{
return Err(ClientCredentialsError::Invalid);
}
let response = response
.error_for_status()
.map_err(|e| ClientCredentialsError::Unavailable(e.into()))?;
let token: ClientCredentialsResponse = response
.json()
.await
.map_err(|e| ClientCredentialsError::Unavailable(e.into()))?;
if token.access_token.is_empty() || !token.token_type.eq_ignore_ascii_case("Bearer") {
return Err(ClientCredentialsError::Unavailable(anyhow::anyhow!(
"unexpected Keycloak token response"
)));
}
Ok(token.expires_in)
}
pub async fn discover( pub async fn discover(
issuer: &str, issuer: &str,
client_id: String, client_id: String,
client_secret: String, client_secret: String,
redirect_uri: String, redirect_uri: String,
) -> Result<Self> { ) -> Result<Self> {
let http = Client::new(); let http = Client::builder()
.timeout(std::time::Duration::from_secs(8))
.build()?;
let discovery_url = format!( let discovery_url = format!(
"{}/.well-known/openid-configuration", "{}/.well-known/openid-configuration",
issuer.trim_end_matches('/') issuer.trim_end_matches('/')
@@ -90,10 +137,7 @@ impl OidcClient {
.await .await
.context("failed to parse JWKS")?; .context("failed to parse JWKS")?;
tracing::info!( tracing::info!(keys = jwks.keys.len(), "OIDC discovery complete");
keys = jwks.keys.len(),
"OIDC discovery complete"
);
Ok(Self { Ok(Self {
http, http,
+30 -16
View File
@@ -6,7 +6,23 @@ use tokio::sync::RwLock;
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct RouteConfig { pub struct RouteConfig {
#[serde(default)]
pub allowed_groups: Vec<String>, pub allowed_groups: Vec<String>,
#[serde(default = "default_browser_access")]
pub browser_access: bool,
#[serde(default)]
pub machine_access: Option<MachineAccess>,
}
fn default_browser_access() -> bool {
true
}
#[derive(Debug, Deserialize, Clone)]
pub struct MachineAccess {
pub client_ids: Vec<String>,
pub method: String,
pub path: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -35,18 +51,20 @@ pub async fn watch_routes(path: String, routes: SharedRoutes) {
// Read through the symlink — k8s ConfigMap updates swap the symlink target, // Read through the symlink — k8s ConfigMap updates swap the symlink target,
// so reading the original path always gives current content. // so reading the original path always gives current content.
match std::fs::read_to_string(&path) { match std::fs::read_to_string(&path) {
Ok(content) if content != last_content => match serde_yaml::from_str::<RoutesFile>(&content) { Ok(content) if content != last_content => {
Ok(routes_file) => { match serde_yaml::from_str::<RoutesFile>(&content) {
let count = routes_file.routes.len(); Ok(routes_file) => {
*routes.write().await = routes_file.routes; let count = routes_file.routes.len();
last_content = content; *routes.write().await = routes_file.routes;
metrics::gauge!("routes_loaded_total").set(count as f64); last_content = content;
tracing::info!(count, "routes reloaded"); metrics::gauge!("routes_loaded_total").set(count as f64);
tracing::info!(count, "routes reloaded");
}
Err(e) => {
tracing::error!(error = %e, "failed to parse routes file");
}
} }
Err(e) => { }
tracing::error!(error = %e, "failed to parse routes file");
}
},
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "failed to read routes file"); tracing::warn!(error = %e, "failed to read routes file");
@@ -117,11 +135,7 @@ routes:
async fn watch_routes_detects_change() { async fn watch_routes_detects_change() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("routes.yaml"); let path = dir.path().join("routes.yaml");
std::fs::write( std::fs::write(&path, "routes:\n a.example.com:\n allowed_groups: []\n").unwrap();
&path,
"routes:\n a.example.com:\n allowed_groups: []\n",
)
.unwrap();
let initial = load_routes(path.to_str().unwrap()).unwrap(); let initial = load_routes(path.to_str().unwrap()).unwrap();
let shared: SharedRoutes = Arc::new(RwLock::new(initial)); let shared: SharedRoutes = Arc::new(RwLock::new(initial));