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