chore: Automate SSL with Cloudflare (#12021)

This PR adds support for automatic SSL issuance using Cloudflare when a
custom domain is updated.

- Introduced a cloudflare configuration. If present, the system will
attempt to issue an SSL certificate via Cloudflare whenever a custom
domain is added or changed.
- SSL verification is handled using an HTTP challenge.
- The job will store the HTTP challenge response provided by Cloudflare
and serve it under the /.well-known/cf path automatically.

How to test:

- Create a Cloudflare zone for your domain and copy the Zone ID.
- Generate a Cloudflare API token with the required SSL certificate
permissions.
- Set the Fallback Origin under SSL -> Custom HostName to the Chatwoot
installation.
- Add or update a custom domain and verify that the SSL certificate is
automatically issued.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Pranav
2025-07-24 13:09:06 +04:00
committed by GitHub
parent 9acb0d86b5
commit 420be64c45
17 changed files with 507 additions and 3 deletions

View File

@@ -0,0 +1,22 @@
class CustomDomainsController < ApplicationController
def verify
challenge_id = permitted_params[:id]
domain = request.host
portal = Portal.find_by(custom_domain: domain)
return render plain: 'Domain not found', status: :not_found unless portal
ssl_settings = portal.ssl_settings || {}
return render plain: 'Challenge ID not found', status: :not_found unless ssl_settings['cf_verification_id'] == challenge_id
render plain: ssl_settings['cf_verification_body'], status: :ok
end
private
def permitted_params
params.permit(:id)
end
end

View File

@@ -34,6 +34,6 @@ module Enterprise::SuperAdmin::AppConfigsController
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF]
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID]
end
end

View File

@@ -0,0 +1,22 @@
class Enterprise::CloudflareVerificationJob < ApplicationJob
queue_as :default
def perform(portal_id)
portal = Portal.find(portal_id)
return unless portal && portal.custom_domain.present?
result = check_hostname_status(portal)
create_hostname(portal) if result[:errors].present?
end
private
def create_hostname(portal)
Cloudflare::CreateCustomHostnameService.new(portal: portal).perform
end
def check_hostname_status(portal)
Cloudflare::CheckCustomHostnameService.new(portal: portal).perform
end
end

View File

@@ -0,0 +1,14 @@
module Enterprise::Concerns::Portal
extend ActiveSupport::Concern
included do
after_save :enqueue_cloudflare_verification, if: :saved_change_to_custom_domain?
end
def enqueue_cloudflare_verification
return if custom_domain.blank?
return unless ChatwootApp.chatwoot_cloud?
Enterprise::CloudflareVerificationJob.perform_later(id)
end
end

View File

@@ -0,0 +1,20 @@
class Cloudflare::BaseCloudflareZoneService
BASE_URI = 'https://api.cloudflare.com/client/v4'.freeze
private
def headers
{
'Authorization' => "Bearer #{api_token}",
'Content-Type' => 'application/json'
}
end
def api_token
InstallationConfig.find_by(name: 'CLOUDFLARE_API_KEY')&.value
end
def zone_id
InstallationConfig.find_by(name: 'CLOUDFLARE_ZONE_ID')&.value
end
end

View File

@@ -0,0 +1,34 @@
class Cloudflare::CheckCustomHostnameService < Cloudflare::BaseCloudflareZoneService
pattr_initialize [:portal!]
def perform
return { errors: ['Cloudflare API token or zone ID not found'] } if api_token.blank? || zone_id.blank?
return { errors: ['No custom domain found'] } if @portal.custom_domain.blank?
response = HTTParty.get(
"#{BASE_URI}/zones/#{zone_id}/custom_hostnames?hostname=#{@portal.custom_domain}", headers: headers
)
return { errors: response.parsed_response['errors'] } unless response.success?
data = response.parsed_response['result']
if data.present?
update_portal_ssl_settings(data.first)
return { data: data }
end
{ errors: ['Hostname is missing in Cloudflare'] }
end
private
def update_portal_ssl_settings(data)
verification_record = data['ownership_verification_http']
ssl_settings = {
'cf_verification_id': verification_record['http_url'].split('/').last,
'cf_verification_body': verification_record['http_body']
}
@portal.update(ssl_settings: ssl_settings)
end
end

View File

@@ -0,0 +1,40 @@
class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneService
pattr_initialize [:portal!]
def perform
return { errors: ['Cloudflare API token or zone ID not found'] } if api_token.blank? || zone_id.blank?
return { errors: ['No hostname found'] } if @portal.custom_domain.blank?
response = create_hostname
return { errors: response.parsed_response['errors'] } unless response.success?
data = response.parsed_response['result']
if data.present?
update_portal_ssl_settings(data)
return { data: data }
end
{ errors: ['Could not create hostname'] }
end
private
def create_hostname
HTTParty.post(
"#{BASE_URI}/zones/#{zone_id}/custom_hostnames",
headers: headers,
body: { hostname: @portal.custom_domain }.to_json
)
end
def update_portal_ssl_settings(data)
verification_record = data['ownership_verification_http']
ssl_settings = {
'cf_verification_id': verification_record['http_url'].split('/').last,
'cf_verification_body': verification_record['http_body']
}
@portal.update(ssl_settings: ssl_settings)
end
end