Skip to main content

lean_ctx/core/ocla/
sidecar.rs

1//! Standalone OCLA wire-API sidecar deployment.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7/// Configuration for the standalone OCLA wire-API process.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(default)]
10pub struct SidecarConfig {
11    /// HTTP bind address for the sidecar.
12    pub bind_addr: String,
13    /// Optional bearer token required on every wire-API request.
14    pub auth_token: Option<String>,
15    /// PEM certificate chain for HTTPS.
16    pub tls_cert_path: Option<PathBuf>,
17    /// PEM private key for HTTPS.
18    pub tls_key_path: Option<PathBuf>,
19    /// Whether startup should bind a listener.
20    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}