diff --git a/Cargo.lock b/Cargo.lock index 9d384d8dac..d017324096 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5642,6 +5642,42 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "scufflecloud-big-bin" +version = "0.1.0" +dependencies = [ + "anyhow", + "diesel", + "diesel-async", + "fred", + "ipnetwork", + "itertools 0.14.0", + "reqsign", + "reqwest", + "rustls", + "scuffle-batching", + "scuffle-bootstrap", + "scuffle-bootstrap-telemetry", + "scuffle-settings", + "scuffle-signal", + "scufflecloud-core", + "scufflecloud-core-db-types", + "scufflecloud-core-traits", + "scufflecloud-email", + "scufflecloud-email-traits", + "scufflecloud-geo-ip", + "scufflecloud-proto", + "serde", + "serde_derive", + "smart-default", + "tokio", + "tonic", + "tracing", + "tracing-subscriber", + "url", + "webauthn-rs", +] + [[package]] name = "scufflecloud-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2f8eac23cb..8224718c6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "cloud/big-bin", "cloud/core", "cloud/core/cedar", "cloud/core/db-types", diff --git a/Justfile b/Justfile index 6c81d4e91c..9b7984b4e5 100644 --- a/Justfile +++ b/Justfile @@ -43,6 +43,8 @@ run bin *args: bazel run //cloud/core:bin -- {{ args }} elif [ {{ bin }} == "email" ]; then bazel run //cloud/email:bin -- {{ args }} + elif [ {{ bin }} == "big-bin" ]; then + bazel run //cloud/big-bin -- {{ args }} else echo "Unknown binary: {{ bin }}" exit 1 @@ -63,7 +65,7 @@ generate-mtls-certs: openssl genpkey -out local/mtls/scufflecloud_core_key.pem -algorithm ED25519 openssl req -new -key local/mtls/scufflecloud_core_key.pem \ -subj "/CN=scufflecloud-core-mtls" \ - -addext "subjectAltName=DNS:localhost" \ + -addext "subjectAltName=DNS:localhost,DNS:127.0.0.1" \ -out local/mtls/scufflecloud_core_csr.pem # Sign core cert with root CA @@ -79,7 +81,7 @@ generate-mtls-certs: openssl genpkey -out local/mtls/scufflecloud_email_key.pem -algorithm ED25519 openssl req -new -key local/mtls/scufflecloud_email_key.pem \ -subj "/CN=scufflecloud-email-mtls" \ - -addext "subjectAltName=DNS:localhost" \ + -addext "subjectAltName=DNS:localhost,DNS:127.0.0.1" \ -out local/mtls/scufflecloud_email_csr.pem # Sign email cert with root CA diff --git a/cargo_targets.bzl b/cargo_targets.bzl index ff8e182484..b778ce9e40 100644 --- a/cargo_targets.bzl +++ b/cargo_targets.bzl @@ -1,4 +1,5 @@ _packages = [ + "//cloud/big-bin", "//cloud/ext-traits", "//cloud/core", "//cloud/core/cedar", diff --git a/cloud/big-bin/BUILD.bazel b/cloud/big-bin/BUILD.bazel new file mode 100644 index 0000000000..45286f29be --- /dev/null +++ b/cloud/big-bin/BUILD.bazel @@ -0,0 +1,31 @@ +load("//misc/utils/rust:manifest.bzl", "cargo_toml") +load("//misc/utils/rust:package.bzl", "scuffle_package") + +cargo_toml() + +scuffle_package( + aliases = { + "//cloud/proto": "pb", + "//cloud/core/db-types": "core_db_types", + "//cloud/geo-ip": "geo_ip", + "//cloud/core/traits": "core_traits", + "//cloud/email": "email", + "//cloud/email/traits": "email_traits", + }, + crate_name = "scufflecloud-big-bin", + crate_type = "bin", + deps = [ + "//cloud/core", + "//cloud/core/db-types", + "//cloud/core/traits", + "//cloud/email", + "//cloud/email/traits", + "//cloud/geo-ip", + "//cloud/proto", + "//crates/batching", + "//crates/bootstrap", + "//crates/bootstrap-telemetry", + "//crates/settings", + "//crates/signal", + ], +) diff --git a/cloud/big-bin/Cargo.toml b/cloud/big-bin/Cargo.toml new file mode 100644 index 0000000000..2a9fc182ab --- /dev/null +++ b/cloud/big-bin/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "scufflecloud-big-bin" +version = "0.1.0" +authors = ["Scuffle "] +edition = "2024" +license = "AGPL-3.0" +publish = false +repository = "https://github.com/scufflecloud/scuffle" +description = "Big binary for scuffle.cloud" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } + +[dependencies] +anyhow = "1.0.98" +core-db-types = { path = "../core/db-types", package = "scufflecloud-core-db-types" } +core-traits = { path = "../core/traits", package = "scufflecloud-core-traits" } +diesel = "2.2.10" +diesel-async = "0.6.1" +email = { path = "../email", package = "scufflecloud-email" } +email-traits = { path = "../email/traits", package = "scufflecloud-email-traits" } +fred = "10.1.0" +geo-ip = { path = "../geo-ip", package = "scufflecloud-geo-ip" } +ipnetwork = { features = ["serde"], version = "0.21.1" } +itertools = "0.14.0" +pb = { path = "../proto", package = "scufflecloud-proto" } +reqsign = { version = "0.17.0", default-features = false, features = ["aws"] } +reqwest = { features = ["rustls-tls-native-roots-no-provider"], version = "0.12.23", default-features = false } +rustls = { default-features = false, version = "0.23.31", features = ["aws_lc_rs"] } +scuffle-batching = { path = "../../crates/batching" } +scuffle-bootstrap = { path = "../../crates/bootstrap" } +scuffle-bootstrap-telemetry = { features = ["opentelemetry-logs", "opentelemetry-traces"], path = "../../crates/bootstrap-telemetry" } +scuffle-settings = { path = "../../crates/settings" } +scuffle-signal = { features = ["bootstrap"], path = "../../crates/signal" } +scufflecloud-core = { path = "../core", package = "scufflecloud-core" } # cannot be called core because it would override the rust core crate +serde = "1.0.219" +serde_derive = "1.0.219" +smart-default = "0.7.1" +tokio = { default-features = false, features = ["sync"], version = "1.47.1" } +tonic = "0.14.1" +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } +url = "2.5.4" +webauthn-rs = "0.5.2" + +[package.metadata.sync-readme.badges] +docs-rs = false +crates-io = false +license = true +codecov = true diff --git a/cloud/big-bin/LICENSE.AGPL-3.0 b/cloud/big-bin/LICENSE.AGPL-3.0 new file mode 120000 index 0000000000..65d20c44a3 --- /dev/null +++ b/cloud/big-bin/LICENSE.AGPL-3.0 @@ -0,0 +1 @@ +../../LICENSE.AGPL-3.0 \ No newline at end of file diff --git a/cloud/big-bin/README.md b/cloud/big-bin/README.md new file mode 100644 index 0000000000..eaa0058385 --- /dev/null +++ b/cloud/big-bin/README.md @@ -0,0 +1,15 @@ + + +# scufflecloud-big-bin + + + +![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-purple.svg?style=flat-square) +[![Codecov](https://img.shields.io/codecov/c/github/scufflecloud/scuffle.svg?label=codecov&logo=codecov&style=flat-square)](https://app.codecov.io/gh/scufflecloud/scuffle) + + +--- + + +Big binary for scuffle.cloud that contains all services. + diff --git a/cloud/big-bin/src/config.rs b/cloud/big-bin/src/config.rs new file mode 100644 index 0000000000..4bae40adb5 --- /dev/null +++ b/cloud/big-bin/src/config.rs @@ -0,0 +1,180 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::str::FromStr; + +use anyhow::Context; +use fred::prelude::ClientLike; + +#[derive(serde_derive::Deserialize, smart_default::SmartDefault, Debug, Clone)] +#[serde(default)] +pub(crate) struct Config { + #[default(env!("CARGO_PKG_NAME").to_string())] + pub service_name: String, + #[default(SocketAddr::from(([127, 0, 0, 1], 3001)))] + pub core_bind: SocketAddr, + #[default(SocketAddr::from(([127, 0, 0, 1], 3003)))] + pub email_bind: SocketAddr, + #[default = "info"] + pub level: String, + #[default(None)] + pub db_url: Option, + #[default(false)] + pub swagger_ui: bool, + #[default = "scuffle.cloud"] + pub rp_id: String, + #[default(url::Url::from_str("https://dashboard.scuffle.cloud").unwrap())] + pub dashboard_origin: url::Url, + #[default = "1x0000000000000000000000000000000AA"] + pub turnstile_secret_key: String, + pub timeouts: TimeoutConfig, + pub google_oauth2: GoogleOAuth2Config, + pub telemetry: Option, + pub redis: RedisConfig, + #[default = "Scuffle"] + pub email_from_name: String, + #[default = "no-reply@scuffle.cloud"] + pub email_from_address: String, + pub reverse_proxy: Option, + #[default("./GeoLite2-City.mmdb".parse().unwrap())] + pub maxminddb_path: PathBuf, + pub aws: AwsConfig, + pub mtls: MtlsConfig, +} + +scuffle_settings::bootstrap!(Config); + +const fn days(days: u64) -> std::time::Duration { + hours(days * 24) +} + +const fn hours(hours: u64) -> std::time::Duration { + minutes(hours * 60) +} + +const fn minutes(mins: u64) -> std::time::Duration { + std::time::Duration::from_secs(mins * 60) +} + +#[derive(serde_derive::Deserialize, smart_default::SmartDefault, Debug, Clone)] +#[serde(default)] +pub(crate) struct TimeoutConfig { + #[default(minutes(2))] + pub max_request_lifetime: std::time::Duration, + #[default(days(30))] + pub user_session: std::time::Duration, + #[default(minutes(5))] + pub mfa: std::time::Duration, + #[default(hours(4))] + pub user_session_token: std::time::Duration, + #[default(hours(1))] + pub new_user_email_request: std::time::Duration, + #[default(minutes(5))] + pub user_session_request: std::time::Duration, + #[default(minutes(15))] + pub magic_link_request: std::time::Duration, +} + +#[derive(serde_derive::Deserialize, smart_default::SmartDefault, Debug, Clone)] +pub(crate) struct GoogleOAuth2Config { + pub client_id: String, + pub client_secret: String, +} + +#[derive(serde_derive::Deserialize, smart_default::SmartDefault, Debug, Clone)] +pub(crate) struct TelemetryConfig { + #[default("[::1]:4317".parse().unwrap())] + pub bind: SocketAddr, +} + +#[derive(serde_derive::Deserialize, smart_default::SmartDefault, Debug, Clone)] +pub(crate) struct RedisConfig { + #[default(vec!["localhost:6379".to_string()])] + pub servers: Vec, + #[default(None)] + pub username: Option, + #[default(None)] + pub password: Option, + #[default(0)] + pub database: u8, + #[default(10)] + pub pool_size: usize, +} + +fn parse_server(server: &str) -> anyhow::Result { + let port_ip = server.split(':').collect::>(); + + if port_ip.len() == 1 { + Ok(fred::types::config::Server::new(port_ip[0], 6379)) + } else { + Ok(fred::types::config::Server::new( + port_ip[0], + port_ip[1].parse::().context("invalid port")?, + )) + } +} + +impl RedisConfig { + pub(crate) async fn setup(&self) -> anyhow::Result { + let redis_server_config = if self.servers.len() == 1 { + fred::types::config::ServerConfig::Centralized { + server: parse_server(&self.servers[0])?, + } + } else { + fred::types::config::ServerConfig::Clustered { + hosts: self + .servers + .iter() + .map(|s| parse_server(s)) + .collect::>>()?, + policy: Default::default(), + } + }; + + tracing::info!(config = ?redis_server_config, "connecting to redis"); + + let config = fred::types::config::Config { + server: redis_server_config, + database: Some(self.database), + fail_fast: true, + password: self.password.clone(), + username: self.username.clone(), + ..Default::default() + }; + + let client = fred::clients::Pool::new(config, None, None, None, self.pool_size).context("redis pool")?; + client.init().await?; + + Ok(client) + } +} + +#[derive(serde_derive::Deserialize, smart_default::SmartDefault, Debug, Clone)] +pub(crate) struct ReverseProxyConfig { + /// List of networks that bypass the IP address extraction from the configured IP header. + /// These are typically internal networks and other services that directly connect to the server without going + /// through the reverse proxy. + pub internal_networks: Vec, + #[default("x-forwarded-for".to_string())] + pub ip_header: String, + /// List of trusted proxy networks that the server accepts connections from. + /// These are typically the networks of the reverse proxies in front of the server, e.g. Cloudflare, etc. + pub trusted_proxies: Vec, +} + +#[derive(serde_derive::Deserialize, smart_default::SmartDefault, Debug, Clone)] +pub(crate) struct AwsConfig { + #[default = "us-east-1"] + pub region: String, + pub access_key_id: String, + pub secret_access_key: String, +} + +// TODO: Remove mTLS from this binary once we don't use a real connection anymore. +#[derive(serde_derive::Deserialize, smart_default::SmartDefault, Debug, Clone)] +pub(crate) struct MtlsConfig { + pub root_cert_path: PathBuf, + pub core_cert_path: PathBuf, + pub core_key_path: PathBuf, + pub email_cert_path: PathBuf, + pub email_key_path: PathBuf, +} diff --git a/cloud/big-bin/src/dataloaders.rs b/cloud/big-bin/src/dataloaders.rs new file mode 100644 index 0000000000..d67fa099ed --- /dev/null +++ b/cloud/big-bin/src/dataloaders.rs @@ -0,0 +1,5 @@ +mod users; +pub(crate) use users::*; + +mod organizations; +pub(crate) use organizations::*; diff --git a/cloud/big-bin/src/dataloaders/organizations.rs b/cloud/big-bin/src/dataloaders/organizations.rs new file mode 100644 index 0000000000..cf7f37acc1 --- /dev/null +++ b/cloud/big-bin/src/dataloaders/organizations.rs @@ -0,0 +1,74 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use core_db_types::models::{Organization, OrganizationId, OrganizationMember, UserId}; +use core_db_types::schema::{organization_members, organizations}; +use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; +use diesel_async::pooled_connection::bb8; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use itertools::Itertools; +use scuffle_batching::{DataLoader, DataLoaderFetcher}; + +pub(crate) struct OrganizationLoader(bb8::Pool); + +impl DataLoaderFetcher for OrganizationLoader { + type Key = OrganizationId; + type Value = Organization; + + async fn load(&self, keys: HashSet) -> Option> { + let mut conn = self + .0 + .get() + .await + .map_err(|e| tracing::error!(err = %e, "failed to get connection")) + .ok()?; + + let organizations = organizations::dsl::organizations + .filter(organizations::dsl::id.eq_any(keys)) + .select(Organization::as_select()) + .load::(&mut conn) + .await + .map_err(|e| tracing::error!(err = %e, "failed to load organizations")) + .ok()?; + + Some(organizations.into_iter().map(|o| (o.id, o)).collect()) + } +} + +impl OrganizationLoader { + pub(crate) fn new(pool: bb8::Pool) -> DataLoader { + DataLoader::new(Self(pool), 1000, 500, Duration::from_millis(5)) + } +} + +pub(crate) struct OrganizationMemberByUserIdLoader(bb8::Pool); + +impl DataLoaderFetcher for OrganizationMemberByUserIdLoader { + type Key = UserId; + type Value = Vec; + + async fn load(&self, keys: HashSet) -> Option> { + let mut conn = self + .0 + .get() + .await + .map_err(|e| tracing::error!(err = %e, "failed to get connection")) + .ok()?; + + let organization_members = organization_members::dsl::organization_members + .filter(organization_members::dsl::user_id.eq_any(keys)) + .select(OrganizationMember::as_select()) + .load::(&mut conn) + .await + .map_err(|e| tracing::error!(err = %e, "failed to load organization members")) + .ok()?; + + Some(organization_members.into_iter().into_group_map_by(|m| m.user_id)) + } +} + +impl OrganizationMemberByUserIdLoader { + pub(crate) fn new(pool: bb8::Pool) -> DataLoader { + DataLoader::new(Self(pool), 1000, 500, Duration::from_millis(5)) + } +} diff --git a/cloud/big-bin/src/dataloaders/users.rs b/cloud/big-bin/src/dataloaders/users.rs new file mode 100644 index 0000000000..5f3876902b --- /dev/null +++ b/cloud/big-bin/src/dataloaders/users.rs @@ -0,0 +1,41 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use core_db_types::models::{User, UserId}; +use core_db_types::schema::users; +use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; +use diesel_async::pooled_connection::bb8; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use scuffle_batching::{DataLoader, DataLoaderFetcher}; + +pub(crate) struct UserLoader(bb8::Pool); + +impl DataLoaderFetcher for UserLoader { + type Key = UserId; + type Value = User; + + async fn load(&self, keys: HashSet) -> Option> { + let mut conn = self + .0 + .get() + .await + .map_err(|e| tracing::error!(err = %e, "failed to get connection")) + .ok()?; + + let users = users::dsl::users + .filter(users::dsl::id.eq_any(keys)) + .select(User::as_select()) + .load::(&mut conn) + .await + .map_err(|e| tracing::error!(err = %e, "failed to load users")) + .ok()?; + + Some(users.into_iter().map(|u| (u.id, u)).collect()) + } +} + +impl UserLoader { + pub(crate) fn new(pool: bb8::Pool) -> DataLoader { + DataLoader::new(Self(pool), 1000, 500, Duration::from_millis(5)) + } +} diff --git a/cloud/big-bin/src/main.rs b/cloud/big-bin/src/main.rs new file mode 100644 index 0000000000..c0fa1c3877 --- /dev/null +++ b/cloud/big-bin/src/main.rs @@ -0,0 +1,368 @@ +//! Big binary for scuffle.cloud that contains all services. +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +// #![deny(missing_docs)] +#![deny(unsafe_code)] +#![deny(unreachable_pub)] +#![deny(clippy::mod_module_files)] + +use std::sync::Arc; + +use anyhow::Context; +use diesel_async::pooled_connection::bb8; +use geo_ip::resolver::GeoIpResolver; +use scuffle_batching::DataLoader; +use scuffle_bootstrap_telemetry::opentelemetry; +use scuffle_bootstrap_telemetry::opentelemetry_sdk::logs::SdkLoggerProvider; +use scuffle_bootstrap_telemetry::opentelemetry_sdk::trace::SdkTracerProvider; +use tonic::transport::ClientTlsConfig; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; + +mod config; +mod dataloaders; + +type EmailClientPb = pb::scufflecloud::email::v1::email_service_client::EmailServiceClient; + +struct Global { + config: config::Config, + database: bb8::Pool, + user_loader: DataLoader, + organization_loader: DataLoader, + organization_member_by_user_id_loader: DataLoader, + external_http_client: reqwest::Client, + webauthn: webauthn_rs::Webauthn, + open_telemetry: opentelemetry::OpenTelemetry, + redis: fred::clients::Pool, + email_service_client: EmailClientPb, + geoip_resolver: GeoIpResolver, + aws_ses_req_signer: reqsign::Signer, + mtls_root_cert: Vec, + mtls_core_cert: Vec, + mtls_core_private_key: Vec, + mtls_email_cert: Vec, + mtls_email_private_key: Vec, +} + +impl scuffle_signal::SignalConfig for Global {} + +impl core_traits::ConfigInterface for Global { + fn dashboard_origin(&self) -> &url::Url { + &self.config.dashboard_origin + } + + fn email_from_name(&self) -> &str { + &self.config.email_from_name + } + + fn email_from_address(&self) -> &str { + &self.config.email_from_address + } + + fn google_oauth2_config(&self) -> core_traits::GoogleOAuth2Config<'_> { + core_traits::GoogleOAuth2Config { + client_id: self.config.google_oauth2.client_id.as_str().into(), + client_secret: self.config.google_oauth2.client_secret.as_str().into(), + } + } + + fn service_bind(&self) -> std::net::SocketAddr { + self.config.core_bind + } + + fn swagger_ui_enabled(&self) -> bool { + self.config.swagger_ui + } + + fn timeout_config(&self) -> core_traits::TimeoutConfig { + core_traits::TimeoutConfig { + new_user_email_request: self.config.timeouts.new_user_email_request, + magic_link_request: self.config.timeouts.magic_link_request, + max_request: self.config.timeouts.max_request_lifetime, + mfa: self.config.timeouts.mfa, + user_session: self.config.timeouts.user_session, + user_session_request: self.config.timeouts.user_session_request, + user_session_token: self.config.timeouts.user_session_token, + } + } + + fn turnstile_secret_key(&self) -> &str { + &self.config.turnstile_secret_key + } +} + +impl core_traits::DatabaseInterface for Global { + type Connection<'a> + = diesel_async::pooled_connection::bb8::PooledConnection<'a, diesel_async::pg::AsyncPgConnection> + where + Self: 'a; + + async fn db(&self) -> anyhow::Result> { + self.database.get().await.context("failed to get database connection") + } +} + +#[allow(refining_impl_trait)] +impl core_traits::DataloaderInterface for Global { + fn organization_loader(&self) -> &DataLoader { + &self.organization_loader + } + + fn user_loader(&self) -> &DataLoader { + &self.user_loader + } + + fn organization_member_by_user_id_loader(&self) -> &DataLoader { + &self.organization_member_by_user_id_loader + } +} + +impl core_traits::HttpClientInterface for Global { + fn external_http_client(&self) -> &reqwest::Client { + &self.external_http_client + } +} + +impl geo_ip::GeoIpInterface for Global { + fn geo_ip_resolver(&self) -> &GeoIpResolver { + &self.geoip_resolver + } + + fn reverse_proxy_config(&self) -> Option> { + let config = self.config.reverse_proxy.as_ref()?; + Some(geo_ip::ReverseProxyConfig { + internal_networks: config.internal_networks.as_slice().into(), + ip_header: config.ip_header.as_str().into(), + trusted_proxies: config.trusted_proxies.as_slice().into(), + }) + } +} + +impl core_traits::EmailInterface for Global { + fn email_service(&self) -> impl core_traits::EmailServiceClient { + struct EmailServiceClient<'a>(&'a EmailClientPb); + + impl core_traits::EmailServiceClient for EmailServiceClient<'_> { + fn send_email( + &self, + email: impl tonic::IntoRequest, + ) -> impl Future, tonic::Status>> + Send { + let email = email.into_request(); + let mut client = self.0.clone(); + async move { client.send_email(email).await } + } + } + + EmailServiceClient(&self.email_service_client) + } +} + +impl core_traits::RedisInterface for Global { + type RedisConnection<'a> + = fred::clients::Pool + where + Self: 'a; + + fn redis(&self) -> &Self::RedisConnection<'_> { + &self.redis + } +} + +impl core_traits::WebAuthnInterface for Global { + fn webauthn(&self) -> &webauthn_rs::Webauthn { + &self.webauthn + } +} + +impl core_traits::MtlsInterface for Global { + fn mtls_root_cert_pem(&self) -> &[u8] { + &self.mtls_root_cert + } + + fn mtls_cert_pem(&self) -> &[u8] { + &self.mtls_core_cert + } + + fn mtls_private_key_pem(&self) -> &[u8] { + &self.mtls_core_private_key + } +} + +impl core_traits::Global for Global {} + +impl email_traits::ConfigInterface for Global { + fn service_bind(&self) -> std::net::SocketAddr { + self.config.email_bind + } +} + +impl email_traits::AwsInterface for Global { + fn aws_region(&self) -> &str { + &self.config.aws.region + } + + fn aws_ses_req_signer(&self) -> &reqsign::Signer { + &self.aws_ses_req_signer + } +} + +impl email_traits::HttpClientInterface for Global { + fn external_http_client(&self) -> &reqwest::Client { + &self.external_http_client + } +} + +impl email_traits::MtlsInterface for Global { + fn mtls_root_cert_pem(&self) -> &[u8] { + &self.mtls_root_cert + } + + fn mtls_cert_pem(&self) -> &[u8] { + &self.mtls_email_cert + } + + fn mtls_private_key_pem(&self) -> &[u8] { + &self.mtls_email_private_key + } +} + +impl email_traits::Global for Global {} + +impl scuffle_bootstrap_telemetry::TelemetryConfig for Global { + fn enabled(&self) -> bool { + self.config.telemetry.is_some() + } + + fn bind_address(&self) -> Option { + self.config.telemetry.as_ref().map(|telemetry| telemetry.bind) + } + + fn http_server_name(&self) -> &str { + "scufflecloud-telemetry" + } + + fn opentelemetry(&self) -> Option<&opentelemetry::OpenTelemetry> { + Some(&self.open_telemetry) + } +} + +impl scuffle_bootstrap::Global for Global { + type Config = config::Config; + + async fn init(config: Self::Config) -> anyhow::Result> { + tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer() + .with_filter(tracing_subscriber::EnvFilter::from_default_env().add_directive(config.level.parse()?)), + ) + .init(); + + if rustls::crypto::aws_lc_rs::default_provider().install_default().is_err() { + anyhow::bail!("failed to install aws-lc-rs as default TLS provider"); + } + + let maxminddb_data = tokio::fs::read(&config.maxminddb_path) + .await + .context("failed to read maxmind db path")?; + let geoip_resolver = GeoIpResolver::new_from_data(maxminddb_data).context("failed to parse maxmind db")?; + + // TODO: Remove mTLS from this binary once we don't use a real connection anymore. + // mTLS + let root_cert = std::fs::read(&config.mtls.root_cert_path).context("failed to read mTLS root cert file")?; + let core_cert = std::fs::read(&config.mtls.core_cert_path).context("failed to read core mTLS cert file")?; + let core_private_key = + std::fs::read(&config.mtls.core_key_path).context("failed to read core mTLS private key file")?; + let email_cert = std::fs::read(&config.mtls.email_cert_path).context("failed to read email mTLS cert file")?; + let email_private_key = + std::fs::read(&config.mtls.email_key_path).context("failed to read email mTLS private key file")?; + + let client_tls_config = ClientTlsConfig::new() + .ca_certificate(tonic::transport::Certificate::from_pem(&root_cert)) + .identity(tonic::transport::Identity::from_pem(&core_cert, &core_private_key)); + + let email_service_address = format!("http://{}", config.email_bind); + // Connect lazily because the service isn't up yet. + let email_service_channel = tonic::transport::Endpoint::from_shared(email_service_address) + .context("create channel to email service")? + .tls_config(client_tls_config) + .context("configure TLS for email service channel")? + .connect_lazy(); + let email_service_client = + pb::scufflecloud::email::v1::email_service_client::EmailServiceClient::new(email_service_channel); + + let Some(db_url) = config.db_url.as_deref() else { + anyhow::bail!("DATABASE_URL is not set"); + }; + + tracing::info!(db_url = config.db_url, "creating database connection pool"); + + let database = bb8::Pool::builder() + .build(diesel_async::pooled_connection::AsyncDieselConnectionManager::new(db_url)) + .await + .context("build database pool")?; + + let user_loader = dataloaders::UserLoader::new(database.clone()); + let organization_loader = dataloaders::OrganizationLoader::new(database.clone()); + let organization_member_by_user_id_loader = dataloaders::OrganizationMemberByUserIdLoader::new(database.clone()); + + // TODO: find someway to restrict this client to only making requests to external ips. + // likely via dns. + let external_http_client = reqwest::Client::builder() + .user_agent(&config.service_name) + .tls_built_in_root_certs(true) + .use_rustls_tls() + .build() + .context("create HTTP client")?; + + let webauthn = webauthn_rs::WebauthnBuilder::new(&config.rp_id, &config.dashboard_origin) + .context("build webauthn")? + .allow_subdomains(true) + .timeout(config.timeouts.mfa) + .build() + .context("initialize webauthn")?; + + let tracer = SdkTracerProvider::default(); + opentelemetry::global::set_tracer_provider(tracer.clone()); + + let logger = SdkLoggerProvider::builder().build(); + + let open_telemetry = crate::opentelemetry::OpenTelemetry::new() + .with_traces(tracer) + .with_logs(logger); + + let redis = config.redis.setup().await?; + + let provider = reqsign::aws::StaticCredentialProvider::new(&config.aws.access_key_id, &config.aws.secret_access_key); + let signer = reqsign::aws::RequestSigner::new("ses", &config.aws.region); + let aws_ses_req_signer = reqsign::Signer::new(reqsign::Context::new(), provider, signer); + + Ok(Arc::new(Self { + config, + database, + user_loader, + organization_loader, + organization_member_by_user_id_loader, + external_http_client, + webauthn, + open_telemetry, + redis, + email_service_client, + geoip_resolver, + aws_ses_req_signer, + mtls_root_cert: root_cert, + mtls_core_cert: core_cert, + mtls_core_private_key: core_private_key, + mtls_email_cert: email_cert, + mtls_email_private_key: email_private_key, + })) + } +} + +scuffle_bootstrap::main! { + Global { + scuffle_signal::SignalSvc, + scufflecloud_core::services::CoreSvc::::default(), + email::services::EmailSvc::::default(), + } +} diff --git a/vendor/cargo/defs.bzl b/vendor/cargo/defs.bzl index 7e1e30f0f9..1cb383280d 100644 --- a/vendor/cargo/defs.bzl +++ b/vendor/cargo/defs.bzl @@ -442,6 +442,28 @@ def aliases( ############################################################################### _NORMAL_DEPENDENCIES = { + "cloud/big-bin": { + _REQUIRED_FEATURE: { + _COMMON_CONDITION: { + "anyhow": Label("@cargo_vendor//:anyhow-1.0.99"), + "diesel": Label("@cargo_vendor//:diesel-2.2.12"), + "diesel-async": Label("@cargo_vendor//:diesel-async-0.6.1"), + "fred": Label("@cargo_vendor//:fred-10.1.0"), + "ipnetwork": Label("@cargo_vendor//:ipnetwork-0.21.1"), + "itertools": Label("@cargo_vendor//:itertools-0.14.0"), + "reqsign": Label("@cargo_vendor//:reqsign-0.17.0"), + "reqwest": Label("@cargo_vendor//:reqwest-0.12.23"), + "rustls": Label("@cargo_vendor//:rustls-0.23.32"), + "serde": Label("@cargo_vendor//:serde-1.0.220"), + "tokio": Label("@cargo_vendor//:tokio-1.47.1"), + "tonic": Label("@cargo_vendor//:tonic-0.14.2"), + "tracing": Label("@cargo_vendor//:tracing-0.1.41"), + "tracing-subscriber": Label("@cargo_vendor//:tracing-subscriber-0.3.20"), + "url": Label("@cargo_vendor//:url-2.5.7"), + "webauthn-rs": Label("@cargo_vendor//:webauthn-rs-0.5.2"), + }, + }, + }, "cloud/core": { _REQUIRED_FEATURE: { _COMMON_CONDITION: { @@ -1372,6 +1394,12 @@ _NORMAL_DEPENDENCIES = { } _NORMAL_ALIASES = { + "cloud/big-bin": { + _REQUIRED_FEATURE: { + _COMMON_CONDITION: { + }, + }, + }, "cloud/core": { _REQUIRED_FEATURE: { _COMMON_CONDITION: { @@ -1863,6 +1891,8 @@ _NORMAL_ALIASES = { } _NORMAL_DEV_DEPENDENCIES = { + "cloud/big-bin": { + }, "cloud/core": { }, "cloud/core/cedar": { @@ -2153,6 +2183,8 @@ _NORMAL_DEV_DEPENDENCIES = { } _NORMAL_DEV_ALIASES = { + "cloud/big-bin": { + }, "cloud/core": { }, "cloud/core/cedar": { @@ -2378,6 +2410,14 @@ _NORMAL_DEV_ALIASES = { } _PROC_MACRO_DEPENDENCIES = { + "cloud/big-bin": { + _REQUIRED_FEATURE: { + _COMMON_CONDITION: { + "serde_derive": Label("@cargo_vendor//:serde_derive-1.0.220"), + "smart-default": Label("@cargo_vendor//:smart-default-0.7.1"), + }, + }, + }, "cloud/core": { _REQUIRED_FEATURE: { _COMMON_CONDITION: { @@ -2799,6 +2839,8 @@ _PROC_MACRO_DEPENDENCIES = { } _PROC_MACRO_ALIASES = { + "cloud/big-bin": { + }, "cloud/core": { }, "cloud/core/cedar": { @@ -2942,6 +2984,8 @@ _PROC_MACRO_ALIASES = { } _PROC_MACRO_DEV_DEPENDENCIES = { + "cloud/big-bin": { + }, "cloud/core": { }, "cloud/core/cedar": { @@ -3090,6 +3134,8 @@ _PROC_MACRO_DEV_DEPENDENCIES = { } _PROC_MACRO_DEV_ALIASES = { + "cloud/big-bin": { + }, "cloud/core": { }, "cloud/core/cedar": { @@ -3315,6 +3361,8 @@ _PROC_MACRO_DEV_ALIASES = { } _BUILD_DEPENDENCIES = { + "cloud/big-bin": { + }, "cloud/core": { }, "cloud/core/cedar": { @@ -3463,6 +3511,8 @@ _BUILD_DEPENDENCIES = { } _BUILD_ALIASES = { + "cloud/big-bin": { + }, "cloud/core": { }, "cloud/core/cedar": { @@ -3606,6 +3656,8 @@ _BUILD_ALIASES = { } _BUILD_PROC_MACRO_DEPENDENCIES = { + "cloud/big-bin": { + }, "cloud/core": { }, "cloud/core/cedar": { @@ -3737,6 +3789,8 @@ _BUILD_PROC_MACRO_DEPENDENCIES = { } _BUILD_PROC_MACRO_ALIASES = { + "cloud/big-bin": { + }, "cloud/core": { }, "cloud/core/cedar": { @@ -3868,6 +3922,8 @@ _BUILD_PROC_MACRO_ALIASES = { } _FEATURE_FLAGS = { + "cloud/big-bin": { + }, "cloud/core": { }, "cloud/core/cedar": { @@ -4228,6 +4284,8 @@ _FEATURE_FLAGS = { } _RESOLVED_FEATURE_FLAGS = { + "cloud/big-bin": { + }, "cloud/core": { }, "cloud/core/cedar": { @@ -4411,6 +4469,7 @@ _RESOLVED_FEATURE_FLAGS = { } _VERSIONS = { + "cloud/big-bin": "0.1.0", "cloud/core": "0.1.0", "cloud/core/cedar": "0.1.0", "cloud/core/db-types": "0.1.0",