mod callback_engines;
mod dispatchers;
mod dtos;
mod mcp;
mod middleware;
mod models;
mod modifiers;
mod openapi;
mod openapi_processor;
mod otel;
mod rest;
mod router;
mod rpc;
pub(crate) mod settings;
#[cfg(all(feature = "standalone", feature = "postgres-backend"))]
compile_error!(
"features `standalone` and `postgres-backend` are mutually exclusive; build \
standalone with `--no-default-features --features standalone`"
);
#[cfg(not(any(feature = "standalone", feature = "postgres-backend")))]
compile_error!(
"no persistence backend selected; enable `postgres-backend` (default) or \
build with `--no-default-features --features standalone`"
);
use crate::openapi_processor::initialize_tools_registry;
use actix_cors::Cors;
use actix_web::{
dev::Service,
middleware::{Logger, NormalizePath, TrailingSlash},
web, App, HttpServer,
};
use actix_web_opentelemetry::RequestTracing;
use awc::{error::HeaderValue, Client};
use dispatchers::{
email_dispatcher, services_health_dispatcher, webhook_dispatcher,
};
use models::active_backend_modules::{KVAppModule, SqlAppModule};
use models::config_handler::ConfigHandler;
#[cfg(feature = "postgres-backend")]
use myc_adapters_shared_lib::models::{
SharedAppModule, SharedClientImpl, SharedClientImplParameters,
SharedClientProvider,
};
#[cfg(feature = "postgres-backend")]
use myc_config::init_vault_config_from_file;
use myc_config::optional_config::OptionalConfig;
#[cfg(feature = "standalone")]
use myc_config::resolve_or_generate_standalone_secret;
#[cfg(feature = "standalone")]
use myc_config::secret_resolver::SecretResolver;
use myc_core::{
domain::{
dtos::callback::CallbackExecutor,
entities::{
GuestRoleRegistration, InstanceSettingsFetching,
LocalMessageReading, LocalMessageWrite, RemoteMessageWrite,
ServiceRead,
},
},
models::AccountLifeCycle,
use_cases::{
gateway::guest_roles::propagate_declared_roles_to_storage_engine,
super_users::staff::bootstrap::staff_bootstrap_is_pending,
},
};
#[cfg(feature = "postgres-backend")]
use myc_diesel::repositories::{
DieselDbPoolProvider, DieselDbPoolProviderParameters,
};
#[cfg(feature = "standalone")]
use myc_diesel_sqlite::{
config::{
DieselSqliteDbPoolProvider, DieselSqliteDbPoolProviderParameters,
},
migration::provision_database,
};
#[cfg(feature = "standalone")]
use myc_http_tools::models::internal_auth_config::InternalOauthConfig;
use myc_http_tools::settings::DEFAULT_REQUEST_ID_KEY;
use myc_mem_db::repositories::{
MemDbAppModule, MemDbPoolProvider, MemDbPoolProviderParameters,
};
#[cfg(feature = "standalone")]
use myc_moka_cache::config::{
MokaCacheProviderImpl, MokaCacheProviderImplParameters,
};
#[cfg(feature = "standalone")]
use myc_notifier::repositories::{
select_local_transport, LocalNotifierAppModule,
LocalTransportMessageSendingRepository,
LocalTransportMessageSendingRepositoryParameters,
};
#[cfg(feature = "postgres-backend")]
use myc_notifier::{
models::ClientProvider,
repositories::{
NotifierAppModule, NotifierClientImpl, NotifierClientImplParameters,
},
};
use mycelium_base::utils::errors::MappedErrors;
use oauth2::http::HeaderName;
use openapi::ApiDoc;
use openssl::{
pkey::PKey,
ssl::{SslAcceptor, SslMethod},
x509::X509,
};
use otel::initialize_otel;
use reqwest::header::{
ACCEPT, ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_METHODS,
ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_LENGTH, CONTENT_TYPE,
};
use rest::{
index::{app_public_config_endpoints, heath_check_endpoints},
instance as instance_endpoints,
manager::{
account_endpoints as manager_account_endpoints,
guest_role_endpoints as manager_guest_role_endpoints,
tenant_endpoints as manager_tenant_endpoints,
},
openid::well_known_endpoints,
role_scoped::configure as configure_standard_endpoints,
service::tools_endpoints as service_tools_endpoints,
shared::insert_role_header,
staff::account_endpoints as staff_account_endpoints,
telegram::configure as configure_telegram_endpoints,
};
use router::route_request;
use settings::{ADMIN_API_SCOPE, TOOLS_API_SCOPE};
use shaku::HasComponent;
use std::{path::PathBuf, str::FromStr, sync::Arc, sync::Mutex};
use tracing::{info, trace, Instrument};
use tracing_actix_web::TracingLogger;
use utoipa::OpenApi;
use utoipa_redoc::{FileConfig, Redoc, Servable};
use utoipa_swagger_ui::{oauth, Config, SwaggerUi};
use uuid::Uuid;
#[tokio::main]
pub async fn main() -> std::io::Result<()> {
if let Err(err) = std::env::var("UTOIPA_REDOC_CONFIG_FILE") {
trace!("Error on get env `UTOIPA_REDOC_CONFIG_FILE`: {err}");
info!("Env variable `UTOIPA_REDOC_CONFIG_FILE` not set. Setting default value");
unsafe {
std::env::set_var(
"UTOIPA_REDOC_CONFIG_FILE",
"ports/api/src/openapi/redoc.config.json",
);
}
}
info!("Initializing services configuration");
let env_config_path = match std::env::var("SETTINGS_PATH") {
Ok(path) => path,
Err(err) => panic!("Error on get env `SETTINGS_PATH`: {err}"),
};
let config =
match ConfigHandler::init_from_file(PathBuf::from(env_config_path)) {
Ok(res) => res,
Err(err) => panic!("Error on init config: {err}"),
};
#[cfg(feature = "standalone")]
let config = {
let mut config = config;
let sqlite_path = match config.sqlite.path.async_get_or_error().await {
Ok(path) => path,
Err(err) => panic!("Error on get sqlite path: {err}"),
};
let secrets_dir = std::path::Path::new(&sqlite_path)
.parent()
.map(|parent| parent.join(".secrets"))
.unwrap_or_else(|| PathBuf::from(".secrets"));
let token_secret = match resolve_standalone_secret(
Some(config.core.account_life_cycle.token_secret_resolver()),
"mycelium-standalone",
&secrets_dir,
"token_secret",
)
.await
{
Ok(secret) => secret,
Err(err) => panic!("Error resolving token secret: {err}"),
};
let hmac_secret = match resolve_standalone_secret(
config
.core
.account_life_cycle
.primary_hmac_secret_resolver(),
"mycelium-standalone",
&secrets_dir,
"hmac_secret",
)
.await
{
Ok(secret) => secret,
Err(err) => panic!("Error resolving hmac secret: {err}"),
};
config.core.account_life_cycle = config
.core
.account_life_cycle
.with_token_secret_override(token_secret)
.with_hmac_secret_override(hmac_secret);
if let OptionalConfig::Enabled(internal) = &config.auth.internal {
let jwt_secret = match resolve_standalone_secret(
Some(&internal.jwt_secret),
"mycelium-standalone",
&secrets_dir,
"jwt_secret",
)
.await
{
Ok(secret) => secret,
Err(err) => panic!("Error resolving jwt secret: {err}"),
};
config.auth.internal =
OptionalConfig::Enabled(InternalOauthConfig {
jwt_secret: SecretResolver::Value(jwt_secret),
jwt_expires_in: internal.jwt_expires_in.clone(),
tmp_expires_in: internal.tmp_expires_in.clone(),
});
}
config
};
let api_config = config.api.clone();
info!("Initializing Logging and Telemetry configuration");
let _guard = initialize_otel(api_config.to_owned().logging)?;
let span = tracing::Span::current();
#[cfg(feature = "postgres-backend")]
{
info!("Initializing Vault configs");
init_vault_config_from_file(None, Some(config.vault.to_owned()))
.instrument(span.to_owned())
.await;
}
info!("Initialize internal dependencies");
#[cfg(feature = "postgres-backend")]
let (sql_module, shared_module, notifier_module, kv_module, mem_module) =
initialize_modules(&config.to_owned())
.await
.map_err(|err| {
tracing::error!("Error initializing modules: {err}");
std::io::Error::new(std::io::ErrorKind::Other, err)
})?;
#[cfg(feature = "standalone")]
let (sql_module, notifier_module, kv_module, mem_module) =
initialize_modules(&config.to_owned())
.await
.map_err(|err| {
tracing::error!("Error initializing modules: {err}");
std::io::Error::new(std::io::ErrorKind::Other, err)
})?;
info!("Propagate declared roles to the SQL database");
propagate_declared_roles_to_storage_engine(
Box::new(mem_module.resolve_ref() as &dyn ServiceRead),
Box::new(sql_module.resolve_ref() as &dyn GuestRoleRegistration),
)
.instrument(span.to_owned())
.await
.map_err(|err| {
tracing::error!(
"Error propagating declared roles to the SQL database: {err}"
);
std::io::Error::new(std::io::ErrorKind::Other, err)
})?;
info!("Initializing tools registry");
let tools_registry_schema = initialize_tools_registry(mem_module.clone())
.instrument(span.to_owned())
.await
.map_err(|err| {
tracing::error!("Error initializing tools registry: {err}");
std::io::Error::new(std::io::ErrorKind::Other, err)
})?;
info!("Fire email dispatcher");
email_dispatcher(
config.queue.to_owned(),
unsafe {
Arc::from_raw(*Arc::new(
sql_module.resolve_ref() as &dyn LocalMessageReading
))
},
unsafe {
Arc::from_raw(*Arc::new(
sql_module.resolve_ref() as &dyn LocalMessageWrite
))
},
unsafe {
Arc::from_raw(*Arc::new(
notifier_module.resolve_ref() as &dyn RemoteMessageWrite
))
},
)
.instrument(span.to_owned())
.await;
info!("Fire webhook dispatcher");
webhook_dispatcher(config.core.to_owned(), sql_module.clone())
.instrument(span.to_owned())
.await;
info!("Fire services health dispatcher");
services_health_dispatcher(config.api.clone(), mem_module.clone())
.instrument(span.to_owned())
.await;
info!("Checking staff bootstrap state");
announce_staff_bootstrap_status(
&sql_module,
&config.core.account_life_cycle,
)
.instrument(span.to_owned())
.await;
info!("Startup the server configuration");
let server = HttpServer::new(move || {
let allowed_origins = config.api.allowed_origins.clone();
let forward_api_config = config.api.clone();
let auth_config = config.auth.clone();
let token_config = config.core.account_life_cycle.clone();
let cors = Cors::default()
.allowed_origin_fn(move |origin, _| {
allowed_origins
.contains(&origin.to_str().unwrap_or("").to_string())
})
.expose_headers(vec![
ACCESS_CONTROL_ALLOW_CREDENTIALS,
ACCESS_CONTROL_ALLOW_METHODS,
ACCESS_CONTROL_ALLOW_ORIGIN,
CONTENT_LENGTH,
CONTENT_TYPE,
ACCEPT,
HeaderName::from_str(DEFAULT_REQUEST_ID_KEY).unwrap(),
])
.allow_any_header()
.allow_any_method()
.max_age(3600);
trace!("Configured Cors: {:?}", cors);
let openrpc_spec_config =
rpc::openrpc::OpenRpcSpecConfig::from_api_config(&config.api);
let base_app = App::new()
.wrap(cors)
.wrap(NormalizePath::new(TrailingSlash::MergeOnly))
.wrap(RequestTracing::default())
.wrap(TracingLogger::default())
.app_data(web::Data::new(openrpc_spec_config))
.app_data(web::Data::new(tools_registry_schema.clone()))
.app_data(web::Data::new(token_config).clone())
.app_data(web::Data::new(auth_config.to_owned()).clone())
.app_data(web::Data::from(sql_module.clone()))
.app_data(web::Data::from(notifier_module.clone()))
.app_data(web::Data::from(kv_module.clone()))
.app_data(web::Data::from(mem_module.clone()));
#[cfg(feature = "postgres-backend")]
let base_app =
base_app.app_data(web::Data::from(shared_module.clone()));
let base_app = base_app
.service(
web::scope("/health")
.configure(heath_check_endpoints::configure),
)
.service(
web::scope("/app-config")
.configure(app_public_config_endpoints::configure),
)
.configure(well_known_endpoints::configure)
.service(
web::scope(TOOLS_API_SCOPE)
.configure(service_tools_endpoints::configure),
)
.service(Redoc::with_url_and_config(
"/doc/redoc",
ApiDoc::openapi(),
FileConfig,
))
.service(
SwaggerUi::new("/doc/swagger/{_:.*}")
.url("/doc/openapi.json", ApiDoc::openapi())
.oauth(
oauth::Config::new()
.client_id("client-id")
.scopes(vec![String::from("openid")])
.use_pkce_with_authorization_code_grant(true),
)
.config(
Config::default()
.filter(true)
.show_extensions(true)
.persist_authorization(true)
.show_common_extensions(true)
.request_snippets_enabled(true),
),
)
.wrap(
Logger::default()
.exclude_regex("/health/*")
.exclude_regex("/doc/swagger/*")
.exclude_regex("/doc/redoc/*"),
);
let admin_scope = web::scope(ADMIN_API_SCOPE)
.service(
web::scope(rest::shared::UrlScope::Staffs.str())
.wrap_fn(|req, srv| {
let req = insert_role_header(req, vec![]);
srv.call(req)
})
.configure(staff_account_endpoints::configure),
)
.service(
web::scope(rest::shared::UrlScope::Managers.str())
.wrap_fn(|req, srv| {
let req = insert_role_header(req, vec![]);
srv.call(req)
})
.configure(manager_tenant_endpoints::configure)
.configure(manager_guest_role_endpoints::configure)
.configure(manager_account_endpoints::configure),
)
.service(
web::scope("instance").configure(instance_endpoints::configure),
)
.service(
web::scope("rpc")
.wrap_fn(|req, srv| {
let req = insert_role_header(req, vec![]);
srv.call(req)
})
.configure(rpc::configure),
)
.configure(configure_standard_endpoints);
let final_app = match auth_config.internal {
OptionalConfig::Enabled(config) => {
info!("Configuring Mycelium Internal authentication");
base_app.app_data(web::Data::new(config.clone()))
}
_ => base_app,
};
final_app
.service(admin_scope)
.service(web::scope("/mcp").configure(mcp::endpoints::configure))
.service(
web::scope("/auth/telegram")
.configure(configure_telegram_endpoints),
)
.app_data(web::Data::new(Client::new()))
.app_data(web::Data::new(forward_api_config.to_owned()).clone())
.wrap_fn(|mut req, srv| {
req.headers_mut().insert(
HeaderName::from_str(DEFAULT_REQUEST_ID_KEY).unwrap(),
HeaderValue::from_str(Uuid::new_v4().to_string().as_str())
.unwrap(),
);
srv.call(req)
})
.default_service(web::to(route_request))
});
let address = (
api_config.to_owned().service_ip,
api_config.to_owned().service_port,
);
info!("Listening on Address and Port: {:?}: ", address);
if let OptionalConfig::Enabled(tls_config) = api_config.to_owned().tls {
let api_config = api_config.clone();
info!("Load TLS cert and key");
let mut builder =
SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap();
let cert_pem = match tls_config.tls_cert.async_get_or_error().await {
Ok(path) => path,
Err(err) => panic!("Error on get TLS cert path: {err}"),
};
let cert = X509::from_pem(cert_pem.as_bytes())?;
let key_pem = match tls_config.tls_key.async_get_or_error().await {
Ok(path) => path,
Err(err) => panic!("Error on get TLS key path: {err}"),
};
let key = PKey::private_key_from_pem(key_pem.as_bytes())?;
builder.set_certificate(&cert).unwrap();
builder.set_private_key(&key).unwrap();
info!("Fire the server with TLS");
return server
.bind_openssl(format!("{}:{}", address.0, address.1), builder)?
.workers(api_config.service_workers.to_owned() as usize)
.run()
.await;
}
info!("Fire the server without TLS");
server
.bind(address)?
.workers(api_config.service_workers as usize)
.run()
.await
}
#[cfg(feature = "standalone")]
async fn resolve_standalone_secret(
existing: Option<&SecretResolver<String>>,
keyring_service: &str,
secrets_dir: &std::path::Path,
name: &str,
) -> Result<String, MappedErrors> {
if let Some(resolver @ SecretResolver::Env(_)) = existing {
return resolver.async_get_or_error().await;
}
resolve_or_generate_standalone_secret(keyring_service, secrets_dir, name)
}
async fn announce_staff_bootstrap_status(
sql_module: &SqlAppModule,
account_life_cycle: &AccountLifeCycle,
) {
let is_pending = match staff_bootstrap_is_pending(Box::new(
sql_module.resolve_ref() as &dyn InstanceSettingsFetching,
))
.await
{
Ok(is_pending) => is_pending,
Err(err) => {
tracing::error!(
%err,
"staff bootstrap unavailable this boot (instance_settings \
table missing or unreachable) -- gateway continues without it"
);
return;
}
};
if !is_pending {
return;
}
let Some(secret_resolver) =
account_life_cycle.staff_bootstrap_secret.as_ref()
else {
return;
};
if secret_resolver.async_get_or_error().await.is_err() {
tracing::error!(
"staff_bootstrap_secret is configured but failed to resolve"
);
return;
}
let claim_url = match account_life_cycle.domain_url.clone() {
Some(resolver) => resolver.async_get_or_error().await.ok().map(|url| {
format!(
"{}/{}/instance/bootstrap",
url.trim_end_matches('/'),
ADMIN_API_SCOPE,
)
}),
None => None,
};
match claim_url {
Some(url) => info!(
claim_url = %url,
"staff bootstrap pending -- visit this URL with your configured \
bootstrap secret to claim the initial staff account"
),
None => info!(
"staff bootstrap pending -- configure `domainUrl` to see the \
full claim URL logged here"
),
}
}
async fn build_mem_db_module(config: &ConfigHandler) -> Arc<MemDbAppModule> {
for service in config.api.services.clone() {
trace!("Service: {:?}", service);
}
let callbacks = config.api.to_owned().callbacks.clone().unwrap_or_default();
let mut engines: Vec<Arc<dyn CallbackExecutor>> = Vec::new();
for callback in &callbacks {
match callback_engines::create_engine_from_callback(callback) {
Ok(engine) => {
tracing::debug!(
"Created engine for callback '{}' (type: {:?})",
callback.name,
callback.callback_type
);
engines.push(engine);
}
Err(e) => {
tracing::warn!(
"Failed to create engine for callback '{}': {e}",
callback.name,
);
}
}
}
Arc::new(
MemDbAppModule::builder()
.with_component_parameters::<MemDbPoolProvider>(
MemDbPoolProviderParameters {
services_db: Arc::new(Mutex::new(
config.api.services.clone(),
)),
callbacks_db: Arc::new(Mutex::new(callbacks)),
engines: Arc::new(Mutex::new(engines)),
mode: Arc::new(Mutex::new(
config.api.to_owned().callback_execution_mode.clone(),
)),
},
)
.build(),
)
}
#[cfg(feature = "postgres-backend")]
async fn initialize_modules(
config: &ConfigHandler,
) -> Result<
(
Arc<SqlAppModule>,
Arc<SharedAppModule>,
Arc<NotifierAppModule>,
Arc<KVAppModule>,
Arc<MemDbAppModule>,
),
MappedErrors,
> {
let sql_module = Arc::new(
SqlAppModule::builder()
.with_component_parameters::<DieselDbPoolProvider>(
DieselDbPoolProviderParameters {
pool: DieselDbPoolProvider::new(
&match config
.diesel
.database_url
.async_get_or_error()
.await
{
Ok(url) => url,
Err(err) => {
panic!("Error on get database url: {err}")
}
}
.as_str(),
),
},
)
.build(),
);
let shared_provider =
match SharedClientImpl::new(config.redis.to_owned()).await {
Ok(provider) => provider,
Err(err) => panic!("Error on initialize shared provider: {err}"),
};
let shared_module = Arc::new(
SharedAppModule::builder()
.with_component_parameters::<SharedClientImpl>(
SharedClientImplParameters {
redis_client: shared_provider.get_redis_client(),
redis_config: shared_provider.get_redis_config(),
},
)
.build(),
);
let notifier_provider = match NotifierClientImpl::new(
config.queue.to_owned(),
config.redis.to_owned(),
config.smtp.to_owned(),
)
.await
{
Ok(provider) => provider,
Err(err) => panic!("Error on initialize notifier provider: {err}"),
};
let notifier_module = Arc::new(
NotifierAppModule::builder()
.with_component_parameters::<SharedClientImpl>(
SharedClientImplParameters {
redis_client: shared_provider.get_redis_client(),
redis_config: shared_provider.get_redis_config(),
},
)
.with_component_parameters::<NotifierClientImpl>(
NotifierClientImplParameters {
smtp_client: notifier_provider.get_smtp_client(),
queue_config: notifier_provider.get_queue_config(),
},
)
.build(),
);
let kv_module = Arc::new(
KVAppModule::builder()
.with_component_parameters::<SharedClientImpl>(
SharedClientImplParameters {
redis_client: shared_provider.get_redis_client(),
redis_config: shared_provider.get_redis_config(),
},
)
.build(),
);
let mem_module = build_mem_db_module(config).await;
Ok((
sql_module,
shared_module,
notifier_module,
kv_module,
mem_module,
))
}
#[cfg(feature = "standalone")]
async fn initialize_modules(
config: &ConfigHandler,
) -> Result<
(
Arc<SqlAppModule>,
Arc<LocalNotifierAppModule>,
Arc<KVAppModule>,
Arc<MemDbAppModule>,
),
MappedErrors,
> {
let sqlite_path = match config.sqlite.path.async_get_or_error().await {
Ok(path) => path,
Err(err) => panic!("Error on get sqlite path: {err}"),
};
if let Err(err) = provision_database(&sqlite_path) {
panic!("Error provisioning SQLite database: {err}");
}
let sql_module = Arc::new(
SqlAppModule::builder()
.with_component_parameters::<DieselSqliteDbPoolProvider>(
DieselSqliteDbPoolProviderParameters {
pool: DieselSqliteDbPoolProvider::new(&sqlite_path),
},
)
.build(),
);
let kv_module = Arc::new(
KVAppModule::builder()
.with_component_parameters::<MokaCacheProviderImpl>(
MokaCacheProviderImplParameters {
cache: MokaCacheProviderImpl::new().cache,
},
)
.build(),
);
let smtp_transport = match config.smtp.to_owned() {
OptionalConfig::Enabled(smtp_config) => {
match smtp_config.build_transport().await {
Ok(transport) => Some(transport),
Err(err) => panic!("Error building SMTP transport: {err}"),
}
}
OptionalConfig::Disabled => None,
};
let notifier_module = Arc::new(
LocalNotifierAppModule::builder()
.with_component_parameters::<LocalTransportMessageSendingRepository>(
LocalTransportMessageSendingRepositoryParameters {
transport: select_local_transport(smtp_transport, None),
},
)
.build(),
);
let mem_module = build_mem_db_module(config).await;
Ok((sql_module, notifier_module, kv_module, mem_module))
}