Skip to content

Add support for custom user domains for webhooks/responders #54

Description

@azasypkin

Summary

Currently, webhooks support two URL types: path-based (/api/webhooks/{user_handle}/{path}) and subdomain-based ({prefix}-{user_handle}.webhooks.secutils.dev/{path}). To support completely arbitrary customer-owned domains (e.g., api.customer.com/webhook), the system needs changes at multiple layers: domain verification/ownership, database storage, API validation, request routing, TLS certificate provisioning, and UI configuration.

Steps

1. Add domain ownership verification system

Create new database table user_data_webhooks_custom_domains in migrations/ to store user-owned custom domains with verification and TLS status:

CREATE TABLE user_data_webhooks_custom_domains (
    domain TEXT PRIMARY KEY,
    user_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    verification_token TEXT NOT NULL,
    verified_at BIGINT,           -- NULL until verified
    tls_provisioned_at BIGINT,    -- NULL until cert ready
    created_at BIGINT NOT NULL
);

Verification flow:

  • User adds domain api.customer.com
  • System generates verification token (e.g., secutils-verify=abc123xyz)
  • User creates DNS TXT record: _secutils-verify.api.customer.com TXT "secutils-verify=abc123xyz"
  • API endpoint checks DNS and updates verified_at timestamp

Create API endpoints in src/utils/webhooks/api_ext.rs:

  • POST /api/utils/webhooks/custom_domains — Add domain, generate token
  • GET /api/utils/webhooks/custom_domains — List user's domains
  • POST /api/utils/webhooks/custom_domains/{domain}/verify — Trigger verification check
  • DELETE /api/utils/webhooks/custom_domains/{domain} — Remove domain and cleanup cert

2. Refactor ResponderLocation to use enum for host type

Currently subdomain_prefix: Option<String> implicitly determines routing type. With custom domains, there are now three mutually exclusive host types. Refactor src/utils/webhooks/responders/responder_location.rs to make this explicit:

/// Determines how the webhook responder is accessed.
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ResponderHostType {
    /// Path-only: /api/webhooks/{user_handle}/{path}
    /// No custom host, simplest option.
    PathOnly,
    /// Subdomain: {prefix}-{user_handle}.webhooks.secutils.dev/{path}
    /// Uses Secutils-managed subdomain with wildcard TLS.
    Subdomain {
        prefix: String,
    },
    /// Custom domain: api.customer.com/{path}
    /// User-owned domain, requires verification and per-domain TLS.
    CustomDomain {
        domain: String,
    },
}

#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ResponderLocation {
    pub path_type: ResponderPathType,
    pub path: String,
    pub host_type: ResponderHostType,
}

JSON serialization examples:

// Path-only
{ "pathType": "=", "path": "/hook", "hostType": { "type": "pathOnly" } }

// Subdomain
{ "pathType": "^", "path": "/api", "hostType": { "type": "subdomain", "prefix": "myapp" } }

// Custom domain
{ "pathType": "=", "path": "/webhook", "hostType": { "type": "customDomain", "domain": "api.customer.com" } }

Migration: Existing data uses subdomain_prefix column. Migration converts:

  • subdomain_prefix IS NULLResponderHostType::PathOnly
  • subdomain_prefix IS NOT NULLResponderHostType::Subdomain { prefix }

3. Implement automatic TLS provisioning via cert-manager (HTTP-01)

