class LetsEncryptWorker class NotAuthorizedYetError < StandardError; end class VerificationTimeoutError < StandardError; end class InvalidAuthError < StandardError; end class VerifyNotFoundWithDomain < StandardError; end include Sidekiq::Worker sidekiq_options queue: :lets_encrypt_worker, retry: 10, backtrace: true sidekiq_retry_in do |count| 5.minutes.to_i * count end # If you need to clear scheduled jobs: # Sidekiq::ScheduledSet.new.select {|s| JSON.parse(s.value)['class'] == 'LetsEncryptWorker'}.each {|j| j.delete} def letsencrypt Acme::Client.new( private_key: OpenSSL::PKey::RSA.new(File.read($config['letsencrypt_key'])), endpoint: $config['letsencrypt_endpoint'] ) end def perform(site_id) # Dispose of dupes queue = Sidekiq::Queue.new self.class.sidekiq_options_hash['queue'] queue.each do |job| if job.args == [site_id] && job.jid != jid job.delete end end site = Site[site_id] return if site.domain.blank? || site.is_deleted || site.is_banned return if site.values[:domain].match /\.neocities\.org$/i domain_raw = site.values[:domain].gsub(/^www\./, '') domains = [domain_raw, "www.#{domain_raw}"] verified_domains = [] domains.each_with_index do |domain, index| puts "verifying accessability of test file on #{domain}" challenge_base_path = File.join '.well-known', 'acme-challenge' testfile_name, testfile_key = "test#{UUIDTools::UUID.random_create}", SecureRandom.hex testfile_fs_path = File.join site.base_files_path, challenge_base_path begin FileUtils.mkdir_p File.join(site.base_files_path, challenge_base_path) File.write File.join(testfile_fs_path, testfile_name), testfile_key rescue => e puts "ERROR WRITING TO WELLKNOWN FILE, FAILING ON THIS SITE: #{site.username} #{domain}: #{e.inspect}" return end # Ensure that both domains work before sending request. Let's Encrypt has a low # pending request limit, and it takes one week (!) to flush out. challenge_url = "http://#{domain}/#{challenge_base_path}/#{testfile_name}" puts "testing #{challenge_url}" begin res = HTTP.timeout(:global, write: 5, connect: 10, read: 10).get(challenge_url) rescue => e puts e.inspect puts "error with #{challenge_url}" next end if res.status != 200 && res.body != testfile_key puts "CONTENT DOWNLOADED DID NOT MATCH #{challenge_url}" next end puts "test succeeded, sending challenge request verification" begin auth = letsencrypt.authorize domain: domain challenge = auth.http01 rescue => e if e.message =~ /Internationalized domain names.+not yet supported/ @international_domain = true puts "This is an internationalized domain, and so is not yet supported. Skipping to next" next else raise e end end if challenge.nil? puts "challenge object is nil, going to next domain" next end begin FileUtils.mkdir_p File.join(site.base_files_path, File.dirname(challenge.filename)) File.write File.join(site.base_files_path, challenge.filename), challenge.file_content rescue => e puts "FAILED TO WRITE CHALLENGE: #{site.domain} #{challenge.filename}" # A verification needs to be attempted anyways, otherwise 300 of them will jam up the system for a week end challenge.request_verification sleep 1 attempts = 0 while true result = challenge.verify_status puts "#{domain} : #{result}" if result == 'valid' puts "VALIDATED: #{domain}" clean_wellknown_turds site verified_domains.push domain break end raise VerificationTimeoutError if attempts == 60 if result == 'invalid' puts "returned invalid, walking away" clean_wellknown_turds site break end attempts += 1 sleep 2 end end if verified_domains.empty? || (site.created_at.year >= 2017 && !verified_domains.include?(domain_raw)) if @international_domain puts "still waiting on IDN support, ignoring failure for now" clean_wellknown_turds site return end puts "no verified domains, skipping cert setup, reporting invalid domain" # This is a bit of an inappropriate shared responsibility, # but since were here, it looks like the domain is dead, so let's # try again a few times, but then pull the domain out of our system # if it looks like it's gone completely. if site.domain_fail_count < 60 site.domain_fail_count += 1 site.save_changes validate: false else old_domain = site.domain site.domain = nil site.domain_fail_count = 0 site.save validate: false site.send_email( subject: "[Neocities] Your domain has stopped working", body: Tilt.new('./views/templates/email/domain_fail.erb', pretty: true).render(self, site: site, domain: old_domain) ) end clean_wellknown_turds site if site.domain_fail_count > 0 LetsEncryptWorker.perform_in((5.minutes * site.domain_fail_count), site.id) end return end puts "verified domains: #{verified_domains.inspect}" clean_wellknown_turds site retries = 0 begin csr = Acme::Client::CertificateRequest.new names: verified_domains certificate = letsencrypt.new_certificate csr rescue Acme::Client::Error => e if retries == 2 puts "Failed to create cert, returning: #{e.message}" return end retries += 1 retry end site.ssl_key = certificate.request.private_key.to_pem site.ssl_cert = certificate.fullchain_to_pem site.cert_updated_at = Time.now site.domain_fail_count = 0 site.save_changes validate: false # Refresh the cert periodically, current expire time is 90 days # We're going for a cron task for this now, so this is commented out. #LetsEncryptWorker.perform_in 60.days, site.id end def clean_wellknown_turds(site) wellknown_path = File.join(site.base_files_path, '.well-known') if File.exist?(wellknown_path) FileUtils.rm_rf wellknown_path end end end