diff --git a/Cargo.toml b/Cargo.toml index 42d7933e..89f5a4a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,11 @@ kubernetes = ["dep:kube", "dep:k8s-openapi", "dep:schemars", "dep:either"] notebook = ["kubernetes"] openwebui = ["kubernetes"] lifecycle = ["notebook"] +chemchat = [] observe = [] mcp = [] -full = ["notebook", "lifecycle", "observe", "mcp", "openwebui"] +moleviewer = [] +full = ["notebook", "lifecycle", "observe", "mcp", "openwebui", "chemchat"] [dependencies] # Async dep diff --git a/Dockerfile b/Dockerfile index eb507545..038aae06 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1 build -FROM rust:1.90.0 AS builder +FROM rust:1.92.0 AS builder WORKDIR /app @@ -10,6 +10,7 @@ ARG LIFECYCLE=false ARG OBSERVE=false ARG MCP=false ARG OWUI=false +ARG CHEMCHAT=false RUN <, pub observability_cred: Option<(String, String)>, + #[cfg(feature = "chemchat")] + pub chemchat_url: String, + #[cfg(feature = "chemchat")] + pub chemchat_internal_url: String, #[cfg(feature = "openwebui")] pub owui_namespace: String, #[cfg(feature = "openwebui")] @@ -245,6 +249,17 @@ pub fn init_once() -> Configuration { ) }; + #[cfg(feature = "chemchat")] + let (chemchat_url, chemchat_internal_url) = { + ( + app_conf["chemchat_url"].as_str().unwrap().to_string(), + app_conf["chemchat_internal_url"] + .as_str() + .unwrap() + .to_string(), + ) + }; + let bridge_url = app_conf["bridge_url"].as_str().unwrap().to_string(); Configuration { @@ -265,6 +280,10 @@ pub fn init_once() -> Configuration { company, oidc: oidc_map, observability_cred, + #[cfg(feature = "chemchat")] + chemchat_url, + #[cfg(feature = "chemchat")] + chemchat_internal_url, #[cfg(feature = "openwebui")] owui_namespace, #[cfg(feature = "openwebui")] diff --git a/src/kube/openwebui/mod.rs b/src/kube/openwebui/mod.rs index eba0e440..e09658d2 100644 --- a/src/kube/openwebui/mod.rs +++ b/src/kube/openwebui/mod.rs @@ -7,4 +7,5 @@ pub const OWUI: &str = "owui"; // This is a placeholder for openwebui CRD struct OpenWebUI { _p: PhantomData<()>, + _id: uuid::Uuid, } diff --git a/src/web/mod.rs b/src/web/mod.rs index f0f4d2cc..3b83794a 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -110,7 +110,7 @@ pub async fn start_server(with_tls: bool) -> Result<()> { // Lifecycle with "advisory lock" #[cfg(all(feature = "notebook", feature = "lifecycle"))] - let handle = tokio::spawn(async move { + let notebook_lock_handle = tokio::spawn(async move { let stream = LifecycleStream::new(notebook_lifecycle); Medium::new(LIFECYCLE_TIME, SIGTERM_FREQ, db, stream, recv.recv()).await; }); @@ -158,6 +158,23 @@ pub async fn start_server(with_tls: bool) -> Result<()> { ) }; + // #[cfg(feature = "moleviewer")] + // { + // todo!(); + // } + + #[cfg(feature = "chemchat")] + let app = { + use self::bridge_middleware::CookieCheck; + + app.service( + web::scope("") + .guard(guard::Host(&CONFIG.chemchat_url)) + .wrap(CookieCheck) + .configure(route::chemchat::config_chemchat), + ) + }; + let app = app.service(actix_files::Files::new("/static", "static")); #[cfg(feature = "notebook")] @@ -207,9 +224,12 @@ pub async fn start_server(with_tls: bool) -> Result<()> { .run() .await?; - if let Some(handler) = redirect_handle { - handler.await??; - } + if let Some(handler) = redirect_handle + && handler.await?.is_err() + { + // error in shutdown for redirect server not a big deal, so just log and move on + tracing::error!("HTTPS redirect server shutdown failed"); + }; } else { server.bind((ip_addr, 8080))?.run().await?; } @@ -219,7 +239,7 @@ pub async fn start_server(with_tls: bool) -> Result<()> { // If the lock was acquired, release it #[cfg(all(feature = "notebook", feature = "lifecycle"))] - handle.await?; + notebook_lock_handle.await?; Ok(()) } diff --git a/src/web/route/auth/mod.rs b/src/web/route/auth/mod.rs index 18730236..3d01c9bc 100644 --- a/src/web/route/auth/mod.rs +++ b/src/web/route/auth/mod.rs @@ -25,20 +25,26 @@ use crate::{ mongo::DB, }, errors::{BridgeError, Result}, - web::helper::{self}, + web::{ + bridge_middleware::CookieCheck, + helper::{self}, + }, }; #[cfg(feature = "observe")] use crate::{config::CONFIG, logger::MESSAGE_DELIMITER}; +pub use self::oauth::generate_token_with_cookie; use self::{ deserialize::CallBackResponse, - oauth::{introspection, jwks, register_app}, + oauth::{get_token, introspection, jwks, register_app}, }; mod deserialize; mod oauth; const NONCE_COOKIE: &str = "nonce"; +pub static TOKEN_LIFETIME: usize = 60 * 60 * 24 * 30; // 24 hours +pub static COOKIE_TOKEN_LIFETIME: usize = 60 * 60 * 24; // 24 hours #[get("/login")] #[instrument] @@ -261,6 +267,7 @@ pub fn config_auth(cfg: &mut web::ServiceConfig) { .service(callback) .service(introspection) .service(register_app) - .service(jwks), + .service(jwks) + .service(web::scope("").wrap(CookieCheck).service(get_token)), ); } diff --git a/src/web/route/auth/oauth.rs b/src/web/route/auth/oauth.rs index 6c43245b..7505bfc1 100644 --- a/src/web/route/auth/oauth.rs +++ b/src/web/route/auth/oauth.rs @@ -1,29 +1,65 @@ -use std::marker::PhantomData; +use std::{marker::PhantomData, str::FromStr}; use actix_web::{ HttpMessage, HttpRequest, HttpResponse, get, http::header::{ContentType, WWW_AUTHENTICATE}, post, - web::{self, Data}, + web::{self, Data, ReqData}, }; use actix_web_httpauth::extractors::{basic::BasicAuth, bearer::BearerAuth}; -use mongodb::bson::doc; +use mongodb::bson::{doc, oid::ObjectId}; use regex::Regex; use serde_json::{Value, json}; use tracing::error; use crate::{ - auth::jwt::validate_token, - config::CONFIG, + auth::jwt::{self, validate_token}, + config::{AUD, CONFIG}, db::{ Database, - models::{APPS, AppPayload, Apps, GroupSubs, USER, User, UserType}, + models::{ + APPS, AppPayload, Apps, BridgeCookie, GROUP, Group, GroupSubs, USER, User, UserType, + }, mongo::{DB, ObjectID}, }, - errors::Result, - web::helper::{self, generate_salt}, + errors::{BridgeError, Result}, + web::{ + helper::{self, generate_salt}, + route::auth::{COOKIE_TOKEN_LIFETIME, TOKEN_LIFETIME}, + }, }; +#[post("token")] +pub async fn get_token( + subject: Option>, + db: Data<&DB>, +) -> Result { + let bc = match subject { + Some(cookie_subject) => cookie_subject.into_inner(), + None => { + return helper::log_with_level!( + Err(BridgeError::UserNotFound( + "subject not passed from middleware".to_string(), + )), + error + ); + } + }; + + let id = + ObjectId::from_str(&bc.subject).map_err(|e| BridgeError::GeneralError(e.to_string()))?; + + let (token, _, _) = generate_token_with_cookie(&id, &bc, &db, COOKIE_TOKEN_LIFETIME).await?; + + let payload = json!({ + "access_token": token, + "token_type": "Bearer", + "expires_in": TOKEN_LIFETIME, + }); + + Ok(HttpResponse::Ok().json(payload)) +} + #[post("introspection")] pub async fn introspection( basic: Option, @@ -177,3 +213,46 @@ fn extract_token(payload: &str) -> Option { re?.captures(payload) .and_then(|cap| cap.get(1).map(|m| m.as_str().to_string())) } + +pub async fn generate_token_with_cookie( + id: &ObjectId, + bc: &BridgeCookie, + db: &DB, + token_lifetime: usize, +) -> Result<(String, String, User)> { + // get information about user + let user: User = helper::log_with_level!( + db.find( + doc! { + "_id": id, + }, + USER, + ) + .await, + error + )?; + + let scp = if user.groups.is_empty() { + vec!["".to_string()] + } else { + // get models + let group: Group = helper::log_with_level!( + db.find( + doc! { + "name": &user.groups[0] + }, + GROUP, + ) + .await, + error + )?; + group.subscriptions + }; + + // Generate bridge token + let (token, exp) = helper::log_with_level!( + jwt::get_token_and_exp(&CONFIG.encoder, token_lifetime, &bc.subject, AUD[0], scp), + error + )?; + Ok((token, exp, user)) +} diff --git a/src/web/route/chemchat/mod.rs b/src/web/route/chemchat/mod.rs new file mode 100644 index 00000000..fa250f53 --- /dev/null +++ b/src/web/route/chemchat/mod.rs @@ -0,0 +1,68 @@ +use std::str::FromStr; + +use actix_web::{ + HttpRequest, HttpResponse, + dev::PeerAddr, + http::Method, + web::{self, ReqData}, +}; +use tracing::instrument; +use url::Url; + +use crate::{ + config::CONFIG, + db::models::BridgeCookie, + errors::{BridgeError, Result}, + web::{ + bridge_middleware::ResourceCookieCheck, + helper::{self, forwarding}, + }, +}; + +use super::resource::resource_http; + +// todo: move this to config +const CHEMCHAT_NAME: &str = "main-api"; + +#[instrument(skip(payload))] +async fn chemchat_forward( + req: HttpRequest, + payload: web::Payload, + method: Method, + peer_addr: Option, + bridge_cookie: Option>, + client: web::Data, +) -> Result { + bridge_cookie + .as_ref() + .and_then(|bc| bc.resources.as_ref()) + .filter(|resources| resources.iter().any(|r| r == CHEMCHAT_NAME)) + .ok_or_else(|| BridgeError::Unauthorized("Access denied to chemchat".to_string()))?; + + let mut url = Url::from_str(&CONFIG.chemchat_internal_url)?; + let path = req.path(); + url.set_path(path); + url.set_query(req.uri().query()); + + helper::forwarding::forward( + req, + payload, + method, + peer_addr, + client, + url, + forwarding::Config { + ..Default::default() + }, + ) + .await +} + +pub fn config_chemchat(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/resource") + .wrap(ResourceCookieCheck) + .default_service(web::to(resource_http)), + ); + cfg.service(web::scope("").default_service(web::to(chemchat_forward))); +} diff --git a/src/web/route/mod.rs b/src/web/route/mod.rs index 473a2bea..f1b33649 100644 --- a/src/web/route/mod.rs +++ b/src/web/route/mod.rs @@ -20,6 +20,8 @@ pub mod mcp; pub mod notebook; #[cfg(feature = "openwebui")] pub mod openwebui; +#[cfg(feature = "chemchat")] +pub mod chemchat; pub mod portal; pub mod proxy; pub mod resource; diff --git a/src/web/route/portal/token.rs b/src/web/route/portal/token.rs index 3d5e1a97..9260a9e2 100644 --- a/src/web/route/portal/token.rs +++ b/src/web/route/portal/token.rs @@ -9,19 +9,18 @@ use mongodb::bson::{doc, oid::ObjectId}; use tera::Tera; use crate::{ - auth::jwt, - config::{AUD, CONFIG}, db::{ Database, - models::{BridgeCookie, GROUP, Group, USER, User}, + models::{BridgeCookie, USER, User}, mongo::DB, }, errors::{BridgeError, Result}, - web::helper::{self, bson}, + web::{ + helper::{self, bson}, + route::auth::{self, TOKEN_LIFETIME}, + }, }; -const TOKEN_LIFETIME: usize = const { 60 * 60 * 24 * 30 }; - #[get("token")] pub async fn get_token_for_user( subject: Option>, @@ -39,43 +38,12 @@ pub async fn get_token_for_user( ); } }; + let id = ObjectId::from_str(&gc.subject).map_err(|e| BridgeError::GeneralError(e.to_string()))?; - // get information about user - let user: User = helper::log_with_level!( - db.find( - doc! { - "_id": id, - }, - USER, - ) - .await, - error - )?; - - let scp = if user.groups.is_empty() { - vec!["".to_string()] - } else { - // get models - let group: Group = helper::log_with_level!( - db.find( - doc! { - "name": &user.groups[0] - }, - GROUP, - ) - .await, - error - )?; - group.subscriptions - }; - - // Generate bridge token - let (token, exp) = helper::log_with_level!( - jwt::get_token_and_exp(&CONFIG.encoder, TOKEN_LIFETIME, &gc.subject, AUD[0], scp), - error - )?; + let (token, exp, user) = + auth::generate_token_with_cookie(&id, &gc, &db, TOKEN_LIFETIME).await?; // store thew newly create token in the database let r = helper::log_with_level!( diff --git a/src/web/route/resource/mod.rs b/src/web/route/resource/mod.rs index 4b079ffc..91cb5cd0 100644 --- a/src/web/route/resource/mod.rs +++ b/src/web/route/resource/mod.rs @@ -29,7 +29,7 @@ use crate::{ static TOKEN_LIFETIME: usize = 60 * 60 * 24; // 24 hours #[instrument(skip(payload, db))] -async fn resource_http( +pub async fn resource_http( req: HttpRequest, payload: web::Payload, db: Data<&DB>,