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

@@ -2,7 +2,7 @@ class MicrosoftController < ApplicationController
after_action :set_version_header after_action :set_version_header
def identity_association def identity_association
microsoft_indentity microsoft_identity
end end
private private
@@ -11,7 +11,7 @@ class MicrosoftController < ApplicationController
response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length
end end
def microsoft_indentity def microsoft_identity
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil) @identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
end end
end end

View File

@@ -12,6 +12,7 @@
# name :string not null # name :string not null
# page_title :string # page_title :string
# slug :string not null # slug :string not null
# ssl_settings :jsonb not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :integer not null # account_id :integer not null
@@ -69,3 +70,5 @@ class Portal < ApplicationRecord
errors.add(:cofig, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any? errors.add(:cofig, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
end end
end end
Portal.include_mod_with('Concerns::Portal')

View File

@@ -416,3 +416,17 @@
locked: false locked: false
description: 'The redirect URI configured in your Google OAuth app' description: 'The redirect URI configured in your Google OAuth app'
## ------ End of Configs added for Google OAuth ------ ## ## ------ End of Configs added for Google OAuth ------ ##
## ------ Configs added for Cloudflare ------ ##
- name: CLOUDFLARE_API_KEY
display_title: 'Cloudflare API Key'
value:
locked: false
description: 'API key for Cloudflare account authentication'
type: secret
- name: CLOUDFLARE_ZONE_ID
display_title: 'Cloudflare Zone ID'
value:
locked: false
description: 'Zone ID for the Cloudflare domain'
## ------ End of Configs added for Cloudflare ------ ##

View File

@@ -517,6 +517,7 @@ Rails.application.routes.draw do
get '.well-known/assetlinks.json' => 'android_app#assetlinks' get '.well-known/assetlinks.json' => 'android_app#assetlinks'
get '.well-known/apple-app-site-association' => 'apple_app#site_association' get '.well-known/apple-app-site-association' => 'apple_app#site_association'
get '.well-known/microsoft-identity-association.json' => 'microsoft#identity_association' get '.well-known/microsoft-identity-association.json' => 'microsoft#identity_association'
get '.well-known/cf-custom-hostname-challenge/:id', to: 'custom_domains#verify'
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Internal Monitoring Routes # Internal Monitoring Routes

View File

@@ -0,0 +1,5 @@
class AddSslSettingsToPortals < ActiveRecord::Migration[7.1]
def change
add_column :portals, :ssl_settings, :jsonb, default: {}, null: false
end
end

View File

@@ -943,6 +943,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
t.jsonb "config", default: {"allowed_locales" => ["en"]} t.jsonb "config", default: {"allowed_locales" => ["en"]}
t.boolean "archived", default: false t.boolean "archived", default: false
t.bigint "channel_web_widget_id" t.bigint "channel_web_widget_id"
t.jsonb "ssl_settings", default: {}, null: false
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id" t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id"
t.index ["custom_domain"], name: "index_portals_on_custom_domain", unique: true t.index ["custom_domain"], name: "index_portals_on_custom_domain", unique: true
t.index ["slug"], name: "index_portals_on_slug", unique: true t.index ["slug"], name: "index_portals_on_slug", unique: true

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 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 %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 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
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

View File

@@ -0,0 +1,47 @@
require 'rails_helper'
RSpec.describe Enterprise::CloudflareVerificationJob do
let(:portal) { create(:portal, custom_domain: 'test.example.com') }
describe '#perform' do
context 'when portal is not found' do
it 'returns early' do
expect(Portal).to receive(:find).with(0).and_return(nil)
expect(Cloudflare::CheckCustomHostnameService).not_to receive(:new)
expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new)
described_class.perform_now(0)
end
end
context 'when portal has no custom domain' do
it 'returns early' do
portal_without_domain = create(:portal, custom_domain: nil)
expect(Cloudflare::CheckCustomHostnameService).not_to receive(:new)
expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new)
described_class.perform_now(portal_without_domain.id)
end
end
context 'when portal exists with custom domain' do
it 'checks hostname status' do
check_service = instance_double(Cloudflare::CheckCustomHostnameService, perform: { data: 'success' })
expect(Cloudflare::CheckCustomHostnameService).to receive(:new).with(portal: portal).and_return(check_service)
expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new)
described_class.perform_now(portal.id)
end
it 'creates hostname when check returns errors' do
check_service = instance_double(Cloudflare::CheckCustomHostnameService, perform: { errors: ['Hostname is missing'] })
create_service = instance_double(Cloudflare::CreateCustomHostnameService, perform: { data: 'success' })
expect(Cloudflare::CheckCustomHostnameService).to receive(:new).with(portal: portal).and_return(check_service)
expect(Cloudflare::CreateCustomHostnameService).to receive(:new).with(portal: portal).and_return(create_service)
described_class.perform_now(portal.id)
end
end
end
end

View File

