Compare commits

...
3 Commits
17 changed files with 869 additions and 95 deletions
+40 -1
View File
@@ -98,9 +98,48 @@ class DomainsController < ApplicationController
redirect_to [:verify, organization, @server, @domain], alert: "You can't set up DNS for this domain until it has been verified."
end
def regenerate_dkim
if @domain.pending_dkim_key?
redirect_to_with_json [:setup, organization, @server, @domain],
alert: "A DKIM key change is already in progress for #{@domain.name}. Publish the new " \
"record shown below, or cancel the change, before generating another key."
return
end
was_verified = @domain.dkim_status == "OK"
@domain.regenerate_dkim_key!
if was_verified
redirect_to_with_json [:setup, organization, @server, @domain],
notice: "A new DKIM key has been generated for #{@domain.name}. Add the new DNS record below — " \
"your existing key will remain active until the new record has been verified. Keep the " \
"old DNS record published afterward while messages signed with it may still be queued, " \
"held, or available for redelivery."
else
redirect_to_with_json [:setup, organization, @server, @domain], notice: "A new DKIM key has been generated for #{@domain.name}. Update your DKIM DNS record with the new value below."
end
end
def cancel_dkim_regeneration
@domain.cancel_pending_dkim_key!
redirect_to_with_json [:setup, organization, @server, @domain], notice: "The pending DKIM key for #{@domain.name} has been discarded. Your existing key remains active."
end
def check
had_pending_dkim_key = @domain.pending_dkim_key?
previous_dkim_record_name = @domain.dkim_record_name
if @domain.check_dns(:manual)
redirect_to_with_json [organization, @server, :domains], notice: "Your DNS records for #{@domain.name} look good!"
if had_pending_dkim_key && !@domain.pending_dkim_key?
redirect_to_with_json [:setup, organization, @server, @domain],
notice: "Your DNS records for #{@domain.name} look good! Your new DKIM key is now active. " \
"Keep the old record at #{previous_dkim_record_name} published while messages " \
"signed with the old key may still be queued, held, or available for redelivery."
elsif @domain.pending_dkim_key?
redirect_to_with_json [:setup, organization, @server, @domain],
alert: "We couldn't verify the record for your new DKIM key yet. Your existing key remains " \
"active. Check below for the expected record details."
else
redirect_to_with_json [organization, @server, :domains], notice: "Your DNS records for #{@domain.name} look good!"
end
else
redirect_to_with_json [:setup, organization, @server, @domain], alert: "There seems to be something wrong with your DNS records. Check below for information."
end
+21
View File
@@ -72,6 +72,17 @@ module HasDNSChecks
#
def check_dkim_record
# If a new DKIM key is waiting to be activated, promote it to be the active
# key as soon as its DNS record is in place. The matching DNS response has
# already verified the key, so avoid a second lookup which could return a
# different answer while DNS changes are propagating.
if pending_dkim_key? && pending_dkim_record_live?
activate_pending_dkim_key
self.dkim_status = "OK"
self.dkim_error = nil
return true
end
domain = "#{dkim_record_name}.#{name}"
records = resolver.txt(domain)
if records.empty?
@@ -98,6 +109,16 @@ module HasDNSChecks
save!
end
# Returns true if the DNS record for the pending DKIM key has been published
# correctly.
def pending_dkim_record_live?
records = resolver.txt("#{pending_dkim_record_name}.#{name}")
return false unless records.size == 1
sanitised_record = records.first.strip.ends_with?(";") ? records.first.strip : "#{records.first.strip};"
sanitised_record == pending_dkim_record
end
#
# MX
#
+123 -36
View File
@@ -4,31 +4,33 @@
#
# Table name: domains
#
# id :integer not null, primary key
# server_id :integer
# uuid :string(255)
# name :string(255)
# verification_token :string(255)
# verification_method :string(255)
# verified_at :datetime
# dkim_private_key :text(65535)
# created_at :datetime
# updated_at :datetime
# dns_checked_at :datetime
# spf_status :string(255)
# spf_error :string(255)
# dkim_status :string(255)
# dkim_error :string(255)
# mx_status :string(255)
# mx_error :string(255)
# return_path_status :string(255)
# return_path_error :string(255)
# outgoing :boolean default(TRUE)
# incoming :boolean default(TRUE)
# owner_type :string(255)
# owner_id :integer
# dkim_identifier_string :string(255)
# use_for_any :boolean
# id :integer not null, primary key
# dkim_error :string(255)
# dkim_identifier_string :string(255)
# dkim_private_key :text(65535)
# dkim_status :string(255)
# dns_checked_at :datetime
# incoming :boolean default(TRUE)
# mx_error :string(255)
# mx_status :string(255)
# name :string(255)
# outgoing :boolean default(TRUE)
# owner_type :string(255)
# pending_dkim_identifier_string :string(255)
# pending_dkim_private_key :text(65535)
# return_path_error :string(255)
# return_path_status :string(255)
# spf_error :string(255)
# spf_status :string(255)
# use_for_any :boolean
# uuid :string(255)
# verification_method :string(255)
# verification_token :string(255)
# verified_at :datetime
# created_at :datetime
# updated_at :datetime
# owner_id :integer
# server_id :integer
#
# Indexes
#
@@ -82,7 +84,7 @@ class Domain < ApplicationRecord
end
def generate_dkim_key
self.dkim_private_key = OpenSSL::PKey::RSA.new(1024).to_s
self.dkim_private_key = OpenSSL::PKey::RSA.new(Postal::Config.dns.dkim_key_size).to_s
end
def dkim_key
@@ -91,6 +93,57 @@ class Domain < ApplicationRecord
@dkim_key ||= OpenSSL::PKey::RSA.new(dkim_private_key)
end
def pending_dkim_key
return nil unless pending_dkim_private_key
@pending_dkim_key ||= OpenSSL::PKey::RSA.new(pending_dkim_private_key)
end
def pending_dkim_key?
pending_dkim_private_key.present?
end
# Generate a new DKIM key for this domain. If the current key is verified and
# in use for signing, the new key is stored as a pending key under a new
# identifier so that signing continues with the current key until the new
# key's DNS record has been published and verified. Otherwise, the key is
# replaced immediately and any in-flight key change is abandoned, since the
# new key supersedes it.
def regenerate_dkim_key!
if dkim_status == "OK"
self.pending_dkim_private_key = OpenSSL::PKey::RSA.new(Postal::Config.dns.dkim_key_size).to_s
self.pending_dkim_identifier_string = generate_unique_dkim_identifier_string
else
generate_dkim_key
self.dkim_status = nil
self.dkim_error = nil
self.pending_dkim_private_key = nil
self.pending_dkim_identifier_string = nil
@dkim_key = nil
end
@pending_dkim_key = nil
save!
end
# Promote the pending DKIM key to be the active key. Does not save the record.
def activate_pending_dkim_key
return unless pending_dkim_key?
self.dkim_private_key = pending_dkim_private_key
self.dkim_identifier_string = pending_dkim_identifier_string
self.pending_dkim_private_key = nil
self.pending_dkim_identifier_string = nil
@dkim_key = nil
@pending_dkim_key = nil
end
def cancel_pending_dkim_key!
self.pending_dkim_private_key = nil
self.pending_dkim_identifier_string = nil
@pending_dkim_key = nil
save!
end
def to_param
uuid
end
@@ -108,23 +161,27 @@ class Domain < ApplicationRecord
end
def dkim_record
return if dkim_key.nil?
public_key = dkim_key.public_key.to_s.gsub(/-+[A-Z ]+-+\n/, "").gsub(/\n/, "")
"v=DKIM1; t=s; h=sha256; p=#{public_key};"
build_dkim_record(dkim_key)
end
def dkim_identifier
return nil unless dkim_identifier_string
Postal::Config.dns.dkim_identifier + "-#{dkim_identifier_string}"
build_dkim_identifier(dkim_identifier_string)
end
def dkim_record_name
identifier = dkim_identifier
return if identifier.nil?
build_dkim_record_name(dkim_identifier)
end
"#{identifier}._domainkey"
def pending_dkim_record
build_dkim_record(pending_dkim_key)
end
def pending_dkim_identifier
build_dkim_identifier(pending_dkim_identifier_string)
end
def pending_dkim_record_name
build_dkim_record_name(pending_dkim_identifier)
end
def return_path_domain
@@ -160,6 +217,36 @@ class Domain < ApplicationRecord
private
def build_dkim_record(key)
return if key.nil?
public_key = key.public_key.to_s.gsub(/-+[A-Z ]+-+\n/, "").gsub(/\n/, "")
"v=DKIM1; t=s; h=sha256; p=#{public_key};"
end
def build_dkim_identifier(identifier_string)
return nil unless identifier_string
Postal::Config.dns.dkim_identifier + "-#{identifier_string}"
end
def build_dkim_record_name(identifier)
return if identifier.nil?
"#{identifier}._domainkey"
end
# Generates a random identifier string in the same format as the
# `random_string :dkim_identifier_string` declaration, unique across both the
# active and pending identifier columns.
def generate_unique_dkim_identifier_string
loop do
string = Nifty::Utils::RandomString.generate(length: 6, upper_letters_only: true)
scope = self.class.where(dkim_identifier_string: string).or(self.class.where(pending_dkim_identifier_string: string))
return string unless scope.exists?
end
end
def update_verification_token_on_method_change
return unless verification_method_changed?
+2
View File
@@ -34,6 +34,8 @@
= link_to domain.name, [:setup, organization, @server, domain]
- if domain.use_for_any?
%span.label.label--blue Any
- if domain.pending_dkim_key?
= link_to "New DKIM key pending", [:setup, organization, @server, domain], :class => "label label--orange", :title => "A new DKIM key is waiting for its DNS record to be published."
%ul.domainList__checks
- if domain.spf_status == 'OK'
%li.domainList__check.domainList__check--ok SPF
+33 -5
View File
@@ -47,7 +47,11 @@
%pre.codeBlock.u-margin= @domain.spf_record
%h3.pageContent__subTitle DKIM Record
- if @domain.dkim_status == 'OK'
- if @domain.pending_dkim_key?
%p.pageContent__text.u-orange.u-bold
%span.label.label--orange Key change in progress
You've generated a new DKIM key which isn't active yet. Both records are shown below.
- elsif @domain.dkim_status == 'OK'
%p.pageContent__text.u-green.u-bold
%span.label.label--green Good
Your DKIM record looks good!
@@ -56,10 +60,34 @@
%span.label.label--orange Warning
= @domain.dkim_error
%p.pageContent__text
You need to add a new TXT record with the name <b>#{@domain.dkim_record_name}</b>
with the following content.
%pre.codeBlock.u-margin= @domain.dkim_record
- if @domain.pending_dkim_key?
%p.pageContent__text.u-bold
%span.label.label--green Current
Keep this record exactly as it is
%p.pageContent__text
This is the record you already have published at <b>#{@domain.dkim_record_name}</b>.
It's signing your mail right now, so leave it in place.
%pre.codeBlock.u-margin= @domain.dkim_record
%p.pageContent__text.u-bold
%span.label.label--orange New
Add this record as well
%p.pageContent__text
Add a <b>second</b> TXT record with the name <b>#{@domain.pending_dkim_record_name}</b>
and the content below, alongside the record above rather than replacing it. Once we detect
it (checked hourly, or when you use the button above) we'll switch to signing with the new
key automatically. After the switch, keep the old record published while messages signed
with it may still be queued, held, or available for redelivery.
%pre.codeBlock.u-margin= @domain.pending_dkim_record
%p.pageContent__text
= link_to "Cancel new DKIM key", [:cancel_dkim_regeneration, organization, @server, @domain], :remote => true, :method => :post, :data => { :confirm => "Are you sure? The new key will be discarded and your existing key will remain active." }, :class => 'button button--neutral'
- else
%p.pageContent__text
You need to add a new TXT record with the name <b>#{@domain.dkim_record_name}</b>
with the following content.
%pre.codeBlock.u-margin= @domain.dkim_record
%p.pageContent__text
= link_to "Regenerate DKIM key", [:regenerate_dkim, organization, @server, @domain], :remote => true, :method => :post, :data => { :confirm => "Are you sure you wish to generate a new DKIM key? You'll need to add a new DNS record before the new key can be used." }, :class => 'button button--neutral'
%h3.pageContent__subTitle Return Path
- if @domain.return_path_status == 'OK'
+4
View File
@@ -12,12 +12,16 @@ Rails.application.routes.draw do
match :verify, on: :member, via: [:get, :post]
get :setup, on: :member
post :check, on: :member
post :regenerate_dkim, on: :member
post :cancel_dkim_regeneration, on: :member
end
resources :servers, except: [:index] do
resources :domains, only: [:index, :new, :create, :destroy] do
match :verify, on: :member, via: [:get, :post]
get :setup, on: :member
post :check, on: :member
post :regenerate_dkim, on: :member
post :cancel_dkim_regeneration, on: :member
end
resources :track_domains do
post :toggle_ssl, on: :member
@@ -0,0 +1,10 @@
# frozen_string_literal: true
class AddPendingDKIMFieldsToDomains < ActiveRecord::Migration[7.0]
def change
add_column :domains, :pending_dkim_private_key, :text
add_column :domains, :pending_dkim_identifier_string, :string
end
end
+3 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_03_11_205229) do
ActiveRecord::Schema[7.1].define(version: 2026_09_03_100000) do
create_table "additional_route_endpoints", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "route_id"
t.string "endpoint_type"
@@ -99,6 +99,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_03_11_205229) do
t.integer "owner_id"
t.string "dkim_identifier_string"
t.boolean "use_for_any"
t.text "pending_dkim_private_key"
t.string "pending_dkim_identifier_string"
t.index ["server_id"], name: "index_domains_on_server_id"
t.index ["uuid"], name: "index_domains_on_uuid", length: 8
end
+2
View File
@@ -43,6 +43,7 @@ This document contains all the environment variables which are available for thi
| `LOGGING_RAILS_LOG_ENABLED` | Boolean | Enable the default Rails logger | false |
| `LOGGING_SENTRY_DSN` | String | A DSN which should be used to report exceptions to Sentry | |
| `LOGGING_ENABLED` | Boolean | Enable the Postal logger to log to STDOUT | true |
| `LOGGING_LEVEL` | String | The minimum log level for the Postal logger (debug, info, warn, error, fatal) | INFO |
| `LOGGING_HIGHLIGHTING_ENABLED` | Boolean | Enable highlighting of log lines | false |
| `GELF_HOST` | String | GELF-capable host to send logs to | |
| `GELF_PORT` | Integer | GELF port to send logs to | 12201 |
@@ -67,6 +68,7 @@ This document contains all the environment variables which are available for thi
| `DNS_TRACK_DOMAIN` | String | The CNAME which tracking domains should be pointed to | track.postal.example.com |
| `DNS_HELO_HOSTNAME` | String | The hostname to use in HELO/EHLO when connecting to external SMTP servers | |
| `DNS_DKIM_IDENTIFIER` | String | The identifier to use for DKIM keys in DNS records | postal |
| `DNS_DKIM_KEY_SIZE` | Integer | The size (in bits) of RSA key to generate for DKIM signing (one of 1024, 2048, 3072 or 4096). Note that records for 2048-bit and larger keys exceed 255 characters and must be published as a split (multi-string) TXT record. | 2048 |
| `DNS_DOMAIN_VERIFY_PREFIX` | String | The prefix to add before TXT record verification string | postal-verification |
| `DNS_CUSTOM_RETURN_PATH_PREFIX` | String | The domain to use on external domains which points to the Postal return path domain | psrp |
| `DNS_TIMEOUT` | Integer | The timeout to wait for DNS resolution | 5 |
+4
View File
@@ -89,6 +89,8 @@ logging:
sentry_dsn:
# Enable the Postal logger to log to STDOUT
enabled: true
# The minimum log level for the Postal logger (debug, info, warn, error, fatal)
level: INFO
# Enable highlighting of log lines
highlighting_enabled: false
@@ -145,6 +147,8 @@ dns:
helo_hostname:
# The identifier to use for DKIM keys in DNS records
dkim_identifier: postal
# The size (in bits) of RSA key to generate for DKIM signing (one of 1024, 2048, 3072 or 4096). Note that records for 2048-bit and larger keys exceed 255 characters and must be published as a split (multi-string) TXT record.
dkim_key_size: 2048
# The prefix to add before TXT record verification string
domain_verify_prefix: postal-verification
# The domain to use on external domains which points to the Postal return path domain
+8 -2
View File
@@ -11,6 +11,7 @@ require "konfig/sources/environment"
require "konfig/sources/yaml"
require "dotenv"
require "klogger"
require "securerandom"
require_relative "error"
require_relative "config_schema"
@@ -82,16 +83,21 @@ module Postal
def logger
@logger ||= begin
k = Klogger.new(nil, destination: Config.logging.enabled? ? $stdout : "/dev/null", highlight: Config.logging.highlighting_enabled?)
k.level = Config.logging.level
k.add_destination(graylog_logging_destination) if Config.gelf.host.present?
k
end
end
def process_identity
@process_identity ||= SecureRandom.hex(8)
end
def process_name
@process_name ||= begin
"host:#{Socket.gethostname} pid:#{Process.pid}"
"host:#{Socket.gethostname} pid:#{Process.pid} id:#{process_identity}"
rescue StandardError
"pid:#{Process.pid}"
"pid:#{Process.pid} id:#{process_identity}"
end
end
+27
View File
@@ -227,6 +227,19 @@ module Postal
default true
end
string :level do
description "The minimum log level for the Postal logger (debug, info, warn, error, fatal)"
default "INFO"
transform do |value|
normalized = value.to_s.strip.downcase
normalized = "info" if normalized.empty?
unless %w[debug info warn error fatal].include?(normalized)
raise ArgumentError, "logging.level must be one of: debug, info, warn, error, fatal (got #{value.inspect})"
end
normalized
end
end
boolean :highlighting_enabled do
description "Enable highlighting of log lines"
default false
@@ -352,6 +365,20 @@ module Postal
default "postal"
end
integer :dkim_key_size do
description "The size (in bits) of RSA key to generate for DKIM signing (one of 1024, 2048, 3072 or 4096). " \
"Note that records for 2048-bit and larger keys exceed 255 characters and must be " \
"published as a split (multi-string) TXT record."
default 2048
transform do |value|
unless value.nil? || [1024, 2048, 3072, 4096].include?(value)
raise Konfig::Error, "dns.dkim_key_size must be one of 1024, 2048, 3072 or 4096 (got #{value})"
end
value
end
end
string :domain_verify_prefix do
description "The prefix to add before TXT record verification string"
default "postal-verification"
+27 -25
View File
@@ -4,31 +4,33 @@
#
# Table name: domains
#
# id :integer not null, primary key
# server_id :integer
# uuid :string(255)
# name :string(255)
# verification_token :string(255)
# verification_method :string(255)
# verified_at :datetime
# dkim_private_key :text(65535)
# created_at :datetime
# updated_at :datetime
# dns_checked_at :datetime
# spf_status :string(255)
# spf_error :string(255)
# dkim_status :string(255)
# dkim_error :string(255)
# mx_status :string(255)
# mx_error :string(255)
# return_path_status :string(255)
# return_path_error :string(255)
# outgoing :boolean default(TRUE)
# incoming :boolean default(TRUE)
# owner_type :string(255)
# owner_id :integer
# dkim_identifier_string :string(255)
# use_for_any :boolean
# id :integer not null, primary key
# dkim_error :string(255)
# dkim_identifier_string :string(255)
# dkim_private_key :text(65535)
# dkim_status :string(255)
# dns_checked_at :datetime
# incoming :boolean default(TRUE)
# mx_error :string(255)
# mx_status :string(255)
# name :string(255)
# outgoing :boolean default(TRUE)
# owner_type :string(255)
# pending_dkim_identifier_string :string(255)
# pending_dkim_private_key :text(65535)
# return_path_error :string(255)
# return_path_status :string(255)
# spf_error :string(255)
# spf_status :string(255)
# use_for_any :boolean
# uuid :string(255)
# verification_method :string(255)
# verification_token :string(255)
# verified_at :datetime
# created_at :datetime
# updated_at :datetime
# owner_id :integer
# server_id :integer
#
# Indexes
#
+300 -25
View File
@@ -4,31 +4,33 @@
#
# Table name: domains
#
# id :integer not null, primary key
# dkim_error :string(255)
# dkim_identifier_string :string(255)
# dkim_private_key :text(65535)
# dkim_status :string(255)
# dns_checked_at :datetime
# incoming :boolean default(TRUE)
# mx_error :string(255)
# mx_status :string(255)
# name :string(255)
# outgoing :boolean default(TRUE)
# owner_type :string(255)
# return_path_error :string(255)
# return_path_status :string(255)
# spf_error :string(255)
# spf_status :string(255)
# use_for_any :boolean
# uuid :string(255)
# verification_method :string(255)
# verification_token :string(255)
# verified_at :datetime
# created_at :datetime
# updated_at :datetime
# owner_id :integer
# server_id :integer
# id :integer not null, primary key
# dkim_error :string(255)
# dkim_identifier_string :string(255)
# dkim_private_key :text(65535)
# dkim_status :string(255)
# dns_checked_at :datetime
# incoming :boolean default(TRUE)
# mx_error :string(255)
# mx_status :string(255)
# name :string(255)
# outgoing :boolean default(TRUE)
# owner_type :string(255)
# pending_dkim_identifier_string :string(255)
# pending_dkim_private_key :text(65535)
# return_path_error :string(255)
# return_path_status :string(255)
# spf_error :string(255)
# spf_status :string(255)
# use_for_any :boolean
# uuid :string(255)
# verification_method :string(255)
# verification_token :string(255)
# verified_at :datetime
# created_at :datetime
# updated_at :datetime
# owner_id :integer
# server_id :integer
#
# Indexes
#
@@ -162,6 +164,22 @@ describe Domain do
it "generates a new dkim key" do
expect { domain.generate_dkim_key }.to change { domain.dkim_private_key }.from(nil).to(match(/\A-+BEGIN RSA PRIVATE KEY-+/))
end
it "generates a key of the configured size" do
domain.generate_dkim_key
expect(OpenSSL::PKey::RSA.new(domain.dkim_private_key).n.num_bits).to eq Postal::Config.dns.dkim_key_size
end
context "when a different key size is configured" do
before do
allow(Postal::Config.dns).to receive(:dkim_key_size).and_return(1024)
end
it "generates a key of that size" do
domain.generate_dkim_key
expect(OpenSSL::PKey::RSA.new(domain.dkim_private_key).n.num_bits).to eq 1024
end
end
end
describe "#dkim_key" do
@@ -183,6 +201,138 @@ describe Domain do
end
end
describe "#pending_dkim_key" do
context "when the domain has a pending DKIM key" do
let(:domain) { create(:domain, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
end
it "returns the pending dkim key as a OpenSSL::PKey::RSA" do
expect(domain.pending_dkim_key).to be_a OpenSSL::PKey::RSA
expect(domain.pending_dkim_key.to_s).to eq domain.pending_dkim_private_key
end
end
context "when the domain has no pending DKIM key" do
let(:domain) { create(:domain) }
it "returns nil" do
expect(domain.pending_dkim_key).to be_nil
end
end
end
describe "#regenerate_dkim_key!" do
context "when the current DKIM record is verified" do
let(:domain) { create(:domain, dkim_status: "OK") }
it "stores the new key as a pending key with a new identifier" do
domain.regenerate_dkim_key!
expect(domain.pending_dkim_private_key).to match(/\A-+BEGIN RSA PRIVATE KEY-+/)
expect(domain.pending_dkim_identifier_string).to be_present
expect(domain.pending_dkim_identifier_string).to_not eq domain.dkim_identifier_string
end
it "does not change the active key or status" do
expect { domain.regenerate_dkim_key! }.to_not(change { domain.reload.dkim_private_key })
expect(domain.dkim_status).to eq "OK"
end
end
context "when the current DKIM record is not verified" do
let(:domain) { create(:domain, dkim_status: "Missing", dkim_error: "No TXT records") }
it "replaces the active key immediately without creating a pending key" do
original_key = domain.dkim_key.to_s
domain.regenerate_dkim_key!
expect(domain.reload.dkim_private_key).to_not eq original_key
expect(domain.pending_dkim_private_key).to be_nil
expect(domain.dkim_key.to_s).to eq domain.dkim_private_key
end
it "resets the DKIM status" do
domain.regenerate_dkim_key!
expect(domain.dkim_status).to be_nil
expect(domain.dkim_error).to be_nil
end
context "when a key change was already in progress" do
# This happens when the active record breaks (so the status is no longer
# "OK") while a pending key is waiting to be activated. The immediate
# replacement supersedes the in-flight change, so the stale pending key
# must not be left behind to be activated later.
let(:domain) { create(:domain, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
domain.update!(dkim_status: "Missing", dkim_error: "No TXT records")
end
it "abandons the pending key" do
expect(domain.pending_dkim_private_key).to_not be_nil
domain.regenerate_dkim_key!
expect(domain.reload.pending_dkim_private_key).to be_nil
expect(domain.pending_dkim_identifier_string).to be_nil
expect(domain.pending_dkim_key).to be_nil
end
it "replaces the active key rather than promoting the pending one" do
stale_pending_key = domain.pending_dkim_private_key
original_key = domain.dkim_private_key
domain.regenerate_dkim_key!
expect(domain.reload.dkim_private_key).to_not eq original_key
expect(domain.dkim_private_key).to_not eq stale_pending_key
end
end
end
end
describe "#activate_pending_dkim_key" do
context "when there is no pending key" do
let(:domain) { create(:domain) }
it "does nothing" do
expect { domain.activate_pending_dkim_key }.to_not(change { domain.dkim_private_key })
end
end
context "when there is a pending key" do
let(:domain) { create(:domain, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
end
it "promotes the pending key and identifier and clears the pending values" do
pending_key = domain.pending_dkim_private_key
pending_identifier = domain.pending_dkim_identifier_string
domain.activate_pending_dkim_key
expect(domain.dkim_private_key).to eq pending_key
expect(domain.dkim_identifier_string).to eq pending_identifier
expect(domain.pending_dkim_private_key).to be_nil
expect(domain.pending_dkim_identifier_string).to be_nil
expect(domain.dkim_key.to_s).to eq pending_key
expect(domain.pending_dkim_key).to be_nil
end
end
end
describe "#cancel_pending_dkim_key!" do
let(:domain) { create(:domain, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
end
it "discards the pending key without changing the active key" do
expect { domain.cancel_pending_dkim_key! }.to_not(change { domain.reload.dkim_private_key })
expect(domain.pending_dkim_private_key).to be_nil
expect(domain.pending_dkim_identifier_string).to be_nil
end
end
describe "#to_param" do
context "when the domain has not been saved" do
it "returns nil" do
@@ -274,6 +424,131 @@ describe Domain do
end
end
describe "#pending_dkim_record" do
context "when the domain has no pending DKIM key" do
it "returns nil" do
expect(domain.pending_dkim_record).to be_nil
end
end
context "when the domain has a pending DKIM key" do
let(:domain) { create(:domain, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
end
it "returns the DKIM record for the pending key" do
expect(domain.pending_dkim_record).to match(/\Av=DKIM1; t=s; h=sha256; p=.*;\z/)
expect(domain.pending_dkim_record).to_not eq domain.dkim_record
end
it "returns a record name using the pending identifier" do
expect(domain.pending_dkim_record_name).to eq "#{Postal::Config.dns.dkim_identifier}-#{domain.pending_dkim_identifier_string}._domainkey"
end
end
end
describe "#check_dkim_record" do
let(:domain) { create(:domain, dkim_status: "OK") }
let(:resolver) { instance_double(DNSResolver) }
before do
allow(domain).to receive(:resolver).and_return(resolver)
end
context "when there is a pending DKIM key" do
before do
domain.regenerate_dkim_key!
end
context "when the pending record has been published" do
it "activates and verifies the pending key from a single DNS response" do
responses = [[domain.pending_dkim_record], []]
allow(resolver).to receive(:txt).with("#{domain.pending_dkim_record_name}.#{domain.name}") { responses.shift }
pending_key = domain.pending_dkim_private_key
domain.check_dkim_record
expect(domain.dkim_private_key).to eq pending_key
expect(domain.pending_dkim_private_key).to be_nil
expect(domain.dkim_status).to eq "OK"
expect(domain.dkim_error).to be_nil
expect(responses).to eq([[]])
end
end
context "when multiple pending records have been published" do
before do
allow(resolver).to receive(:txt).with("#{domain.pending_dkim_record_name}.#{domain.name}").and_return([domain.pending_dkim_record, domain.pending_dkim_record])
allow(resolver).to receive(:txt).with("#{domain.dkim_record_name}.#{domain.name}").and_return([domain.dkim_record])
end
it "keeps the current key active and retains the pending key" do
original_key = domain.dkim_private_key
domain.check_dkim_record
expect(domain.dkim_private_key).to eq original_key
expect(domain.pending_dkim_private_key).to_not be_nil
expect(domain.dkim_status).to eq "OK"
end
end
context "when the pending record has not been published" do
before do
allow(resolver).to receive(:txt).with("#{domain.pending_dkim_record_name}.#{domain.name}").and_return([])
allow(resolver).to receive(:txt).with("#{domain.dkim_record_name}.#{domain.name}").and_return([domain.dkim_record])
end
it "keeps the current key active and retains the pending key" do
original_key = domain.dkim_private_key
domain.check_dkim_record
expect(domain.dkim_private_key).to eq original_key
expect(domain.pending_dkim_private_key).to_not be_nil
expect(domain.dkim_status).to eq "OK"
end
end
context "when the pending record does not match" do
before do
allow(resolver).to receive(:txt).with("#{domain.pending_dkim_record_name}.#{domain.name}").and_return(["v=DKIM1; t=s; h=sha256; p=something;"])
allow(resolver).to receive(:txt).with("#{domain.dkim_record_name}.#{domain.name}").and_return([domain.dkim_record])
end
it "keeps the current key active and retains the pending key" do
original_key = domain.dkim_private_key
domain.check_dkim_record
expect(domain.dkim_private_key).to eq original_key
expect(domain.pending_dkim_private_key).to_not be_nil
expect(domain.dkim_status).to eq "OK"
end
end
end
context "when there is no pending DKIM key" do
context "when the record is published" do
before do
allow(resolver).to receive(:txt).with("#{domain.dkim_record_name}.#{domain.name}").and_return([domain.dkim_record])
end
it "marks the record as OK" do
domain.check_dkim_record
expect(domain.dkim_status).to eq "OK"
expect(domain.dkim_error).to be_nil
end
end
context "when the record is missing" do
before do
allow(resolver).to receive(:txt).with("#{domain.dkim_record_name}.#{domain.name}").and_return([])
end
it "marks the record as missing" do
domain.check_dkim_record
expect(domain.dkim_status).to eq "Missing"
end
end
end
end
describe "#return_path_domain" do
it "returns the return path domain" do
expect(domain.return_path_domain).to eq "#{Postal::Config.dns.custom_return_path_prefix}.#{domain.name}"
+144
View File
@@ -0,0 +1,144 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "DomainsController", type: :request do
let(:user) { create(:user, admin: true) }
let(:organization) { create(:organization, owner: user) }
let(:server) { create(:server, organization: organization) }
before do
post "/login", params: { email_address: user.email_address, password: "passw0rd" }
end
describe "POST /org/:org/servers/:server/domains/:domain/regenerate_dkim" do
context "when the domain's DKIM record is verified" do
let(:domain) { create(:domain, owner: server, dkim_status: "OK") }
it "generates a pending DKIM key without changing the active key" do
original_key = domain.dkim_private_key
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/regenerate_dkim"
expect(response).to redirect_to(setup_organization_server_domain_path(organization, server, domain))
expect(flash[:notice]).to include("Keep the old DNS record published afterward")
expect(flash[:notice]).to include("queued, held, or available for redelivery")
expect(flash[:notice]).to_not include("you can remove the old record")
domain.reload
expect(domain.dkim_private_key).to eq original_key
expect(domain.pending_dkim_private_key).to_not be_nil
end
end
context "when the domain's DKIM record is not verified" do
let(:domain) { create(:domain, owner: server, dkim_status: "Missing") }
it "replaces the active key immediately" do
original_key = domain.dkim_private_key
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/regenerate_dkim"
expect(response).to redirect_to(setup_organization_server_domain_path(organization, server, domain))
domain.reload
expect(domain.dkim_private_key).to_not eq original_key
expect(domain.pending_dkim_private_key).to be_nil
expect(domain.dkim_status).to be_nil
end
end
context "when a key change is already in progress" do
let(:domain) { create(:domain, owner: server, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
end
it "refuses and leaves the pending key untouched" do
existing_pending_key = domain.pending_dkim_private_key
existing_pending_identifier = domain.pending_dkim_identifier_string
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/regenerate_dkim"
expect(response).to redirect_to(setup_organization_server_domain_path(organization, server, domain))
expect(flash[:alert]).to include("already in progress")
domain.reload
expect(domain.pending_dkim_private_key).to eq existing_pending_key
expect(domain.pending_dkim_identifier_string).to eq existing_pending_identifier
end
it "does not change the active key" do
expect do
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/regenerate_dkim"
end.to_not(change { domain.reload.dkim_private_key })
end
end
end
describe "POST /org/:org/domains/:domain/regenerate_dkim" do
let(:domain) { create(:domain, owner: organization, dkim_status: "OK") }
it "generates a pending DKIM key for an organization-owned domain" do
post "/org/#{organization.permalink}/domains/#{domain.uuid}/regenerate_dkim"
expect(response).to redirect_to(setup_organization_domain_path(organization, domain))
expect(domain.reload.pending_dkim_private_key).to_not be_nil
end
end
describe "POST /org/:org/servers/:server/domains/:domain/check with a pending DKIM key" do
let(:domain) { create(:domain, owner: server, dkim_status: "OK") }
let(:resolver) { instance_double(DNSResolver) }
before do
domain.regenerate_dkim_key!
allow(DNSResolver).to receive(:for_domain).and_return(resolver)
allow(resolver).to receive(:txt).with(domain.name).and_return([domain.spf_record])
allow(resolver).to receive(:mx).with(domain.name).and_return([])
allow(resolver).to receive(:cname).and_return([])
end
context "when the pending DKIM record has been published" do
before do
allow(resolver).to receive(:txt).with("#{domain.pending_dkim_record_name}.#{domain.name}").and_return([domain.pending_dkim_record])
end
it "activates the pending key and tells the user to retain the old record" do
old_record_name = domain.dkim_record_name
new_key = domain.pending_dkim_private_key
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/check"
expect(response).to redirect_to(setup_organization_server_domain_path(organization, server, domain))
expect(flash[:notice]).to include("new DKIM key is now active")
expect(flash[:notice]).to include(old_record_name)
expect(flash[:notice]).to include("queued, held, or available for redelivery")
expect(flash[:notice]).to_not include("can remove")
domain.reload
expect(domain.dkim_private_key).to eq new_key
expect(domain.pending_dkim_private_key).to be_nil
expect(domain.dkim_status).to eq "OK"
end
end
context "when the pending DKIM record has not been published" do
before do
allow(resolver).to receive(:txt).with("#{domain.pending_dkim_record_name}.#{domain.name}").and_return([])
allow(resolver).to receive(:txt).with("#{domain.dkim_record_name}.#{domain.name}").and_return([domain.dkim_record])
end
it "keeps the current key active and tells the user the new record wasn't verified" do
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/check"
expect(response).to redirect_to(setup_organization_server_domain_path(organization, server, domain))
expect(flash[:alert]).to include("couldn't verify the record for your new DKIM key")
domain.reload
expect(domain.pending_dkim_private_key).to_not be_nil
expect(domain.dkim_status).to eq "OK"
end
end
end
describe "POST /org/:org/servers/:server/domains/:domain/cancel_dkim_regeneration" do
let(:domain) { create(:domain, owner: server, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
end
it "discards the pending DKIM key" do
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/cancel_dkim_regeneration"
expect(response).to redirect_to(setup_organization_server_domain_path(organization, server, domain))
expect(domain.reload.pending_dkim_private_key).to be_nil
end
end
end
@@ -0,0 +1,48 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "domains/index", type: :view do
let(:user) { create(:user, admin: true) }
let(:organization) { create(:organization, owner: user) }
let(:domain) { create(:domain, owner: organization, dkim_status: "OK") }
before do
assign(:organization, organization)
assign(:domains, [domain])
assign(:server, nil)
without_partial_double_verification do
allow(view).to receive(:organization).and_return(organization)
allow(view).to receive(:current_user).and_return(user)
allow(view).to receive(:page_title).and_return(["Postal"])
end
end
context "when no key change is in progress" do
it "does not flag a pending DKIM key" do
render
expect(rendered).to_not include("New DKIM key pending")
end
end
context "when a key change is in progress" do
before do
domain.regenerate_dkim_key!
end
it "flags the domain as having a pending DKIM key" do
render
expect(rendered).to include("New DKIM key pending")
end
it "links the flag to the DNS setup page" do
render
expect(rendered).to include(setup_organization_domain_path(organization, domain))
end
it "still shows DKIM as OK because the active key is unaffected" do
render
expect(rendered).to include("domainList__check--ok")
end
end
end
@@ -0,0 +1,73 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "domains/setup", type: :view do
let(:user) { create(:user, admin: true) }
let(:organization) { create(:organization, owner: user) }
let(:domain) { create(:domain, owner: organization, dkim_status: "OK") }
before do
assign(:organization, organization)
assign(:domain, domain)
assign(:server, nil)
without_partial_double_verification do
allow(view).to receive(:organization).and_return(organization)
allow(view).to receive(:current_user).and_return(user)
allow(view).to receive(:page_title).and_return(["Postal"])
end
end
context "when no key change is in progress" do
it "shows the active record and offers regeneration" do
render
expect(rendered).to include(domain.dkim_record_name)
expect(rendered).to include(domain.dkim_record)
expect(rendered).to include("Regenerate DKIM key")
expect(rendered).to_not include("Key change in progress")
expect(rendered).to_not include("Cancel new DKIM key")
end
end
context "when a key change is in progress" do
before do
domain.regenerate_dkim_key!
end
it "shows both the current and the new record" do
render
expect(rendered).to include(domain.dkim_record_name)
expect(rendered).to include(domain.dkim_record)
expect(rendered).to include(domain.pending_dkim_record_name)
expect(rendered).to include(domain.pending_dkim_record)
end
it "labels which record is active and which must be added" do
render
expect(rendered).to include("Key change in progress")
expect(rendered).to include("Keep this record exactly as it is")
expect(rendered).to include("Add this record as well")
end
it "tells the user to add the new record rather than replace the old one" do
render
expect(rendered).to include("alongside the record above rather than replacing it")
end
it "does not describe the active record as one which needs adding" do
render
expect(rendered).to_not include("You need to add a new TXT record")
end
it "offers cancellation instead of another regeneration" do
render
expect(rendered).to include("Cancel new DKIM key")
expect(rendered).to_not include("Regenerate DKIM key")
end
it "does not claim the DKIM record is fully good" do
render
expect(rendered).to_not include("Your DKIM record looks good!")
end
end
end