When a custom domain is verified, automatically provision a TLS certificate using cert-manager with HTTP-01 challenge (since we don't control customer DNS for DNS-01):

Process:

  1. Customer points api.customer.com → Secutils ingress IP (CNAME or A record)
  2. After DNS verification succeeds, API creates a cert-manager Certificate resource via Kubernetes API:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: custom-domain-api-customer-com
  namespace: secutils
  labels:
    secutils.dev/custom-domain: "true"
spec:
  secretName: tls-custom-api-customer-com
  issuerRef:
    name: letsencrypt-prod-issuer
    kind: ClusterIssuer
  dnsNames:
    - api.customer.com
  1. cert-manager handles HTTP-01 challenge (creates temp ingress for /.well-known/acme-challenge/)
  2. Certificate stored in secret, tls_provisioned_at updated
  3. Traefik picks up the new TLS secret automatically

Rust implementation: Use kube crate to create/delete Certificate resources:

async fn provision_tls_certificate(kube_client: &Client, domain: &str) -> Result<()> {
    let certs: Api<Certificate> = Api::namespaced(kube_client.clone(), "secutils");
    let cert = Certificate { /* ... */ };
    certs.create(&PostParams::default(), &cert).await?;
    Ok(())
}

Cleanup: When domain is deleted, remove the Certificate resource (cert-manager cleans up the secret).

Rate limits to consider:

  • Let's Encrypt: 50 certificates per registered domain per week
  • 300 new orders per 3 hours per account
  • cert-manager handles automatic renewal (30 days before expiry)

4. Update subscription config for custom domains

Add fields to src/config/subscriptions_config/subscription_webhooks_config.rs:

pub struct SubscriptionWebhooksConfig {
    // ...existing fields...
    /// Whether custom domains are allowed for this subscription tier.
    pub custom_domains_enabled: bool,
    /// Maximum number of custom domains user can register.
    pub max_custom_domains: usize,
}

Extend ClientSubscriptionWebhooksConfig in src/users/user_subscription/client_subscription_features.rs to expose to UI.

Tier limits:

  • Free: 0 custom domains
  • Basic: 1 custom domain
  • Professional: 5 custom domains
  • Ultimate: Unlimited

5. Modify API validation and request routing

Validation in src/utils/webhooks/api_ext.rs:

  • When ResponderHostType::CustomDomain { domain } is used, verify:
    • Domain exists in user_data_webhooks_custom_domains for this user
    • Domain is verified (verified_at IS NOT NULL)
    • TLS is provisioned (tls_provisioned_at IS NOT NULL)
    • User hasn't exceeded max_custom_domains limit

Request routing in src/server/handlers/webhooks_responders.rs:

Update parse_webhook_host() to handle custom domains:

async fn parse_webhook_host(host: &str, db: &Database) -> Result<WebhookHost> {
    // 1. Check if it's a known Secutils subdomain pattern
    if let Some(captures) = SUBDOMAIN_REGEX.captures(host) {
        return Ok(WebhookHost::Subdomain { prefix, user_handle });
    }
    
    // 2. Check if it's a verified custom domain
    if let Some(custom_domain) = db.get_verified_custom_domain(host).await? {
        return Ok(WebhookHost::CustomDomain { 
            domain: host.to_string(),
            user_id: custom_domain.user_id,
        });
    }
    
    // 3. Unknown host
    Err(Error::UnknownWebhookHost)
}

6. Configure Traefik for custom domain routing

Current static HostRegexp won't work for arbitrary domains. Use catch-all route with application-level routing:

# Existing subdomain routes (higher priority)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: secutils-webhooks-subdomains-https
spec:
  entryPoints:
    - websecure
  routes:
    - kind: Rule
      match: HostRegexp(`[a-z0-9-]+\.webhooks\.secutils\.dev`)
      priority: 100
      services:
        - name: secutils-api-svc
          port: 7070
  tls:
    secretName: secutils-webhooks-wildcard-tls

---
# Catch-all for custom domains (lower priority)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: secutils-webhooks-custom-domains-https
spec:
  entryPoints:
    - websecure
  routes:
    - kind: Rule
      match: PathPrefix(`/`)
      priority: 1  # Low priority, only matches if nothing else does
      services:
        - name: secutils-api-svc
          port: 7070
  tls:
    passthrough: false
    # Traefik uses SNI to select correct certificate from secrets

TLS Secret Loading: Configure Traefik to watch secrets with label secutils.dev/custom-domain=true:

# In Traefik deployment args or config
--providers.kubernetessecret.labelselector=secutils.dev/custom-domain=true

7. Update WebUI for custom domain configuration

New "Custom Domains" management section:

  • List verified domains with status (pending verification, verified, TLS ready)
  • Add domain form with verification instructions
  • "Verify Now" button to trigger DNS check
  • Delete domain button

Update responder_edit_flyout.tsx:

  • Radio button group for host type selection:
    • "Path only" (default)
    • "Subdomain" (shows prefix input)
    • "Custom domain" (shows dropdown of verified domains)
  • Custom domain option disabled if none verified or subscription doesn't allow

Update responders.tsx:

  • Display full URL based on host type
  • Show warning icon if custom domain TLS not yet ready

Implementation Order

  1. Database migration + domain CRUD API (no TLS yet)
  2. Refactor ResponderLocation to use ResponderHostType enum + migration
  3. Update API validation to check domain ownership
  4. Add TLS provisioning via cert-manager
  5. Update Traefik configuration
  6. WebUI: Custom domains management page
  7. WebUI: Responder editor with host type selection
  8. Subscription tier limits

Further Considerations

  1. DNS verification approach: DNS TXT record is standard (similar to Google/AWS domain verification).

  2. What if TLS provisioning fails? Add status field to track provisioning state. UI shows error with retry option. Common failures: DNS not pointed correctly, rate limited. Consider background job to retry failed provisions.

  3. Domain transfer between users? If user A deletes domain and user B adds it, need to handle gracefully. Require re-verification. Consider cooldown period.

  4. Wildcard custom domains? For v1, only exact domains. Wildcard (*.customer.com) would require DNS-01 challenge which needs customer DNS API access, maybe in the future.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    • Status
      📋 Not started

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions