Rack CustomDomain converts CNAME hosts to subdomains
This method is out-of-date. For a far better way please read Rails subdomains, CNAMEs, and crzy.me.
When we created our CMS we used tried-and-true subdomains to separate editions. SubdomainFu handled the logic of separating editions and it was easy — until we added custom domains. The standard method is to point — via CNAME — the custom domain (www.davesouth.org) to the subdomain (davesouth.example.com). Unfortunately the rails app can’t use SubdomainFu routing.
Instead we have to manage recognizing both the full domain and the subdomain forms on all incoming requests. We went down this road for a while until we found a rack app that converts incoming domains into subdomains. Tyler Hunt’s example was a good starting point. I did a few tweaks and added a test and it worked well. The trick is to use DNS calls to resolve incoming domains into their subdomains and pass that to Rails using HTTP_X_FORWARDED_HOST.
The HTTP_X_FORWARDED_HOST is a string with both the domain and subdomain forms — “www.davesouth.org, davesouth.example.com.”. Rails gives HTTP_X_FORWARDED_HOST preference over HTTP_HOST for all requests. For page caching inside the rails app I also added X_CUSTOM_CNAME and X_CUSTOM_SUBDOMAIN to the environment so the app can create the proper page caching directories.
This approach guarantees all incoming requests to rails will be a subdomain. The rack middleware caches the requests so it doesn’t require a DNS call on every request. To force a canonical domain for each edition we use HTTP_X_FORWARDED_HOST to detect if the request came in on a non-canonical domain and forward if necessary.
Although this works fine, there are drawbacks. CustomDomain’s cache can use a lot of memory if you have a lot of editions. It’s best for sites hosting dozens not hundreds of editions. There are still two ways to represent editions that must be dealt with — which is real pain.
We’ve abandoned this approach for a much simpler method that I’ll write about it in the next article. However, I thought this code could be useful to someone so I’m publishing it.
Save custom_domain.rb in your middleware directory (lib/middleware). Add it to the load path. In your production environment add config.middleware.use "CustomDomain", ".example.com"
. Where .example.com
is your CMS domain.
require 'net/dns/resolver'
# Custom Domain
#
# Requires net-dns gem
#
# Rack middleware to resolve custom CNAME domains to subdomains and to
# enforce canonical namespace of host URL.
#
# It's all transparant to your application, it performs cname lookup and
# sets HTTP_X_FORWARDED_HOST if needed
#
# www.example.org => example.myapp.com
#
# I was going to enforce canonical name for rails application however,
# this configuration allows any CNAME to forward to the subdomain. The
# Rails app should redirect if subdomain and cname do not match records
#
# Credit: Inspired by http://codetunes.com/2009/04/17/dynamic-cookie-domains-with-racks-middleware/
# Credit: http://coderack.org/users/tylerhunt/middlewares/6-canonical-host
class CustomDomain
@@cache = {}
class Cname
def self.resolver(host, domain)
Net::DNS::Resolver.new.query(host).each_cname do |cname|
return cname.sub(/\.?$/, '') if cname.include?(domain)
end
end
end
def initialize(app, default_domain)
@app = app
@default_domain = default_domain
end
def call(env)
host = env['X_CUSTOM_CNAME'] = env['SERVER_NAME']
# If custom domain found, set forwarded host
if custom_domain?(host)
domain = env['X_CUSTOM_SUBDOMAIN'] = cname_lookup(host)
env['HTTP_X_FORWARDED_HOST'] = [host, domain].join(', ')
logger.info("CustomDomain: mapped #{host} => #{domain}") if defined?(Rails.logger)
end
@app.call(env)
end
def custom_domain?(host)
host !~ /#{@default_domain.sub(/^\./, '')}/i
end
def cname_lookup(host)
@@cache[host] ||= Cname.resolver(host, @default_domain)
end
def reverse_lookup(host)
@@cache.find {|k,v| v == host}
end
private
def logger
defined?(Rails.logger) ? Rails.logger : Logger.new(STDOUT)
end
end
Test uses Double-R for mocking DNS response.
require 'rr'
require 'custom_domain'
require 'rack/test'
# http://www.brynary.com/2009/3/5/rack-test-released-a-simple-testing-api-for-rack-based-frameworks-and-apps
# http://effectif.com/articles/testing-rails-with-rack-test
# http://gitrdoc.com/brynary/rack-test/tree/master
# http://github.com/brynary/rack-test
# http://jasonseifer.com/2009/04/08/32-rack-resources-to-get-you-started
# http://guides.rubyonrails.org/rails_on_rack.html
# http://rack.rubyforge.org/doc/SPEC.html
class CustomDomainTest < Test::Unit::TestCase
include Rack::Test::Methods
include RR::Adapters::TestUnit
def app
mock_app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Good Morning!"] }
app = CustomDomain.new(mock_app, '.local')
end
def test_cname_to_subdomain
mock(CustomDomain::Cname).resolver('www.example.com', '.local') { 'subdomain.local' }
get 'http://www.example.com:80/stories/123?search=recent'
assert_equal 'www.example.com, subdomain.local', last_request.env['HTTP_X_FORWARDED_HOST']
assert_equal 'www.example.com', last_request.env['SERVER_NAME']
assert_equal 'www.example.com', last_request.env['X_CUSTOM_CNAME']
assert_equal 'subdomain.local', last_request.env['X_CUSTOM_SUBDOMAIN']
end
def test_subdomain_passes_through_without_cname_in_cache
dont_allow(CustomDomain::Cname).resolver('subdomain.local', '.local')
get 'http://subdomain.local:80/stories/123?search=recent'
assert_equal 'subdomain.local', last_request.env['SERVER_NAME']
end
end