use std::io::Write;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, Response};
use tower::ServiceExt as _;
use super::error::TenancyError;
use super::operator_console;
use super::pools::TenantPools;
use super::resolver::ChainResolver;
use crate::sql::sqlx::Database;
use crate::tenancy::admin::TenantAdminBuilder;
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub bind: String,
pub apex_domain: String,
pub operator_show_only: Vec<String>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind: "0.0.0.0:8080".into(),
apex_domain: "localhost".into(),
operator_show_only: vec!["rustango_orgs".into(), "rustango_operators".into()],
}
}
}
impl ServerConfig {
#[must_use]
pub fn from_env() -> Self {
let mut cfg = Self::default();
if let Ok(v) = std::env::var("RUSTANGO_BIND") {
cfg.bind = v;
}
if let Some(v) = configured_apex_domain() {
cfg.apex_domain = v;
}
cfg
}
}
static APEX_FROM_SETTINGS: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
#[cfg(test)]
pub(crate) static APEX_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[cfg(any(test, all(feature = "config", feature = "manage")))]
pub(crate) fn set_apex_domain_setting(apex: &str) {
if apex.is_empty() {
return;
}
let mut slot = APEX_FROM_SETTINGS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match slot.as_deref() {
None => *slot = Some(apex.to_owned()),
Some(kept) if kept != apex => tracing::warn!(
target: "rustango::tenancy",
kept,
ignored = apex,
"[tenancy] apex_domain was already set by an earlier Cli::with_settings; keeping the first"
),
Some(_) => {}
}
}
#[cfg(test)]
pub(crate) fn reset_apex_domain_setting() {
*APEX_FROM_SETTINGS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
pub(crate) fn apex_domain_setting() -> Option<String> {
APEX_FROM_SETTINGS
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub(crate) fn configured_apex_domain() -> Option<String> {
pick_apex(
std::env::var("RUSTANGO_APEX_DOMAIN").ok(),
apex_domain_setting(),
)
}
fn pick_apex(env: Option<String>, setting: Option<String>) -> Option<String> {
env.or(setting)
}
pub(crate) fn apex_domain() -> String {
configured_apex_domain().unwrap_or_else(|| "localhost".into())
}
pub(crate) fn tenant_scheme_setting() -> Option<String> {
std::env::var("RUSTANGO_TENANT_SCHEME")
.ok()
.filter(|s| !s.is_empty())
}
pub(crate) fn tenant_origin(host: &str, port_suffix: &str) -> String {
let scheme = pick_tenant_scheme(
tenant_scheme_setting(),
crate::session::secure_cookies(),
host,
);
format!("{scheme}://{host}{port_suffix}")
}
fn pick_tenant_scheme(setting: Option<String>, secure: bool, host: &str) -> String {
if let Some(scheme) = setting.filter(|s| !s.is_empty()) {
return scheme;
}
if secure && !is_loopback_host(host) {
"https"
} else {
"http"
}
.into()
}
fn is_loopback_host(host: &str) -> bool {
use std::net::IpAddr;
let name = if let Some(rest) = host.strip_prefix('[') {
rest.split(']').next().unwrap_or(rest)
} else if host.parse::<IpAddr>().is_ok() {
host
} else {
host.rsplit_once(':')
.filter(|(_, port)| port.bytes().all(|b| b.is_ascii_digit()))
.map_or(host, |(name, _)| name)
};
let name = name.to_ascii_lowercase();
name == "localhost"
|| name.ends_with(".localhost")
|| name.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
pub async fn run<DB: Database, W: Write + Send>(
pools: Arc<TenantPools<DB>>,
registry_url: String,
config: ServerConfig,
writer: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
if !no_operators_warn(&pools.registry_pool(), writer).await? {
}
let session_secret = crate::session::load_session_secret_for_tier(
&crate::session::tier_from_env(),
std::path::Path::new("./var/.rustango_session.key"),
);
let operator_console = operator_console::router(pools.registry_pool(), session_secret.clone());
let resolver = ChainResolver::standard(config.apex_domain.clone());
let tenant_admin = TenantAdminBuilder::<DB>::new(pools.clone(), registry_url, resolver)
.with_session(session_secret)
.build();
let apex = config.apex_domain.clone();
let app = axum::Router::new().fallback_service(tower::service_fn({
let operator = operator_console.clone();
let tenants = tenant_admin.clone();
move |req: Request<Body>| {
let mut operator = operator.clone();
let mut tenants = tenants.clone();
let apex = apex.clone();
async move {
let on_apex = super::resolver::host_is_apex(req.headers(), req.uri(), &apex);
let response: Response<Body> = if on_apex {
operator.as_service().oneshot(req).await
} else {
tenants.as_service().oneshot(req).await
}
.map_err(|e| -> std::convert::Infallible {
panic!("axum router service is Infallible: {e}")
})?;
Ok::<_, std::convert::Infallible>(response)
}
}
}));
let listener = tokio::net::TcpListener::bind(&config.bind).await?;
let bound = listener.local_addr()?;
writeln!(writer, "==> rustango operator + tenant server")?;
writeln!(writer, " bound to {bound}")?;
writeln!(
writer,
" operator UI http://{}:{}/",
config.apex_domain,
bound.port()
)?;
writeln!(
writer,
" tenant URLs http://<slug>.{}:{}/<table>",
config.apex_domain,
bound.port()
)?;
writeln!(
writer,
" apex domain {} (override with RUSTANGO_APEX_DOMAIN)",
config.apex_domain
)?;
writeln!(writer, " Ctrl-C to stop")?;
writeln!(writer)?;
writer.flush()?;
crate::shutdown::serve_until_drained(listener, app, crate::shutdown::DEFAULT_DRAIN_TIMEOUT)
.await
.map_err(|e| TenancyError::Validation(format!("server error: {e}")))?;
Ok(())
}
async fn no_operators_warn<W: Write + Send>(
registry: &crate::sql::Pool,
w: &mut W,
) -> Result<bool, TenancyError> {
use crate::core::Column as _;
use crate::sql::FetcherPool;
let active: Vec<super::auth::Operator> = super::auth::Operator::objects()
.where_(super::auth::Operator::active.eq(true))
.fetch(registry)
.await?;
if active.is_empty() {
writeln!(w, "WARNING: no active operators in `rustango_operators` —")?;
writeln!(
w,
" the operator UI will reject every login until you run"
)?;
writeln!(
w,
" `cargo run -- create-operator <username> --password <p>`."
)?;
writeln!(w)?;
Ok(false)
} else {
Ok(true)
}
}
#[cfg(test)]
mod apex_tests {
use super::*;
#[test]
fn env_wins_over_the_setting() {
let s = || Some("toml.test".to_owned());
assert_eq!(
pick_apex(Some("env.test".into()), s()).as_deref(),
Some("env.test")
);
assert_eq!(pick_apex(None, s()).as_deref(), Some("toml.test"));
}
#[test]
fn tenant_links_default_to_https_when_cookies_are_secure() {
assert_eq!(pick_tenant_scheme(None, true, "acme.example.com"), "https");
assert_eq!(pick_tenant_scheme(None, false, "acme.example.com"), "http");
assert_eq!(pick_tenant_scheme(None, true, "acme.localhost"), "http");
assert_eq!(pick_tenant_scheme(None, true, "127.0.0.1"), "http");
assert_eq!(
pick_tenant_scheme(Some("http".into()), true, "acme.example.com"),
"http"
);
assert_eq!(
pick_tenant_scheme(Some(String::new()), true, "a.example.com"),
"https"
);
}
#[test]
fn only_real_loopback_hosts_keep_http() {
for host in [
"localhost",
"localhost:8080",
"acme.localhost:8080",
"127.0.0.1",
"127.0.0.1:8000",
"::1",
"[::1]",
"[::1]:8080",
] {
assert!(is_loopback_host(host), "{host}");
}
for host in [
"127.example.com",
"acme.example.com:443",
"[2001:db8::1]:80",
"10.0.0.1",
] {
assert!(!is_loopback_host(host), "{host}");
}
}
#[test]
fn the_first_apex_setting_wins() {
let _g = APEX_TEST_LOCK.blocking_lock();
reset_apex_domain_setting();
set_apex_domain_setting("one.test");
set_apex_domain_setting("two.test");
assert_eq!(apex_domain_setting().as_deref(), Some("one.test"));
reset_apex_domain_setting();
}
}