lean_ctx/core/ocla/
sidecar.rs1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(default)]
10pub struct SidecarConfig {
11 pub bind_addr: String,
13 pub auth_token: Option<String>,
15 pub tls_cert_path: Option<PathBuf>,
17 pub tls_key_path: Option<PathBuf>,
19 pub enabled: bool,
21}
22
23impl Default for SidecarConfig {
24 fn default() -> Self {
25 Self {
26 bind_addr: "127.0.0.1:3334".to_string(),
27 auth_token: None,
28 tls_cert_path: None,
29 tls_key_path: None,
30 enabled: false,
31 }
32 }
33}
34
35#[cfg(feature = "http-server")]
36mod http {
37 use std::sync::Arc;
38
39 use anyhow::{Result, anyhow};
40 use axum::{
41 Router,
42 body::Body,
43 http::{Request, StatusCode, header},
44 middleware::{self, Next},
45 response::{IntoResponse, Response},
46 };
47 use subtle::ConstantTimeEq;
48 use tokio::net::TcpListener;
49
50 use super::SidecarConfig;
51
52 pub(super) async fn start(config: &SidecarConfig) -> Result<()> {
53 if !config.enabled {
54 return Ok(());
55 }
56
57 if config.auth_token.as_ref().is_none_or(String::is_empty) {
58 return Err(anyhow!(
59 "OCLA sidecar requires auth_token when enabled — \\
60 set [ocla.sidecar] auth_token in config.toml"
61 ));
62 }
63
64 if config.tls_cert_path.is_some() != config.tls_key_path.is_some() {
65 return Err(anyhow!(
66 "tls_cert_path and tls_key_path must be configured together"
67 ));
68 }
69 if config.tls_cert_path.is_some() {
70 return Err(anyhow!(
71 "TLS sidecar transport requires a TLS listener dependency; \
72 configure the plain HTTP sidecar or add that runtime dependency"
73 ));
74 }
75
76 let listener = TcpListener::bind(&config.bind_addr).await?;
77 axum::serve(listener, router(config.auth_token.as_deref())).await?;
78 Ok(())
79 }
80
81 fn router(auth_token: Option<&str>) -> Router {
82 let router = crate::core::ocla::wire_api::ocla_router();
83 match auth_token.filter(|token| !token.is_empty()) {
84 Some(token) => {
85 let expected = Arc::new(token.as_bytes().to_vec());
86 router.layer(middleware::from_fn(move |req, next| {
87 let expected = Arc::clone(&expected);
88 async move { auth_middleware(req, next, expected).await }
89 }))
90 }
91 None => router,
92 }
93 }
94
95 async fn auth_middleware(
96 request: Request<Body>,
97 next: Next,
98 expected: Arc<Vec<u8>>,
99 ) -> Response {
100 let Some(value) = request.headers().get(header::AUTHORIZATION) else {
101 return unauthorized();
102 };
103 let Ok(value) = value.to_str() else {
104 return unauthorized();
105 };
106 let Some(token) = value
107 .strip_prefix("Bearer ")
108 .or_else(|| value.strip_prefix("bearer "))
109 else {
110 return unauthorized();
111 };
112 if !bool::from(token.as_bytes().ct_eq(expected.as_slice())) {
113 return unauthorized();
114 }
115 next.run(request).await
116 }
117
118 fn unauthorized() -> Response {
119 (StatusCode::UNAUTHORIZED, "unauthorized\n").into_response()
120 }
121
122 #[cfg(test)]
123 mod tests {
124 use axum::body::{Body, to_bytes};
125 use axum::http::{Request, StatusCode, header};
126 use tower::ServiceExt;
127
128 use super::{SidecarConfig, router, start};
129
130 #[tokio::test]
131 async fn sidecar_requires_auth_when_enabled() {
132 for auth_token in [None, Some(String::new())] {
133 let config = SidecarConfig {
134 enabled: true,
135 auth_token,
136 ..Default::default()
137 };
138
139 let error = start(&config).await.expect_err("missing auth token");
140 assert!(
141 error
142 .to_string()
143 .contains("OCLA sidecar requires auth_token when enabled")
144 );
145 }
146 }
147
148 #[tokio::test]
149 async fn auth_rejects_missing_and_wrong_bearer_tokens() {
150 for authorization in [None, Some("Bearer wrong"), Some("Basic secret")] {
151 let mut request = Request::builder().method("GET").uri("/ocla/v1/health");
152 if let Some(value) = authorization {
153 request = request.header(header::AUTHORIZATION, value);
154 }
155 let response = router(Some("secret"))
156 .oneshot(request.body(Body::empty()).expect("request"))
157 .await
158 .expect("response");
159 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
160 }
161 }
162
163 #[tokio::test]
164 async fn auth_accepts_matching_bearer_token() {
165 let response = router(Some("secret"))
166 .oneshot(
167 Request::builder()
168 .method("GET")
169 .uri("/ocla/v1/health")
170 .header(header::AUTHORIZATION, "Bearer secret")
171 .body(Body::empty())
172 .expect("request"),
173 )
174 .await
175 .expect("response");
176 assert_eq!(response.status(), StatusCode::OK);
177 let body = to_bytes(response.into_body(), 4096).await.expect("body");
178 assert!(body.windows(2).any(|window| window == b"ok"));
179 }
180 }
181}
182
183#[cfg(feature = "http-server")]
184pub async fn start_sidecar(config: &SidecarConfig) -> anyhow::Result<()> {
185 http::start(config).await
186}
187
188#[cfg(not(feature = "http-server"))]
189pub async fn start_sidecar(_config: &SidecarConfig) -> anyhow::Result<()> {
190 Err(anyhow::anyhow!(
191 "OCLA sidecar requires the `http-server` feature"
192 ))
193}