├── .gitignore ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── README.md ├── UNLICENSE ├── app.rb ├── common.rb ├── config.ru ├── freshcerts-client ├── freshcerts-cpanel-client ├── freshcerts-multi-client ├── generate-token ├── monitoring.rb ├── register-account-key └── views └── index.erb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle 2 | /vendor 3 | /data 4 | /tmp 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project owner at val@packett.cool. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project owner is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'puma' 4 | gem 'sinatra' 5 | gem 'sinatra-contrib' 6 | gem 'rack-attack', '4.3.1' 7 | gem 'activesupport' 8 | gem 'thread_safe' 9 | gem 'acme-client' 10 | gem 'mail' 11 | gem 'domain_name' 12 | gem 'json-jwt' 13 | gem 'psych' 14 | gem 'pstore' 15 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | acme-client (2.0.19) 5 | base64 (~> 0.2.0) 6 | faraday (>= 1.0, < 3.0.0) 7 | faraday-retry (>= 1.0, < 3.0.0) 8 | activesupport (7.2.1.1) 9 | base64 10 | bigdecimal 11 | concurrent-ruby (~> 1.0, >= 1.3.1) 12 | connection_pool (>= 2.2.5) 13 | drb 14 | i18n (>= 1.6, < 2) 15 | logger (>= 1.4.2) 16 | minitest (>= 5.1) 17 | securerandom (>= 0.3) 18 | tzinfo (~> 2.0, >= 2.0.5) 19 | aes_key_wrap (1.1.0) 20 | base64 (0.2.0) 21 | bigdecimal (3.1.8) 22 | bindata (2.5.0) 23 | concurrent-ruby (1.3.4) 24 | connection_pool (2.4.1) 25 | date (3.3.4) 26 | domain_name (0.6.20240107) 27 | drb (2.2.1) 28 | erubis (2.7.0) 29 | faraday (2.12.0) 30 | faraday-net_http (>= 2.0, < 3.4) 31 | json 32 | logger 33 | faraday-follow_redirects (0.3.0) 34 | faraday (>= 1, < 3) 35 | faraday-net_http (3.3.0) 36 | net-http 37 | faraday-retry (2.2.1) 38 | faraday (~> 2.0) 39 | i18n (1.14.6) 40 | concurrent-ruby (~> 1.0) 41 | json (2.7.2) 42 | json-jwt (1.16.7) 43 | activesupport (>= 4.2) 44 | aes_key_wrap 45 | base64 46 | bindata 47 | faraday (~> 2.0) 48 | faraday-follow_redirects 49 | logger (1.6.1) 50 | mail (2.8.1) 51 | mini_mime (>= 0.1.1) 52 | net-imap 53 | net-pop 54 | net-smtp 55 | mini_mime (1.1.5) 56 | minitest (5.25.1) 57 | multi_json (1.15.0) 58 | mustermann (3.0.3) 59 | ruby2_keywords (~> 0.0.1) 60 | net-http (0.4.1) 61 | uri 62 | net-imap (0.5.0) 63 | date 64 | net-protocol 65 | net-pop (0.1.2) 66 | net-protocol 67 | net-protocol (0.2.2) 68 | timeout 69 | net-smtp (0.5.0) 70 | net-protocol 71 | nio4r (2.7.3) 72 | pstore (0.1.3) 73 | psych (5.1.2) 74 | stringio 75 | puma (6.4.3) 76 | nio4r (~> 2.0) 77 | rack (3.1.8) 78 | rack-attack (4.3.1) 79 | rack 80 | rack-protection (4.0.0) 81 | base64 (>= 0.1.0) 82 | rack (>= 3.0.0, < 4) 83 | rack-session (2.0.0) 84 | rack (>= 3.0.0) 85 | ruby2_keywords (0.0.5) 86 | securerandom (0.3.1) 87 | sinatra (4.0.0) 88 | mustermann (~> 3.0) 89 | rack (>= 3.0.0, < 4) 90 | rack-protection (= 4.0.0) 91 | rack-session (>= 2.0.0, < 3) 92 | tilt (~> 2.0) 93 | sinatra-contrib (4.0.0) 94 | multi_json (>= 0.0.2) 95 | mustermann (~> 3.0) 96 | rack-protection (= 4.0.0) 97 | sinatra (= 4.0.0) 98 | tilt (~> 2.0) 99 | stringio (3.1.1) 100 | thread_safe (0.3.6) 101 | tilt (2.4.0) 102 | timeout (0.4.1) 103 | tzinfo (2.0.6) 104 | concurrent-ruby (~> 1.0) 105 | uri (0.13.1) 106 | 107 | PLATFORMS 108 | ruby 109 | 110 | DEPENDENCIES 111 | acme-client 112 | activesupport 113 | domain_name 114 | erubis 115 | json-jwt 116 | mail 117 | pstore 118 | psych 119 | puma 120 | rack-attack (= 4.3.1) 121 | sinatra 122 | sinatra-contrib 123 | thread_safe 124 | 125 | BUNDLED WITH 126 | 2.5.16 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # freshcerts [![unlicense](https://img.shields.io/badge/un-license-green.svg?style=flat)](http://unlicense.org) 2 | 3 | ![Screenshot](https://files.app.net/h02q76bXk.png) 4 | 5 | [ACME](https://letsencrypt.github.io/acme-spec/) (currently implemented by [Let's Encrypt](https://letsencrypt.org)) is a way to automatically (re)issue TLS certificates. 6 | 7 | Most ACME clients are designed to run on the same machine as your TLS services. 8 | But if you have a lot of servers, there are two problems with that: 9 | - you either have to copy your account private key onto all of them, or register multiple accounts; 10 | - you don't have a nice monitoring dashboard & notifications! 11 | 12 | freshcerts solves both problems. 13 | It runs a server that exposes a much simpler API to your servers (they'll use a tiny shell script that's pretty much `openssl | curl | tar`) and a dashboard to your system administrators. 14 | Servers are monitored to ensure they actually use the certs issued for them. 15 | Email notifications are sent to the admins for all errors found by monitoring and for all issued certificates. 16 | 17 | ## Installation 18 | 19 | It's a typical Ruby app, so you'll need [Bundler](https://bundler.io): 20 | 21 | ```bash 22 | git clone https://github.com/valpackett/freshcerts.git 23 | cd freshcerts 24 | bundle install --path vendor/bundle 25 | mkdir data 26 | ``` 27 | 28 | Use environment variables to configure the app. Read `common.rb` to see which variables are available. 29 | You probably should change the ACME endpoint (by default, Let's Encrypt **staging** is used, not production): 30 | 31 | ```bash 32 | export ACME_ENDPOINT="https://acme-v01.api.letsencrypt.org/" 33 | export ADMIN_EMAIL="support@example.com" 34 | ``` 35 | 36 | Generate a tokens key: 37 | 38 | ```bash 39 | openssl ecparam -genkey -name prime256v1 -out data/tokens.key.pem 40 | ``` 41 | 42 | Generate and register an account key: 43 | 44 | ```bash 45 | openssl genrsa -out data/account.key.pem 4096 46 | chmod 0400 data/account.key.pem 47 | bundle exec ./register-account-key 48 | ``` 49 | 50 | Run: 51 | 52 | ```bash 53 | bundle exec puma -p 9393 54 | ``` 55 | 56 | In production, you'll want to configure your process manager to run it. 57 | Set `RACK_ENV=production` there in addition to the config variables (`ACME_ENDPOINT`, etc.) 58 | 59 | ### Minimizing Memory Footprint 60 | 61 | If you want to run freshcerts on e.g. a cheap VPS with low RAM: 62 | 63 | - by default, the monitoring worker runs in a thread inside of the app. You can run it separately with cron: 64 | - set `SEPARATE_MONITORING=1` for the server process (puma/rackup); 65 | - put `bundle exec ruby monitoring.rb` into your crontab for every 10 minutes or so. 66 | - run the server process under [soad](https://github.com/valpackett/soad)! It will start the server on demand and shut it down when it's inactive. Don't set the `time-until-stop` to something ridiculously low like 1 second, because freshcerts keeps challenges in memory. 67 | 68 | This way, memory will only be used when there are requests to the freshcerts server or when it's doing the monitoring. 69 | 70 | ## Usage 71 | 72 | For every domain: 73 | 74 | Generate an auth token with `bundle exec ./generate-token`. 75 | 76 | Configure the HTTP server to forward `/.well-known/acme-challenge/*` requests to the freshcerts server. 77 | 78 | Configure cron to run the `freshcerts-client` script every day. 79 | 80 | Args: domain, subject, ports (comma separated), reload command, auth token. Like this: 81 | 82 | ``` 83 | FRESHCERTS_HOST="https://certs.example.com:4333" freshcerts-client example.com /CN=example.com 443 "service nginx reload" "eyJ0eXAiOi..." 84 | ``` 85 | 86 | And figure out cert paths and file permissions :-) 87 | 88 | ### Multi-domain certificates (SAN, Subject Alternative Name) 89 | 90 | If you want to issue a certificate for multiple domains, there's a more advanced Ruby client, use it like that: 91 | 92 | ``` 93 | FRESHCERTS_HOST="https://certs.example.com:4333" FRESHCERTS_TOKEN="eyJ0eXAiOi..." freshcerts-multi-client example.com,www.example.com 443 && service nginx reload 94 | ``` 95 | 96 | If you can't use Ruby, you can modify the shell client to support multi-domain certificates. [Set up openssl.cnf to read SAN from the environment](https://security.stackexchange.com/a/86999), modify the client to read that config section (add e.g. `-extensions san_env` to the CSR generation command) and pass the domains via that variable. For the freshcerts part (first arg), use a comma-separated list of domains instead of just one domain. Do not use `subjectAltName` as a subject field, that's a special syntax supported by *some* CAs (not Let's Encrypt!) that will turn it into real SAN fields. 97 | 98 | ## Contributing 99 | 100 | Please feel free to submit pull requests! 101 | 102 | By participating in this project you agree to follow the [Contributor Code of Conduct](http://contributor-covenant.org/version/1/4/). 103 | 104 | [The list of contributors is available on GitHub](https://github.com/valpackett/freshcerts/graphs/contributors). 105 | 106 | ## License 107 | 108 | This is free and unencumbered software released into the public domain. 109 | For more information, please refer to the `UNLICENSE` file or [unlicense.org](http://unlicense.org). 110 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | require 'sinatra/streaming' # IO object compatibility 3 | require 'active_support/time' 4 | require 'openssl' 5 | require 'domain_name' 6 | require 'thread_safe' 7 | require 'rubygems/package' 8 | require './common' 9 | 10 | $challenges = ThreadSafe::Cache.new 11 | 12 | class Freshcerts::App < Sinatra::Base 13 | class DomainError < StandardError 14 | end 15 | 16 | def domains 17 | ds = params[:domain] 18 | raise DomainError if ds.nil? || ds.include?(' ') 19 | @domains ||= ds.split(',').map { |d| DomainName(d).hostname } 20 | end 21 | 22 | def domain_str 23 | @domain_str ||= domains.join(',') 24 | end 25 | 26 | def issue_error!(msg) 27 | Freshcerts.notify_admin 'certificate issue error', "Error message:\n#{msg}\n\nRequest:\n#{request.to_yaml}" 28 | halt 400, msg 29 | end 30 | 31 | error OpenSSL::X509::RequestError do 32 | issue_error! 'Could not read the CSR. You should send a valid CSR as a multipart part named "csr".' 33 | end 34 | 35 | error DomainError do 36 | issue_error! "Domain list '#{domain_str}' is not valid." 37 | end 38 | 39 | error Freshcerts::TokenError do 40 | issue_error! 'A valid authentication token was not provided.' 41 | end 42 | 43 | error Acme::Client::Error::Malformed do 44 | issue_error! "Some domain from the list '#{domain_str}' is not supported by the CA." 45 | end 46 | 47 | helpers Sinatra::Streaming 48 | configure :production, :development do 49 | enable :logging 50 | disable :show_exceptions 51 | end 52 | 53 | get '/.well-known/acme-challenge/:id' do 54 | content_type 'text/plain' 55 | $challenges[params[:id]] 56 | end 57 | 58 | get '/v1/cert/:domain/should_reissue' do 59 | domains.each do |domain| 60 | site = Freshcerts.sites[domain] 61 | halt 200, "Reissue reason: No certs for domain #{domain} have been issued yet!\n" if site.nil? 62 | halt 200, "Reissue reason: Cert expires sooner than 10 days!\n" if Time.now > site.expires - 10.days 63 | halt 200, "Reissue reason: Wrong cert is used!\n" if site.status == :wrong_cert 64 | halt 200, "Reissue reason: Colud not connect!\n" if site.status == :conn_error 65 | end 66 | halt 400, "Everything is OK, no reissue required.\n" 67 | end 68 | 69 | post '/v1/cert/:domain/issue' do 70 | Freshcerts.tokens.check! params[:token] 71 | challenges = make_challenges 72 | challenges.each do |challenge| 73 | verify_challenge challenge 74 | end 75 | issue 76 | end 77 | 78 | post '/v1/cert/:domain/issue-multistep/challenge' do 79 | Freshcerts.tokens.check! params[:token] 80 | challenges = make_challenges.map { |challenge| 81 | data = challenge.to_h 82 | data[:file_content] = challenge.file_content 83 | data[:filename] = challenge.filename 84 | data 85 | } 86 | content_type :json 87 | challenges.to_json 88 | end 89 | 90 | post '/v1/cert/:domain/issue-multistep/issue' do 91 | Freshcerts.tokens.check! params[:token] 92 | challenges = JSON.parse(params[:challenge][:tempfile].read).map { |hash| 93 | Freshcerts.acme.challenge_from_hash hash 94 | } 95 | challenges.each do |challenge| 96 | verify_challenge challenge 97 | end 98 | issue 99 | end 100 | 101 | def make_challenges 102 | domains.map do |domain| 103 | authorization = Freshcerts.acme.authorize :domain => domain 104 | challenge = authorization.http01 105 | challenge_id = challenge.filename.sub /.*challenge\/?/, '' 106 | $challenges[challenge_id] = challenge.file_content 107 | logger.info "make_challenge domain=#{domain} id=#{challenge_id}" 108 | challenge 109 | end 110 | end 111 | 112 | def verify_challenge(challenge) 113 | sleep 0.1 114 | challenge.request_verification 115 | status = nil 116 | while (status = challenge.verify_status) == 'pending' 117 | sleep 0.5 118 | end 119 | challenge_id = challenge.filename.sub /.*challenge\/?/, '' 120 | logger.info "verify_challenge domains=#{domain_str} id=#{challenge_id} status=#{status}" 121 | unless status == 'valid' 122 | $challenges.delete challenge_id 123 | issue_error! "CA returned challenge validation status: #{status}.\n\nChallenge:\n#{challenge.to_yaml}" 124 | end 125 | $challenges.delete challenge_id 126 | end 127 | 128 | def issue 129 | csr = OpenSSL::X509::Request.new (params[:csr].is_a?(String) ? params[:csr] : params[:csr][:tempfile].read) 130 | ports = (params[:ports] || '443').split(',').map { |port| port.strip.to_i } 131 | certificate = Freshcerts.acme.new_certificate csr 132 | cert_hash = Freshcerts.hash_cert certificate 133 | logger.info "issue domains=#{domain_str} subject=#{certificate.x509.subject.to_s} sha256=#{cert_hash} expires=#{certificate.x509.not_after.to_s}" 134 | domains.each do |domain| 135 | Freshcerts.sites[domain] = Freshcerts::Site.new ports, :fresh, Time.now, cert_hash, certificate.x509.not_after 136 | end 137 | content_type 'application/x-tar' 138 | stream do |out| 139 | Gem::Package::TarWriter.new(out) do |tar| 140 | cert = certificate.to_pem 141 | tar.add_file_simple("#{domain_str}.cert.pem", 0444, cert.length) { |io| io.write(cert) } 142 | chain = certificate.chain_to_pem 143 | tar.add_file_simple("#{domain_str}.cert.chain.pem", 0444, chain.length) { |io| io.write(chain) } 144 | fullchain = certificate.fullchain_to_pem 145 | tar.add_file_simple("#{domain_str}.cert.fullchain.pem", 0444, fullchain.length) { |io| io.write(fullchain) } 146 | end 147 | out.flush 148 | end 149 | Freshcerts.notify_admin "successfully issued a certificate for #{domain_str}", 150 | "Successfully issued a certificate for domains:\n#{domains.join("\n")}\n\nSHA-256 fingerprint: #{cert_hash}.\n\nRequest:\n#{request.to_yaml}" 151 | end 152 | 153 | get '/robots.txt' do 154 | "User-agent: *\nDisallow: /" 155 | end 156 | 157 | get '/humans.txt' do 158 | 'freshcerts is created by Val ' 159 | end 160 | 161 | get '/' do 162 | headers "Refresh" => "30" 163 | erb :index, :locals => { 164 | :domains => Freshcerts.sites.all, 165 | :config_host => request.host, 166 | :config_port => request.port, 167 | :config_secure => request.secure?, 168 | :client_script => CLIENT_SCRIPT 169 | } 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /common.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | require 'acme-client' 3 | require 'yaml/store' 4 | require 'json/jwt' 5 | require 'mail' 6 | 7 | ACME_ENDPOINT = ENV['ACME_ENDPOINT'] || 'https://acme-staging.api.letsencrypt.org/' 8 | DATA_ROOT = ENV['DATA_ROOT'] || File.join(File.dirname(__FILE__), 'data') 9 | ADMIN_EMAIL = ENV['ADMIN_EMAIL'] || 'root@localhost' 10 | SMTP_ADDRESS = ENV['SMTP_ADDRESS'] || 'localhost' 11 | SMTP_PORT =(ENV['SMTP_PORT'] || '25').to_i 12 | SMTP_USERNAME = ENV['SMTP_USERNAME'] 13 | SMTP_PASSWORD = ENV['SMTP_PASSWORD'] 14 | SMTP_AUTH = ENV['SMTP_AUTH'] 15 | SENDER_EMAIL = ENV['SENDER_EMAIL'] || "#{Etc.getlogin}@#{Socket.gethostname}" 16 | 17 | ACCOUNT_KEY_PATH = ENV['ACCOUNT_KEY_PATH'] || File.join(DATA_ROOT, 'account.key.pem') 18 | STORE_PATH = ENV['STORE_PATH'] || File.join(DATA_ROOT, 'store.yaml') 19 | TOKENS_KEY_PATH = ENV['TOKENS_KEY_PATH'] || File.join(DATA_ROOT, 'tokens.key.pem') 20 | 21 | CLIENT_SCRIPT = File.read File.join File.dirname(__FILE__), 'freshcerts-client' 22 | TOKENS_EC_CURVE = 'prime256v1' 23 | 24 | unless File.exist? ACCOUNT_KEY_PATH 25 | STDERR.puts "No account key found at #{ACCOUNT_KEY_PATH}. Create one with `openssl genrsa -out #{ACCOUNT_KEY_PATH} 4096`." 26 | exit 1 27 | end 28 | 29 | unless File.exist? TOKENS_KEY_PATH 30 | STDERR.puts "No tokens key found at #{TOKENS_KEY_PATH}. Create one with `openssl ecparam -genkey -name #{TOKENS_EC_CURVE} -out #{TOKENS_KEY_PATH}`." 31 | exit 1 32 | end 33 | 34 | Mail.defaults do 35 | delivery_method :smtp, :address => SMTP_ADDRESS, :port => SMTP_PORT, :user_name => SMTP_USERNAME, :password => SMTP_PASSWORD, :authentication => SMTP_AUTH 36 | end 37 | 38 | module Freshcerts 39 | Site = Struct.new :ports, :status, :last_checked, :cert_sha256, :expires 40 | 41 | class SitesProxy 42 | def initialize(store) 43 | @store = store 44 | end 45 | 46 | def transaction(read_only = false) 47 | @store.transaction(read_only) { yield @store } 48 | end 49 | 50 | def all 51 | @store.transaction(true) { Hash[@store.roots.map { |k| [k, @store[k]] }] } 52 | end 53 | 54 | def [](key) 55 | @store.transaction(true) { @store[key] } 56 | end 57 | 58 | def []=(key, val) 59 | @store.transaction { @store[key] = val } 60 | end 61 | end 62 | 63 | def self.sites 64 | @@sites_store ||= YAML::Store.new(STORE_PATH).tap do |store| 65 | store.ultra_safe = true 66 | store.instance_eval { |_| @thread_safe = true } # LOL: it's set to {} because the initializer calls super without args, and the 2nd arg has a different meaning 67 | end 68 | @@sites ||= SitesProxy.new @@sites_store 69 | end 70 | 71 | class TokenError < StandardError 72 | end 73 | 74 | class TokensProxy 75 | def initialize(key) 76 | @key = key 77 | end 78 | 79 | def generate(info) 80 | JSON::JWT.new(:iss => 'freshcerts', :info => info).sign(@key, :ES256).to_s 81 | end 82 | 83 | def check!(token) 84 | JSON::JWT.decode (token || 'WRONG'), @key 85 | rescue Exception => e 86 | raise TokenError.new(e) 87 | end 88 | end 89 | 90 | def self.tokens 91 | @@tokens ||= TokensProxy.new OpenSSL::PKey::EC.new File.read TOKENS_KEY_PATH 92 | end 93 | 94 | def self.acme 95 | @@acme_client ||= Acme::Client.new :private_key => OpenSSL::PKey::RSA.new(File.read(ACCOUNT_KEY_PATH)), :endpoint => ACME_ENDPOINT 96 | end 97 | 98 | def self.hash_cert(certificate) 99 | OpenSSL::Digest::SHA256.hexdigest(certificate.to_der).scan(/../).join(':') 100 | end 101 | 102 | def self.notify_admin(event, description) 103 | Mail.new { 104 | from SENDER_EMAIL 105 | to ADMIN_EMAIL 106 | subject "freshcerts event: #{event}" 107 | body description 108 | }.deliver 109 | rescue => e 110 | puts "Error sending mail! Exception: #{e.class}: #{e.message}" 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | unless ['1', 'yes', 'true', 'on'].include? (ENV['SEPARATE_MONITORING'] || '0').downcase 2 | require './monitoring' 3 | 4 | Thread.new do 5 | puts "Monitor thread started" 6 | loop do 7 | Freshcerts::Monitoring.check_sites 8 | GC.start 9 | sleep 5.minutes 10 | end 11 | end 12 | end 13 | 14 | require 'rack/attack' 15 | require './app' 16 | 17 | Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new 18 | Rack::Attack.throttle('req/ip', :limit => 4, :period => 1.second) { |req| req.ip } 19 | 20 | use Rack::Attack 21 | run Freshcerts::App 22 | -------------------------------------------------------------------------------- /freshcerts-client: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Freshcerts client 3 | # https://github.com/valpackett/freshcerts 4 | # 5 | # Run this via cron every day for each domain 6 | # 7 | # You can comment out the genrsa if you don't want to rotate the private key 8 | # and, really, customize the script however you want 9 | # (e.g. add DANE records generation for your DNS server) 10 | 11 | : ${FRESHCERTS_HOST:="localhost:9292"} 12 | : ${KEYS_DIRECTORY:="/usr/local/etc/certs"} 13 | 14 | DOMAIN="$1" 15 | SUBJ="$2" 16 | PORTS="$3" 17 | RELOAD_CMD="$4" 18 | AUTH_TOKEN="$5" 19 | 20 | KEY_PATH="${KEYS_DIRECTORY}/${DOMAIN}.key.pem" 21 | 22 | curl -f "${FRESHCERTS_HOST}/v1/cert/${DOMAIN}/should_reissue" 2>/dev/null && \ 23 | openssl genrsa -out "$KEY_PATH.new" 2048 2>/dev/null && \ 24 | chmod 0400 "$KEY_PATH.new" && \ 25 | openssl req -new -batch -subj "$SUBJ" -key "$KEY_PATH.new" -out /dev/stdout | \ 26 | curl -s -X POST "${FRESHCERTS_HOST}/v1/cert/${DOMAIN}/issue" \ 27 | -F "csr=@-" -F "ports=$PORTS" -F "domain=$DOMAIN" -F "token=$AUTH_TOKEN" | \ 28 | tar -C "$KEYS_DIRECTORY" -xf - && \ 29 | mv "$KEY_PATH.new" "$KEY_PATH" && \ 30 | sh -c "$RELOAD_CMD" 31 | -------------------------------------------------------------------------------- /freshcerts-cpanel-client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # Requires: gem install multipart-post 4 | 5 | require 'net/http' 6 | require 'net/http/post/multipart' 7 | require 'rubygems/package' 8 | require 'openssl' 9 | require 'uri' 10 | require 'json' 11 | 12 | class CPanelClient 13 | def initialize(host, username, password) 14 | uri = URI.parse host 15 | @http = Net::HTTP.new uri.host, uri.port 16 | @http.use_ssl = true 17 | @http.verify_mode = OpenSSL::SSL::VERIFY_PEER 18 | @username = username 19 | @password = password 20 | end 21 | 22 | def uapi_get(mod, fun, params = {}) 23 | uri = URI.parse "/execute/#{mod}/#{fun}" 24 | uri.query = URI.encode_www_form(params) 25 | req = Net::HTTP::Get.new uri.to_s 26 | req.basic_auth @username, @password 27 | handle_resp @http.request req 28 | end 29 | 30 | def uapi_post(mod, fun, params = {}) 31 | req = Net::HTTP::Post.new "/execute/#{mod}/#{fun}", params 32 | req.basic_auth @username, @password 33 | req.set_form_data params 34 | handle_resp @http.request req 35 | end 36 | 37 | def uapi_post_multi(mod, fun, params = {}) 38 | req = Net::HTTP::Post::Multipart.new "/execute/#{mod}/#{fun}", params 39 | req.basic_auth @username, @password 40 | handle_resp @http.request req 41 | end 42 | 43 | private 44 | def handle_resp(resp) 45 | raise "Bad response: #{resp.code} #{resp.body}" if resp.code.to_i >= 300 46 | jresp = JSON.parse(resp.body) 47 | raise "Errors: #{jresp['errors']}" if (jresp['errors'] || []).length > 0 48 | jresp['data'] 49 | end 50 | end 51 | 52 | class FreshcertsClient 53 | def initialize(host, token) 54 | uri = URI.parse host 55 | @http = Net::HTTP.new uri.host, uri.port 56 | @token = token 57 | end 58 | 59 | def challenge(domain) 60 | req = Net::HTTP::Post.new "/v1/cert/#{domain},www.#{domain}/issue-multistep/challenge?token=#{URI.encode @token}" 61 | JSON.parse handle_resp @http.request req 62 | end 63 | 64 | def issue(domain, challenge) 65 | key = OpenSSL::PKey::RSA.new 2048 66 | csr = OpenSSL::X509::Request.new 67 | csr.version = 0 68 | csr.subject = OpenSSL::X509::Name.new([ 69 | ['CN', domain, OpenSSL::ASN1::UTF8STRING], 70 | ]) 71 | domains = [domain, "www.#{domain}"] 72 | ef = OpenSSL::X509::ExtensionFactory.new 73 | extensions = [ 74 | ['subjectAltName', domains.map { |d| "DNS:#{d}" }.join(',')] 75 | ].map { |e| ef.create_extension(*e) } 76 | attr_values = OpenSSL::ASN1::Set([OpenSSL::ASN1::Sequence(extensions)]) 77 | csr.add_attribute OpenSSL::X509::Attribute.new('extReq', attr_values) 78 | csr.add_attribute OpenSSL::X509::Attribute.new('msExtReq', attr_values) 79 | csr.public_key = key.public_key 80 | req = Net::HTTP::Post::Multipart.new "/v1/cert/#{domain},www.#{domain}/issue-multistep/issue?token=#{URI.encode @token}", 81 | 'challenge' => UploadIO.new(StringIO.new(challenge.to_h.to_json), 'application/json', 'challenge'), 82 | 'csr' => UploadIO.new(StringIO.new(csr.sign(key, OpenSSL::Digest::SHA256.new).to_s), 'application/x-pem-file', 'csr') 83 | resp_body = handle_resp @http.request req 84 | result = { 85 | :key => key.to_s 86 | } 87 | Gem::Package::TarReader.new(StringIO.new(resp_body)).each do |entry| 88 | result[:cert] = entry.read if entry.full_name =~ /cert.pem/ 89 | result[:cert_chain] = entry.read if entry.full_name =~ /cert.chain.pem/ 90 | result[:cert_fullchain] = entry.read if entry.full_name =~ /cert.fullchain.pem/ 91 | end 92 | result 93 | end 94 | 95 | private 96 | def handle_resp(resp) 97 | raise "Bad response: #{resp.code} #{resp.body}" if resp.code.to_i >= 300 98 | resp.body 99 | end 100 | end 101 | 102 | panel = CPanelClient.new ENV['CPANEL_HOST'], ENV['CPANEL_USERNAME'], ENV['CPANEL_PASSWORD'] 103 | certs = FreshcertsClient.new ENV['FRESHCERTS_HOST'], ENV['FRESHCERTS_TOKEN'] 104 | domains_resp = panel.uapi_get 'DomainInfo', 'list_domains' 105 | domains = domains_resp['addon_domains'] + domains_resp['sub_domains'] + domains_resp['parked_domains'] + [domains_resp['main_domain']] 106 | domains.each do |domain| 107 | challenge = certs.challenge domain 108 | challenge_id = challenge['filename'].sub /.*challenge\/?/, '' 109 | upload_resp = panel.uapi_post_multi 'Fileman', 'upload_files', 110 | 'dir' => 'public_html/.well-known/acme-challenge', 111 | 'file-1' => UploadIO.new(StringIO.new(challenge['file_content']), 'text/plain', challenge_id) 112 | raise "Upload failed!" if upload_resp['succeeded'] != 1 113 | data = certs.issue domain, challenge 114 | p panel.uapi_post 'SSL', 'install_ssl', 'domain' => domain, 115 | 'cert' => data[:cert], 'key' => data[:key], 'cabundle' => data[:cert_chain] 116 | end 117 | 118 | p panel.uapi_get 'SSL', 'list_keys' 119 | -------------------------------------------------------------------------------- /freshcerts-multi-client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # Freshcerts client in Ruby with support for multi-domain certs (SAN) 3 | # https://github.com/valpackett/freshcerts 4 | # 5 | # Run this via cron every day for each domain 6 | 7 | require 'openssl' 8 | require 'net/http' 9 | require 'uri' 10 | require 'open3' 11 | require 'fileutils' 12 | 13 | AUTH_TOKEN = ENV['FRESHCERTS_TOKEN'] 14 | FRESHCERTS_HOST = ENV['FRESHCERTS_HOST'] || 'localhost:9292' 15 | KEYS_DIRECTORY = ENV['KEYS_DIRECTORY'] || '/usr/local/etc/certs' 16 | 17 | def gen_csr(key, domains) 18 | csr = OpenSSL::X509::Request.new 19 | csr.version = 0 20 | csr.public_key = key.public_key 21 | csr.subject = OpenSSL::X509::Name.new([ 22 | ['CN', domains.first, OpenSSL::ASN1::UTF8STRING] 23 | ]) 24 | ef = OpenSSL::X509::ExtensionFactory.new 25 | extensions = [ 26 | ['subjectAltName', domains.map { |d| "DNS:#{d}" }.join(',')] 27 | ].map { |e| ef.create_extension(*e) } 28 | attr_values = OpenSSL::ASN1::Set([OpenSSL::ASN1::Sequence(extensions)]) 29 | csr.add_attribute OpenSSL::X509::Attribute.new('extReq', attr_values) 30 | csr.add_attribute OpenSSL::X509::Attribute.new('msExtReq', attr_values) 31 | csr.sign(key, OpenSSL::Digest::SHA256.new) 32 | end 33 | 34 | def issue(domains, ports) 35 | key = OpenSSL::PKey::RSA.new 2048 36 | keypath = File.join(KEYS_DIRECTORY, "#{domains.join(',')}.key.pem") 37 | File.write "#{keypath}.new", key.to_s 38 | # multipart requires a gem (or reimplementing it) 39 | # ruby tar support is in rubygems' code which might be absent 40 | stdin, wait_thrs = Open3.pipeline_w( 41 | ['curl', '-s', '-X', 'POST', 42 | "#{FRESHCERTS_HOST}/v1/cert/#{domains.join(',')}/issue", 43 | '-F', 'csr=@-', '-F', "ports=#{ports}", '-F', "token=#{AUTH_TOKEN}"], 44 | ['tar', '-C', KEYS_DIRECTORY, '-xf', '-'] 45 | ) 46 | stdin.write gen_csr(key, domains).to_s 47 | stdin.close 48 | exit_status = wait_thrs.last.value 49 | exit exit_status.exitstatus unless exit_status.success? 50 | FileUtils.mv "#{keypath}.new", keypath 51 | end 52 | 53 | domains = ARGV.shift.split(',') 54 | ports = ARGV.shift 55 | resp = Net::HTTP.get_response(URI("#{FRESHCERTS_HOST}/v1/cert/#{domains.join(',')}/should_reissue")) 56 | issue(domains, ports) if resp.code == "200" 57 | -------------------------------------------------------------------------------- /generate-token: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require './common' 4 | 5 | token = Freshcerts.tokens.generate ARGV.join(' ').strip 6 | Freshcerts.tokens.check! token 7 | puts token 8 | -------------------------------------------------------------------------------- /monitoring.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'openssl' 3 | require 'active_support/time' 4 | require './common' 5 | 6 | module Freshcerts::Monitoring 7 | def self.check_site(domain, port, wanted_hash) 8 | OpenSSL::SSL::SSLSocket.new(TCPSocket.new domain, port).tap do |sock| 9 | sock.hostname = domain 10 | sock.sync_close = true 11 | sock.connect 12 | found_hash = Freshcerts.hash_cert sock.peer_cert 13 | yield (wanted_hash == found_hash ? :ok : :wrong_cert), found_hash 14 | sock.close 15 | end 16 | end 17 | 18 | def self.check_sites 19 | Freshcerts.sites.all.each do |domain, site| 20 | site.ports.each do |port| 21 | begin 22 | puts "Checking #{domain}:#{port}" 23 | wanted_hash = site.cert_sha256 24 | check_site(domain, port, wanted_hash) do |status, found_hash| 25 | if status == :wrong_cert 26 | Freshcerts.notify_admin "monitoring found cert error for #{domain}:#{port}", 27 | "Found a certificate with SHA-256 figerprint\n\n#{found_hash}\n\n, should be\n\n#{wanted_hash}." 28 | puts "#{domain}:#{port} wrong cert: #{found_hash}, should be #{wanted_hash}" 29 | else 30 | puts "#{domain}:#{port} ok" 31 | end 32 | site.status = status 33 | end 34 | rescue => e 35 | Freshcerts.notify_admin "monitoring could not connect to #{domain}:#{port}", 36 | "Could not connect to #{domain}:#{port}.\n\nException: #{e.class}: #{e.message}\nBacktrace:\n#{e.backtrace.join "\n"}" 37 | puts "#{domain}:#{port} exception: #{e}" 38 | site.status = :conn_error 39 | end 40 | site.last_checked = Time.now 41 | Freshcerts.sites[domain] = site 42 | sleep 2.seconds 43 | end 44 | end 45 | end 46 | end 47 | 48 | if File.identical?(__FILE__, $0) 49 | Freshcerts::Monitoring.check_sites 50 | end 51 | -------------------------------------------------------------------------------- /register-account-key: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require './common' 4 | 5 | print "Contact (e.g. mailto:your@email.address): " 6 | registration = Freshcerts.acme.register :contact => gets.strip 7 | registration.agree_terms 8 | p registration 9 | -------------------------------------------------------------------------------- /views/index.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | freshcerts 5 | 6 | 31 | 32 |

freshcerts

33 |
34 |

certificates issued through this server

35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | <% domains.each do |domain, site| %> 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | <% end %> 57 | 58 |
StatusDomainPortsLast checkedCert expiresCert SHA-256 digest
<%= site.status %><%= domain %><%= site.ports.join(', ') %><%= site.cert_sha256 %>
59 | 60 | <% this_host = "http#{config_secure ? 's' : ''}://#{config_host}:#{config_port}" %> 61 |

nginx configuration

62 |

domain verification:

63 |
location ^~ /.well-known/acme-challenge/ {
64 | 	proxy_set_header Host $host;
65 | 	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
66 |         proxy_pass <%= this_host %>;
67 | }
68 |

strong TLS settings: cipherli.st

69 | 70 |

client script for cron

71 |
<%= client_script.sub('localhost:9292', "#{this_host}") %>
72 |
73 | --------------------------------------------------------------------------------