├── .gitignore
├── generate-token
├── register-account-key
├── Gemfile
├── config.ru
├── freshcerts-client
├── UNLICENSE
├── monitoring.rb
├── freshcerts-multi-client
├── views
└── index.erb
├── Gemfile.lock
├── CODE_OF_CONDUCT.md
├── common.rb
├── freshcerts-cpanel-client
├── README.md
└── app.rb
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle
2 | /vendor
3 | /data
4 | /tmp
5 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/views/index.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
freshcerts
5 |
6 |
31 |
32 | freshcerts
33 |
34 | certificates issued through this server
35 |
36 |
37 |
38 | | Status |
39 | Domain |
40 | Ports |
41 | Last checked |
42 | Cert expires |
43 | Cert SHA-256 digest |
44 |
45 |
46 |
47 | <% domains.each do |domain, site| %>
48 |
49 | | <%= site.status %> |
50 | <%= domain %> |
51 | <%= site.ports.join(', ') %> |
52 | |
53 | |
54 | <%= site.cert_sha256 %> |
55 |
56 | <% end %>
57 |
58 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # freshcerts [](http://unlicense.org)
2 |
3 | 
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------