Look South

By Dave South

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