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                // A domain asking for ACME is queued, not marked
162                // `provisioning`.
163                //
164                // `pending` is the worker's inbox: `saas::ssl::run` claims
165                // these rows, flips them to `provisioning` itself, and records
166                // `active` or `failed` with a reason. Setting `provisioning`
167                // here would claim work was under way before anything had
168                // started, and would take the row out of the worker's own
169                // claim query -- so it would sit mid-issuance for ever.
170                if domain.ssl_provider == SslProvider::Acme {
171                    tracing::info!(
172                        domain = %domain.domain,
173                        "queued for ACME certificate issuance"
174                    );
175                    db::update_ssl_status(&self.pool, id, SslStatus::Pending, None).await?;
176                }
177
178                tracing::info!(domain = %domain.domain, "Domain verified successfully");
179            }
180            VerificationResult::Failed { reason } => {
181                tracing::warn!(domain = %domain.domain, reason = %reason, "Domain verification failed");
182            }
183            VerificationResult::Pending => {}
184        }
185
186        Ok(result)
187    }
188
189    /// Look up a live HTTP verification challenge by its token.
190    ///
191    /// Exposed so the well-known endpoint can answer challenges without
192    /// reaching past this service for the pool. The caller must still check
193    /// that the request arrived for the returned domain.
194    pub async fn find_live_http_challenge(
195        &self,
196        token: &str,
197    ) -> ProxyResult<Option<db::HttpChallenge>> {
198        db::find_live_http_challenge(&self.pool, token).await
199    }
200
201    /// Apply a partial update to a domain.
202    ///
203    /// The domain name cannot be changed -- see [`UpdateDomainRequest`] for
204    /// why -- so this only moves the verification method and the SSL provider.
205    pub async fn update_domain(
206        &self,
207        id: Uuid,
208        tenant_id: Uuid,
209        req: UpdateDomainRequest,
210    ) -> ProxyResult<Domain> {
211        db::update_domain(
212            &self.pool,
213            id,
214            tenant_id,
215            req.verification_method.as_ref(),
216            req.ssl_provider.as_ref(),
217        )
218        .await?
219        .ok_or_else(|| ProxyError::NotFound("Domain not found".into()))
220    }
221
222    /// Store a certificate an operator supplied, after checking it.
223    ///
224    /// The checks are the point. An unchecked upload gives a listener that
225    /// accepts the certificate now and fails every handshake later: the key may
226    /// not match the chain, the certificate may be expired, or it may be for
227    /// another domain entirely. All three are refused here, with a message
228    /// saying which.
229    pub async fn upload_certificate(
230        &self,
231        id: Uuid,
232        tenant_id: Uuid,
233        cert_store: &crate::tls::CertificateStore,
234        req: UploadCertificateRequest,
235    ) -> ProxyResult<Domain> {
236        let domain = db::get_domain_for_tenant(&self.pool, id, tenant_id)
237            .await?
238            .ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
239
240        let facts = crate::tls::validate_for_domain(
241            &domain.domain,
242            req.cert_pem.as_bytes(),
243            req.key_pem.as_bytes(),
244        )?;
245
246        cert_store
247            .save(crate::tls::Certificate {
248                domain: domain.domain.clone(),
249                cert_pem: req.cert_pem.into_bytes(),
250                key_pem: req.key_pem.into_bytes(),
251                expires_at: Some(facts.expires_at),
252            })
253            .await?;
254
255        let updated = db::record_uploaded_certificate(&self.pool, id, tenant_id, facts.expires_at)
256            .await?
257            .ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
258
259        tracing::info!(
260            domain = %updated.domain,
261            expires_at = %facts.expires_at,
262            "stored an uploaded certificate"
263        );
264        Ok(updated)
265    }
266
267    /// Queue a verified ACME domain for certificate issuance.
268    ///
269    /// Sets state and returns; the work is done by the issuance worker. That is
270    /// the whole endpoint, and deliberately so: an ACME order is several round
271    /// trips to the CA plus a challenge the CA has to fetch back from us, and
272    /// retrying under a rate limit is the normal failure mode. Doing it inline
273    /// would hold the request open and let a client timeout orphan an order the
274    /// CA had already started.
275    ///
276    /// Idempotent, and also the way to retry after a failure -- it clears the
277    /// attempt count and the backoff.
278    pub async fn queue_ssl_issuance(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<Domain> {
279        db::queue_for_issuance(&self.pool, id, tenant_id)
280            .await?
281            .ok_or_else(|| {
282                ProxyError::NotFound(
283                    "No verified domain with ssl_provider = acme for this tenant".into(),
284                )
285            })
286    }
287
288    /// Enable a verified domain.
289    pub async fn enable_domain(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
290        // First check if domain is verified
291        let domain = db::get_domain_for_tenant(&self.pool, id, tenant_id)
292            .await?
293            .ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
294
295        if domain.verification_status != VerificationStatus::Verified {
296            return Err(ProxyError::Validation(
297                "Domain must be verified before enabling".into(),
298            ));
299        }
300
301        db::enable_domain(&self.pool, id).await
302    }
303
304    /// Disable a domain.
305    pub async fn disable_domain(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
306        // Verify ownership
307        db::get_domain_for_tenant(&self.pool, id, tenant_id)
308            .await?
309            .ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
310
311        db::disable_domain(&self.pool, id).await
312    }
313
314    // =========================================================================
315    // Route Management
316    // =========================================================================
317
318    /// Create a route for a domain.
319    pub async fn create_route(
320        &self,
321        domain_id: Uuid,
322        tenant_id: Uuid,
323        req: CreateDomainRouteRequest,
324    ) -> ProxyResult<DomainRoute> {
325        // Verify domain belongs to tenant
326        let _domain = db::get_domain_for_tenant(&self.pool, domain_id, tenant_id)
327            .await?
328            .ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
329
330        // Verify upstream belongs to tenant
331        db::get_upstream_for_tenant(&self.pool, req.upstream_id, tenant_id)
332            .await?
333            .ok_or_else(|| ProxyError::NotFound("Upstream not found".into()))?;
334
335        db::create_route(&self.pool, domain_id, tenant_id, req).await
336    }
337
338    /// Get a route by ID.
339    pub async fn get_route(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<Option<DomainRoute>> {
340        db::get_route_for_tenant(&self.pool, id, tenant_id).await
341    }
342
343    /// List routes for a domain.
344    pub async fn list_routes_for_domain(
345        &self,
346        domain_id: Uuid,
347        tenant_id: Uuid,
348    ) -> ProxyResult<Vec<DomainRoute>> {
349        // Verify domain belongs to tenant
350        db::get_domain_for_tenant(&self.pool, domain_id, tenant_id)
351            .await?
352            .ok_or_else(|| ProxyError::NotFound("Domain not found".into()))?;
353
354        db::list_routes_for_domain(&self.pool, domain_id, tenant_id).await
355    }
356
357    /// Update a route.
358    pub async fn update_route(
359        &self,
360        id: Uuid,
361        tenant_id: Uuid,
362        req: UpdateDomainRouteRequest,
363    ) -> ProxyResult<Option<DomainRoute>> {
364        // If upstream_id is being updated, verify it belongs to tenant
365        if let Some(upstream_id) = req.upstream_id {
366            db::get_upstream_for_tenant(&self.pool, upstream_id, tenant_id)
367                .await?
368                .ok_or_else(|| ProxyError::NotFound("Upstream not found".into()))?;
369        }
370
371        db::update_route(&self.pool, id, tenant_id, req).await
372    }
373
374    /// Delete a route.
375    pub async fn delete_route(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
376        db::delete_route(&self.pool, id, tenant_id).await
377    }
378
379    // =========================================================================
380    // Upstream Management
381    // =========================================================================
382
383    /// Create an upstream for a tenant.
384    pub async fn create_upstream(
385        &self,
386        tenant_id: Uuid,
387        req: CreateUpstreamRequest,
388    ) -> ProxyResult<DomainUpstream> {
389        db::create_upstream(&self.pool, tenant_id, req).await
390    }
391
392    /// Get an upstream by ID.
393    pub async fn get_upstream(
394        &self,
395        id: Uuid,
396        tenant_id: Uuid,
397    ) -> ProxyResult<Option<DomainUpstream>> {
398        db::get_upstream_for_tenant(&self.pool, id, tenant_id).await
399    }
400
401    /// List upstreams for a tenant.
402    pub async fn list_upstreams(&self, tenant_id: Uuid) -> ProxyResult<Vec<DomainUpstream>> {
403        db::list_upstreams(&self.pool, tenant_id).await
404    }
405
406    /// Update an upstream.
407    pub async fn update_upstream(
408        &self,
409        id: Uuid,
410        tenant_id: Uuid,
411        req: UpdateUpstreamRequest,
412    ) -> ProxyResult<Option<DomainUpstream>> {
413        db::update_upstream(&self.pool, id, tenant_id, req).await
414    }
415
416    /// Delete an upstream.
417    pub async fn delete_upstream(&self, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
418        db::delete_upstream(&self.pool, id, tenant_id).await
419    }
420
421    /// Add a backend to an upstream.
422    pub async fn add_backend(
423        &self,
424        upstream_id: Uuid,
425        tenant_id: Uuid,
426        req: CreateBackendRequest,
427    ) -> ProxyResult<DomainBackend> {
428        // Verify upstream belongs to tenant
429        db::get_upstream_for_tenant(&self.pool, upstream_id, tenant_id)
430            .await?
431            .ok_or_else(|| ProxyError::NotFound("Upstream not found".into()))?;
432
433        db::create_backend(&self.pool, upstream_id, req).await
434    }
435
436    /// Remove a backend from an upstream.
437    pub async fn remove_backend(
438        &self,
439        backend_id: Uuid,
440        upstream_id: Uuid,
441        tenant_id: Uuid,
442    ) -> ProxyResult<bool> {
443        db::delete_backend(&self.pool, backend_id, upstream_id, tenant_id).await
444    }
445
446    // =========================================================================
447    // Tenant Management
448    // =========================================================================
449
450    /// Get tenant usage statistics.
451    pub async fn get_tenant_usage(&self, tenant_id: Uuid) -> ProxyResult<TenantUsage> {
452        db::get_tenant_usage(&self.pool, tenant_id).await
453    }
454
455    // =========================================================================
456    // Validation Helpers
457    // =========================================================================
458
459    /// Validate domain format.
460    fn validate_domain_format(&self, domain: &str) -> ProxyResult<()> {
461        // Check length
462        if domain.is_empty() || domain.len() > 253 {
463            return Err(ProxyError::Validation("Invalid domain length".into()));
464        }
465
466        // Must have at least one dot
467        if !domain.contains('.') {
468            return Err(ProxyError::Validation(
469                "Domain must have at least one dot".into(),
470            ));
471        }
472
473        // Check each label
474        for label in domain.split('.') {
475            if label.is_empty() || label.len() > 63 {
476                return Err(ProxyError::Validation("Invalid domain label length".into()));
477            }
478
479            // Check first and last characters
480            let chars: Vec<char> = label.chars().collect();
481            if chars.first().is_none_or(|c| !c.is_alphanumeric())
482                || chars.last().is_none_or(|c| !c.is_alphanumeric())
483            {
484                return Err(ProxyError::Validation(
485                    "Domain labels must start and end with alphanumeric characters".into(),
486                ));
487            }
488
489            // Check all characters
490            for c in label.chars() {
491                if !c.is_alphanumeric() && c != '-' {
492                    return Err(ProxyError::Validation(
493                        "Domain labels can only contain alphanumeric characters and hyphens".into(),
494                    ));
495                }
496            }
497        }
498
499        Ok(())
500    }
501}
502
503/// Generate a secure verification token.
504fn generate_verification_token() -> String {
505    const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
506    let mut rng = rand::rng();
507
508    (0..32)
509        .map(|_| {
510            let idx = rng.random_range(0..CHARSET.len());
511            CHARSET[idx] as char
512        })
513        .collect()
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    // Note: `validate_domain_format` is exercised via integration tests that
521    // build a real `DomainManager` (it needs a live pool). A previous unit test
522    // here constructed the manager from `std::mem::zeroed()`, which is undefined
523    // behavior and crashed on drop, so it was removed.
524
525    #[test]
526    fn test_generate_verification_token() {
527        let token = generate_verification_token();
528        assert_eq!(token.len(), 32);
529        assert!(token.chars().all(|c| c.is_alphanumeric()));
530    }
531}