use crate::error::{ProxyError, ProxyResult};
use crate::saas::db;
use crate::saas::types::*;
use crate::saas::verification::DomainVerificationService;
use rand::Rng;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
pub struct DomainManager {
pool: PgPool,
verification_service: Arc<DomainVerificationService>,
}
impl DomainManager {
pub fn new(pool: PgPool, verification_service: Arc<DomainVerificationService>) -> Self {
Self {
pool,
verification_service,
}
}
pub async fn create_domain(
&self,
tenant_id: Uuid,
req: CreateDomainRequest,
) -> ProxyResult<DomainResponse> {
self.validate_domain_format(&req.domain)?;
let (current, max) = db::check_domain_quota(&self.pool, tenant_id).await?;
if current >= max as i64 {
return Err(ProxyError::QuotaExceeded(format!(
"Domain limit reached ({}/{})",
current, max
)));
}
if db::domain_exists(&self.pool, &req.domain).await? {
return Err(ProxyError::Conflict("Domain already registered".into()));
}
let verification_token = generate_verification_token();
let domain =
db::create_domain(&self.pool, tenant_id, req.clone(), &verification_token).await?;
let expected_value = format!("postrust-verify={}", verification_token);
let challenge_type = match req.verification_method {
VerificationMethod::Dns => "dns",
VerificationMethod::Http => "http",
};
db::create_verification_challenge(
&self.pool,
domain.id,
challenge_type,
&verification_token,
&expected_value,
)
.await?;
let instructions = match domain.verification_method {
VerificationMethod::Dns => {
VerificationInstructions::dns(&domain.domain, &verification_token)
}
VerificationMethod::Http => {
VerificationInstructions::http(&domain.domain, &verification_token)
}
};
Ok(DomainResponse {
domain,
verification_instructions: instructions,
})
}
pub async fn get_domain(
&self,
id: Uuid,
tenant_id: Uuid,
) -> ProxyResult<Option<DomainResponse>> {
let domain = db::get_domain_for_tenant(&self.pool, id, tenant_id).await?;
Ok(domain.map(|d| {
let instructions = match d.verification_method {
VerificationMethod::Dns => {
VerificationInstructions::dns(&d.domain, &d.verification_token)
}
VerificationMethod::Http => {
VerificationInstructions::http(&d.domain, &d.verification_token)
}
};
DomainResponse {
domain: d,
verification_instructions: instructions,
}
}))
}
pub async fn list_domains(&self, tenant_id: Uuid) -> ProxyResult<Vec<Domain>> {
db::list_domains(&self.pool, tenant_id).await
}
pub async fn delete_domain(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
db::delete_domain(&self.pool, id, tenant_id).await
}
pub async fn verify_domain(
&self,
id: Uuid,
tenant_id: Uuid,
) -> ProxyResult<VerificationResult> {
let domain = db::get_domain_for_tenant(&self.pool, id, tenant_id)
.await?
.ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
db::record_verification_attempt(&self.pool, id).await?;
let result = match domain.verification_method {
VerificationMethod::Dns => {
self.verification_service
.verify_dns(&domain.domain, &domain.verification_token)
.await
}
VerificationMethod::Http => {
self.verification_service
.verify_http(&domain.domain, &domain.verification_token)
.await
}
};
match &result {
VerificationResult::Verified => {
db::update_verification_status(&self.pool, id, VerificationStatus::Verified)
.await?;
if domain.ssl_provider == SslProvider::Acme {
db::update_ssl_status(&self.pool, id, SslStatus::Provisioning, None).await?;
}
tracing::info!(domain = %domain.domain, "Domain verified successfully");
}
VerificationResult::Failed { reason } => {
tracing::warn!(domain = %domain.domain, reason = %reason, "Domain verification failed");
}
VerificationResult::Pending => {}
}
Ok(result)
}
pub async fn enable_domain(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
let domain = db::get_domain_for_tenant(&self.pool, id, tenant_id)
.await?
.ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
if domain.verification_status != VerificationStatus::Verified {
return Err(ProxyError::Validation(
"Domain must be verified before enabling".into(),
));
}
db::enable_domain(&self.pool, id).await
}
pub async fn disable_domain(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
db::get_domain_for_tenant(&self.pool, id, tenant_id)
.await?
.ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
db::disable_domain(&self.pool, id).await
}
pub async fn create_route(
&self,
domain_id: Uuid,
tenant_id: Uuid,
req: CreateDomainRouteRequest,
) -> ProxyResult<DomainRoute> {
let _domain = db::get_domain_for_tenant(&self.pool, domain_id, tenant_id)
.await?
.ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
db::get_upstream_for_tenant(&self.pool, req.upstream_id, tenant_id)
.await?
.ok_or_else(|| ProxyError::NotFound("Upstream not found".into()))?;
db::create_route(&self.pool, domain_id, tenant_id, req).await
}
pub async fn get_route(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<Option<DomainRoute>> {
db::get_route_for_tenant(&self.pool, id, tenant_id).await
}
pub async fn list_routes_for_domain(
&self,
domain_id: Uuid,
tenant_id: Uuid,
) -> ProxyResult<Vec<DomainRoute>> {
db::get_domain_for_tenant(&self.pool, domain_id, tenant_id)
.await?
.ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
db::list_routes_for_domain(&self.pool, domain_id, tenant_id).await
}
pub async fn update_route(
&self,
id: Uuid,
tenant_id: Uuid,
req: UpdateDomainRouteRequest,
) -> ProxyResult<Option<DomainRoute>> {
if let Some(upstream_id) = req.upstream_id {
db::get_upstream_for_tenant(&self.pool, upstream_id, tenant_id)
.await?
.ok_or_else(|| ProxyError::NotFound("Upstream not found".into()))?;
}
db::update_route(&self.pool, id, tenant_id, req).await
}
pub async fn delete_route(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
db::delete_route(&self.pool, id, tenant_id).await
}
pub async fn create_upstream(
&self,
tenant_id: Uuid,
req: CreateUpstreamRequest,
) -> ProxyResult<DomainUpstream> {
db::create_upstream(&self.pool, tenant_id, req).await
}
pub async fn get_upstream(
&self,
id: Uuid,
tenant_id: Uuid,
) -> ProxyResult<Option<DomainUpstream>> {
db::get_upstream_for_tenant(&self.pool, id, tenant_id).await
}
pub async fn list_upstreams(&self, tenant_id: Uuid) -> ProxyResult<Vec<DomainUpstream>> {
db::list_upstreams(&self.pool, tenant_id).await
}
pub async fn update_upstream(
&self,
id: Uuid,
tenant_id: Uuid,
req: UpdateUpstreamRequest,
) -> ProxyResult<Option<DomainUpstream>> {
db::update_upstream(&self.pool, id, tenant_id, req).await
}
pub async fn delete_upstream(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
db::delete_upstream(&self.pool, id, tenant_id).await
}
pub async fn add_backend(
&self,
upstream_id: Uuid,
tenant_id: Uuid,
req: CreateBackendRequest,
) -> ProxyResult<DomainBackend> {
db::get_upstream_for_tenant(&self.pool, upstream_id, tenant_id)
.await?
.ok_or_else(|| ProxyError::NotFound("Upstream not found".into()))?;
db::create_backend(&self.pool, upstream_id, req).await
}
pub async fn remove_backend(
&self,
backend_id: Uuid,
upstream_id: Uuid,
tenant_id: Uuid,
) -> ProxyResult<bool> {
db::delete_backend(&self.pool, backend_id, upstream_id, tenant_id).await
}
pub async fn get_tenant_usage(&self, tenant_id: Uuid) -> ProxyResult<TenantUsage> {
db::get_tenant_usage(&self.pool, tenant_id).await
}
fn validate_domain_format(&self, domain: &str) -> ProxyResult<()> {
if domain.is_empty() || domain.len() > 253 {
return Err(ProxyError::Validation("Invalid domain length".into()));
}
if !domain.contains('.') {
return Err(ProxyError::Validation(
"Domain must have at least one dot".into(),
));
}
for label in domain.split('.') {
if label.is_empty() || label.len() > 63 {
return Err(ProxyError::Validation("Invalid domain label length".into()));
}
let chars: Vec<char> = label.chars().collect();
if chars.first().is_none_or(|c| !c.is_alphanumeric())
|| chars.last().is_none_or(|c| !c.is_alphanumeric())
{
return Err(ProxyError::Validation(
"Domain labels must start and end with alphanumeric characters".into(),
));
}
for c in label.chars() {
if !c.is_alphanumeric() && c != '-' {
return Err(ProxyError::Validation(
"Domain labels can only contain alphanumeric characters and hyphens".into(),
));
}
}
}
Ok(())
}
}
fn generate_verification_token() -> String {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::rng();
(0..32)
.map(|_| {
let idx = rng.random_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_verification_token() {
let token = generate_verification_token();
assert_eq!(token.len(), 32);
assert!(token.chars().all(|c| c.is_alphanumeric()));
}
}