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:
@@ -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
|
||||
59
spec/enterprise/models/enterprise/concerns/portal_spec.rb
Normal file
59
spec/enterprise/models/enterprise/concerns/portal_spec.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user