@@ -0,0 +1,59 @@
require 'rails_helper'
RSpec.describe Enterprise::Concerns::Portal do
describe '#enqueue_cloudflare_verification' do
let(:portal) { create(:portal, custom_domain: nil) }
context 'when custom_domain is changed' do
context 'when on chatwoot cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
end
it 'enqueues cloudflare verification job' do
expect do
portal.update(custom_domain: 'test.example.com')
end.to have_enqueued_job(Enterprise::CloudflareVerificationJob).with(portal.id)
end
end
context 'when not on chatwoot cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
end
it 'does not enqueue cloudflare verification job' do
expect do
portal.update(custom_domain: 'test.example.com')
end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob)
end
end
end
context 'when custom_domain is not changed' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
portal.update(custom_domain: 'test.example.com')
end
it 'does not enqueue cloudflare verification job' do
expect do
portal.update(name: 'New Name')
end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob)
end
end
context 'when custom_domain is set to blank' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
portal.update(custom_domain: 'test.example.com')
end
it 'does not enqueue cloudflare verification job' do
expect do
portal.update(custom_domain: '')
end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob)
end
end
end
end

View File

@@ -0,0 +1,111 @@
require 'rails_helper'
RSpec.describe Cloudflare::CheckCustomHostnameService do
let(:portal) { create(:portal, custom_domain: 'test.example.com') }
let(:installation_config_api_key) { create(:installation_config, name: 'CLOUDFLARE_API_KEY', value: 'test-api-key') }
let(:installation_config_zone_id) { create(:installation_config, name: 'CLOUDFLARE_ZONE_ID', value: 'test-zone-id') }
describe '#perform' do
context 'when API token or zone ID is not found' do
it 'returns error when API token is missing' do
installation_config_zone_id
service = described_class.new(portal: portal)
result = service.perform
expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
end
it 'returns error when zone ID is missing' do
installation_config_api_key
service = described_class.new(portal: portal)
result = service.perform
expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
end
end
context 'when no hostname ID is found' do
it 'returns error' do
installation_config_api_key
installation_config_zone_id
portal.update(custom_domain: nil)
service = described_class.new(portal: portal)
result = service.perform
expect(result).to eq(errors: ['No custom domain found'])
end
end
context 'when API request is made' do
before do
installation_config_api_key
installation_config_zone_id
end
context 'when API request fails' do
it 'returns error response' do
service = described_class.new(portal: portal)
error_response = {
'errors' => [{ 'message' => 'API error' }]
}
stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com')
.to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
expect(result[:errors]).to eq(error_response['errors'])
end
end
context 'when API request succeeds but no data is returned' do
it 'returns hostname missing error' do
service = described_class.new(portal: portal)
success_response = {
'result' => []
}
stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com')
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
expect(result).to eq(errors: ['Hostname is missing in Cloudflare'])
end
end
context 'when API request succeeds and data is returned' do
it 'updates portal SSL settings and returns success' do
service = described_class.new(portal: portal)
success_response = {
'result' => [
{
'ownership_verification_http' => {
'http_url' => 'http://example.com/.well-known/cf-verification/verification-id',
'http_body' => 'verification-body'
}
}
]
}
stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com')
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
expect(portal).to receive(:update).with(
ssl_settings: {
'cf_verification_id': 'verification-id',
'cf_verification_body': 'verification-body'
}
)
result = service.perform
expect(result).to eq(data: success_response['result'])
end
end
end
end
end

View File

@@ -0,0 +1,111 @@
require 'rails_helper'
RSpec.describe Cloudflare::CreateCustomHostnameService do
let(:portal) { create(:portal, custom_domain: 'test.example.com') }
let(:installation_config_api_key) { create(:installation_config, name: 'CLOUDFLARE_API_KEY', value: 'test-api-key') }
let(:installation_config_zone_id) { create(:installation_config, name: 'CLOUDFLARE_ZONE_ID', value: 'test-zone-id') }
describe '#perform' do
context 'when API token or zone ID is not found' do
it 'returns error when API token is missing' do
installation_config_zone_id
service = described_class.new(portal: portal)
result = service.perform
expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
end
it 'returns error when zone ID is missing' do
installation_config_api_key
service = described_class.new(portal: portal)
result = service.perform
expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
end
end
context 'when no hostname is found' do
it 'returns error' do
installation_config_api_key
installation_config_zone_id
portal.update(custom_domain: nil)
service = described_class.new(portal: portal)
result = service.perform
expect(result).to eq(errors: ['No hostname found'])
end
end
context 'when API request is made' do
before do
installation_config_api_key
installation_config_zone_id
end
context 'when API request fails' do
it 'returns error response' do
service = described_class.new(portal: portal)
error_response = {
'errors' => [{ 'message' => 'API error' }]
}
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
body: { hostname: 'test.example.com' }.to_json)
.to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
expect(result[:errors]).to eq(error_response['errors'])
end
end
context 'when API request succeeds but no data is returned' do
it 'returns hostname creation error' do
service = described_class.new(portal: portal)
success_response = {
'result' => nil
}
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
body: { hostname: 'test.example.com' }.to_json)
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
expect(result).to eq(errors: ['Could not create hostname'])
end
end
context 'when API request succeeds and data is returned' do
it 'updates portal SSL settings and returns success' do
service = described_class.new(portal: portal)
success_response = {
'result' => {
'ownership_verification_http' => {
'http_url' => 'http://example.com/.well-known/cf-verification/verification-id',
'http_body' => 'verification-body'
}
}
}
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
body: { hostname: 'test.example.com' }.to_json)
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
expect(portal).to receive(:update).with(ssl_settings: { 'cf_verification_id': 'verification-id',
'cf_verification_body': 'verification-body' })
result = service.perform
expect(result).to eq(data: success_response['result'])
end
end
end
end
end