Skip to main content

postrust_proxy/saas/
manager.rs

1//! Domain management service.
2//!
3//! High-level service for managing custom domains, routes, and upstreams.
4
5use crate::error::{ProxyError, ProxyResult};
6use crate::saas::db;
7use crate::saas::types::*;
8use crate::saas::verification::DomainVerificationService;
9use rand::Rng;
10use sqlx::PgPool;
11use std::sync::Arc;
12use uuid::Uuid;
13
14/// Domain management service.
15pub struct DomainManager {
16    pool: PgPool,
17    verification_service: Arc<DomainVerificationService>,
18}
19
20impl DomainManager {
21    /// Create a new domain manager.
22    pub fn new(pool: PgPool, verification_service: Arc<DomainVerificationService>) -> Self {
23        Self {
24            pool,
25            verification_service,
26        }
27    }
28
29    // =========================================================================
30    // Domain Management
31    // =========================================================================
32
33    /// Create a new domain for a tenant.
34    pub async fn create_domain(
35        &self,
36        tenant_id: Uuid,
37        req: CreateDomainRequest,
38    ) -> ProxyResult<DomainResponse> {
39        // Validate domain format
40        self.validate_domain_format(&req.domain)?;
41
42        // Check tenant quota
43        let (current, max) = db::check_domain_quota(&self.pool, tenant_id).await?;
44        if current >= max as i64 {
45            return Err(ProxyError::QuotaExceeded(format!(
46                "Domain limit reached ({}/{})",
47                current, max
48            )));
49        }
50
51        // Check if domain already exists
52        if db::domain_exists(&self.pool, &req.domain).await? {
53            return Err(ProxyError::Conflict("Domain already registered".into()));
54        }
55
56        // Generate verification token
57        let verification_token = generate_verification_token();
58
59        // Create domain
60        let domain =
61            db::create_domain(&self.pool, tenant_id, req.clone(), &verification_token).await?;
62
63        // Create verification challenge
64        let expected_value = format!("postrust-verify={}", verification_token);
65        let challenge_type = match req.verification_method {
66            VerificationMethod::Dns => "dns",
67            VerificationMethod::Http => "http",
68        };
69        db::create_verification_challenge(
70            &self.pool,
71            domain.id,
72            challenge_type,
73            &verification_token,
74            &expected_value,
75        )
76        .await?;
77
78        // Generate verification instructions
79        let instructions = match domain.verification_method {
80            VerificationMethod::Dns => {
81                VerificationInstructions::dns(&domain.domain, &verification_token)
82            }
83            VerificationMethod::Http => {
84                VerificationInstructions::http(&domain.domain, &verification_token)
85            }
86        };
87
88        Ok(DomainResponse {
89            domain,
90            verification_instructions: instructions,
91        })
92    }
93
94    /// Get a domain by ID for a tenant.
95    pub async fn get_domain(
96        &self,
97        id: Uuid,
98        tenant_id: Uuid,
99    ) -> ProxyResult<Option<DomainResponse>> {
100        let domain = db::get_domain_for_tenant(&self.pool, id, tenant_id).await?;
101
102        Ok(domain.map(|d| {
103            let instructions = match d.verification_method {
104                VerificationMethod::Dns => {
105                    VerificationInstructions::dns(&d.domain, &d.verification_token)
106                }
107                VerificationMethod::Http => {
108                    VerificationInstructions::http(&d.domain, &d.verification_token)
109                }
110            };
111            DomainResponse {
112                domain: d,
113                verification_instructions: instructions,
114            }
115        }))
116    }
117
118    /// List all domains for a tenant.
119    pub async fn list_domains(&self, tenant_id: Uuid) -> ProxyResult<Vec<Domain>> {
120        db::list_domains(&self.pool, tenant_id).await
121    }
122
123    /// Delete a domain.
124    pub async fn delete_domain(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
125        db::delete_domain(&self.pool, id, tenant_id).await
126    }
127
128    /// Verify a domain.
129    pub async fn verify_domain(
130        &self,
131        id: Uuid,
132        tenant_id: Uuid,
133    ) -> ProxyResult<VerificationResult> {
134        let domain = db::get_domain_for_tenant(&self.pool, id, tenant_id)
135            .await?
136            .ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
137
138        // Record verification attempt
139        db::record_verification_attempt(&self.pool, id).await?;
140
141        // Perform verification based on method
142        let result = match domain.verification_method {
143            VerificationMethod::Dns => {
144                self.verification_service
145                    .verify_dns(&domain.domain, &domain.verification_token)
146                    .await
147            }
148            VerificationMethod::Http => {
149                self.verification_service
150                    .verify_http(&domain.domain, &domain.verification_token)
151                    .await
152            }
153        };
154
155        match &result {
156            VerificationResult::Verified => {
157                // Update domain status
158                db::update_verification_status(&self.pool, id, VerificationStatus::Verified)
159                    .await?;
160
161                // If ACME is enabled, trigger SSL provisioning
162                if domain.ssl_provider == SslProvider::Acme {
163                    // TODO: Trigger ACME certificate provisioning
164                    db::update_ssl_status(&self.pool, id, SslStatus::Provisioning, None).await?;
165                }
166
167                tracing::info!(domain = %domain.domain, "Domain verified successfully");
168            }
169            VerificationResult::Failed { reason } => {
170                tracing::warn!(domain = %domain.domain, reason = %reason, "Domain verification failed");
171            }
172            VerificationResult::Pending => {}
173        }
174
175        Ok(result)
176    }
177
178    /// Enable a verified domain.
179    pub async fn enable_domain(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
180        // First check if domain is verified
181        let domain = db::get_domain_for_tenant(&self.pool, id, tenant_id)
182            .await?
183            .ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
184
185        if domain.verification_status != VerificationStatus::Verified {
186            return Err(ProxyError::Validation(
187                "Domain must be verified before enabling".into(),
188            ));
189        }
190
191        db::enable_domain(&self.pool, id).await
192    }
193
194    /// Disable a domain.
195    pub async fn disable_domain(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
196        // Verify ownership
197        db::get_domain_for_tenant(&self.pool, id, tenant_id)
198            .await?
199            .ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
200
201        db::disable_domain(&self.pool, id).await
202    }
203
204    // =========================================================================
205    // Route Management
206    // =========================================================================
207
208    /// Create a route for a domain.
209    pub async fn create_route(
210        &self,
211        domain_id: Uuid,
212        tenant_id: Uuid,
213        req: CreateDomainRouteRequest,
214    ) -> ProxyResult<DomainRoute> {
215        // Verify domain belongs to tenant
216        let _domain = db::get_domain_for_tenant(&self.pool, domain_id, tenant_id)
217            .await?
218            .ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
219
220        // Verify upstream belongs to tenant
221        db::get_upstream_for_tenant(&self.pool, req.upstream_id, tenant_id)
222            .await?
223            .ok_or_else(|| ProxyError::NotFound("Upstream not found".into()))?;
224
225        db::create_route(&self.pool, domain_id, tenant_id, req).await
226    }
227
228    /// Get a route by ID.
229    pub async fn get_route(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<Option<DomainRoute>> {
230        db::get_route_for_tenant(&self.pool, id, tenant_id).await
231    }
232
233    /// List routes for a domain.
234    pub async fn list_routes_for_domain(
235        &self,
236        domain_id: Uuid,
237        tenant_id: Uuid,
238    ) -> ProxyResult<Vec<DomainRoute>> {
239        // Verify domain belongs to tenant
240        db::get_domain_for_tenant(&self.pool, domain_id, tenant_id)
241            .await?
242            .ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
243
244        db::list_routes_for_domain(&self.pool, domain_id, tenant_id).await
245    }
246
247    /// Update a route.
248    pub async fn update_route(
249        &self,
250        id: Uuid,
251        tenant_id: Uuid,
252        req: UpdateDomainRouteRequest,
253    ) -> ProxyResult<Option<DomainRoute>> {
254        // If upstream_id is being updated, verify it belongs to tenant
255        if let Some(upstream_id) = req.upstream_id {
256            db::get_upstream_for_tenant(&self.pool, upstream_id, tenant_id)
257                .await?
258                .ok_or_else(|| ProxyError::NotFound("Upstream not found".into()))?;
259        }
260
261        db::update_route(&self.pool, id, tenant_id, req).await
262    }
263
264    /// Delete a route.
265    pub async fn delete_route(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
266        db::delete_route(&self.pool, id, tenant_id).await
267    }
268
269    // =========================================================================
270    // Upstream Management
271    // =========================================================================
272
273    /// Create an upstream for a tenant.
274    pub async fn create_upstream(
275        &self,
276        tenant_id: Uuid,
277        req: CreateUpstreamRequest,
278    ) -> ProxyResult<DomainUpstream> {
279        db::create_upstream(&self.pool, tenant_id, req).await
280    }
281
282    /// Get an upstream by ID.
283    pub async fn get_upstream(
284        &self,
285        id: Uuid,
286        tenant_id: Uuid,
287    ) -> ProxyResult<Option<DomainUpstream>> {
288        db::get_upstream_for_tenant(&self.pool, id, tenant_id).await
289    }
290
291    /// List upstreams for a tenant.
292    pub async fn list_upstreams(&self, tenant_id: Uuid) -> ProxyResult<Vec<DomainUpstream>> {
293        db::list_upstreams(&self.pool, tenant_id).await
294    }
295
296    /// Update an upstream.
297    pub async fn update_upstream(
298        &self,
299        id: Uuid,
300        tenant_id: Uuid,
301        req: UpdateUpstreamRequest,
302    ) -> ProxyResult<Option<DomainUpstream>> {
303        db::update_upstream(&self.pool, id, tenant_id, req).await
304    }
305
306    /// Delete an upstream.
307    pub async fn delete_upstream(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
308        db::delete_upstream(&self.pool, id, tenant_id).await
309    }
310
311    /// Add a backend to an upstream.
312    pub async fn add_backend(
313        &self,
314        upstream_id: Uuid,
315        tenant_id: Uuid,
316        req: CreateBackendRequest,
317    ) -> ProxyResult<DomainBackend> {
318        // Verify upstream belongs to tenant
319        db::get_upstream_for_tenant(&self.pool, upstream_id, tenant_id)
320            .await?
321            .ok_or_else(|| ProxyError::NotFound("Upstream not found".into()))?;
322
323        db::create_backend(&self.pool, upstream_id, req).await
324    }
325
326    /// Remove a backend from an upstream.
327    pub async fn remove_backend(
328        &self,
329        backend_id: Uuid,
330        upstream_id: Uuid,
331        tenant_id: Uuid,
332    ) -> ProxyResult<bool> {
333        db::delete_backend(&self.pool, backend_id, upstream_id, tenant_id).await
334    }
335
336    // =========================================================================
337    // Tenant Management
338    // =========================================================================
339
340    /// Get tenant usage statistics.
341    pub async fn get_tenant_usage(&self, tenant_id: Uuid) -> ProxyResult<TenantUsage> {
342        db::get_tenant_usage(&self.pool, tenant_id).await
343    }
344
345    // =========================================================================
346    // Validation Helpers
347    // =========================================================================
348
349    /// Validate domain format.
350    fn validate_domain_format(&self, domain: &str) -> ProxyResult<()> {
351        // Check length
352        if domain.is_empty() || domain.len() > 253 {
353            return Err(ProxyError::Validation("Invalid domain length".into()));
354        }
355
356        // Must have at least one dot
357        if !domain.contains('.') {
358            return Err(ProxyError::Validation(
359                "Domain must have at least one dot".into(),
360            ));
361        }
362
363        // Check each label
364        for label in domain.split('.') {
365            if label.is_empty() || label.len() > 63 {
366                return Err(ProxyError::Validation("Invalid domain label length".into()));
367            }
368
369            // Check first and last characters
370            let chars: Vec<char> = label.chars().collect();
371            if chars.first().is_none_or(|c| !c.is_alphanumeric())
372                || chars.last().is_none_or(|c| !c.is_alphanumeric())
373            {
374                return Err(ProxyError::Validation(
375                    "Domain labels must start and end with alphanumeric characters".into(),
376                ));
377            }
378
379            // Check all characters
380            for c in label.chars() {
381                if !c.is_alphanumeric() && c != '-' {
382                    return Err(ProxyError::Validation(
383                        "Domain labels can only contain alphanumeric characters and hyphens".into(),
384                    ));
385                }
386            }
387        }
388
389        Ok(())
390    }
391}
392
393/// Generate a secure verification token.
394fn generate_verification_token() -> String {
395    const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
396    let mut rng = rand::rng();
397
398    (0..32)
399        .map(|_| {
400            let idx = rng.random_range(0..CHARSET.len());
401            CHARSET[idx] as char
402        })
403        .collect()
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    // Note: `validate_domain_format` is exercised via integration tests that
411    // build a real `DomainManager` (it needs a live pool). A previous unit test
412    // here constructed the manager from `std::mem::zeroed()`, which is undefined
413    // behavior and crashed on drop, so it was removed.
414
415    #[test]
416    fn test_generate_verification_token() {
417        let token = generate_verification_token();
418        assert_eq!(token.len(), 32);
419        assert!(token.chars().all(|c| c.is_alphanumeric()));
420    }
421}