Skip to main content

glues_server/
lib.rs

1use {
2    axum::{
3        Json, Router,
4        body::Body,
5        extract::State,
6        http::{Method, Request, StatusCode, header::AUTHORIZATION},
7        middleware::{Next, from_fn},
8        response::Response,
9        routing::{get, post},
10    },
11    clap::{Args, Parser, Subcommand},
12    color_eyre::Result,
13    glues_core::{
14        Task, Transition,
15        backend::{
16            CoreBackend,
17            local::Db,
18            proxy::{ProxyServer, request::ProxyRequest, response::ProxyResponse},
19        },
20        handle_tasks,
21    },
22    std::{
23        collections::VecDeque,
24        net::SocketAddr,
25        sync::{
26            Arc, Mutex,
27            mpsc::{Sender, channel},
28        },
29        time::Duration,
30    },
31    tokio::{net::TcpListener, signal, sync::Mutex as AsyncMutex, time::sleep},
32    tower_http::cors::{Any, CorsLayer},
33    tracing::{error, info, warn},
34    tracing_subscriber::EnvFilter,
35};
36
37#[derive(Clone, Args)]
38pub struct ServerArgs {
39    #[arg(long, default_value = "127.0.0.1:4000")]
40    pub listen: SocketAddr,
41
42    #[arg(long, env = "GLUES_SERVER_TOKEN")]
43    pub auth_token: Option<String>,
44
45    #[command(subcommand)]
46    pub storage: StorageCommand,
47}
48
49#[derive(Parser)]
50#[command(author, version, about = "Glues proxy server")]
51struct Cli {
52    #[command(flatten)]
53    args: ServerArgs,
54}
55
56#[derive(Subcommand, Clone)]
57pub enum StorageCommand {
58    /// In-memory storage (data resets on restart)
59    Memory,
60    /// File storage backend rooted at the given path
61    File { path: String },
62    /// redb single-file storage backend
63    Redb { path: String },
64    /// Git storage backend
65    Git {
66        path: String,
67        remote: String,
68        branch: String,
69    },
70    /// MongoDB storage backend
71    Mongo { conn_str: String, db_name: String },
72}
73
74pub fn parse_args() -> ServerArgs {
75    Cli::parse().args
76}
77
78pub async fn run(args: ServerArgs) -> Result<()> {
79    color_eyre::install()?;
80    tracing_subscriber::fmt()
81        .with_env_filter(EnvFilter::from_default_env().add_directive("info".parse()?))
82        .with_target(false)
83        .init();
84
85    let ServerArgs {
86        listen,
87        auth_token,
88        storage,
89    } = args;
90
91    let (task_tx, task_rx) = channel();
92    let transition_queue = Arc::new(Mutex::new(VecDeque::<Transition>::new()));
93    let _task_handle = handle_tasks(task_rx, &transition_queue);
94    spawn_transition_drain(Arc::clone(&transition_queue));
95
96    let backend = build_backend(storage, task_tx).await?;
97    let server = Arc::new(AsyncMutex::new(ProxyServer::new(backend)));
98
99    let cors = CorsLayer::new()
100        .allow_origin(Any)
101        .allow_methods(Any)
102        .allow_headers(Any);
103
104    let mut app = Router::new()
105        .route("/", post(handle_proxy))
106        .route("/health", get(health))
107        .with_state(server.clone())
108        .layer(cors);
109
110    if let Some(token) = auth_token.as_ref() {
111        info!("authentication token required for proxy requests");
112        let token = Arc::new(token.clone());
113        let auth_layer = from_fn(move |req, next| {
114            let token = Arc::clone(&token);
115            async move { enforce_bearer(token, req, next).await }
116        });
117        app = app.layer(auth_layer);
118    } else if !listen.ip().is_loopback() {
119        warn!(
120            "proxy server is listening on {listen} without authentication; set GLUES_SERVER_TOKEN or --auth-token to protect access"
121        );
122    }
123
124    let listener = TcpListener::bind(listen).await?;
125    info!("listening on {}", listen);
126
127    axum::serve(listener, app)
128        .with_graceful_shutdown(shutdown_signal())
129        .await?;
130
131    Ok(())
132}
133
134pub async fn run_cli() -> Result<()> {
135    run(parse_args()).await
136}
137
138async fn build_backend(
139    storage: StorageCommand,
140    task_tx: Sender<Task>,
141) -> Result<Box<dyn CoreBackend + Send>> {
142    let backend: Box<dyn CoreBackend + Send> = match storage {
143        StorageCommand::Memory => Box::new(Db::memory(task_tx.clone()).await?),
144        StorageCommand::File { path } => Box::new(Db::file(task_tx.clone(), &path).await?),
145        StorageCommand::Redb { path } => Box::new(Db::redb(task_tx.clone(), &path).await?),
146        StorageCommand::Git {
147            path,
148            remote,
149            branch,
150        } => Box::new(Db::git(task_tx.clone(), &path, remote, branch).await?),
151        StorageCommand::Mongo { conn_str, db_name } => {
152            Box::new(Db::mongo(task_tx, &conn_str, &db_name).await?)
153        }
154    };
155
156    Ok(backend)
157}
158
159async fn handle_proxy(
160    State(server): State<Arc<AsyncMutex<ProxyServer>>>,
161    Json(request): Json<ProxyRequest>,
162) -> (StatusCode, Json<ProxyResponse>) {
163    let mut server = server.lock_owned().await;
164    let response = server.handle(request).await;
165    (StatusCode::OK, Json(response))
166}
167
168async fn health() -> StatusCode {
169    StatusCode::OK
170}
171
172fn spawn_transition_drain(queue: Arc<Mutex<VecDeque<Transition>>>) {
173    tokio::spawn(async move {
174        loop {
175            {
176                let mut guard = queue.lock().expect("transition queue poisoned");
177                guard.clear();
178            }
179            sleep(Duration::from_millis(500)).await;
180        }
181    });
182}
183
184async fn shutdown_signal() {
185    if let Err(err) = signal::ctrl_c().await {
186        error!("failed to install Ctrl+C handler: {err}");
187        return;
188    }
189
190    info!("shutting down");
191}
192
193async fn enforce_bearer(
194    token: Arc<String>,
195    req: Request<Body>,
196    next: Next,
197) -> Result<Response, StatusCode> {
198    if req.method() == Method::OPTIONS {
199        return Ok(next.run(req).await);
200    }
201
202    let Some(header) = req.headers().get(AUTHORIZATION) else {
203        return Err(StatusCode::UNAUTHORIZED);
204    };
205    let Ok(value) = header.to_str() else {
206        return Err(StatusCode::UNAUTHORIZED);
207    };
208    let Some(provided) = value.strip_prefix("Bearer ").map(str::trim) else {
209        return Err(StatusCode::UNAUTHORIZED);
210    };
211    if provided != token.as_str() {
212        return Err(StatusCode::UNAUTHORIZED);
213    }
214
215    Ok(next.run(req).await)
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use axum::{Router, routing::get};
222    use tower::ServiceExt;
223
224    async fn ok() -> StatusCode {
225        StatusCode::OK
226    }
227
228    #[tokio::test]
229    async fn options_requests_bypass_auth() {
230        let cors = CorsLayer::new()
231            .allow_origin(Any)
232            .allow_methods(Any)
233            .allow_headers(Any);
234
235        let token = Arc::new("secret".to_owned());
236        let app = Router::new()
237            .route("/", get(ok))
238            .layer(cors)
239            .layer(from_fn(move |req, next| {
240                let token = Arc::clone(&token);
241                async move { enforce_bearer(token, req, next).await }
242            }));
243
244        let response = app
245            .oneshot(
246                Request::builder()
247                    .method(Method::OPTIONS)
248                    .uri("/")
249                    .body(Body::empty())
250                    .unwrap(),
251            )
252            .await
253            .expect("preflight request should succeed");
254
255        assert_ne!(response.status(), StatusCode::UNAUTHORIZED);
256    }
257
258    #[tokio::test]
259    async fn missing_token_still_rejected_for_non_preflight() {
260        let cors = CorsLayer::new()
261            .allow_origin(Any)
262            .allow_methods(Any)
263            .allow_headers(Any);
264
265        let token = Arc::new("secret".to_owned());
266        let app = Router::new()
267            .route("/", get(ok))
268            .layer(cors)
269            .layer(from_fn(move |req, next| {
270                let token = Arc::clone(&token);
271                async move { enforce_bearer(token, req, next).await }
272            }));
273
274        let response = app
275            .oneshot(
276                Request::builder()
277                    .method(Method::GET)
278                    .uri("/")
279                    .body(Body::empty())
280                    .unwrap(),
281            )
282            .await
283            .expect("request should succeed");
284
285        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
286    }
287}